pdfgutter: two facing pages photographed as one, found and cut

This commit is contained in:
funkpower
2026-09-19 15:19:35 -04:00
parent 49f12c9718
commit 3c697bee50
17 changed files with 2374 additions and 13 deletions
+1
View File
@@ -23,6 +23,7 @@ cueseg.workspace = true
mdscrub.workspace = true
mdsplit.workspace = true
avplan.workspace = true
pdfgutter.workspace = true
pdftriage.workspace = true
rrf.workspace = true
corpus-core.workspace = true
+67
View File
@@ -201,3 +201,70 @@ metadata and is wrong often enough that the comment above it says so.
**Why this is acceptable:** there is no database, and inventing a codec is
how a compatible file gets re-encoded for forty minutes. Saying so is the
honest answer and the cheap one.
## pdfgutter — a different rasteriser, so the profiles are compared rather than the pixels
**Python:** renders each sampled page with pdfium at a scale that makes it a
thousand pixels wide, and counts dark pixels per column.
**Rust:** asks `pdftoppm` for the same thousand-pixel-wide greymap and counts
the same way.
**Why this is acceptable:** two rasterisers antialias differently, so a pixel
on the edge of a letterform can land either side of the ink threshold in one
and not the other. No amount of care makes two renderers agree pixel for
pixel, and a parity test that demanded it would be a test of pdfium rather
than of this.
What is compared instead is everything that decides: the aspect ratio, which
pages get sampled, whether a given ink profile has a gutter, and the verdict
a set of ratios and gutters adds up to. Both sides are handed the same
profiles and have to agree exactly.
**Measured:** 72 cases, including profiles either side of the ink threshold by
one ten-thousandth and profiles too narrow to hold the three bands the test
looks at.
## pdfgutter — the thresholds are arguments, not environment variables
**Python:** reads `BOOK_PIPELINE_2UP_MIN_RATIO` and
`BOOK_PIPELINE_2UP_GUTTER_FRAC` at import.
**Rust:** `--min-ratio` and `--gutter-fraction`, with the same defaults.
**Why this is acceptable:** an environment variable read at import makes the
answer depend on the shell that happened to launch the process, which is the
one thing a filter must not do. A flag says the same thing where a reader can
see it, and shows up in the record `--json` writes.
## pdfgutter — a sheet on its side is cut the way it is read
**Python:** takes the media box and ignores `/Rotate` entirely. A sheet stored
tall and displayed wide is never a split candidate; one stored wide and
displayed tall is a candidate, and would be cut down the middle of an axis
that has nothing down the middle of it.
**Rust:** turns the media box by whatever `/Rotate` asks for before measuring
it, and cuts across rather than down for a quarter turn, in the order a
reader would read the halves.
**Why this is acceptable:** the wrong half of a rotated sheet is a page with
the top of one page and the bottom of another on it, and no pipeline
downstream can recover from that. The rotation is in the file precisely so
that readers honour it. Documents with no rotation — nearly all of them —
behave identically.
## pdfgutter — an unreadable file is an answer, not a false
**Python:** `is_double_page` catches everything and returns `False`, so a
corrupt file, an encrypted one and an ordinary single-page book are the same
answer.
**Rust:** exit 2 for a file it cannot judge, exit 74 for one it cannot read,
and exit 1 only for a document it looked at and found to be one page per
sheet.
**Why this is acceptable:** "no" and "could not tell" are different answers
and the contract gives them different numbers. A caller that wants the
Python's behaviour treats both as no; a caller that wants to know which files
it failed on can now find out.
File diff suppressed because one or more lines are too long
+72
View File
@@ -0,0 +1,72 @@
{"kind": "ratio", "name": "two-portraits-side-by-side", "ratio": 1.4}
{"kind": "ratio", "name": "a4-landscape", "ratio": 1.4142756349952963}
{"kind": "ratio", "name": "a4-portrait", "ratio": 0.7070757462376319}
{"kind": "ratio", "name": "us-letter", "ratio": 0.7727272727272727}
{"kind": "ratio", "name": "square", "ratio": 1.0}
{"kind": "ratio", "name": "exactly-at-the-threshold", "ratio": 1.15}
{"kind": "ratio", "name": "a-hair-over", "ratio": 1.1501388888888888}
{"kind": "ratio", "name": "no-height", "ratio": 0.0}
{"kind": "ratio", "name": "no-width", "ratio": 0.0}
{"kind": "ratio", "name": "a-very-long-foldout", "ratio": 5.714285714285714}
{"gutter": true, "kind": "gutter", "name": "two-columns"}
{"gutter": false, "kind": "gutter", "name": "one-wide-block"}
{"gutter": false, "kind": "gutter", "name": "a-narrow-centred-column"}
{"gutter": false, "kind": "gutter", "name": "blank"}
{"gutter": false, "kind": "gutter", "name": "text-on-the-left-only"}
{"gutter": false, "kind": "gutter", "name": "text-on-the-right-only"}
{"gutter": true, "kind": "gutter", "name": "a-single-blank-column"}
{"gutter": true, "kind": "gutter", "name": "a-gutter-off-centre"}
{"gutter": false, "kind": "gutter", "name": "text-just-at-the-ink-threshold"}
{"gutter": true, "kind": "gutter", "name": "text-just-over-it"}
{"gutter": false, "kind": "gutter", "name": "a-gutter-that-is-not-quite-blank"}
{"gutter": false, "kind": "gutter", "name": "empty"}
{"gutter": false, "kind": "gutter", "name": "two-columns-too-few"}
{"gutter": true, "kind": "gutter", "name": "odd-width"}
{"gutter": true, "kind": "gutter", "name": "a-shadow-at-the-edges"}
{"gutter": false, "kind": "gutter", "name": "tiny-7"}
{"gutter": true, "kind": "gutter", "name": "tiny-13"}
{"gutter": true, "kind": "gutter", "name": "tiny-25"}
{"gutter": true, "kind": "gutter", "name": "tiny-101"}
{"gutter": true, "kind": "gutter", "name": "random-0"}
{"gutter": true, "kind": "gutter", "name": "random-1"}
{"gutter": true, "kind": "gutter", "name": "random-2"}
{"gutter": false, "kind": "gutter", "name": "random-3"}
{"gutter": false, "kind": "gutter", "name": "random-4"}
{"gutter": true, "kind": "gutter", "name": "random-5"}
{"gutter": true, "kind": "gutter", "name": "random-6"}
{"gutter": true, "kind": "gutter", "name": "random-7"}
{"gutter": true, "kind": "gutter", "name": "random-8"}
{"gutter": true, "kind": "gutter", "name": "random-9"}
{"gutter": false, "kind": "gutter", "name": "random-10"}
{"gutter": true, "kind": "gutter", "name": "random-11"}
{"indices": [], "kind": "sample", "name": "pages-0"}
{"indices": [0], "kind": "sample", "name": "pages-1"}
{"indices": [0, 1], "kind": "sample", "name": "pages-2"}
{"indices": [0, 1, 2, 3, 4], "kind": "sample", "name": "pages-5"}
{"indices": [0, 1, 2, 3, 4, 5], "kind": "sample", "name": "pages-6"}
{"indices": [2, 3, 4], "kind": "sample", "name": "pages-7"}
{"indices": [2, 3, 4, 5], "kind": "sample", "name": "pages-8"}
{"indices": [2, 3, 4, 5, 6], "kind": "sample", "name": "pages-9"}
{"indices": [2, 3, 4, 5, 6, 7, 8], "kind": "sample", "name": "pages-11"}
{"indices": [2, 3, 4, 5, 6, 7, 8, 9], "kind": "sample", "name": "pages-12"}
{"indices": [2, 3, 4, 5, 6, 7, 8, 9], "kind": "sample", "name": "pages-16"}
{"indices": [2, 3, 4, 5, 6, 7, 8, 9], "kind": "sample", "name": "pages-17"}
{"indices": [2, 3, 4, 5, 6, 7, 8, 9], "kind": "sample", "name": "pages-20"}
{"indices": [2, 7, 12, 17, 22, 27, 32, 37], "kind": "sample", "name": "pages-50"}
{"indices": [2, 13, 24, 35, 46, 57, 68, 79], "kind": "sample", "name": "pages-99"}
{"indices": [2, 13, 24, 35, 46, 57, 68, 79], "kind": "sample", "name": "pages-100"}
{"indices": [2, 32, 62, 92, 122, 152, 182, 212], "kind": "sample", "name": "pages-250"}
{"indices": [2, 64, 126, 188, 250, 312, 374, 436], "kind": "sample", "name": "pages-501"}
{"indices": [2, 126, 250, 374, 498, 622, 746, 870], "kind": "sample", "name": "pages-1000"}
{"gutter_fraction": 1.0, "kind": "verdict", "median_ratio": 1.4, "name": "a-clear-two-up", "two_up": true}
{"gutter_fraction": 0.0, "kind": "verdict", "median_ratio": 0.68, "name": "a-clear-single", "two_up": false}
{"gutter_fraction": 0.0, "kind": "verdict", "median_ratio": 1.4, "name": "wide-but-no-gutters", "two_up": false}
{"gutter_fraction": 0.6, "kind": "verdict", "median_ratio": 1.4, "name": "exactly-six-in-ten", "two_up": true}
{"gutter_fraction": 0.5, "kind": "verdict", "median_ratio": 1.4, "name": "five-in-ten", "two_up": false}
{"gutter_fraction": 1.0, "kind": "verdict", "median_ratio": 1.4, "name": "mixed-shapes", "two_up": true}
{"gutter_fraction": 1.0, "kind": "verdict", "median_ratio": 1.04, "name": "an-even-split-of-shapes", "two_up": false}
{"gutter_fraction": 0.0, "kind": "verdict", "median_ratio": 0.0, "name": "nothing-sampled", "two_up": false}
{"gutter_fraction": 1.0, "kind": "verdict", "median_ratio": 1.4, "name": "one-page", "two_up": true}
{"gutter_fraction": 0.6666666666666666, "kind": "verdict", "median_ratio": 1.4, "name": "two-of-three", "two_up": true}
{"gutter_fraction": 1.0, "kind": "verdict", "median_ratio": 1.1501, "name": "just-over-the-ratio", "two_up": true}
{"gutter_fraction": 1.0, "kind": "verdict", "median_ratio": 1.15, "name": "exactly-the-ratio", "two_up": false}
+81
View File
@@ -0,0 +1,81 @@
"""Decide 2-up scans the way the book pipeline did, for comparison.
The four functions below are the originals, copied out of the preprocessing
module verbatim — importing it would pull in a PDF renderer and a page tree,
and none of that decides anything. What decides is the arithmetic, which is
what this compares.
python3 oracle/pdfgutter.py < cases.ndjson
One case per line, tagged with what it is asking about. Writes one JSON
answer per line.
"""
from __future__ import annotations
import json
import sys
import numpy as np
_MIN_RATIO = 1.15
_MIN_GUTTER_FRAC = 0.6
_TEXT_COL_INK = 0.02
def _ratio(width: float, height: float) -> float:
return width / height if height else 0.0
def _has_gutter(ink: np.ndarray) -> bool:
w = len(ink)
center = ink[int(0.46 * w):int(0.54 * w)]
left = ink[int(0.10 * w):int(0.44 * w)]
right = ink[int(0.56 * w):int(0.90 * w)]
if center.size == 0 or left.size == 0 or right.size == 0:
return False
return bool(
center.min() < _TEXT_COL_INK
and np.median(left) > _TEXT_COL_INK
and np.median(right) > _TEXT_COL_INK
)
def _sample_indices(n: int, k: int = 8) -> list[int]:
if n <= 6:
return list(range(n))
lo, hi = 2, n - 3
step = max(1, (hi - lo) // k)
return list(range(lo, hi + 1, step))[:k]
def _verdict(ratios: list[float], gutters: list[bool]) -> dict:
median_ratio = float(np.median(ratios)) if ratios else 0.0
gutter_frac = (sum(gutters) / len(gutters)) if gutters else 0.0
return {
"median_ratio": median_ratio,
"gutter_fraction": gutter_frac,
"two_up": bool(median_ratio > _MIN_RATIO and gutter_frac >= _MIN_GUTTER_FRAC),
}
def answer(case: dict) -> dict:
kind = case["kind"]
if kind == "ratio":
return {"ratio": _ratio(case["width"], case["height"])}
if kind == "gutter":
return {"gutter": _has_gutter(np.asarray(case["ink"], dtype=float))}
if kind == "sample":
return {"indices": _sample_indices(case["pages"])}
if kind == "verdict":
return _verdict(case["ratios"], case["gutters"])
raise SystemExit(f"no such case: {kind}")
for line in sys.stdin:
if not line.strip():
continue
case = json.loads(line)
out = {"name": case["name"], "kind": case["kind"]}
out.update(answer(case))
sys.stdout.write(json.dumps(out, sort_keys=True) + "\n")
+14 -1
View File
@@ -19,7 +19,7 @@ type Written = Result<Vec<String>, String>;
type Job = (&'static str, fn() -> Written);
fn main() -> ExitCode {
let jobs: [Job; 16] = [
let jobs: [Job; 17] = [
("tokens", tokens),
("unescape", unescape),
("chunkmd", chunkmd),
@@ -36,6 +36,7 @@ fn main() -> ExitCode {
("vocab", vocab),
("mdsplit", mdsplit),
("avplan", avplan),
("pdfgutter", pdfgutter),
];
for (name, job) in jobs {
@@ -218,6 +219,18 @@ fn cueseg() -> Written {
Ok(written)
}
fn pdfgutter() -> Written {
let dir = parity::golden("pdfgutter");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let cases = parity::dir().join("cases/pdfgutter/cases.ndjson");
let text = std::fs::read_to_string(&cases).map_err(|e| e.to_string())?;
let answers = parity::run_oracle("pdfgutter.py", &text)?;
let path = dir.join("cases.ndjson");
std::fs::write(&path, answers).map_err(|e| e.to_string())?;
Ok(vec![path.display().to_string()])
}
fn avplan() -> Written {
let dir = parity::golden("avplan");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
+143
View File
@@ -0,0 +1,143 @@
//! The arithmetic that decides a two-up scan must match the Python exactly.
//!
//! The consequence of getting it wrong is not a wrong page count — it is a
//! book whose text comes out interleaved line by line between two pages,
//! which reads as prose and is not. Nobody notices that in a corpus until
//! they read a retrieved passage and find it is nonsense.
use serde_json::Value;
fn records(text: &str) -> Vec<Value> {
text.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("valid JSON"))
.collect()
}
fn cases() -> Vec<Value> {
let text = std::fs::read_to_string(parity::dir().join("cases/pdfgutter/cases.ndjson"))
.expect("a readable case file");
records(&text)
}
fn floats(value: &Value) -> Vec<f64> {
value
.as_array()
.expect("an array")
.iter()
.map(|number| number.as_f64().expect("a number"))
.collect()
}
#[test]
fn every_answer_matches_python() {
let golden = records(&parity::read_golden_text("pdfgutter/cases.ndjson"));
let cases = cases();
assert_eq!(cases.len(), golden.len(), "case count moved — regenerate");
let mut wrong: Vec<String> = Vec::new();
for (case, theirs) in cases.iter().zip(&golden) {
let name = case["name"].as_str().expect("a name");
let kind = case["kind"].as_str().expect("a kind");
assert_eq!(theirs["name"].as_str(), Some(name), "cases out of order");
assert_eq!(theirs["kind"].as_str(), Some(kind), "cases out of order");
let mut note = |field: &str, ours: String, theirs: &Value| {
wrong.push(format!("{name}.{field}: python {theirs}, rust {ours}"));
};
match kind {
"ratio" => {
let ours = pdfgutter::aspect_ratio(
case["width"].as_f64().expect("a width"),
case["height"].as_f64().expect("a height"),
);
if Some(ours) != theirs["ratio"].as_f64() {
note("ratio", ours.to_string(), &theirs["ratio"]);
}
}
"gutter" => {
let ours = pdfgutter::has_gutter(&floats(&case["ink"]));
if Some(ours) != theirs["gutter"].as_bool() {
note("gutter", ours.to_string(), &theirs["gutter"]);
}
}
"sample" => {
let ours = pdfgutter::sample_indices(
case["pages"].as_u64().expect("a page count") as usize
);
let theirs_indices: Vec<usize> = theirs["indices"]
.as_array()
.expect("an array")
.iter()
.map(|index| index.as_u64().expect("a number") as usize)
.collect();
if ours != theirs_indices {
note("indices", format!("{ours:?}"), &theirs["indices"]);
}
}
"verdict" => {
let ratios = floats(&case["ratios"]);
let gutters: Vec<bool> = case["gutters"]
.as_array()
.expect("an array")
.iter()
.map(|found| found.as_bool().expect("a boolean"))
.collect();
let median = pdfgutter::median(&ratios);
if Some(median) != theirs["median_ratio"].as_f64() {
note("median_ratio", median.to_string(), &theirs["median_ratio"]);
}
let fraction = pdfgutter::gutter_fraction(&gutters);
if Some(fraction) != theirs["gutter_fraction"].as_f64() {
note(
"gutter_fraction",
fraction.to_string(),
&theirs["gutter_fraction"],
);
}
let two_up = pdfgutter::is_two_up(&ratios, &gutters);
if Some(two_up) != theirs["two_up"].as_bool() {
note("two_up", two_up.to_string(), &theirs["two_up"]);
}
}
other => panic!("nothing answers a {other} case"),
}
}
assert!(
wrong.is_empty(),
"{} answers differ:\n{}",
wrong.len(),
wrong
.iter()
.take(20)
.map(|line| line.chars().take(200).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
);
}
/// All four questions have to be asked, or the file proves less than it looks
/// like it does.
#[test]
fn every_kind_of_case_is_present() {
let cases = cases();
for kind in ["ratio", "gutter", "sample", "verdict"] {
assert!(
cases.iter().any(|case| case["kind"] == kind),
"no {kind} case"
);
}
}
/// Catches a golden file drifting away from a Python that has moved on.
#[test]
#[cfg(feature = "python")]
fn the_golden_file_still_matches_live_python() {
let cases = std::fs::read_to_string(parity::dir().join("cases/pdfgutter/cases.ndjson"))
.expect("a readable case file");
let live = parity::run_oracle("pdfgutter.py", &cases).expect("the oracle runs");
assert_eq!(live, parity::read_golden_text("pdfgutter/cases.ndjson"));
}