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
+210
View File
@@ -0,0 +1,210 @@
//! Reading a rendered page as a column-by-column measure of how much ink is
//! on it.
//!
//! The page comes in as a greymap — the format `pdftoppm` writes and the
//! simplest image format there is: a header, then one byte per pixel. Only
//! the column sums are wanted, so nothing is kept but the profile.
use std::fmt;
/// What can be wrong with a greymap.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
/// It does not begin `P5`.
NotAGreymap,
/// The header ended before the three numbers did.
Truncated,
/// A number in the header was not one.
BadHeader,
/// Sixteen-bit samples, which nothing here produces.
SampleTooWide(u32),
/// The header promises more pixels than the file holds.
ShortImage {
/// How many bytes the header calls for.
expected: usize,
/// How many are there.
found: usize,
},
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::NotAGreymap => write!(f, "not a binary greymap"),
Error::Truncated => write!(f, "the greymap header is cut short"),
Error::BadHeader => write!(f, "the greymap header is not three numbers"),
Error::SampleTooWide(max) => write!(f, "{max} levels of grey, which is more than 255"),
Error::ShortImage { expected, found } => {
write!(f, "the greymap promises {expected} bytes and holds {found}")
}
}
}
}
impl std::error::Error for Error {}
/// The fraction of each column that is ink.
///
/// One number per pixel column, left to right, each between zero and one.
pub fn profile(greymap: &[u8], threshold: u8) -> Result<Vec<f64>, Error> {
let (width, height, levels, pixels) = header(greymap)?;
if levels > 255 {
return Err(Error::SampleTooWide(levels));
}
let wanted = width.checked_mul(height).ok_or(Error::BadHeader)?;
if pixels.len() < wanted {
return Err(Error::ShortImage {
expected: wanted,
found: pixels.len(),
});
}
if width == 0 || height == 0 {
return Ok(Vec::new());
}
// A greymap need not go all the way to white, and the threshold is on the
// usual nought-to-255 scale, so compare in that scale rather than the
// file's.
let dark = |value: u8| (u32::from(value) * 255 / levels.max(1)) < u32::from(threshold);
let mut counts = vec![0usize; width];
for row in pixels[..wanted].chunks_exact(width) {
for (column, value) in row.iter().enumerate() {
if dark(*value) {
counts[column] += 1;
}
}
}
Ok(counts
.into_iter()
.map(|count| count as f64 / height as f64)
.collect())
}
/// Width, height, levels of grey, and where the pixels start.
fn header(greymap: &[u8]) -> Result<(usize, usize, u32, &[u8]), Error> {
let rest = greymap.strip_prefix(b"P5").ok_or(Error::NotAGreymap)?;
let mut at = 0;
let mut numbers = [0u32; 3];
for number in &mut numbers {
at += skip(&rest[at..]);
let digits = rest[at..]
.iter()
.position(|byte| !byte.is_ascii_digit())
.ok_or(Error::Truncated)?;
if digits == 0 {
return Err(Error::BadHeader);
}
*number = std::str::from_utf8(&rest[at..at + digits])
.map_err(|_| Error::BadHeader)?
.parse()
.map_err(|_| Error::BadHeader)?;
at += digits;
}
// Exactly one byte of whitespace separates the header from the pixels,
// and a pixel may itself be a whitespace byte.
let pixels = rest.get(at + 1..).ok_or(Error::Truncated)?;
Ok((numbers[0] as usize, numbers[1] as usize, numbers[2], pixels))
}
/// Whitespace and comments, which a greymap header may carry anywhere.
fn skip(bytes: &[u8]) -> usize {
let mut at = 0;
loop {
let was = at;
while bytes.get(at).is_some_and(u8::is_ascii_whitespace) {
at += 1;
}
if bytes.get(at) == Some(&b'#') {
at += bytes[at..]
.iter()
.position(|byte| *byte == b'\n')
.map_or(bytes.len() - at, |end| end + 1);
}
if at == was {
return at;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Four columns, two rows: black, white, black, white.
const STRIPES: &[u8] = b"P5\n4 2\n255\n\x00\xff\x00\xff\x00\xff\x00\xff";
#[test]
fn a_column_of_black_is_all_ink_and_a_column_of_white_is_none() {
assert_eq!(
profile(STRIPES, 160).expect("a greymap"),
[1.0, 0.0, 1.0, 0.0]
);
}
#[test]
fn grey_falls_on_the_side_of_the_threshold_it_belongs_to() {
let greys = b"P5\n3 1\n255\n\x9e\x9f\xa0"; // 158, 159, 160
assert_eq!(profile(greys, 160).expect("a greymap"), [1.0, 1.0, 0.0]);
}
#[test]
fn comments_and_extra_whitespace_in_the_header_are_skipped() {
let commented = b"P5\n# written by something\n4 2\n255\n\x00\xff\x00\xff\x00\xff\x00\xff";
assert_eq!(
profile(commented, 160).expect("a greymap"),
[1.0, 0.0, 1.0, 0.0]
);
}
#[test]
fn a_pixel_that_is_a_whitespace_byte_is_still_a_pixel() {
// 0x20 is a space and also a very dark grey. Eating it as header
// whitespace would shift every row by one and smear the profile.
let spaces = b"P5\n2 1\n255\n\x20\xff";
assert_eq!(profile(spaces, 160).expect("a greymap"), [1.0, 0.0]);
}
#[test]
fn fewer_levels_of_grey_are_scaled_to_the_usual_range() {
// Maximum 1, so 0 is black and 1 is white however it is written.
let bilevel = b"P5\n2 1\n1\n\x00\x01";
assert_eq!(profile(bilevel, 160).expect("a greymap"), [1.0, 0.0]);
}
#[test]
fn what_is_not_a_greymap_says_so() {
assert_eq!(
profile(b"P6\n1 1\n255\n\x00\x00\x00", 160),
Err(Error::NotAGreymap)
);
assert_eq!(profile(b"", 160), Err(Error::NotAGreymap));
assert_eq!(profile(b"P5\n4 2\n", 160), Err(Error::Truncated));
assert_eq!(profile(b"P5\nwide 2\n255\n", 160), Err(Error::BadHeader));
assert_eq!(
profile(b"P5\n1 1\n65535\n\x00\x00", 160),
Err(Error::SampleTooWide(65535))
);
}
#[test]
fn a_greymap_cut_short_is_not_padded_with_white() {
assert_eq!(
profile(b"P5\n4 2\n255\n\x00\xff\x00", 160),
Err(Error::ShortImage {
expected: 8,
found: 3
})
);
}
#[test]
fn a_greymap_with_no_pixels_has_no_profile() {
assert_eq!(
profile(b"P5\n0 0\n255\n", 160).expect("a greymap"),
Vec::<f64>::new()
);
}
}
+305
View File
@@ -0,0 +1,305 @@
//! Two facing pages photographed as one landscape sheet, found and split.
//!
//! A book held open under a camera gives one wide image per shot, with two
//! pages on it. Everything downstream reads it as a single page: the columns
//! interleave, the reading order comes out scrambled, and the text that
//! results is worse than useless because it looks like text.
//!
//! Aspect ratio alone is not enough to say so — a foldout, a plate, a page
//! set in one narrow centred column are all genuinely wide. So the shape of
//! the ink is checked too: two blocks of text with a blank band down the
//! middle, on most of the pages sampled.
//!
//! Splitting is a change to the page boxes and nothing else. Nothing is
//! re-rendered, so a 250-sheet book becomes a 500-page book at no cost in
//! quality and almost none in size.
pub mod ink;
pub mod pdf;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Above this width-to-height ratio a sheet is wide enough to hold two pages.
///
/// Two ordinary portrait pages side by side land near 1.36. A lone wide page
/// can sit anywhere, which is why the gutter is the real test.
pub const MIN_RATIO: f64 = 1.15;
/// How many of the sampled wide sheets must show a gutter before the whole
/// document is called two-up.
pub const MIN_GUTTER_FRACTION: f64 = 0.6;
/// How dark a pixel has to be to count as ink, from 0 black to 255 white.
pub const INK_THRESHOLD: u8 = 160;
/// What fraction of a column has to be ink before the column counts as text.
pub const TEXT_COLUMN_INK: f64 = 0.02;
/// How many pages are looked at, however long the document is.
pub const SAMPLE_SIZE: usize = 8;
/// What one page looks like.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Page {
/// Which page it is, counting from one.
pub number: usize,
/// Its width divided by its height.
#[serde(with = "corpus_core::finite")]
#[schemars(with = "f64")]
pub ratio: f64,
/// Whether a gutter was found. `None` when the page was too narrow to be
/// worth looking at.
pub gutter: Option<bool>,
}
/// What a document is.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Verdict {
/// Record version.
#[serde(default = "version")]
pub v: u32,
/// The file the verdict is about.
pub path: String,
/// Whether it is two pages to a sheet.
pub two_up: bool,
/// How many pages it has.
pub pages: usize,
/// The middle ratio of the sampled pages.
#[serde(with = "corpus_core::finite")]
#[schemars(with = "f64")]
pub median_ratio: f64,
/// What share of the sampled wide sheets showed a gutter.
#[serde(with = "corpus_core::finite")]
#[schemars(with = "f64")]
pub gutter_fraction: f64,
/// The pages that were looked at.
pub sampled: Vec<Page>,
}
fn version() -> u32 {
corpus_core::V
}
impl corpus_core::Record for Verdict {
fn version(&self) -> u32 {
self.v
}
}
/// A page's width divided by its height, or zero for a page with no height.
pub fn aspect_ratio(width: f64, height: f64) -> f64 {
match height {
0.0 => 0.0,
height => width / height,
}
}
/// Which pages to look at: evenly spaced, and away from the ends.
///
/// Covers and end matter are single pages even in a book that was otherwise
/// shot two at a time, so sampling them would argue against a document that
/// plainly is two-up.
pub fn sample_indices(pages: usize) -> Vec<usize> {
if pages <= 6 {
return (0..pages).collect();
}
let (low, high) = (2, pages - 3);
let step = ((high - low) / SAMPLE_SIZE).max(1);
(low..=high).step_by(step).take(SAMPLE_SIZE).collect()
}
/// Whether a column ink profile has a blank band down the middle with text on
/// both sides of it.
///
/// The three bands are deliberately narrow and deliberately apart: the middle
/// eight per cent for the gutter, and a third of the width either side of it
/// for the text, leaving the outer tenth alone because a scan's edges carry
/// the shadow of the book's binding and the table it sat on.
pub fn has_gutter(ink: &[f64]) -> bool {
let width = ink.len() as f64;
let band = |from: f64, to: f64| {
let (from, to) = ((from * width) as usize, (to * width) as usize);
ink.get(from..to.min(ink.len())).unwrap_or(&[])
};
let centre = band(0.46, 0.54);
let left = band(0.10, 0.44);
let right = band(0.56, 0.90);
if centre.is_empty() || left.is_empty() || right.is_empty() {
return false;
}
let blank = centre.iter().copied().fold(f64::INFINITY, f64::min);
blank < TEXT_COLUMN_INK && median(left) > TEXT_COLUMN_INK && median(right) > TEXT_COLUMN_INK
}
/// The middle value, averaging the two middle values of an even-length run.
///
/// The middle rather than the mean throughout, because one page of solid
/// plate or one blank leaf should not move the answer for a whole book.
pub fn median(values: &[f64]) -> f64 {
if values.is_empty() {
return 0.0;
}
let mut sorted = values.to_vec();
sorted.sort_by(f64::total_cmp);
let middle = sorted.len() / 2;
match sorted.len() % 2 {
0 => (sorted[middle - 1] + sorted[middle]) / 2.0,
_ => sorted[middle],
}
}
/// Whether the sampled pages add up to a two-up document.
///
/// `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
}
/// What share of the pages looked at showed a gutter.
pub fn gutter_fraction(gutters: &[bool]) -> f64 {
match gutters.len() {
0 => 0.0,
len => gutters.iter().filter(|found| **found).count() as f64 / len as f64,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A profile with text either side of a blank middle.
fn two_pages() -> Vec<f64> {
(0..1000)
.map(|x| match x {
x if (100..440).contains(&x) => 0.3,
x if (560..900).contains(&x) => 0.3,
_ => 0.0,
})
.collect()
}
#[test]
fn two_columns_either_side_of_a_blank_band_is_a_gutter() {
assert!(has_gutter(&two_pages()));
}
#[test]
fn one_wide_block_of_text_is_not() {
let solid: Vec<f64> = (0..1000).map(|_| 0.3).collect();
assert!(!has_gutter(&solid));
}
#[test]
fn a_narrow_centred_column_is_not_two_pages() {
// The shape that makes aspect ratio alone useless: a wide sheet with
// one column of text down the middle of it.
let centred: Vec<f64> = (0..1000)
.map(|x| match (400..600).contains(&x) {
true => 0.3,
false => 0.0,
})
.collect();
assert!(!has_gutter(&centred));
}
#[test]
fn a_blank_page_has_no_gutter_because_it_has_no_text() {
assert!(!has_gutter(&[0.0; 1000]));
}
#[test]
fn text_on_only_one_side_is_one_page_that_happens_to_be_wide() {
let half: Vec<f64> = (0..1000)
.map(|x| match (100..440).contains(&x) {
true => 0.3,
false => 0.0,
})
.collect();
assert!(!has_gutter(&half));
}
#[test]
fn a_gutter_narrower_than_the_band_it_is_looked_for_in_still_counts() {
// Only one blank column is needed, not a blank band: scans are rarely
// square to the camera and the gutter wanders.
let mut ink = vec![0.3; 1000];
ink[500] = 0.0;
assert!(has_gutter(&ink));
}
#[test]
fn a_profile_too_short_to_have_bands_is_not_a_gutter() {
assert!(!has_gutter(&[]));
assert!(!has_gutter(&[0.0, 1.0]));
}
#[test]
fn short_documents_are_sampled_whole() {
assert_eq!(sample_indices(0), Vec::<usize>::new());
assert_eq!(sample_indices(1), [0]);
assert_eq!(sample_indices(6), [0, 1, 2, 3, 4, 5]);
}
#[test]
fn longer_ones_skip_the_covers() {
assert_eq!(sample_indices(7), [2, 3, 4]);
assert_eq!(sample_indices(12), [2, 3, 4, 5, 6, 7, 8, 9]);
assert_eq!(sample_indices(100), [2, 13, 24, 35, 46, 57, 68, 79]);
}
#[test]
fn no_more_than_eight_pages_are_ever_looked_at() {
for pages in 7..500 {
assert!(sample_indices(pages).len() <= SAMPLE_SIZE, "{pages}");
}
}
#[test]
fn every_sampled_page_exists() {
for pages in 0..500 {
for index in sample_indices(pages) {
assert!(index < pages, "{pages} has no page {index}");
}
}
}
#[test]
fn a_page_with_no_height_has_no_ratio_rather_than_an_infinite_one() {
assert_eq!(aspect_ratio(600.0, 0.0), 0.0);
assert_eq!(aspect_ratio(1008.0, 720.0), 1.4);
}
#[test]
fn the_middle_of_an_even_run_is_the_mean_of_the_two_middle_values() {
assert_eq!(median(&[1.0, 2.0, 3.0, 4.0]), 2.5);
assert_eq!(median(&[1.0, 2.0, 3.0]), 2.0);
assert_eq!(median(&[]), 0.0);
}
#[test]
fn a_document_needs_both_the_shape_and_the_gutter() {
let wide = [1.4; 8];
let narrow = [0.7; 8];
assert!(is_two_up(&wide, &[true; 8]));
assert!(!is_two_up(&narrow, &[true; 8]));
assert!(!is_two_up(&wide, &[]));
// Three of eight is not most of them.
let mostly_not = [true, true, true, false, false, false, false, false];
assert!(!is_two_up(&wide, &mostly_not));
}
#[test]
fn a_document_only_just_over_the_line_still_counts() {
// Six of ten is exactly the threshold, and the threshold is inclusive.
let gutters = [
true, true, true, true, true, true, false, false, false, false,
];
assert_eq!(gutter_fraction(&gutters), 0.6);
assert!(is_two_up(&[1.4; 10], &gutters));
}
}
+237
View File
@@ -0,0 +1,237 @@
//! Finds books scanned two pages to a sheet, and splits them back into pages.
use std::path::PathBuf;
use std::process::{Command, ExitCode};
use clap::Parser;
use corpus_core::exit;
use pdfgutter::{Page, Verdict};
/// Say whether a PDF is two facing pages photographed as one sheet, and split
/// it back into pages when it is.
///
/// A book held open under a camera gives one wide image per shot. Anything
/// that reads it afterwards runs the two columns together, and the text that
/// comes out looks like text while being unusable.
///
/// Shells out to `pdftoppm` to look at the pages. Splitting does not: it
/// changes the page boxes and leaves the content alone, so nothing is
/// re-rendered and nothing is lost.
#[derive(Parser)]
#[command(
version,
about,
after_help = "\
Exit codes: 0 two pages to a sheet, 1 one page to a sheet, 2 could not tell,
69 pdftoppm is not installed, 74 the file could not be read or written.
Example:
pdfgutter scan.pdf && pdfgutter --into split.pdf scan.pdf
for f in *.pdf; do pdfgutter --quiet \"$f\" && pdfgutter --into \"split/$f\" \"$f\"; done"
)]
struct Args {
/// The PDF to look at.
path: PathBuf,
/// Write the split document here. Implies nothing about the verdict: a
/// document that is not two-up is copied through unchanged.
#[arg(long, value_name = "FILE")]
into: Option<PathBuf>,
/// Emit a record instead of a sentence.
#[arg(long)]
json: bool,
/// Say nothing; the exit code is the answer.
#[arg(long, short)]
quiet: bool,
/// How wide a sheet has to be, as a multiple of its height, before it
/// could hold two pages.
#[arg(long, default_value_t = pdfgutter::MIN_RATIO, value_name = "RATIO")]
min_ratio: f64,
/// What share of the wide sheets looked at must show a gutter.
#[arg(long, default_value_t = pdfgutter::MIN_GUTTER_FRACTION, value_name = "FRACTION")]
gutter_fraction: f64,
}
fn main() -> ExitCode {
let args: Args = match catena_cli::parse() {
Ok(args) => args,
Err(code) => return code,
};
if !(args.min_ratio.is_finite() && args.gutter_fraction.is_finite()) {
eprintln!("pdfgutter: a threshold has to be a number");
return ExitCode::from(exit::USAGE);
}
let mut pdf = match pdfgutter::pdf::Pdf::open(&args.path) {
Ok(pdf) => pdf,
Err(why) => {
eprintln!("pdfgutter: {}: {why}", args.path.display());
return ExitCode::from(match why {
pdfgutter::pdf::Error::Unreadable(_) => exit::IO,
_ => exit::INDETERMINATE,
});
}
};
let verdict = match measure(&args, &pdf) {
Ok(verdict) => verdict,
Err(code) => return code,
};
// The verdict first, because it is the answer; what was written to disk
// after it, because that is what happened.
let said = say(&args, &verdict);
if let Some(destination) = args.into.as_deref() {
let pages = pdf.split(args.min_ratio);
if let Err(why) = pdf.save(destination) {
eprintln!("pdfgutter: {}: {why}", destination.display());
return ExitCode::from(exit::IO);
}
if !args.quiet && !args.json {
println!("{pages} pages written to {}", destination.display());
}
}
said
}
/// Sample the document and decide.
fn measure(args: &Args, pdf: &pdfgutter::pdf::Pdf) -> Result<Verdict, ExitCode> {
let mut rasteriser = None;
let mut sampled = Vec::new();
let mut ratios = Vec::new();
let mut gutters = Vec::new();
for index in pdfgutter::sample_indices(pdf.len()) {
let ratio = pdf.geometry(index).ratio();
ratios.push(ratio);
// Rendering is the expensive part, and a sheet too narrow to hold two
// pages cannot be two pages whatever its ink looks like.
let gutter = match ratio > args.min_ratio {
false => None,
true => {
if rasteriser.is_none() {
match which("pdftoppm") {
None => {
eprintln!("pdfgutter: pdftoppm is not on PATH");
return Err(ExitCode::from(exit::UNAVAILABLE));
}
Some(found) => rasteriser = Some(found),
}
}
Some(gutter_on(&args.path, index))
}
};
if let Some(found) = gutter {
gutters.push(found);
}
sampled.push(Page {
number: index + 1,
ratio,
gutter,
});
}
let median_ratio = pdfgutter::median(&ratios);
let gutter_fraction = pdfgutter::gutter_fraction(&gutters);
Ok(Verdict {
v: corpus_core::V,
path: args.path.display().to_string(),
two_up: median_ratio > args.min_ratio && gutter_fraction >= args.gutter_fraction,
pages: pdf.len(),
median_ratio,
gutter_fraction,
sampled,
})
}
/// Whether one page has a blank band down the middle with text either side.
///
/// A page that will not render is a page with no gutter rather than a reason
/// to give up on the document: one bad page in a book is ordinary.
fn gutter_on(path: &std::path::Path, index: usize) -> bool {
let page = (index + 1).to_string();
let rendered = Command::new("pdftoppm")
.args([
"-gray",
"-scale-to-x",
"1000",
"-scale-to-y",
"-1",
"-singlefile",
])
.args(["-f", &page, "-l", &page])
.arg(path)
.output();
let Ok(rendered) = rendered else {
return false;
};
if !rendered.status.success() {
return false;
}
match pdfgutter::ink::profile(&rendered.stdout, pdfgutter::INK_THRESHOLD) {
Ok(profile) => pdfgutter::has_gutter(&profile),
Err(_) => false,
}
}
/// Write the verdict, and leave with it.
fn say(args: &Args, verdict: &Verdict) -> ExitCode {
let answer = match verdict.two_up {
true => exit::OK,
false => exit::NO,
};
if args.quiet {
return ExitCode::from(answer);
}
let text = match args.json {
true => format!(
"{}\n",
serde_json::to_string(verdict).expect("a verdict serialises")
),
false => format!(
"{}\n {} pages, median ratio {:.2}, gutter on {:.0}% of the wide ones\n",
match verdict.two_up {
true => "two pages to a sheet",
false => "one page to a sheet",
},
verdict.pages,
verdict.median_ratio,
verdict.gutter_fraction * 100.0
),
};
let wrote = catena_cli::text("pdfgutter", &text);
match format!("{wrote:?}") == format!("{:?}", ExitCode::from(exit::OK)) {
true => ExitCode::from(answer),
false => wrote,
}
}
fn which(program: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(program))
.find(|candidate| is_program(candidate))
}
#[cfg(unix)]
fn is_program(path: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt as _;
std::fs::metadata(path)
.is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
fn is_program(path: &std::path::Path) -> bool {
path.is_file()
}
+367
View File
@@ -0,0 +1,367 @@
//! The page geometry, and the split itself.
//!
//! Splitting is a change to two rectangles per page. The content stream is
//! not touched, not re-rendered and not even read: both halves of a sheet
//! point at the same stream and differ only in how much of it they show.
use std::fmt;
use std::path::Path;
use lopdf::{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"];
/// The boxes that describe a region smaller than the page, which a half-page
/// would leave pointing at the wrong place.
const NARROWING: [&[u8]; 3] = [b"BleedBox", b"TrimBox", b"ArtBox"];
/// How far up a page tree to look for an inherited attribute before deciding
/// the file is looping.
const MAX_DEPTH: usize = 32;
/// The default page, for a file whose pages carry no box at all: US Letter,
/// which is what every reader falls back to.
const DEFAULT_BOX: [f64; 4] = [0.0, 0.0, 612.0, 792.0];
/// What can be wrong with a PDF.
#[derive(Debug)]
pub enum Error {
/// It could not be read at all.
Unreadable(String),
/// It is encrypted, and no password was offered.
Encrypted,
/// It has no pages.
Empty,
/// It could not be written.
Unwritable(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Unreadable(why) => write!(f, "{why}"),
Error::Encrypted => write!(f, "it is encrypted"),
Error::Empty => write!(f, "it has no pages"),
Error::Unwritable(why) => write!(f, "{why}"),
}
}
}
impl std::error::Error for Error {}
/// A page's shape as a reader sees it.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Geometry {
/// The media box, left, bottom, right, top, with the corners in order.
pub media: [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.
pub fn displayed(self) -> (f64, f64) {
let width = self.media[2] - self.media[0];
let height = self.media[3] - self.media[1];
match self.quarter_turns() % 2 {
0 => (width, height),
_ => (height, width),
}
}
/// Width over height, as displayed.
pub fn ratio(self) -> f64 {
let (width, height) = self.displayed();
crate::aspect_ratio(width, height)
}
/// The rotation as a number of quarter turns, from zero to three.
///
/// A file may write any multiple of ninety, negative ones included.
fn quarter_turns(self) -> i64 {
(self.rotate / 90).rem_euclid(4)
}
/// The two halves of the sheet, in the order a reader would read them.
///
/// 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 middle_x = (left + right) / 2.0;
let middle_y = (bottom + top) / 2.0;
let west = [left, bottom, middle_x, top];
let east = [middle_x, bottom, right, top];
let south = [left, bottom, right, middle_y];
let north = [left, middle_y, right, top];
match self.quarter_turns() {
0 => [west, east],
1 => [south, north],
2 => [east, west],
_ => [north, south],
}
}
}
/// A PDF, open and ready to be measured or cut.
pub struct Pdf {
document: Document,
pages: Vec<ObjectId>,
}
impl Pdf {
/// Open a file.
pub fn open(path: &Path) -> Result<Self, Error> {
let document = Document::load(path).map_err(|e| Error::Unreadable(e.to_string()))?;
if document.is_encrypted() {
return Err(Error::Encrypted);
}
let pages: Vec<ObjectId> = document.get_pages().into_values().collect();
match pages.is_empty() {
true => Err(Error::Empty),
false => Ok(Pdf { document, pages }),
}
}
/// How many pages it has.
pub fn len(&self) -> usize {
self.pages.len()
}
/// Whether it has no pages. It never does; `open` refuses one.
pub fn is_empty(&self) -> bool {
self.pages.is_empty()
}
/// The shape of one page, counting from zero.
pub fn geometry(&self, index: usize) -> Geometry {
let id = self.pages[index];
let media = self
.inherited(id, b"MediaBox")
.and_then(|object| rectangle(&self.document, object))
.unwrap_or(DEFAULT_BOX);
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 }
}
/// An attribute of a page, from the page itself or from the tree above it.
fn inherited(&self, page: ObjectId, key: &[u8]) -> Option<&Object> {
let mut at = page;
for _ in 0..MAX_DEPTH {
let dictionary = self.document.get_dictionary(at).ok()?;
if let Ok(object) = dictionary.get(key) {
return Some(object);
}
at = dictionary.get(b"Parent").ok()?.as_reference().ok()?;
}
None
}
/// Cut every sheet wide enough to hold two pages into two, and leave the
/// rest alone.
///
/// Returns how many pages the document now has.
pub fn split(&mut self, min_ratio: f64) -> usize {
// 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<ObjectId> = Vec::with_capacity(self.pages.len());
for index in 0..self.pages.len() {
let id = self.pages[index];
let geometry = self.geometry(index);
if geometry.ratio() <= min_ratio {
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;
}
};
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);
}
self.reparent(&order);
self.pages = order;
self.pages.len()
}
/// Write the result.
pub fn save(&mut self, path: &Path) -> Result<(), Error> {
self.document
.save(path)
.map(|_| ())
.map_err(|e| Error::Unwritable(e.to_string()))
}
/// Copy every inheritable attribute down onto the pages that rely on it.
fn settle_inherited(&mut self) {
for page in self.pages.clone() {
for key in INHERITED {
let Some(value) = self.inherited(page, key).cloned() else {
continue;
};
if let Ok(dictionary) = self.document.get_dictionary_mut(page) {
if !dictionary.has(key) {
dictionary.set(key, value);
}
}
}
}
}
/// 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<Object> = 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
.document
.catalog()
.and_then(|catalog| catalog.get(b"Pages"))
.and_then(Object::as_reference)
else {
return;
};
for page in order {
if let Ok(dictionary) = self.document.get_dictionary_mut(*page) {
dictionary.set("Parent", Object::Reference(root));
}
}
let kids: Vec<Object> = order.iter().map(|id| Object::Reference(*id)).collect();
if let Ok(pages) = self.document.get_dictionary_mut(root) {
pages.set("Kids", Object::Array(kids));
pages.set("Count", Object::Integer(order.len() as i64));
}
}
}
/// Four numbers, in the order a PDF rectangle gives them, with the corners
/// put back in order.
///
/// A file may write a rectangle from any corner to any other, and readers are
/// required to cope, so a box that runs right to left is not a broken one.
fn rectangle(document: &Document, object: &Object) -> Option<[f64; 4]> {
let (_, object) = document.dereference(object).ok()?;
let array = object.as_array().ok()?;
if array.len() != 4 {
return None;
}
let mut edges = [0.0f64; 4];
for (edge, object) in edges.iter_mut().zip(array) {
let (_, object) = document.dereference(object).ok()?;
*edge = f64::from(object.as_float().ok()?);
if !edge.is_finite() {
return None;
}
}
Some([
edges[0].min(edges[2]),
edges[1].min(edges[3]),
edges[0].max(edges[2]),
edges[1].max(edges[3]),
])
}
#[cfg(test)]
mod tests {
use super::*;
fn sheet(rotate: i64) -> Geometry {
Geometry {
media: [0.0, 0.0, 1000.0, 700.0],
rotate,
}
}
#[test]
fn an_upright_sheet_is_cut_left_then_right() {
assert_eq!(
sheet(0).halves(),
[[0.0, 0.0, 500.0, 700.0], [500.0, 0.0, 1000.0, 700.0]]
);
}
#[test]
fn a_sheet_turned_upside_down_is_cut_right_then_left() {
assert_eq!(
sheet(180).halves(),
[[500.0, 0.0, 1000.0, 700.0], [0.0, 0.0, 500.0, 700.0]]
);
}
#[test]
fn a_sheet_on_its_side_is_cut_across_rather_than_down() {
// Turned a quarter clockwise, the file's bottom half is what the
// reader sees on the left.
assert_eq!(
sheet(90).halves(),
[[0.0, 0.0, 1000.0, 350.0], [0.0, 350.0, 1000.0, 700.0]]
);
assert_eq!(
sheet(270).halves(),
[[0.0, 350.0, 1000.0, 700.0], [0.0, 0.0, 1000.0, 350.0]]
);
}
#[test]
fn a_rotation_the_file_wrote_oddly_still_means_a_quarter_turn() {
assert_eq!(sheet(-90).halves(), sheet(270).halves());
assert_eq!(sheet(450).halves(), sheet(90).halves());
assert_eq!(sheet(360).halves(), sheet(0).halves());
}
#[test]
fn turning_a_sheet_on_its_side_turns_its_shape_with_it() {
assert_eq!(sheet(0).displayed(), (1000.0, 700.0));
assert_eq!(sheet(90).displayed(), (700.0, 1000.0));
assert!(sheet(0).ratio() > crate::MIN_RATIO);
assert!(sheet(90).ratio() < crate::MIN_RATIO);
}
#[test]
fn the_two_halves_add_back_up_to_the_sheet() {
for rotate in [0, 90, 180, 270] {
let [first, second] = sheet(rotate).halves();
let area = |b: [f64; 4]| (b[2] - b[0]) * (b[3] - b[1]);
assert_eq!(area(first) + area(second), 1000.0 * 700.0, "{rotate}");
}
}
}