mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-22 18:57:14 +00:00
pdfgutter: two facing pages photographed as one, found and cut
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "pdfgutter"
|
||||
description = "Finds books scanned two pages to a sheet, and splits them back into pages."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
catena-cli.workspace = true
|
||||
clap.workspace = true
|
||||
corpus-core.workspace = true
|
||||
lopdf.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,164 @@
|
||||
# pdfgutter
|
||||
|
||||
Finds books scanned two pages to a sheet, and splits them back into pages.
|
||||
|
||||
Part of [catena](../../README.md).
|
||||
|
||||
## What it does
|
||||
|
||||
A book held open under a camera gives one wide image per shot, with two pages
|
||||
on it. Everything that reads it afterwards runs the two columns together: the
|
||||
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.
|
||||
|
||||
## When you would use it
|
||||
|
||||
- **Before OCR.** Layout models read a spread as one page and produce jumbled
|
||||
text, or none.
|
||||
- **Before chunking.** A passage that alternates lines between two pages is
|
||||
unusable and does not look it.
|
||||
- **Sorting a scanned library.** Most books are fine; the answer tells you
|
||||
which ones are not.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
cargo install --git https://git.pumpin.online/funkpower/catena pdfgutter
|
||||
```
|
||||
|
||||
Detection needs `pdftoppm`, from poppler. Splitting needs nothing.
|
||||
|
||||
## Example
|
||||
|
||||
A book photographed two pages at a time:
|
||||
|
||||
```
|
||||
$ pdfgutter scan.pdf
|
||||
two pages to a sheet
|
||||
12 pages, median ratio 1.40, gutter on 100% of the wide ones
|
||||
```
|
||||
|
||||
An ordinary one:
|
||||
|
||||
```
|
||||
$ pdfgutter book.pdf
|
||||
one page to a sheet
|
||||
12 pages, median ratio 0.68, gutter on 0% of the wide ones
|
||||
$ echo $?
|
||||
1
|
||||
```
|
||||
|
||||
Splitting it:
|
||||
|
||||
```
|
||||
$ pdfgutter --into split.pdf scan.pdf
|
||||
two pages to a sheet
|
||||
12 pages, median ratio 1.40, gutter on 100% of the wide ones
|
||||
20 pages written to split.pdf
|
||||
```
|
||||
|
||||
Twelve sheets became twenty pages: eight spreads split in two, and the four
|
||||
covers and end leaves, which were single pages already, left alone.
|
||||
|
||||
What it looked at:
|
||||
|
||||
```
|
||||
$ pdfgutter --json scan.pdf | jq -c '.sampled[:3][]'
|
||||
{"number":3,"ratio":1.4,"gutter":true}
|
||||
{"number":4,"ratio":1.4,"gutter":true}
|
||||
{"number":5,"ratio":1.4,"gutter":true}
|
||||
```
|
||||
|
||||
Over a directory, splitting only what needs it:
|
||||
|
||||
```sh
|
||||
for f in *.pdf; do
|
||||
pdfgutter --quiet "$f" && pdfgutter --quiet --into "split/$f" "$f" || cp "$f" "split/$f"
|
||||
done
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Effect |
|
||||
|---|---|
|
||||
| `--into <FILE>` | Write the split document here |
|
||||
| `--json` | Emit a record — every page looked at, and what it measured |
|
||||
| `--quiet`, `-q` | Say nothing; the exit code is the answer |
|
||||
| `--min-ratio <RATIO>` | How wide a sheet must be to hold two pages. Default `1.15` |
|
||||
| `--gutter-fraction <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.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `0` | Two pages to a sheet |
|
||||
| `1` | One page to a sheet |
|
||||
| `2` | It could not be judged — encrypted, or no pages |
|
||||
| `64` | The command line was wrong |
|
||||
| `69` | `pdftoppm` is not installed |
|
||||
| `74` | The file could not be read or written |
|
||||
|
||||
## How it decides
|
||||
|
||||
**Shape is not enough.** A foldout, a plate, a page set in one narrow centred
|
||||
column are all genuinely wide, and splitting one down the middle destroys it.
|
||||
So the shape of the ink is checked as well.
|
||||
|
||||
Up to eight pages are sampled, evenly spaced and away from the ends — covers
|
||||
and end matter are single pages even in a book otherwise shot two at a time,
|
||||
and sampling them would argue against a document that plainly is a spread.
|
||||
|
||||
Each sampled page that is wide enough to hold two pages gets rendered small
|
||||
and read as a column-by-column measure of how much ink is on it. A gutter is:
|
||||
|
||||
- a **blank column** somewhere in the middle eight per cent of the width,
|
||||
- with **real text** in the third of the page to its left,
|
||||
- and **real text** in the third to its right.
|
||||
|
||||
The outer tenth of the sheet is ignored on both sides, because that is where
|
||||
a scan carries the shadow of the binding and of the table the book sat on.
|
||||
|
||||
A document is two-up when the **middle** sampled page is wide enough and
|
||||
**at least three in five** of the wide ones showed a gutter. The middle rather
|
||||
than the average throughout, so that one plate or one blank leaf does not move
|
||||
the answer for a whole book.
|
||||
|
||||
### The split
|
||||
|
||||
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.
|
||||
|
||||
Splitting is safe to repeat: the halves are portrait, so a second pass finds
|
||||
nothing to do.
|
||||
|
||||
## As a library
|
||||
|
||||
```rust
|
||||
let mut pdf = pdfgutter::pdf::Pdf::open(std::path::Path::new("scan.pdf"))?;
|
||||
for index in pdfgutter::sample_indices(pdf.len()) {
|
||||
println!("page {} ratio {:.2}", index + 1, pdf.geometry(index).ratio());
|
||||
}
|
||||
let pages = pdf.split(pdfgutter::MIN_RATIO);
|
||||
pdf.save(std::path::Path::new("split.pdf"))?;
|
||||
println!("{pages} pages");
|
||||
# Ok::<(), pdfgutter::pdf::Error>(())
|
||||
```
|
||||
|
||||
`pdfgutter::has_gutter` takes a column ink profile and answers on its own, for
|
||||
anything that rasterises its own pages. `pdfgutter::ink::profile` turns a
|
||||
greymap into one.
|
||||
|
||||
## See also
|
||||
|
||||
- [`pdftriage`](../pdftriage/README.md) — whether the text layer is worth
|
||||
keeping, which is the next question after this one.
|
||||
- [`booksniff`](../booksniff/README.md) — whether the file is a PDF at all.
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(¢red));
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Splitting a real file, checked by reading the result back.
|
||||
//!
|
||||
//! The PDFs are built here rather than kept as fixtures, so that what each
|
||||
//! test is about — an inherited box, a rotated sheet, a mixed document — is
|
||||
//! visible in the test instead of hidden in a binary.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use pdfgutter::pdf::Pdf;
|
||||
|
||||
/// One page of a document under construction.
|
||||
struct Sheet {
|
||||
width: f64,
|
||||
height: f64,
|
||||
rotate: i64,
|
||||
}
|
||||
|
||||
/// Write a PDF whose pages carry their own boxes.
|
||||
fn write(path: &Path, sheets: &[Sheet]) {
|
||||
write_with(path, sheets, false);
|
||||
}
|
||||
|
||||
/// Write a PDF, optionally hoisting the box onto the page tree so that every
|
||||
/// page has to inherit it.
|
||||
fn write_with(path: &Path, sheets: &[Sheet], inherit: bool) {
|
||||
let first = 4;
|
||||
let kids: Vec<String> = (0..sheets.len())
|
||||
.map(|i| format!("{} 0 R", first + 2 * i))
|
||||
.collect();
|
||||
|
||||
let mut objects: Vec<(usize, Vec<u8>)> = Vec::new();
|
||||
objects.push((1, b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()));
|
||||
|
||||
let shared = match (inherit, sheets.first()) {
|
||||
(true, Some(sheet)) => format!(" /MediaBox [0 0 {} {}]", sheet.width, sheet.height),
|
||||
_ => String::new(),
|
||||
};
|
||||
objects.push((
|
||||
2,
|
||||
format!(
|
||||
"<< /Type /Pages /Count {} /Kids [{}]{shared} >>",
|
||||
sheets.len(),
|
||||
kids.join(" ")
|
||||
)
|
||||
.into_bytes(),
|
||||
));
|
||||
objects.push((
|
||||
3,
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
|
||||
));
|
||||
|
||||
for (i, sheet) in sheets.iter().enumerate() {
|
||||
let content = format!("BT /F1 12 Tf 40 40 Td (page {}) Tj ET", i + 1);
|
||||
let own = match inherit {
|
||||
true => String::new(),
|
||||
false => format!(" /MediaBox [0 0 {} {}]", sheet.width, sheet.height),
|
||||
};
|
||||
let rotate = match sheet.rotate {
|
||||
0 => String::new(),
|
||||
turn => format!(" /Rotate {turn}"),
|
||||
};
|
||||
objects.push((
|
||||
first + 2 * i,
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R{own}{rotate} /Resources << /Font << /F1 3 0 R >> >> /Contents {} 0 R >>",
|
||||
first + 2 * i + 1
|
||||
)
|
||||
.into_bytes(),
|
||||
));
|
||||
let mut stream = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes();
|
||||
stream.extend_from_slice(content.as_bytes());
|
||||
stream.extend_from_slice(b"\nendstream");
|
||||
objects.push((first + 2 * i + 1, stream));
|
||||
}
|
||||
|
||||
let mut out: Vec<u8> = b"%PDF-1.4\n".to_vec();
|
||||
let mut offsets = vec![0usize; objects.len() + 5];
|
||||
for (number, body) in &objects {
|
||||
offsets[*number] = out.len();
|
||||
out.extend_from_slice(format!("{number} 0 obj\n").as_bytes());
|
||||
out.extend_from_slice(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let start = out.len();
|
||||
let top = objects.iter().map(|(n, _)| *n).max().unwrap_or(0) + 1;
|
||||
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 1 0 R >>\nstartxref\n{start}\n%%EOF\n").as_bytes(),
|
||||
);
|
||||
std::fs::write(path, out).expect("a writable file");
|
||||
}
|
||||
|
||||
/// A directory this test can write into, named for the test.
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("pdfgutter-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("a writable directory");
|
||||
dir
|
||||
}
|
||||
|
||||
fn wide() -> Sheet {
|
||||
Sheet {
|
||||
width: 1008.0,
|
||||
height: 720.0,
|
||||
rotate: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn tall() -> Sheet {
|
||||
Sheet {
|
||||
width: 480.0,
|
||||
height: 720.0,
|
||||
rotate: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wide_sheet_becomes_two_pages_and_a_tall_one_is_left_alone() {
|
||||
let dir = scratch("mixed");
|
||||
let (source, destination) = (dir.join("in.pdf"), dir.join("out.pdf"));
|
||||
write(&source, &[tall(), wide(), wide(), tall()]);
|
||||
|
||||
let mut pdf = Pdf::open(&source).expect("a readable PDF");
|
||||
assert_eq!(pdf.len(), 4);
|
||||
assert_eq!(pdf.split(pdfgutter::MIN_RATIO), 6);
|
||||
pdf.save(&destination).expect("a written PDF");
|
||||
|
||||
let split = Pdf::open(&destination).expect("a readable PDF");
|
||||
assert_eq!(split.len(), 6);
|
||||
|
||||
// The covers keep their shape; the halves are each half a sheet wide.
|
||||
let widths: Vec<f64> = (0..6).map(|i| split.geometry(i).displayed().0).collect();
|
||||
assert_eq!(widths, [480.0, 504.0, 504.0, 504.0, 504.0, 480.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_halves_come_out_in_reading_order() {
|
||||
let dir = scratch("order");
|
||||
let (source, destination) = (dir.join("in.pdf"), dir.join("out.pdf"));
|
||||
write(&source, &[wide()]);
|
||||
|
||||
let mut pdf = Pdf::open(&source).expect("a readable PDF");
|
||||
pdf.split(pdfgutter::MIN_RATIO);
|
||||
pdf.save(&destination).expect("a written PDF");
|
||||
|
||||
let split = Pdf::open(&destination).expect("a readable PDF");
|
||||
assert_eq!(split.geometry(0).media, [0.0, 0.0, 504.0, 720.0]);
|
||||
assert_eq!(split.geometry(1).media, [504.0, 0.0, 1008.0, 720.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_the_pages_inherit_is_found_and_kept() {
|
||||
// A page tree that puts one MediaBox at the top and none on any page is
|
||||
// ordinary, and reading the pages alone would see no box at all.
|
||||
let dir = scratch("inherited");
|
||||
let (source, destination) = (dir.join("in.pdf"), dir.join("out.pdf"));
|
||||
write_with(&source, &[wide(), wide()], true);
|
||||
|
||||
let pdf = Pdf::open(&source).expect("a readable PDF");
|
||||
assert_eq!(pdf.geometry(0).media, [0.0, 0.0, 1008.0, 720.0]);
|
||||
|
||||
let mut pdf = pdf;
|
||||
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.geometry(0).media, [0.0, 0.0, 504.0, 720.0]);
|
||||
assert_eq!(split.geometry(3).media, [504.0, 0.0, 1008.0, 720.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheet_on_its_side_is_cut_the_way_it_is_read_and_not_the_way_it_is_stored() {
|
||||
let dir = scratch("rotated");
|
||||
let (source, destination) = (dir.join("in.pdf"), dir.join("out.pdf"));
|
||||
// Stored tall, displayed wide: a quarter turn makes this a spread.
|
||||
write(
|
||||
&source,
|
||||
&[Sheet {
|
||||
width: 720.0,
|
||||
height: 1008.0,
|
||||
rotate: 90,
|
||||
}],
|
||||
);
|
||||
|
||||
let pdf = Pdf::open(&source).expect("a readable PDF");
|
||||
assert_eq!(pdf.geometry(0).displayed(), (1008.0, 720.0));
|
||||
|
||||
let mut pdf = pdf;
|
||||
assert_eq!(pdf.split(pdfgutter::MIN_RATIO), 2);
|
||||
pdf.save(&destination).expect("a written PDF");
|
||||
|
||||
let split = Pdf::open(&destination).expect("a readable PDF");
|
||||
// Cut across rather than down, and each half still displays upright.
|
||||
assert_eq!(split.geometry(0).media, [0.0, 0.0, 720.0, 504.0]);
|
||||
assert_eq!(split.geometry(1).media, [0.0, 504.0, 720.0, 1008.0]);
|
||||
assert_eq!(split.geometry(0).displayed(), (504.0, 720.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_of_ordinary_pages_comes_through_unchanged() {
|
||||
let dir = scratch("untouched");
|
||||
let (source, destination) = (dir.join("in.pdf"), dir.join("out.pdf"));
|
||||
write(&source, &[tall(), tall(), tall()]);
|
||||
|
||||
let mut pdf = Pdf::open(&source).expect("a readable PDF");
|
||||
assert_eq!(pdf.split(pdfgutter::MIN_RATIO), 3);
|
||||
pdf.save(&destination).expect("a written PDF");
|
||||
|
||||
let split = Pdf::open(&destination).expect("a readable PDF");
|
||||
assert_eq!(split.len(), 3);
|
||||
for index in 0..3 {
|
||||
assert_eq!(split.geometry(index).media, [0.0, 0.0, 480.0, 720.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitting_twice_does_not_split_twice() {
|
||||
// Halves are portrait, so a second pass has nothing to do. Anything that
|
||||
// runs over a directory will eventually run over its own output.
|
||||
let dir = scratch("idempotent");
|
||||
let (source, once, twice) = (dir.join("in.pdf"), dir.join("1.pdf"), dir.join("2.pdf"));
|
||||
write(&source, &[wide(), wide(), wide()]);
|
||||
|
||||
let mut pdf = Pdf::open(&source).expect("a readable PDF");
|
||||
pdf.split(pdfgutter::MIN_RATIO);
|
||||
pdf.save(&once).expect("a written PDF");
|
||||
|
||||
let mut again = Pdf::open(&once).expect("a readable PDF");
|
||||
assert_eq!(again.split(pdfgutter::MIN_RATIO), 6);
|
||||
again.save(&twice).expect("a written PDF");
|
||||
|
||||
let split = Pdf::open(&twice).expect("a readable PDF");
|
||||
assert_eq!(split.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_not_a_pdf_is_refused_rather_than_guessed_at() {
|
||||
let dir = scratch("refused");
|
||||
let path = dir.join("not.pdf");
|
||||
std::fs::write(&path, b"this is not a PDF at all").expect("a writable file");
|
||||
assert!(Pdf::open(&path).is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user