From 619672d40352fd036cad1402d0f9ad7579426816 Mon Sep 17 00:00:00 2001 From: funkpower Date: Sat, 19 Sep 2026 16:51:12 -0400 Subject: [PATCH] pdfgutter: never rewrite a page tree it cannot account for --- crates/pdfgutter/README.md | 30 ++- crates/pdfgutter/src/lib.rs | 41 ++++- crates/pdfgutter/src/main.rs | 2 +- crates/pdfgutter/src/pdf.rs | 265 ++++++++++++++++++++++----- crates/pdfgutter/tests/split.rs | 117 ++++++++++++ parity/KNOWN.md | 61 +++++- parity/cases/pdfgutter/cases.ndjson | 22 +++ parity/golden/pdfgutter/cases.ndjson | 22 +++ parity/oracle/pdfgutter.py | 11 ++ parity/tests/pdfgutter.rs | 24 ++- 10 files changed, 534 insertions(+), 61 deletions(-) diff --git a/crates/pdfgutter/README.md b/crates/pdfgutter/README.md index 459f601..9adc262 100644 --- a/crates/pdfgutter/README.md +++ b/crates/pdfgutter/README.md @@ -12,8 +12,9 @@ lines interleave, the reading order comes out scrambled, and what lands in the index looks like prose and is not. `pdfgutter` says whether a PDF is that, and splits it when it is. The split -changes the page boxes and nothing else — no re-rendering, no loss, and a -250-sheet book becomes a 500-page book at almost no cost in size. +changes the page boxes and nothing else — nothing is re-rendered, the text and +the images are the ones that were there, and a 250-sheet book becomes a +500-page book at almost no cost in size. ## When you would use it @@ -30,7 +31,8 @@ changes the page boxes and nothing else — no re-rendering, no loss, and a cargo install --git https://git.pumpin.online/funkpower/catena pdfgutter ``` -Detection needs `pdftoppm`, from poppler. Splitting needs nothing. +Needs `pdftoppm`, from poppler. Nothing is split without first being looked +at, so it is needed either way. ## Example @@ -91,8 +93,9 @@ done | `--min-ratio ` | How wide a sheet must be to hold two pages. Default `1.15` | | `--gutter-fraction ` | What share of wide sheets must show a gutter. Default `0.6` | -`--into` writes whatever the verdict was: a document that is not two-up is -copied through unchanged, so a loop over a directory does not have to branch. +`--into` writes whatever the verdict was, so a loop over a directory does not +have to branch. A document with no sheet wide enough to cut is not altered at +all — every page comes through as it was. ## Exit codes @@ -100,7 +103,7 @@ copied through unchanged, so a loop over a directory does not have to branch. |---|---| | `0` | Two pages to a sheet | | `1` | One page to a sheet | -| `2` | It could not be judged — encrypted, or no pages | +| `2` | It could not be judged — encrypted, no pages, or a page tree that does not add up | | `64` | The command line was wrong | | `69` | `pdftoppm` is not installed | | `74` | The file could not be read or written | @@ -135,11 +138,24 @@ the answer for a whole book. Each wide sheet becomes two pages pointing at the same content, differing only in how much of it they show. A sheet the file asks to be turned on its side is cut across rather than down, and the halves come out in the order a reader -would read them. +would read them. An annotation is kept on the half it sits on rather than +copied onto both. Splitting is safe to repeat: the halves are portrait, so a second pass finds nothing to do. +Splitting replaces the page tree, which means a page this could not find in +that tree would be a page dropped from the result. So the number of pages +found is checked against the number the file says it has, and a document where +those disagree is refused rather than written short. + +### What it measures + +The crop box, where a page has one — that is the rectangle a reader shows and +the one that gets rendered. A wide sheet cropped down to a single page is +therefore not a spread, which is what stops a second pass cutting a page in +half. Pages with no crop box, which is most of them, are their media box. + ## As a library ```rust diff --git a/crates/pdfgutter/src/lib.rs b/crates/pdfgutter/src/lib.rs index 3a33039..dce722b 100644 --- a/crates/pdfgutter/src/lib.rs +++ b/crates/pdfgutter/src/lib.rs @@ -142,6 +142,12 @@ pub fn median(values: &[f64]) -> f64 { if values.is_empty() { return 0.0; } + // A run with a number that is not one has no middle. Answering anyway + // would put a sheet's shape on the wrong side of a threshold on the + // strength of a value that means nothing. + if values.iter().any(|value| value.is_nan()) { + return f64::NAN; + } let mut sorted = values.to_vec(); sorted.sort_by(f64::total_cmp); let middle = sorted.len() / 2; @@ -156,8 +162,20 @@ pub fn median(values: &[f64]) -> f64 { /// `gutters` holds one answer per sampled page that was wide enough to look /// at, which is why it is usually shorter than `ratios`. pub fn is_two_up(ratios: &[f64], gutters: &[bool]) -> bool { - let median_ratio = median(ratios); - median_ratio > MIN_RATIO && gutter_fraction(gutters) >= MIN_GUTTER_FRACTION + two_up_above(ratios, gutters, MIN_RATIO, MIN_GUTTER_FRACTION) +} + +/// The same question against thresholds of your own. +/// +/// The one place the answer is decided, so that a command offering its own +/// thresholds cannot come to decide it a second and slightly different way. +pub fn two_up_above( + ratios: &[f64], + gutters: &[bool], + min_ratio: f64, + min_gutter_fraction: f64, +) -> bool { + median(ratios) > min_ratio && gutter_fraction(gutters) >= min_gutter_fraction } /// What share of the pages looked at showed a gutter. @@ -281,6 +299,25 @@ mod tests { assert_eq!(median(&[]), 0.0); } + #[test] + fn a_run_with_no_number_in_it_has_no_middle() { + assert!(median(&[1.0, f64::NAN, 3.0]).is_nan()); + // And a shape that is not a number is not a wide sheet. + assert!(!is_two_up(&[f64::NAN, 1.4], &[true; 4])); + } + + #[test] + fn thresholds_of_your_own_decide_it_the_same_way() { + let ratios = [1.4; 8]; + let gutters = [true, true, true, false, false, false, false, false]; + assert_eq!( + is_two_up(&ratios, &gutters), + two_up_above(&ratios, &gutters, MIN_RATIO, MIN_GUTTER_FRACTION) + ); + // Three in eight clears a threshold of three in ten. + assert!(two_up_above(&ratios, &gutters, MIN_RATIO, 0.3)); + } + #[test] fn a_document_needs_both_the_shape_and_the_gutter() { let wide = [1.4; 8]; diff --git a/crates/pdfgutter/src/main.rs b/crates/pdfgutter/src/main.rs index 01d2f53..5bcf5d9 100644 --- a/crates/pdfgutter/src/main.rs +++ b/crates/pdfgutter/src/main.rs @@ -144,7 +144,7 @@ fn measure(args: &Args, pdf: &pdfgutter::pdf::Pdf) -> Result Ok(Verdict { v: corpus_core::V, path: args.path.display().to_string(), - two_up: median_ratio > args.min_ratio && gutter_fraction >= args.gutter_fraction, + two_up: pdfgutter::two_up_above(&ratios, &gutters, args.min_ratio, args.gutter_fraction), pages: pdf.len(), median_ratio, gutter_fraction, diff --git a/crates/pdfgutter/src/pdf.rs b/crates/pdfgutter/src/pdf.rs index 1f7b906..5f7b544 100644 --- a/crates/pdfgutter/src/pdf.rs +++ b/crates/pdfgutter/src/pdf.rs @@ -7,7 +7,7 @@ use std::fmt; use std::path::Path; -use lopdf::{Document, Object, ObjectId}; +use lopdf::{Dictionary, Document, Object, ObjectId}; /// The four page attributes a page may inherit from the tree above it. const INHERITED: [&[u8]; 4] = [b"Resources", b"MediaBox", b"CropBox", b"Rotate"]; @@ -33,6 +33,14 @@ pub enum Error { Encrypted, /// It has no pages. Empty, + /// The page tree does not add up: the file says one number of pages and + /// walking it finds another. + Miscounted { + /// What the file says. + declared: usize, + /// What walking the tree found. + found: usize, + }, /// It could not be written. Unwritable(String), } @@ -43,6 +51,10 @@ impl fmt::Display for Error { Error::Unreadable(why) => write!(f, "{why}"), Error::Encrypted => write!(f, "it is encrypted"), Error::Empty => write!(f, "it has no pages"), + Error::Miscounted { declared, found } => write!( + f, + "it says it has {declared} pages and walking it finds {found}" + ), Error::Unwritable(why) => write!(f, "{why}"), } } @@ -55,16 +67,40 @@ impl std::error::Error for Error {} pub struct Geometry { /// The media box, left, bottom, right, top, with the corners in order. pub media: [f64; 4], + /// The crop box, where the page names one. This is what a reader shows + /// and what a renderer draws, so it is what everything here measures. + pub crop: Option<[f64; 4]>, /// A quarter turn clockwise per unit, as the file asks for it. pub rotate: i64, } impl Geometry { - /// Width and height as displayed, which is the media box turned by - /// whatever the page's rotation asks for. + /// The rectangle a reader actually shows. + /// + /// A crop box outside the media box is meaningless, so it is clipped to + /// it, and one that clips away the whole page is ignored. + pub fn visible(self) -> [f64; 4] { + let Some(crop) = self.crop else { + return self.media; + }; + let clipped = [ + crop[0].max(self.media[0]), + crop[1].max(self.media[1]), + crop[2].min(self.media[2]), + crop[3].min(self.media[3]), + ]; + match clipped[2] > clipped[0] && clipped[3] > clipped[1] { + true => clipped, + false => self.media, + } + } + + /// Width and height as displayed, which is the visible rectangle turned + /// by whatever the page's rotation asks for. pub fn displayed(self) -> (f64, f64) { - let width = self.media[2] - self.media[0]; - let height = self.media[3] - self.media[1]; + let visible = self.visible(); + let width = visible[2] - visible[0]; + let height = visible[3] - visible[1]; match self.quarter_turns() % 2 { 0 => (width, height), _ => (height, width), @@ -89,7 +125,7 @@ impl Geometry { /// Which way to cut depends on the rotation: a sheet turned on its side /// has its two pages one above the other in the file, however they look. pub fn halves(self) -> [[f64; 4]; 2] { - let [left, bottom, right, top] = self.media; + let [left, bottom, right, top] = self.visible(); let middle_x = (left + right) / 2.0; let middle_y = (bottom + top) / 2.0; @@ -115,16 +151,30 @@ pub struct Pdf { impl Pdf { /// Open a file. + /// + /// Refuses a document whose page tree does not add up. Walking a tree and + /// finding fewer pages than the file says it has means there is a page + /// here this does not understand, and the one thing that must not then + /// happen is rewriting the tree around it — a page left out of the new + /// tree is a page deleted. pub fn open(path: &Path) -> Result { let document = Document::load(path).map_err(|e| Error::Unreadable(e.to_string()))?; if document.is_encrypted() { return Err(Error::Encrypted); } let pages: Vec = document.get_pages().into_values().collect(); - match pages.is_empty() { - true => Err(Error::Empty), - false => Ok(Pdf { document, pages }), + if pages.is_empty() { + return Err(Error::Empty); } + if let Some(declared) = declared_pages(&document) { + if declared != pages.len() { + return Err(Error::Miscounted { + declared, + found: pages.len(), + }); + } + } + Ok(Pdf { document, pages }) } /// How many pages it has. @@ -144,12 +194,19 @@ impl Pdf { .inherited(id, b"MediaBox") .and_then(|object| rectangle(&self.document, object)) .unwrap_or(DEFAULT_BOX); + let crop = self + .inherited(id, b"CropBox") + .and_then(|object| rectangle(&self.document, object)); let rotate = self .inherited(id, b"Rotate") .and_then(|object| self.document.dereference(object).ok()) .and_then(|(_, object)| object.as_i64().ok()) .unwrap_or(0); - Geometry { media, rotate } + Geometry { + media, + crop, + rotate, + } } /// An attribute of a page, from the page itself or from the tree above it. @@ -169,35 +226,46 @@ impl Pdf { /// rest alone. /// /// Returns how many pages the document now has. + /// Cut every sheet wide enough to hold two pages into two, and leave the + /// rest alone. + /// + /// Returns how many pages the document now has. A document with nothing + /// to cut is not touched at all, so writing it back out is a copy. pub fn split(&mut self, min_ratio: f64) -> usize { + let wide: Vec = (0..self.pages.len()) + .map(|index| self.geometry(index).ratio() > min_ratio) + .collect(); + if !wide.iter().any(|wide| *wide) { + return self.pages.len(); + } + // Whatever a page would have inherited has to be written onto it // first, because the tree it would have inherited from is about to be // replaced by a flat one. self.settle_inherited(); let mut order: Vec = Vec::with_capacity(self.pages.len()); - for index in 0..self.pages.len() { + for (index, wide) in wide.iter().enumerate() { let id = self.pages[index]; - let geometry = self.geometry(index); - if geometry.ratio() <= min_ratio { + if !wide { order.push(id); continue; } - let [first, second] = geometry.halves(); - let twin = match self.document.get_dictionary(id) { - Ok(dictionary) => dictionary.clone(), - Err(_) => { - order.push(id); - continue; - } + // Both halves are new objects and the original is left as it was, + // so a page the tree happens to list twice is cut twice rather + // than cut once and then measured after the fact. + let Ok(original) = self.document.get_dictionary(id).cloned() else { + order.push(id); + continue; }; - let twin = self.document.add_object(Object::Dictionary(twin)); - - self.set_box(id, first); - self.set_box(twin, second); - order.push(id); - order.push(twin); + let geometry = self.geometry(index); + for half in geometry.halves() { + let mut page = original.clone(); + narrow(&mut page, half); + keep_annotations_within(&self.document, &mut page, half); + order.push(self.document.add_object(Object::Dictionary(page))); + } } self.reparent(&order); @@ -229,24 +297,6 @@ impl Pdf { } } - /// Point a page at half the sheet. - fn set_box(&mut self, page: ObjectId, rectangle: [f64; 4]) { - let Ok(dictionary) = self.document.get_dictionary_mut(page) else { - return; - }; - let value: Vec = rectangle - .iter() - .map(|edge| Object::Real(*edge as f32)) - .collect(); - dictionary.set("MediaBox", Object::Array(value.clone())); - dictionary.set("CropBox", Object::Array(value)); - // These describe a region inside the old page, and half of them now - // lie outside it entirely. - for key in NARROWING { - dictionary.remove(key); - } - } - /// Replace the page tree with one flat node holding the pages in order. fn reparent(&mut self, order: &[ObjectId]) { let Ok(root) = self @@ -272,6 +322,79 @@ impl Pdf { } } +/// Point a page dictionary at half the sheet. +fn narrow(page: &mut Dictionary, rectangle: [f64; 4]) { + let value: Vec = rectangle + .iter() + .map(|edge| Object::Real(*edge as f32)) + .collect(); + page.set("MediaBox", Object::Array(value.clone())); + page.set("CropBox", Object::Array(value)); + // These describe a region inside the old page, and half of them now lie + // outside it entirely. + for key in NARROWING { + page.remove(key); + } +} + +/// Drop the annotations that belong to the other half. +/// +/// A link sitting on the left page would otherwise be attached to the right +/// one as well, where a reader would show it over whatever text is now in +/// that position. +fn keep_annotations_within(document: &Document, page: &mut Dictionary, half: [f64; 4]) { + let Ok(annotations) = page.get(b"Annots").and_then(|object| { + document + .dereference(object) + .map(|(_, object)| object) + .and_then(Object::as_array) + }) else { + return; + }; + + let kept: Vec = annotations + .iter() + .filter(|annotation| { + document + .dereference(annotation) + .ok() + .and_then(|(_, object)| object.as_dict().ok()) + .and_then(|dictionary| dictionary.get(b"Rect").ok()) + .and_then(|object| rectangle(document, object)) + .is_none_or(|rect| centre_within(rect, half)) + }) + .cloned() + .collect(); + + match kept.is_empty() { + true => { + page.remove(b"Annots"); + } + false => page.set("Annots", Object::Array(kept)), + } +} + +/// Whether one rectangle's middle sits inside another. +/// +/// The middle rather than any overlap, because an annotation straddling the +/// gutter has to land on one page and not both. +fn centre_within(rect: [f64; 4], half: [f64; 4]) -> bool { + let (x, y) = ((rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0); + (half[0]..=half[2]).contains(&x) && (half[1]..=half[3]).contains(&y) +} + +/// How many pages the file says it has. +fn declared_pages(document: &Document) -> Option { + let root = document + .catalog() + .and_then(|catalog| catalog.get(b"Pages")) + .and_then(Object::as_reference) + .ok()?; + let count = document.get_dictionary(root).ok()?.get(b"Count").ok()?; + let (_, count) = document.dereference(count).ok()?; + usize::try_from(count.as_i64().ok()?).ok() +} + /// Four numbers, in the order a PDF rectangle gives them, with the corners /// put back in order. /// @@ -307,10 +430,64 @@ mod tests { fn sheet(rotate: i64) -> Geometry { Geometry { media: [0.0, 0.0, 1000.0, 700.0], + crop: None, rotate, } } + fn cropped(crop: [f64; 4]) -> Geometry { + Geometry { + media: [0.0, 0.0, 1000.0, 700.0], + crop: Some(crop), + rotate: 0, + } + } + + #[test] + fn the_visible_page_is_the_crop_box_where_there_is_one() { + assert_eq!( + cropped([200.0, 0.0, 1000.0, 700.0]).visible(), + [200.0, 0.0, 1000.0, 700.0] + ); + assert_eq!(sheet(0).visible(), [0.0, 0.0, 1000.0, 700.0]); + // And the cut goes down the middle of what is shown, not of the sheet. + assert_eq!( + cropped([200.0, 0.0, 1000.0, 700.0]).halves()[0], + [200.0, 0.0, 600.0, 700.0] + ); + } + + #[test] + fn a_crop_box_outside_the_sheet_is_clipped_to_it() { + assert_eq!( + cropped([-50.0, -50.0, 2000.0, 900.0]).visible(), + [0.0, 0.0, 1000.0, 700.0] + ); + } + + #[test] + fn a_crop_box_that_leaves_nothing_is_ignored_rather_than_obeyed() { + // A box outside the sheet entirely would otherwise give a page with + // no area, which no reader shows and nothing can measure. + assert_eq!( + cropped([2000.0, 0.0, 3000.0, 700.0]).visible(), + [0.0, 0.0, 1000.0, 700.0] + ); + assert_eq!( + cropped([10.0, 10.0, 10.0, 10.0]).visible(), + [0.0, 0.0, 1000.0, 700.0] + ); + } + + #[test] + fn a_page_cropped_down_to_one_page_is_not_a_spread() { + // The shape that matters: a wide sheet whose crop box shows only one + // of the two pages on it. Measuring the sheet would split it again. + let one_page = cropped([500.0, 0.0, 1000.0, 700.0]); + assert_eq!(one_page.displayed(), (500.0, 700.0)); + assert!(one_page.ratio() < crate::MIN_RATIO); + } + #[test] fn an_upright_sheet_is_cut_left_then_right() { assert_eq!( diff --git a/crates/pdfgutter/tests/split.rs b/crates/pdfgutter/tests/split.rs index 6bb8ae3..099dfe2 100644 --- a/crates/pdfgutter/tests/split.rs +++ b/crates/pdfgutter/tests/split.rs @@ -236,6 +236,123 @@ fn splitting_twice_does_not_split_twice() { assert_eq!(split.len(), 6); } +/// A PDF assembled from object bodies, for the shapes the ordinary writer +/// above cannot make. +fn write_objects(path: &Path, objects: &[(usize, &str)], root: usize) { + let mut out: Vec = b"%PDF-1.4\n".to_vec(); + let top = objects.iter().map(|(n, _)| *n).max().unwrap_or(0) + 1; + let mut offsets = vec![0usize; top]; + for (number, body) in objects { + offsets[*number] = out.len(); + out.extend_from_slice(format!("{number} 0 obj\n{body}\nendobj\n").as_bytes()); + } + let start = out.len(); + out.extend_from_slice(format!("xref\n0 {top}\n0000000000 65535 f \n").as_bytes()); + for offset in offsets.iter().take(top).skip(1) { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice( + format!("trailer\n<< /Size {top} /Root {root} 0 R >>\nstartxref\n{start}\n%%EOF\n") + .as_bytes(), + ); + std::fs::write(path, out).expect("a writable file"); +} + +#[test] +fn a_page_tree_that_does_not_add_up_is_refused_rather_than_rewritten() { + // The inner node has no `/Type`, so a strict walk finds one page where + // the file says three. Rewriting the tree around pages it could not see + // would delete them, and the document would look like it had always been + // one page long. + let dir = scratch("miscounted"); + let path = dir.join("in.pdf"); + write_objects( + &path, + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>"), + (2, "<< /Type /Pages /Count 3 /Kids [4 0 R 3 0 R] >>"), + (3, "<< /Count 2 /Kids [6 0 R 8 0 R] /Parent 2 0 R >>"), + (4, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 480 720] >>"), + (6, "<< /Type /Page /Parent 3 0 R /MediaBox [0 0 480 720] >>"), + (8, "<< /Type /Page /Parent 3 0 R /MediaBox [0 0 480 720] >>"), + ], + 1, + ); + + match Pdf::open(&path) { + Err(pdfgutter::pdf::Error::Miscounted { declared, found }) => { + assert_eq!((declared, found), (3, 1)); + } + other => panic!("expected a refusal, got {:?}", other.map(|pdf| pdf.len())), + } +} + +#[test] +fn a_page_the_tree_lists_twice_is_cut_twice() { + // Cutting the original in place would leave the second listing measuring + // a page that had already been halved, and it would come through whole. + let dir = scratch("duplicated"); + let (source, destination) = (dir.join("in.pdf"), dir.join("out.pdf")); + write_objects( + &source, + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>"), + (2, "<< /Type /Pages /Count 2 /Kids [4 0 R 4 0 R] >>"), + ( + 4, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 1008 720] >>", + ), + ], + 1, + ); + + let mut pdf = Pdf::open(&source).expect("a readable PDF"); + assert_eq!(pdf.len(), 2); + assert_eq!(pdf.split(pdfgutter::MIN_RATIO), 4); + pdf.save(&destination).expect("a written PDF"); + + let split = Pdf::open(&destination).expect("a readable PDF"); + assert_eq!(split.len(), 4); + let widths: Vec = (0..4).map(|i| split.geometry(i).displayed().0).collect(); + assert_eq!(widths, [504.0, 504.0, 504.0, 504.0]); +} + +#[test] +fn a_document_with_nothing_to_cut_keeps_every_page() { + let dir = scratch("nothing-to-cut"); + let (source, destination) = (dir.join("in.pdf"), dir.join("out.pdf")); + write(&source, &[tall(), tall(), tall(), tall(), tall()]); + + let mut pdf = Pdf::open(&source).expect("a readable PDF"); + assert_eq!(pdf.split(pdfgutter::MIN_RATIO), 5); + pdf.save(&destination).expect("a written PDF"); + assert_eq!(Pdf::open(&destination).expect("a readable PDF").len(), 5); +} + +#[test] +fn a_page_cropped_to_one_of_its_two_halves_is_not_cut_again() { + // What running this over its own output would meet if the crop box were + // ignored: a sheet whose box is still wide, showing one page. + let dir = scratch("cropped"); + let path = dir.join("in.pdf"); + write_objects( + &path, + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>"), + (2, "<< /Type /Pages /Count 1 /Kids [4 0 R] >>"), + ( + 4, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 1008 720] /CropBox [504 0 1008 720] >>", + ), + ], + 1, + ); + + let mut pdf = Pdf::open(&path).expect("a readable PDF"); + assert_eq!(pdf.geometry(0).displayed(), (504.0, 720.0)); + assert_eq!(pdf.split(pdfgutter::MIN_RATIO), 1); +} + #[test] fn what_is_not_a_pdf_is_refused_rather_than_guessed_at() { let dir = scratch("refused"); diff --git a/parity/KNOWN.md b/parity/KNOWN.md index f9875e9..6481a57 100644 --- a/parity/KNOWN.md +++ b/parity/KNOWN.md @@ -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 diff --git a/parity/cases/pdfgutter/cases.ndjson b/parity/cases/pdfgutter/cases.ndjson index 095b921..208500d 100644 --- a/parity/cases/pdfgutter/cases.ndjson +++ b/parity/cases/pdfgutter/cases.ndjson @@ -70,3 +70,25 @@ {"name": "two-of-three", "kind": "verdict", "ratios": [1.4, 1.4, 1.4], "gutters": [true, true, false]} {"name": "just-over-the-ratio", "kind": "verdict", "ratios": [1.1501, 1.1501, 1.1501, 1.1501], "gutters": [true, true, true, true]} {"name": "exactly-the-ratio", "kind": "verdict", "ratios": [1.15, 1.15, 1.15, 1.15], "gutters": [true, true, true, true]} +{"name": "black-and-white-columns", "kind": "greymap", "width": 4, "height": 2, "pixels": [0, 255, 0, 255, 0, 255, 0, 255]} +{"name": "either-side-of-the-threshold", "kind": "greymap", "width": 3, "height": 1, "pixels": [158, 159, 160]} +{"name": "the-threshold-itself-everywhere", "kind": "greymap", "width": 5, "height": 4, "pixels": [160, 160, 160, 160, 160, 160, 160, 160, 160, 160, 160, 160, 160, 160, 160, 160, 160, 160, 160, 160]} +{"name": "one-below-the-threshold-everywhere", "kind": "greymap", "width": 5, "height": 4, "pixels": [159, 159, 159, 159, 159, 159, 159, 159, 159, 159, 159, 159, 159, 159, 159, 159, 159, 159, 159, 159]} +{"name": "a-page-with-a-gutter", "kind": "greymap", "width": 100, "height": 40, "pixels": [255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 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, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]} +{"name": "a-single-row", "kind": "greymap", "width": 17, "height": 1, "pixels": [0, 200, 200, 0, 200, 200, 0, 200, 200, 0, 200, 200, 0, 200, 200, 0, 200]} +{"name": "a-single-column", "kind": "greymap", "width": 1, "height": 9, "pixels": [0, 255, 0, 255, 0, 160, 159, 1, 254]} +{"name": "awkward-7x3", "kind": "greymap", "width": 7, "height": 3, "pixels": [0, 37, 74, 111, 148, 185, 222, 11, 48, 85, 122, 159, 196, 233, 22, 59, 96, 133, 170, 207, 244]} +{"name": "awkward-11x7", "kind": "greymap", "width": 11, "height": 7, "pixels": [0, 37, 74, 111, 148, 185, 222, 3, 40, 77, 114, 11, 48, 85, 122, 159, 196, 233, 14, 51, 88, 125, 22, 59, 96, 133, 170, 207, 244, 25, 62, 99, 136, 33, 70, 107, 144, 181, 218, 255, 36, 73, 110, 147, 44, 81, 118, 155, 192, 229, 10, 47, 84, 121, 158, 55, 92, 129, 166, 203, 240, 21, 58, 95, 132, 169, 66, 103, 140, 177, 214, 251, 32, 69, 106, 143, 180]} +{"name": "awkward-13x13", "kind": "greymap", "width": 13, "height": 13, "pixels": [0, 37, 74, 111, 148, 185, 222, 3, 40, 77, 114, 151, 188, 11, 48, 85, 122, 159, 196, 233, 14, 51, 88, 125, 162, 199, 22, 59, 96, 133, 170, 207, 244, 25, 62, 99, 136, 173, 210, 33, 70, 107, 144, 181, 218, 255, 36, 73, 110, 147, 184, 221, 44, 81, 118, 155, 192, 229, 10, 47, 84, 121, 158, 195, 232, 55, 92, 129, 166, 203, 240, 21, 58, 95, 132, 169, 206, 243, 66, 103, 140, 177, 214, 251, 32, 69, 106, 143, 180, 217, 254, 77, 114, 151, 188, 225, 6, 43, 80, 117, 154, 191, 228, 9, 88, 125, 162, 199, 236, 17, 54, 91, 128, 165, 202, 239, 20, 99, 136, 173, 210, 247, 28, 65, 102, 139, 176, 213, 250, 31, 110, 147, 184, 221, 2, 39, 76, 113, 150, 187, 224, 5, 42, 121, 158, 195, 232, 13, 50, 87, 124, 161, 198, 235, 16, 53, 132, 169, 206, 243, 24, 61, 98, 135, 172, 209, 246, 27, 64]} +{"name": "awkward-3x101", "kind": "greymap", "width": 3, "height": 101, "pixels": [0, 37, 74, 11, 48, 85, 22, 59, 96, 33, 70, 107, 44, 81, 118, 55, 92, 129, 66, 103, 140, 77, 114, 151, 88, 125, 162, 99, 136, 173, 110, 147, 184, 121, 158, 195, 132, 169, 206, 143, 180, 217, 154, 191, 228, 165, 202, 239, 176, 213, 250, 187, 224, 5, 198, 235, 16, 209, 246, 27, 220, 1, 38, 231, 12, 49, 242, 23, 60, 253, 34, 71, 8, 45, 82, 19, 56, 93, 30, 67, 104, 41, 78, 115, 52, 89, 126, 63, 100, 137, 74, 111, 148, 85, 122, 159, 96, 133, 170, 107, 144, 181, 118, 155, 192, 129, 166, 203, 140, 177, 214, 151, 188, 225, 162, 199, 236, 173, 210, 247, 184, 221, 2, 195, 232, 13, 206, 243, 24, 217, 254, 35, 228, 9, 46, 239, 20, 57, 250, 31, 68, 5, 42, 79, 16, 53, 90, 27, 64, 101, 38, 75, 112, 49, 86, 123, 60, 97, 134, 71, 108, 145, 82, 119, 156, 93, 130, 167, 104, 141, 178, 115, 152, 189, 126, 163, 200, 137, 174, 211, 148, 185, 222, 159, 196, 233, 170, 207, 244, 181, 218, 255, 192, 229, 10, 203, 240, 21, 214, 251, 32, 225, 6, 43, 236, 17, 54, 247, 28, 65, 2, 39, 76, 13, 50, 87, 24, 61, 98, 35, 72, 109, 46, 83, 120, 57, 94, 131, 68, 105, 142, 79, 116, 153, 90, 127, 164, 101, 138, 175, 112, 149, 186, 123, 160, 197, 134, 171, 208, 145, 182, 219, 156, 193, 230, 167, 204, 241, 178, 215, 252, 189, 226, 7, 200, 237, 18, 211, 248, 29, 222, 3, 40, 233, 14, 51, 244, 25, 62, 255, 36, 73, 10, 47, 84, 21, 58, 95, 32, 69, 106, 43, 80, 117, 54, 91, 128, 65, 102, 139, 76, 113, 150]} +{"name": "awkward-101x3", "kind": "greymap", "width": 101, "height": 3, "pixels": [0, 37, 74, 111, 148, 185, 222, 3, 40, 77, 114, 151, 188, 225, 6, 43, 80, 117, 154, 191, 228, 9, 46, 83, 120, 157, 194, 231, 12, 49, 86, 123, 160, 197, 234, 15, 52, 89, 126, 163, 200, 237, 18, 55, 92, 129, 166, 203, 240, 21, 58, 95, 132, 169, 206, 243, 24, 61, 98, 135, 172, 209, 246, 27, 64, 101, 138, 175, 212, 249, 30, 67, 104, 141, 178, 215, 252, 33, 70, 107, 144, 181, 218, 255, 36, 73, 110, 147, 184, 221, 2, 39, 76, 113, 150, 187, 224, 5, 42, 79, 116, 11, 48, 85, 122, 159, 196, 233, 14, 51, 88, 125, 162, 199, 236, 17, 54, 91, 128, 165, 202, 239, 20, 57, 94, 131, 168, 205, 242, 23, 60, 97, 134, 171, 208, 245, 26, 63, 100, 137, 174, 211, 248, 29, 66, 103, 140, 177, 214, 251, 32, 69, 106, 143, 180, 217, 254, 35, 72, 109, 146, 183, 220, 1, 38, 75, 112, 149, 186, 223, 4, 41, 78, 115, 152, 189, 226, 7, 44, 81, 118, 155, 192, 229, 10, 47, 84, 121, 158, 195, 232, 13, 50, 87, 124, 161, 198, 235, 16, 53, 90, 127, 22, 59, 96, 133, 170, 207, 244, 25, 62, 99, 136, 173, 210, 247, 28, 65, 102, 139, 176, 213, 250, 31, 68, 105, 142, 179, 216, 253, 34, 71, 108, 145, 182, 219, 0, 37, 74, 111, 148, 185, 222, 3, 40, 77, 114, 151, 188, 225, 6, 43, 80, 117, 154, 191, 228, 9, 46, 83, 120, 157, 194, 231, 12, 49, 86, 123, 160, 197, 234, 15, 52, 89, 126, 163, 200, 237, 18, 55, 92, 129, 166, 203, 240, 21, 58, 95, 132, 169, 206, 243, 24, 61, 98, 135, 172, 209, 246, 27, 64, 101, 138]} +{"name": "random-grey-0", "kind": "greymap", "width": 2, "height": 2, "pixels": [61, 102, 201, 177]} +{"name": "random-grey-1", "kind": "greymap", "width": 5, "height": 64, "pixels": [148, 75, 133, 55, 133, 210, 167, 137, 55, 166, 159, 11, 103, 36, 103, 58, 233, 198, 37, 50, 212, 8, 50, 216, 202, 225, 153, 81, 208, 119, 55, 106, 199, 98, 70, 68, 252, 53, 234, 3, 232, 245, 61, 245, 252, 188, 219, 93, 1, 125, 46, 13, 238, 192, 231, 58, 134, 65, 243, 145, 230, 206, 150, 87, 236, 224, 154, 157, 23, 39, 107, 159, 215, 51, 237, 34, 239, 231, 8, 89, 223, 140, 38, 43, 64, 87, 220, 146, 239, 57, 103, 240, 34, 219, 78, 4, 196, 148, 20, 182, 129, 107, 135, 162, 31, 12, 143, 107, 32, 100, 150, 238, 69, 165, 133, 156, 146, 219, 244, 31, 114, 79, 20, 234, 59, 51, 133, 239, 194, 166, 168, 189, 95, 227, 169, 48, 230, 211, 20, 185, 47, 162, 223, 187, 234, 187, 23, 151, 134, 140, 45, 115, 200, 16, 87, 84, 240, 54, 46, 18, 223, 23, 71, 166, 125, 17, 141, 97, 75, 127, 148, 62, 227, 65, 36, 230, 159, 207, 115, 181, 52, 123, 132, 70, 177, 235, 113, 229, 155, 30, 224, 63, 97, 32, 252, 64, 197, 143, 250, 179, 231, 25, 217, 13, 115, 158, 124, 52, 204, 213, 69, 117, 79, 234, 95, 244, 44, 142, 205, 224, 32, 1, 123, 42, 138, 127, 248, 83, 203, 134, 237, 216, 11, 71, 48, 88, 218, 9, 137, 250, 170, 127, 158, 92, 36, 155, 13, 62, 37, 24, 45, 234, 32, 18, 47, 195, 251, 158, 190, 172, 143, 76, 61, 28, 22, 185, 92, 59, 172, 250, 180, 129, 238, 54, 106, 17, 87, 139, 197, 161, 9, 107, 251, 36, 12, 248, 116, 90, 94, 85, 196, 87, 35, 96, 128, 52, 84, 249, 212, 106, 37, 223, 177, 96, 162, 77, 99, 6, 21, 134, 157, 217, 239, 95, 33, 207, 12, 228, 130, 142]} +{"name": "random-grey-2", "kind": "greymap", "width": 64, "height": 3, "pixels": [198, 161, 48, 247, 67, 202, 76, 24, 200, 110, 121, 48, 41, 178, 74, 245, 203, 189, 55, 247, 21, 74, 11, 247, 81, 170, 249, 224, 77, 140, 65, 144, 168, 156, 249, 197, 149, 205, 198, 10, 116, 103, 186, 85, 97, 6, 67, 17, 149, 38, 179, 174, 247, 189, 206, 208, 232, 246, 35, 34, 180, 186, 79, 100, 147, 77, 184, 139, 192, 252, 172, 92, 167, 180, 17, 217, 236, 141, 42, 226, 40, 164, 254, 17, 68, 108, 187, 68, 207, 225, 134, 161, 90, 124, 250, 14, 87, 237, 107, 161, 142, 158, 3, 189, 221, 65, 162, 69, 90, 58, 226, 17, 140, 181, 130, 152, 140, 133, 71, 36, 186, 187, 4, 214, 153, 147, 225, 92, 242, 235, 116, 174, 255, 148, 29, 122, 189, 61, 184, 230, 35, 125, 232, 235, 76, 51, 134, 220, 205, 222, 84, 57, 56, 174, 58, 107, 175, 78, 73, 198, 252, 152, 173, 123, 79, 235, 241, 253, 7, 56, 66, 211, 211, 23, 46, 40, 105, 161, 168, 176, 90, 221, 123, 99, 91, 207, 224, 91, 244, 225, 39, 228]} +{"name": "random-grey-3", "kind": "greymap", "width": 64, "height": 3, "pixels": [239, 115, 229, 55, 13, 250, 124, 77, 188, 184, 183, 2, 225, 153, 95, 247, 160, 234, 96, 153, 63, 116, 228, 254, 218, 179, 183, 160, 71, 62, 245, 113, 246, 187, 11, 84, 98, 43, 200, 203, 213, 2, 183, 94, 44, 27, 185, 112, 248, 204, 186, 52, 152, 110, 173, 47, 12, 226, 138, 190, 54, 53, 162, 154, 71, 44, 129, 100, 158, 57, 58, 255, 150, 45, 247, 2, 250, 10, 7, 144, 56, 102, 155, 210, 248, 49, 27, 97, 7, 105, 73, 157, 145, 227, 190, 150, 180, 137, 106, 33, 193, 237, 220, 72, 42, 107, 211, 99, 224, 240, 58, 187, 0, 211, 196, 73, 154, 90, 60, 167, 77, 145, 233, 66, 64, 67, 100, 200, 252, 170, 242, 132, 159, 188, 119, 113, 203, 119, 115, 170, 89, 165, 190, 188, 200, 208, 98, 55, 151, 68, 152, 163, 172, 86, 118, 48, 107, 127, 222, 222, 105, 165, 16, 21, 165, 191, 152, 145, 111, 255, 85, 68, 179, 0, 233, 124, 168, 64, 148, 195, 177, 68, 199, 213, 138, 1, 140, 139, 58, 43, 43, 100]} +{"name": "random-grey-4", "kind": "greymap", "width": 2, "height": 2, "pixels": [232, 173, 207, 94]} +{"name": "random-grey-5", "kind": "greymap", "width": 8, "height": 5, "pixels": [178, 69, 89, 139, 210, 54, 174, 36, 127, 33, 8, 247, 247, 111, 250, 90, 229, 91, 227, 80, 193, 181, 107, 223, 229, 112, 94, 111, 69, 157, 229, 4, 138, 69, 242, 143, 44, 75, 149, 221]} +{"name": "random-grey-6", "kind": "greymap", "width": 5, "height": 64, "pixels": [178, 251, 117, 200, 103, 113, 203, 92, 127, 92, 91, 109, 150, 65, 139, 74, 183, 154, 40, 170, 232, 224, 90, 80, 132, 108, 42, 222, 104, 100, 125, 204, 2, 6, 44, 248, 90, 146, 203, 153, 21, 67, 131, 219, 55, 75, 211, 220, 168, 191, 60, 7, 245, 145, 78, 143, 125, 180, 154, 225, 209, 229, 48, 234, 87, 82, 12, 115, 129, 109, 213, 55, 7, 231, 234, 226, 193, 34, 35, 136, 81, 233, 32, 230, 36, 173, 154, 227, 124, 171, 80, 232, 104, 96, 10, 128, 70, 187, 78, 119, 250, 229, 148, 155, 220, 105, 83, 230, 192, 163, 214, 60, 174, 173, 226, 202, 234, 70, 66, 250, 3, 213, 41, 16, 139, 5, 190, 8, 147, 58, 9, 139, 174, 237, 240, 189, 70, 152, 229, 233, 169, 124, 160, 168, 64, 242, 61, 172, 151, 113, 8, 187, 8, 216, 250, 27, 146, 0, 214, 237, 184, 82, 67, 207, 122, 135, 161, 64, 19, 1, 247, 254, 3, 238, 47, 26, 172, 29, 181, 20, 196, 118, 212, 61, 102, 95, 144, 143, 94, 109, 64, 118, 202, 54, 135, 236, 125, 83, 208, 120, 240, 245, 140, 232, 77, 86, 113, 132, 158, 85, 85, 217, 163, 64, 72, 101, 197, 165, 196, 148, 200, 175, 112, 225, 190, 205, 215, 13, 220, 60, 38, 59, 75, 188, 71, 32, 232, 166, 53, 6, 240, 236, 82, 212, 29, 247, 107, 140, 182, 188, 132, 147, 24, 214, 99, 151, 45, 144, 144, 54, 214, 233, 118, 158, 212, 131, 153, 208, 198, 91, 31, 62, 0, 108, 68, 162, 41, 227, 60, 247, 111, 67, 31, 181, 138, 70, 67, 183, 222, 63, 56, 56, 238, 137, 160, 126, 173, 203, 210, 134, 213, 74, 200, 147, 34, 254, 90, 241, 59, 222, 14, 202, 220, 252, 109, 32, 178, 83, 174, 23]} +{"name": "random-grey-7", "kind": "greymap", "width": 5, "height": 64, "pixels": [237, 130, 80, 75, 116, 48, 246, 14, 244, 146, 93, 102, 14, 155, 255, 149, 103, 114, 123, 156, 222, 218, 216, 65, 16, 0, 132, 229, 67, 124, 89, 57, 213, 122, 83, 223, 7, 41, 221, 218, 68, 71, 193, 124, 23, 134, 104, 23, 158, 142, 108, 111, 213, 230, 221, 67, 107, 146, 252, 243, 219, 185, 127, 218, 83, 44, 24, 65, 1, 165, 217, 220, 189, 131, 9, 233, 73, 156, 219, 123, 98, 15, 29, 76, 246, 159, 8, 60, 151, 122, 165, 55, 148, 39, 113, 197, 134, 16, 112, 155, 9, 188, 142, 147, 19, 101, 240, 220, 47, 7, 9, 140, 93, 215, 200, 246, 165, 64, 9, 140, 231, 13, 169, 135, 88, 134, 114, 229, 157, 6, 168, 82, 18, 53, 192, 85, 187, 75, 212, 209, 106, 215, 225, 242, 152, 242, 201, 225, 240, 209, 26, 225, 238, 147, 124, 0, 133, 39, 206, 17, 90, 81, 19, 185, 110, 131, 177, 219, 29, 143, 194, 159, 227, 147, 190, 194, 201, 23, 75, 207, 99, 13, 14, 45, 99, 161, 73, 19, 132, 227, 36, 52, 228, 27, 174, 34, 165, 221, 14, 192, 26, 163, 169, 233, 230, 44, 45, 235, 219, 75, 65, 48, 148, 249, 44, 238, 79, 255, 220, 249, 250, 194, 33, 98, 118, 140, 76, 110, 252, 45, 64, 80, 248, 86, 154, 79, 87, 29, 251, 246, 56, 159, 42, 180, 122, 129, 73, 40, 156, 205, 76, 236, 58, 108, 80, 250, 97, 229, 236, 130, 23, 96, 60, 145, 68, 242, 202, 232, 3, 102, 238, 234, 61, 178, 6, 24, 75, 48, 242, 63, 101, 40, 213, 232, 92, 56, 83, 115, 53, 12, 37, 41, 43, 21, 67, 151, 211, 102, 71, 71, 135, 220, 179, 127, 16, 114, 93, 82, 47, 64, 109, 66, 241, 45, 196, 184, 127, 155, 206, 252]} +{"name": "random-grey-8", "kind": "greymap", "width": 8, "height": 5, "pixels": [234, 23, 77, 84, 189, 212, 152, 21, 100, 47, 204, 255, 80, 21, 236, 208, 132, 213, 84, 81, 236, 76, 34, 121, 55, 100, 82, 85, 221, 50, 138, 15, 154, 136, 117, 41, 152, 225, 247, 94]} +{"name": "random-grey-9", "kind": "greymap", "width": 5, "height": 64, "pixels": [46, 52, 170, 182, 133, 34, 95, 14, 143, 42, 127, 87, 126, 224, 170, 50, 131, 96, 231, 82, 67, 153, 28, 75, 164, 196, 4, 50, 104, 144, 222, 217, 236, 245, 36, 205, 231, 133, 156, 81, 126, 125, 253, 20, 146, 36, 6, 116, 243, 136, 74, 172, 28, 247, 188, 115, 111, 74, 217, 88, 71, 54, 230, 166, 236, 105, 220, 203, 236, 76, 221, 65, 120, 61, 200, 95, 98, 165, 105, 189, 38, 141, 76, 73, 130, 15, 168, 99, 223, 215, 161, 155, 176, 126, 137, 108, 122, 254, 158, 77, 238, 133, 96, 167, 8, 57, 87, 255, 231, 85, 186, 155, 55, 222, 127, 27, 54, 1, 223, 34, 12, 72, 76, 218, 73, 204, 169, 123, 172, 234, 160, 80, 211, 129, 74, 102, 158, 133, 20, 76, 96, 150, 163, 87, 102, 106, 47, 94, 189, 82, 95, 79, 31, 176, 55, 63, 247, 100, 191, 27, 130, 64, 32, 100, 63, 116, 154, 100, 89, 135, 129, 133, 231, 128, 175, 24, 95, 189, 70, 122, 13, 182, 218, 59, 109, 137, 184, 4, 238, 139, 178, 189, 187, 212, 73, 166, 67, 121, 66, 174, 130, 66, 111, 172, 80, 241, 190, 255, 164, 179, 78, 222, 134, 204, 253, 205, 30, 182, 60, 240, 134, 248, 74, 16, 52, 216, 42, 91, 216, 191, 198, 237, 90, 105, 11, 88, 249, 61, 245, 212, 57, 142, 191, 176, 199, 41, 103, 1, 167, 241, 87, 71, 125, 85, 82, 160, 165, 229, 145, 80, 34, 161, 195, 103, 144, 232, 105, 47, 210, 238, 111, 58, 147, 179, 42, 48, 140, 93, 78, 6, 202, 90, 146, 54, 222, 103, 222, 57, 232, 106, 224, 145, 192, 203, 154, 144, 39, 115, 107, 143, 255, 252, 21, 93, 6, 244, 223, 195, 149, 135, 117, 242, 69, 246, 130, 15, 76, 253, 111, 21]} diff --git a/parity/golden/pdfgutter/cases.ndjson b/parity/golden/pdfgutter/cases.ndjson index 46597a5..47ef085 100644 --- a/parity/golden/pdfgutter/cases.ndjson +++ b/parity/golden/pdfgutter/cases.ndjson @@ -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]} diff --git a/parity/oracle/pdfgutter.py b/parity/oracle/pdfgutter.py index 315d0ae..ac61fd7 100644 --- a/parity/oracle/pdfgutter.py +++ b/parity/oracle/pdfgutter.py @@ -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}") diff --git a/parity/tests/pdfgutter.rs b/parity/tests/pdfgutter.rs index b0bba2a..dc7dd8b 100644 --- a/parity/tests/pdfgutter.rs +++ b/parity/tests/pdfgutter.rs @@ -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 = 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 = 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"