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
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.
222 lines
11 KiB
Markdown
222 lines
11 KiB
Markdown
# 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::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` → `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 |
|