nigig-org/REVIEWS/adr/0034-pdf-text-search-and-layout.md
andodeki a82c8f7ff7
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
spreadsheet / engine-coverage (push) Has been cancelled
spreadsheet / ui-controller-coverage (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
nigig-map / test (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
sms / gates (push) Has been cancelled
feat(pdf): Unicode-aware search and layout-aware reading order
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.
2026-08-19 16:12:12 +00:00

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 / 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::find is 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_mark is 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) 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