Phase 8 bullets one and two. Probing the existing code first, as the
workflow requires, found five defects rather than the one the plan names:
SPLIT MATCH 'Hello': 0 hits
plain_text: "Hello"
PRECOMPOSED 'café': 0 hits
COLUMNS plain_text: "LeftTopRightTop\nLeftBotRightBot"
OUT OF ORDER plain_text: "second\nfirst"
`PageText::find` searched one run at a time and documented that as a known
limitation. It is a limitation from inside the code and a broken feature
from outside it: a writer starts a new run wherever it adjusts kerning, so
an ordinary word arrives as two runs, and the find bar says a word plainly
visible on the page is not there.
`search.rs` indexes the page as one flattened string with a map back to
(run, character), so a cross-run match is found and highlighted with one
rectangle per run — never a merged box, which across a line break covers
half the paragraph.
The separator between two runs is a geometric question with three answers:
abutting runs join with nothing (one word, split by kerning), separated
runs with a space, and a different line or column with a newline. The
newline matters as much as the empty join: joining lines with a space lets
"one Right" match across a column gutter, text that appears nowhere.
Whether two runs share a column is *asked* of the layout analysis rather
than re-derived, or the extracted text and the searched text disagree about
where a column ends — the original defect wearing a different hat.
NFD, never NFC: composition needs the next character, so an NFC fold
applied per character composes nothing and the two spellings of an accent
stay different. That was a real bug in the first draft. And case *folding*,
not lowercasing — Rust lowercases ß to ß, so "Strasse" never found
"Straße".
Columns are detected before lines, because two columns share their
baselines; that is what makes them columns. Bands are separated by a gutter
rather than by bare non-overlap, since two abutting runs on a line do not
overlap either.
Also fixed, found by running the gates rather than by looking: a stream
reader trimmed a trailing CR before `endstream` as if it were the writer's
separator. Binary data ends in CR about one time in 256, and when it did
the reader returned a stream one byte short — no longer AES-block-aligned,
so decryption produced garbage and Flate failed. Roughly one encrypted
document in 250 was silently corrupt on read. The test failed once under
coverage, passed five times in isolation, and failed 2 in 40 when actually
counted. A /Length consistent with the file is now the authority; both
stream readers are fixed and a test reads one file through each.
1477 tests pass (was 1426), coverage 88.37%, all floors met, external
readers pass. 10 mutations across the two modules, all killed.
ADR 0034.
11 KiB
ADR 0034: text search and layout — a word you can see and cannot find
- Status: Accepted
- Date: 2026-08-19
- Review item:
NIGIG_PDF_FEATURE_PARITY_PLAN.md§1 Phase 8, "Text search: Unicode-aware search with hit rects" and "Selection improvement: layout-aware (multi-line, multi-column) hit-testing" - Supersedes: the per-run search in
PageText::find - Related: ADR 0031 (per-glyph pen offsets, which these rectangles use), ADR 0017 (declared versus delivered)
Context
PageText::find searched one run at a time, and its doc comment said
so plainly:
A match split across two segments is not reported, which is a known limitation recorded here rather than papered over with an approximate rectangle.
Honest — and a search that does not work. A PDF writer starts a new run wherever it adjusts kerning, so a perfectly ordinary word arrives as two runs. Probing the existing code before changing it:
SPLIT MATCH 'Hello': 0 hits
plain_text: "Hello"
The page displays Hello. Extraction returns Hello. Search returns
nothing. From the user's side there is no limitation to understand: the word
is right there and the find bar says it is not.
The same probe found four more:
PRECOMPOSED 'café': 0 hits (document has cafe + U+0301)
COLUMNS plain_text: "LeftTopRightTop\nLeftBotRightBot"
OUT OF ORDER plain_text: "second\nfirst"
- Accents. A document using a combining-accent font is unsearchable with the precomposed spelling, and vice versa. The two look identical on screen.
- Columns. Reading order was content-stream order. A two-column page glued each left line to the line beside it.
- Emission order. A writer that emits runs bottom-up produced reversed text, because nothing sorted.
Decision
Search a page-level flattened string, not each run
SearchIndex builds one character vector for the page with a map back to
(run, character index). A match is found in the flat string and projected
back onto runs, which is what makes a cross-run match findable and
highlightable: one rectangle per run it touches, never one merged box, since
a merged box across a line break covers half the paragraph.
The separator between runs is a geometric question
Three cases, and the third is the one a naive implementation misses:
| Runs | Join with | Because |
|---|---|---|
| Abutting on a line | nothing | one word split by kerning |
| Separated on a line | a space | two words |
| Different line or column | a newline | a query must not span the break |
The newline matters as much as the empty join. Joining lines with a space
lets "one Right" match the last word of the left column and the first of
the right — text that appears nowhere and that no highlight can honestly
draw. A newline is a character a find-bar query does not contain, so the
match stops at the boundary.
Whether two runs are in the same column is asked of the layout analysis,
not re-derived from the gap. Measuring twice means the extracted text and
the searched text can disagree about where a column ends, and then a user
searching for what they can see gets nothing — the original defect wearing a
different hat. the_extracted_text_and_the_searched_text_agree is that
invariant as a test.
NFD, never NFC
Composition is not a per-character operation: turning e + U+0301 into é
requires the next character. Decomposition is — é always becomes
e + U+0301 regardless of neighbours — so decomposing both document and
query makes the two spellings identical with no lookahead, and the index
stays a simple character-to-character map.
This was a real bug in the first draft, caught by
a_decomposed_accent_matches_its_precomposed_spelling: an NFC fold applied
one character at a time composes nothing and the two spellings stay
different.
Case folding, not lowercasing
char::to_lowercase is not case folding. Rust lowercases ß to ß — it is
already lower case — so "Strasse" never finds "Straße". Unicode case folding
maps ß to ss. fold_case handles the multi-character folds a Latin
document produces, including the fi/fl ligatures a PDF emits as single
code points, and falls through to to_lowercase otherwise.
Because a fold can be one-to-many, the index maps each folded character
back to the one source character it came from, so ß still highlights one
letter rather than shifting every rectangle after it.
Columns from runs, lines within columns
Ordering matters and is the whole difficulty: two columns share their baselines — that is what makes them columns — so grouping into lines first merges a left run with the run beside it and the boundary is gone. Columns are detected first, from horizontal extents; lines are detected within each column.
Bands are separated by a gutter, not by bare non-overlap: two runs abutting on one line do not overlap either, and treating that as a column boundary splits every kerned line on the page. Two ems separates a gutter from the widest ordinary word space and scales with the type size.
Bands that a later run bridges are merged, so a full-width heading pulls the page into one column. That is correct — a page with a heading across the top is not two independent columns — and it is why the gutter threshold turns out not to be load-bearing on a realistic page, which the merge criteria below record rather than hide.
Diacritic folding is opt-in
Dropping accents by default silently widens a search the user did not ask to widen: searching a name for "Muller" and getting "Müller" is a decision. Case folding is on by default because that is what a find bar does.
Consequences
- A word visible on the page is findable, whatever the writer did to it.
- Highlights land on real glyph positions, using ADR 0031's per-glyph offsets.
PageText::findis left alone. It is per-run by design and several tests assert the old behaviour deliberately, as the thing being improved on; deleting it would remove the evidence that the defect was real.is_combining_markis a range table, not a Unicode general-category lookup. A mark outside the listed blocks is kept, so the failure mode is a search that is too strict rather than one matching the wrong word.- Right-to-left and vertical writing are not handled. Lines are ordered by x ascending, which is wrong for Arabic and Hebrew.
Addendum: a flaky encryption test, found by running the gates
The coverage run failed in encryption_write.rs — a test with nothing to do
with search. It passed five times in isolation, which is exactly the point
at which "flaky, not mine" is the tempting conclusion. Running it forty
times gave 2 failures, so it was reproducible and therefore real.
The cause was in xref.rs, not in the encryption code. A stream reader
trims a trailing CR or LF before endstream, because that is how a writer
separates the data from the keyword — and it cannot tell that separator from
a data byte that happens to be CR. Binary data ends in CR about one time
in 256.
When it happened, the reader returned a stream one byte short. The declared
/Length was right, the file was right, and the stream was no longer a
multiple of the AES block size, so decryption produced garbage and Flate
then failed with "cannot make progress". Roughly one encrypted document in
250 was silently corrupt on read.
The fix is that a /Length consistent with the file is the authority:
if the declared length lands on the scanned end, or on it minus a one- or
two-byte separator, it is used as written. Only a length that genuinely does
not fit falls back to the scan, which is the case the trim exists for.
Both stream readers had the flaw and both are fixed, with a test that reads the same file through each and asserts they agree — a file that reads correctly through one path and short through the other is worse than one that fails in both, because which answer you get is not reproducible from the file.
The general lesson, recorded because this project keeps meeting it: a
lenient reader hides a broken writer, and a heuristic that repairs broken
files corrupts correct ones. The trim was added to recover from a wrong
/Length; it silently damaged files whose /Length was right.
Merge criteria
Enumerated from the plan bullets first, per ADR 0021.
| Criterion | State |
|---|---|
| A match split across runs is found | ✅ a_word_split_by_kerning_is_found_in_a_real_document |
| One hit rectangle per run, not a merged box | ✅ asserted on a real page |
| Hit rectangles use real glyph offsets | ✅ via ADR 0031; extents asserted |
| Decomposed and precomposed accents match each other | ✅ both directions |
| Case-insensitive by default | ✅ and switchable |
Case folding (ß → ss, fi → fi) |
✅ mutation-killed |
| A one-to-many fold still highlights one letter | ✅ |
| Diacritic-insensitive search, opt-in | ✅ default asserted to be strict |
| Whole-word search | ✅ mutation-killed at unit and integration level |
| Overlapping matches reported once | ✅ mutation-killed |
| A query cannot span a line break | ✅ mutation-killed |
| A query cannot span a column gutter | ✅ |
| Lines grouped by baseline, ordered left to right | ✅ |
| Columns detected before lines | ✅ two columns sharing a baseline stay separate |
| Reading order is a permutation — no run dropped or duplicated | ✅ across three fixtures |
| A single-column page is unchanged by the analysis | ✅ |
| Extracted text and searched text agree | ✅ every extracted line is findable |
| Selection across lines | ✅ asserted to be a contiguous slice |
| Hit-testing lands on the run under the point | ✅ every run of a real page |
| A click in the gutter hits nothing | ✅ |
| Gutter threshold is load-bearing on a real page | ⚠️ no — band merging makes a longer line bridge the gap anyway. Killed by the unit test, survives the integration test, and both are kept with the reason written in the test |
| Right-to-left and vertical writing | ❌ not handled — lines are ordered by x ascending |
| Unicode general-category table for combining marks | ❌ deferred — range table; errs toward too strict |
| Mutation-checked | ✅ 7 mutations, all killed by the unit suite; 6 of 7 also by the integration suite, and the seventh is explained above |
Incidental fix, verified separately:
| Criterion | State |
|---|---|
| A stream whose data ends in CR, LF or CRLF is not trimmed | ✅ three tests; was corrupting ~1 encrypted document in 250 |
A /Length short by one or two bytes is trusted over the scan |
✅ the case the two rules differ on |
A badly short /Length still loses to endstream |
✅ the case the trim exists for, unchanged |
A /Length past the end of the file falls back to the scan |
✅ |
| Both stream readers agree | ✅ same file through each |
| Mutation-checked | ✅ 3 mutations, all killed |