Files

pdfgutter

Finds books scanned two pages to a sheet, and splits them back into pages.

Part of catena.

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 — 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

  • 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

cargo install --git https://git.pumpin.online/funkpower/catena pdfgutter

Needs pdftoppm, from poppler. Nothing is split without first being looked at, so it is needed either way.

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:

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
--man Write this page as a manual page, in roff

--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

Code Meaning
0 Two pages to a sheet
1 One page to a sheet
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

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. 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.

The record

--json writes one of these, whatever the verdict:

Field
path The file it is about
two_up Whether it is two pages to a sheet
pages How many pages it has
median_ratio The middle ratio of the pages looked at
gutter_fraction What share of the wide ones showed a gutter
sampled One entry per page looked at: its number, its ratio, and whether it had a gutter — null where the page was too narrow to be worth rendering

It is a shape of its own rather than one of the records the tools pass between them, because nothing downstream consumes it.

As a library

let mut pdf = pdfgutter::pdf::Pdf::open(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(Path::new("split.pdf"))?;
println!("{pages} pages");

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 — whether the text layer is worth keeping, which is the next question after this one.
  • booksniff — whether the file is a PDF at all.