mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-23 03:07:13 +00:00
pdfgutter: never rewrite a page tree it cannot account for
This commit is contained in:
+55
-6
@@ -217,13 +217,62 @@ 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.
|
||||
pages get sampled, the reduction from pixels to a column profile, whether a
|
||||
given profile has a gutter, and the verdict a set of ratios and gutters adds
|
||||
up to. Both sides are handed the same pixels and 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.
|
||||
The two renderers do have to be pointed at the same rectangle, which is what
|
||||
`-cropbox` is for — see the next note.
|
||||
|
||||
**Measured:** 94 cases, including profiles either side of the ink threshold by
|
||||
one ten-thousandth, profiles too narrow to hold the three bands the test looks
|
||||
at, and greymaps whose heights do not divide evenly so that both sides have to
|
||||
round a fraction the same way.
|
||||
|
||||
## pdfgutter — the page is the crop box, consistently
|
||||
|
||||
**Python:** takes the aspect ratio from the media box and the ink profile
|
||||
from `page.render()`, which draws the **crop box**. On a page that carries
|
||||
both — most scanned books do — those are two different rectangles, so the
|
||||
shape is measured on one and the ink on the other. The bands the gutter is
|
||||
looked for in then fall somewhere other than the middle of what was
|
||||
rendered, and a real gutter is missed.
|
||||
|
||||
**Rust:** the crop box is what a reader shows, so it is what everything
|
||||
measures: the ratio, the render (`pdftoppm -cropbox`), and where the cut
|
||||
goes. A page with no crop box is its media box, which is what the spec says
|
||||
and what most files are.
|
||||
|
||||
**Why this is acceptable:** the original is not internally consistent, and on
|
||||
a wide sheet cropped to show only one of its two pages it gets the answer
|
||||
wrong in the destructive direction — the sheet still measures wide, so it is
|
||||
split again, down the middle of a page rather than between two. Measuring one
|
||||
rectangle throughout is the only version of this that is right.
|
||||
|
||||
**Measured:** a wide sheet cropped to one page is no longer a split
|
||||
candidate, and a cropped spread's gutter lands where it is looked for. The
|
||||
arithmetic that both sides share is compared exactly, as above.
|
||||
|
||||
## pdfgutter — a page tree that does not add up is refused
|
||||
|
||||
**Python:** pdfium enumerates the pages and the question never comes up.
|
||||
|
||||
**Rust:** the page tree is walked directly, and a walk is stricter than a
|
||||
renderer: a node missing its `/Type`, a `/Type` written as a reference, a kid
|
||||
that is a dictionary rather than a reference to one — each of those is a page
|
||||
a strict walk does not see and a reader does. So the number of pages found is
|
||||
checked against the number the file says it has, and a document where they
|
||||
disagree is refused with exit 2.
|
||||
|
||||
**Why this is acceptable:** splitting replaces the page tree, and a page left
|
||||
out of the new tree is a page deleted. A file this does not fully understand
|
||||
is one it must not rewrite. Refusing costs a document that could perhaps have
|
||||
been handled; not refusing costs pages, silently, in the copy you keep.
|
||||
|
||||
**Measured:** five shapes that a strict walk under-counts, each refused rather
|
||||
than written short. A document with nothing wide enough to cut is not
|
||||
rewritten at all, so every page comes through however the tree is built.
|
||||
|
||||
## pdfgutter — the thresholds are arguments, not environment variables
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -70,3 +70,25 @@
|
||||
{"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}
|
||||
{"kind": "greymap", "name": "black-and-white-columns", "profile": [1.0, 0.0, 1.0, 0.0]}
|
||||
{"kind": "greymap", "name": "either-side-of-the-threshold", "profile": [1.0, 1.0, 0.0]}
|
||||
{"kind": "greymap", "name": "the-threshold-itself-everywhere", "profile": [0.0, 0.0, 0.0, 0.0, 0.0]}
|
||||
{"kind": "greymap", "name": "one-below-the-threshold-everywhere", "profile": [1.0, 1.0, 1.0, 1.0, 1.0]}
|
||||
{"kind": "greymap", "name": "a-page-with-a-gutter", "profile": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.35, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]}
|
||||
{"kind": "greymap", "name": "a-single-row", "profile": [1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0]}
|
||||
{"kind": "greymap", "name": "a-single-column", "profile": [0.5555555555555556]}
|
||||
{"kind": "greymap", "name": "awkward-7x3", "profile": [1.0, 1.0, 1.0, 1.0, 0.6666666666666666, 0.0, 0.0]}
|
||||
{"kind": "greymap", "name": "awkward-11x7", "profile": [1.0, 1.0, 1.0, 0.7142857142857143, 0.2857142857142857, 0.0, 0.42857142857142855, 1.0, 1.0, 1.0, 0.7142857142857143]}
|
||||
{"kind": "greymap", "name": "awkward-13x13", "profile": [1.0, 0.9230769230769231, 0.6153846153846154, 0.38461538461538464, 0.38461538461538464, 0.46153846153846156, 0.6923076923076923, 1.0, 0.8461538461538461, 0.6153846153846154, 0.38461538461538464, 0.3076923076923077, 0.46153846153846156]}
|
||||
{"kind": "greymap", "name": "awkward-3x101", "profile": [0.6534653465346535, 0.6534653465346535, 0.6534653465346535]}
|
||||
{"kind": "greymap", "name": "awkward-101x3", "profile": [1.0, 1.0, 1.0, 1.0, 0.6666666666666666, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.3333333333333333, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.3333333333333333, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.3333333333333333, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.3333333333333333, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.3333333333333333, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.3333333333333333, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.3333333333333333, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.6666666666666666, 1.0, 1.0, 1.0, 0.6666666666666666, 0.0, 0.0, 0.6666666666666666, 1.0, 1.0, 1.0, 0.6666666666666666, 0.0, 0.0, 0.6666666666666666, 1.0, 1.0, 1.0, 0.6666666666666666, 0.0, 0.0, 0.6666666666666666, 1.0, 1.0, 1.0, 0.6666666666666666, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.3333333333333333, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]}
|
||||
{"kind": "greymap", "name": "random-grey-0", "profile": [0.5, 0.5]}
|
||||
{"kind": "greymap", "name": "random-grey-1", "profile": [0.625, 0.640625, 0.6875, 0.609375, 0.65625]}
|
||||
{"kind": "greymap", "name": "random-grey-2", "profile": [0.3333333333333333, 0.3333333333333333, 0.6666666666666666, 0.3333333333333333, 0.3333333333333333, 0.3333333333333333, 0.6666666666666666, 1.0, 0.0, 0.6666666666666666, 0.6666666666666666, 0.3333333333333333, 0.6666666666666666, 0.6666666666666666, 0.6666666666666666, 0.0, 0.6666666666666666, 0.3333333333333333, 0.6666666666666666, 0.3333333333333333, 0.6666666666666666, 0.6666666666666666, 0.6666666666666666, 0.6666666666666666, 0.6666666666666666, 0.0, 0.6666666666666666, 0.3333333333333333, 0.6666666666666666, 1.0, 0.6666666666666666, 0.6666666666666666, 0.3333333333333333, 0.6666666666666666, 0.3333333333333333, 0.3333333333333333, 1.0, 0.3333333333333333, 0.3333333333333333, 0.3333333333333333, 0.6666666666666666, 1.0, 0.3333333333333333, 0.6666666666666666, 0.6666666666666666, 1.0, 0.6666666666666666, 1.0, 1.0, 0.3333333333333333, 0.3333333333333333, 0.3333333333333333, 0.6666666666666666, 0.3333333333333333, 0.6666666666666666, 0.6666666666666666, 0.3333333333333333, 0.0, 0.6666666666666666, 0.6666666666666666, 0.3333333333333333, 0.3333333333333333, 0.6666666666666666, 0.6666666666666666]}
|
||||
{"kind": "greymap", "name": "random-grey-3", "profile": [0.3333333333333333, 0.6666666666666666, 0.3333333333333333, 1.0, 1.0, 0.3333333333333333, 1.0, 0.6666666666666666, 0.3333333333333333, 0.6666666666666666, 0.3333333333333333, 0.6666666666666666, 0.3333333333333333, 0.6666666666666666, 0.6666666666666666, 0.3333333333333333, 0.3333333333333333, 0.3333333333333333, 1.0, 0.6666666666666666, 0.6666666666666666, 1.0, 0.6666666666666666, 0.3333333333333333, 0.3333333333333333, 0.6666666666666666, 0.6666666666666666, 0.6666666666666666, 1.0, 0.6666666666666666, 0.0, 0.6666666666666666, 0.3333333333333333, 0.3333333333333333, 1.0, 1.0, 0.3333333333333333, 0.3333333333333333, 0.3333333333333333, 0.6666666666666666, 0.6666666666666666, 0.6666666666666666, 0.3333333333333333, 1.0, 0.3333333333333333, 0.6666666666666666, 0.3333333333333333, 0.6666666666666666, 0.3333333333333333, 0.3333333333333333, 0.3333333333333333, 0.6666666666666666, 0.6666666666666666, 1.0, 0.3333333333333333, 0.3333333333333333, 1.0, 0.6666666666666666, 0.6666666666666666, 0.6666666666666666, 1.0, 1.0, 0.6666666666666666, 0.6666666666666666]}
|
||||
{"kind": "greymap", "name": "random-grey-4", "profile": [0.0, 0.5]}
|
||||
{"kind": "greymap", "name": "random-grey-5", "profile": [0.4, 1.0, 0.6, 0.8, 0.4, 0.8, 0.4, 0.6]}
|
||||
{"kind": "greymap", "name": "random-grey-6", "profile": [0.59375, 0.546875, 0.625, 0.5, 0.703125]}
|
||||
{"kind": "greymap", "name": "random-grey-7", "profile": [0.671875, 0.671875, 0.609375, 0.625, 0.671875]}
|
||||
{"kind": "greymap", "name": "random-grey-8", "profile": [0.8, 0.8, 0.8, 0.8, 0.4, 0.6, 0.6, 0.8]}
|
||||
{"kind": "greymap", "name": "random-grey-9", "profile": [0.671875, 0.671875, 0.640625, 0.484375, 0.703125]}
|
||||
|
||||
@@ -21,12 +21,19 @@ import numpy as np
|
||||
_MIN_RATIO = 1.15
|
||||
_MIN_GUTTER_FRAC = 0.6
|
||||
_TEXT_COL_INK = 0.02
|
||||
_INK_THRESHOLD = 160
|
||||
|
||||
|
||||
def _ratio(width: float, height: float) -> float:
|
||||
return width / height if height else 0.0
|
||||
|
||||
|
||||
def _col_ink_profile(pixels: list[int], width: int, height: int) -> list[float]:
|
||||
"""The reduction the renderer's output went through, minus the render."""
|
||||
arr = np.asarray(pixels, dtype=np.uint8).reshape(height, width)
|
||||
return [float(x) for x in (arr < _INK_THRESHOLD).mean(axis=0)]
|
||||
|
||||
|
||||
def _has_gutter(ink: np.ndarray) -> bool:
|
||||
w = len(ink)
|
||||
center = ink[int(0.46 * w):int(0.54 * w)]
|
||||
@@ -67,6 +74,10 @@ def answer(case: dict) -> dict:
|
||||
return {"gutter": _has_gutter(np.asarray(case["ink"], dtype=float))}
|
||||
if kind == "sample":
|
||||
return {"indices": _sample_indices(case["pages"])}
|
||||
if kind == "greymap":
|
||||
return {
|
||||
"profile": _col_ink_profile(case["pixels"], case["width"], case["height"])
|
||||
}
|
||||
if kind == "verdict":
|
||||
return _verdict(case["ratios"], case["gutters"])
|
||||
raise SystemExit(f"no such case: {kind}")
|
||||
|
||||
@@ -76,6 +76,28 @@ fn every_answer_matches_python() {
|
||||
note("indices", format!("{ours:?}"), &theirs["indices"]);
|
||||
}
|
||||
}
|
||||
"greymap" => {
|
||||
let width = case["width"].as_u64().expect("a width") as usize;
|
||||
let height = case["height"].as_u64().expect("a height") as usize;
|
||||
let pixels: Vec<u8> = case["pixels"]
|
||||
.as_array()
|
||||
.expect("an array")
|
||||
.iter()
|
||||
.map(|value| value.as_u64().expect("a number") as u8)
|
||||
.collect();
|
||||
|
||||
// The renderer is not the thing under test; the reduction it
|
||||
// feeds is. Both sides are handed the same pixels.
|
||||
let mut greymap = format!("P5\n{width} {height}\n255\n").into_bytes();
|
||||
greymap.extend_from_slice(&pixels);
|
||||
let ours = pdfgutter::ink::profile(&greymap, pdfgutter::INK_THRESHOLD)
|
||||
.expect("a readable greymap");
|
||||
|
||||
let theirs_profile = floats(&theirs["profile"]);
|
||||
if ours != theirs_profile {
|
||||
note("profile", format!("{ours:?}"), &theirs["profile"]);
|
||||
}
|
||||
}
|
||||
"verdict" => {
|
||||
let ratios = floats(&case["ratios"]);
|
||||
let gutters: Vec<bool> = case["gutters"]
|
||||
@@ -124,7 +146,7 @@ fn every_answer_matches_python() {
|
||||
#[test]
|
||||
fn every_kind_of_case_is_present() {
|
||||
let cases = cases();
|
||||
for kind in ["ratio", "gutter", "sample", "verdict"] {
|
||||
for kind in ["ratio", "gutter", "sample", "verdict", "greymap"] {
|
||||
assert!(
|
||||
cases.iter().any(|case| case["kind"] == kind),
|
||||
"no {kind} case"
|
||||
|
||||
Reference in New Issue
Block a user