Read a page back as text somebody can actually search - #2
Open
tannevaled wants to merge 5 commits into
Open
Conversation
Runs, Text and Images all walk the page the same way, and the walk undid every picture's filters whatever the caller had asked for. A page's images have nothing to do with its text, and undoing their filters is the most expensive thing on the page. One arXiv figure holds 378 MB of image once decompressed. Asking that page what it said cost 1 094 MB and 1.33 seconds, all of it spent inflating pictures the answer does not hold. extract.Runs on teaser.pdf 1.33 s, 1094 MB -> 1.11 ms, 5 MB Where a picture lands is still worked out either way, since that costs a matrix multiply; only the bytes are left alone. Images is unchanged and still unpacks them. The text is unchanged too, and that is checked rather than assumed: every page's runs with their positions, sizes, fonts and flags, the assembled text, and every image's dimensions, placement, filter and content hash, over 21 311 files. The one file that differed differed from itself, from a defect in the reader that this campaign fixed separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The walk only ever goes forwards, so nothing needs the list of operations that reader.Operations builds. Keeping it costs a struct and a slice header for every operator on the page, and pages are not always small. 2608.06617/neglected_curvature.pdf is 13 MB and holds one page of 84.8 MB of content stream and 4 579 973 operations. Reading its text allocated 2 180 MB; taking the operations one at a time through the scanner the reader already has costs 948 MB. extract.Runs on neglected_curvature.pdf 2180 MB -> 948 MB The scanner is the same code the list was built from — Operations is a loop over it — so the operations are the same operations in the same order. The error it swallowed was already being swallowed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The workflow's last two runs failed to start. Nothing in the tree changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
reader v0.4.1 stopped answering "which objects call themselves a catalogue?" by counting from zero to the largest object number a file names. This package was still asking for v0.4.0, so it still handed its callers the defect. bug1980958.pdf in mozilla's pdf.js corpus is 219 bytes. It has no trailer and no startxref, so it can only be read by repairing it, and the last object it declares is numbered 2 147 483 647. Two thousand million map lookups for four objects: reader.Open on 219 bytes 21.2 s, 0 MB allocated -> under 1 ms Not one byte allocated, which is why no memory limit anywhere caught it. The test builds the file rather than committing somebody else's, and guards the dependency rather than this package's own code: the answer is either a fraction of a millisecond or twenty-one seconds and nothing in between, so there is no threshold to tune. It fails in 15.5 s against the parent commit. Found by a fuzz target added here: it was the 205th seed, and it did not fail the run so much as stop it — the fuzzing coordinator gave up on the worker as hung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A run's position came through the text matrix and the page's transform together. Its width, its size and the width of a space in it came through the page's transform alone: the text matrix's own scale was left out. A document may put the whole of its scale there — "/F1 1 Tf" followed by a text matrix of ten, rather than "/F1 10 Tf" — and TeX puts it there in every PDF it has ever produced. Such a document reported one-point text with half-point letters while saying quite correctly where on the page each run began. Those are the two numbers a word break is decided by. A gap measured in points was weighed against a space width ten times too small, so every ordinary kern between two letters looked like a space: F o r t h e y e a r J a n . 1 - D e c . 3 1 , 2 0 2 5 For the year Jan. 1-Dec. 31, 2025 That is IRS Form 1040, before and after. Nothing failed and nothing was slow; the text was simply no longer text, and no panic or timing measurement would ever have said so. Measured over 2 268 government forms — 2 079 with text, 7 630 pages, 19.4 million characters — the share of extracted words that are one or two letters long: 52.42% -> 25.81% 1 036 400 fragments became parts of words again, and 1.23 million spurious spaces went away. Over 4 000 arXiv figures, which are mostly axis labels and single-letter mathematics and so have a high floor, 57.26% -> 38.43%. The threshold itself is unchanged and did not need changing: it was already relative to the font's own space width. Sweeping it from a tenth of a space to a whole one moves the measure by 1.6 points, against the 26.6 the units were worth, so leaving it at 0.3 is the measured answer rather than the untouched one. Every test in this package wrote its size into Tf, which is why none of them saw it. The new ones draw the same page both ways and require the same answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tannevaled
force-pushed
the
battle-text-without-pictures
branch
from
August 27, 2026 08:12
f82d25f to
830638a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Found by a robustness campaign over mozilla's pdf.js adversarial corpus (977 files), 2 268 public forms from 18 issuers (including the deliberately broken suites of veraPDF, qpdf, pdfbox, pypdf, pdfcpu and openpdf), and 33 628 arXiv figure PDFs.
Four independent findings, one theme: what it costs to read a page, and whether what comes back is worth having.
1. Word breaks that were not there
A run's position came through the text matrix and the page's transform together. Its width, its size and the width of a space in it came through the page's transform alone — the text matrix's own scale was left out.
A document may put the whole of its scale there —
/F1 1 Tffollowed by a text matrix of ten, rather than/F1 10 Tf— and TeX puts it there in every PDF it has ever produced. Such a document reported one-point text with half-point letters, while saying quite correctly where on the page each run began.Those are the two numbers a word break is decided by. A gap measured in points was weighed against a space width ten times too small, so every ordinary kern between two letters looked like a space. IRS Form 1040:
Nothing failed and nothing was slow. The page read back as fully readable and was unusable, and no panic or timing measurement would ever have said so.
Measured
Share of extracted words that are one or two letters long — on a page of prose that number should be small:
On the forms, 1 036 400 fragments became parts of words again and 1.23 million spurious spaces went away.
The threshold was not the problem
wantsSpacewas already relative to the font's own space width, not an absolute displacement. Sweeping the factor across an order of magnitude, with a two-sided measure so that running words together would show up too:A flat plateau: the factor is worth 1.6 points across a tenfold range, against the 26.6 the units were worth. Leaving it at 0.3 is the measured answer, not the untouched one.
Every test in this package wrote its size into
Tf, which is why none of them saw this. The new ones draw the same page both ways and require the same answer.2. Reading the text unpacked every picture
Runs,TextandImagesall walk the page through the samewalk, and the walk undid every picture's filters whatever the caller had asked for.2608.05837/figures/torus/teaser.pdfis a 4.96 MB arXiv figure holding 378 MB of image once decompressed. Asking that page what it said cost 1 094 MB and 1.33 seconds to produce 53 text runs — 98.93% of the allocation undernoteImagethenreader.Decode.After: 1.110 ms, 5 MB. 1 200× faster, 219× less memory.
Imagesis unchanged and still unpacks them. Where a picture lands is still worked out either way; only the bytes are left alone.3. Every operation on the page was materialised first
The walk only ever goes forwards, so nothing needs the list
reader.Operationsbuilds.2608.06617/neglected_curvature.pdfholds one page of 84.8 MB of content stream and 4 579 973 operations:The scanner is the same code the list was built from —
Operationsis a loop over it — so the operations are the same in the same order.4. A stale dependency still shipping a known denial of service
readerv0.4.1 stopped counting from zero to the largest object number a file names. This package was still asking for v0.4.0.bug1980958.pdfis 219 bytes, has no trailer and no startxref, and its last object is numbered 2 147 483 647:Not one byte allocated, which is why no memory limit caught it. Measured with
GOMAXPROCS(1)— a goroutine waiting for a core cannot tell waiting from working.Found by the fuzz target this PR adds: it was the 205th seed, and it did not fail the run so much as stop it — the coordinator gave up on the worker as hung.
The regression tests, each shown failing first
TestKerningIsNotAWordBreakandTestTheTextMatrixScalesTheRunItself, against the parent commit:The
scale in the font sizesubtests pass in both, so the fix is aimed at the broken case and leaves the working one alone.TestARealSpaceIsStillAWordBreakis the other half — a fix that never breaks a word would score perfectly on the measure that found this and be useless.TestTextDoesNotUnpackThePicturesasserts on memory rather than time, because memory does not depend on how busy the machine is; reverting only the behaviour fails it withreading the text allocated 16884792 bytes. It also requiresImagesto still return all 8 000 000 bytes, so it cannot be satisfied by never decoding anything.TestATinyFileWithAHugeObjectNumberbuilds the 219-byte file rather than committing somebody else's, and fails in 15.5 s against the stale pin.What was verified not to change
Before the word-break commit, the text and image output was checked to be byte-identical: every page's runs with their
Text,X,Y,Width,Size,Font,Space,InvisibleandUnreadable, the assembled text, and every image's dimensions, placement, filter, length and content hash, over 21 311 files.issue14256.pdfgave four different answers in five runs of the unmodified code. Fixed separately in content: an inline image that spells an entry twice must not be read twice reader#13.The word-break commit changes text output deliberately, and is measured by the tables above rather than by a hash.
Fuzz target
There was none in this repository.
FuzzExtractreads a page back three ways and asserts a time budget as well as absence of panic. 2 438 962 executions over 10 minutes: clean (after finding #4 above). PointPDF_SEEDSat a corpus to seed from it.What else was looked for and not found
Runs,TextandImages: no panics, no non-termination — re-run after the word-break change with the same result.Gates
go test ./...green, 100.0% of statementsgo vetclean,gofmtclean,CGO_ENABLED=0🤖 Generated with Claude Code