nigig-org/NIGIG_PDF_FEATURE_PARITY_PLAN.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

756 lines
44 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# nigig-pdf feature-parity plan (view → create → edit)
Target: bring the four-crate `nigig-pdf` stack in `crates/apps/pdf/` to feature
parity with the dart-pdf reference implementation (Apache-2.0) for **creating
and editing** PDFs, and absorb the Makepad `dev`-branch PDF code so we no longer
need it. Viewing is already the baseline; this plan closes the read/edit/write
gaps phase by phase.
Coverage parity is a first-class goal, not an afterthought: Section 2 maps all
137 dart-pdf test suites (pdf_cos 31, pdf_document 66, pdf_graphics 37) to a
nigig port/decision per phase, so each phase lands its features **with** the
corresponding dart-pdf test coverage. "Parity" for a phase = the dart-pdf
feature exists in the matching nigig crate **and** its dart-pdf test suite has a
passing nigig equivalent.
## 0. Reference codebases and current state
### 0.1 Makepad upstream `dev` PDF code (the current baseline to supersede)
| File | LOC | Capability |
|------|-----|-----------|
| `libs/pdf_parse/src/*.rs` | ~155K | Minimal parse-only library: lexer, parser, object, page, content ops, filters (zlib), font (char widths), image (inline + extraction), document, tests |
| `widgets/src/pdf_view.rs` | ~40K | View-only widget: page rendering via `PortalList`, text selection, zoom. **No edit, no form, no annotations, no save** |
| `examples/pdf` | ~3.3K | Viewer demo app |
Upstream `dev` `pdf_view.rs` capabilities (from source): text-segment tracking
for selection, selection anchor/cursor, zoom, page caching. That is the full
feature set — reading/display only.
### 0.2 nigig-pdf current state (what we have)
- `nigig-pdf-cos` (8 files): `lexer.rs`, `parser.rs`, `object.rs`, `xref.rs`,
`writer.rs`, `filter.rs` (Flate + TIFF predictor), `encrypt.rs` (RC4,
AES-128/256), `incremental.rs`.
- `nigig-pdf-document` (12 files): `document.rs`, `page.rs`, `annotations.rs`,
`annotation_edit.rs`, `appearance.rs`, `destinations.rs`, `form.rs`,
`form_actions.rs`, `signature.rs` (read-only), `structure.rs`, `save.rs`,
`content_writer.rs`.
- `nigig-pdf-graphics` (19 files): `content.rs` interpreter, `device.rs`
(`PdfDevice` trait), `recording.rs` (`RenderCommand`), `font.rs`, `sfnt.rs`,
`cid.rs`, `cmap.rs`, `advance.rs`, `colorspace.rs`, `icc.rs`, `function.rs`,
`transparency.rs`, `image.rs`, `cache.rs`, `worker.rs`, `text.rs`,
`content_writer.rs`, `graphics_state.rs`.
- `nigig-pdf-makepad` (5 files): `renderer.rs`, `device.rs`, `page_view.rs`,
`interaction.rs` (form-field interaction: `render_fields`, `render_open_combo`,
`PdfAction`, selection, key input), `lib.rs`.
- Tests: 571 passing (cos 72, document 147 unit + ~160 integration, graphics
191 unit + 19 golden + 7 phase-5 exit criterion). Fuzz targets in
`pdf-cos/fuzz/`. Corpus in `crates/apps/pdf/tests/corpus/`.
### 0.3 dart-pdf reference inventory (feature target)
- `pdf_cos` (~23K LOC): full lexer/parser/xref with xref streams + hybrid
refs + recovery; filters **Flate, LZW, CCITT, JBIG2, JPX, RunLength,
ASCIIHex, ASCII85**; crypto **RC4, AES-128/256, RSA, CMS, OCSP, CRL**;
serializer/builder/updater/compactor/byte-source.
- `pdf_document` (~500K LOC): document, page editing, **annotation editor +
sync + clipboard**, **form editor + styling + admin**, **content editor +
type-0 + rewriter + reflow**, **redaction**, **signature editor + PAdES +
signing identity + Fulcio**, outlines + outline editor, page labels, struct
tree + editor, XMP, **PDF/A + PDF/UA conformance**, OCR editor, attachment
editor, header/footer, watermark/stamps, office conversion, disk cache.
- `pdf_graphics` (~74K LOC): **interpreter (3.7K)**, **PdfDevice + Recording
+ RenderCommand + wire codec (binary, isolate-safe)**, font info (embedded
fonts, encodings, CID), ICC, colour spaces, functions, **mesh gradients,
shading, overprint compositor**, text extraction with layout/search/
selection, translating device, document AI, vector print.
- `dart_pdf_editor` (Flutter): canvas device, full editor UI (annotations,
forms, redaction, search, OCR, AI).
**Scale reference (from the nigig-vs-dart-pdf review).** nigig is ~30K LOC in
~40 files against dart-pdf's ~230K LOC in ~178 files. Per-layer ratios matter
for phase sizing — expect the biggest effort where the ratio is largest:
| Layer | dart-pdf | nigig | ratio |
|-------|----------|-------|-------|
| pdf_cos / pdf-cos | ~23K | ~1.9K | ~12× |
| pdf_document / pdf-document | ~500K | ~3.5K | ~140× |
| pdf_graphics / pdf-graphics | ~74K | ~2K | ~37× |
| UI (dart_pdf_editor / pdf-makepad) | canvas_device ~74K | ~2.5K | ~30× |
| Test files | 137 | ~20 | ~7× |
The pdf_document ratio (~140×) is the dominant cost; phases 46 (create/edit)
carry most of it. Module-level comparisons (lexer/parser/xref/object,
annotations/form_editor/redaction, interpreter/font_info/text_extraction) are
the per-phase checklists in §1 and §2.
## 1. Phase plan
Phases are ordered by dependency. Each phase ends with a green build and a
committed, tested tranche. "Parity" for a phase = the dart-pdf feature exists
in the matching nigig crate with tests.
### Phase 1 — Fork sync + makepad_test enablement (immediate, ~1-2 sessions)
- Sync the Gitdab makepad fork to upstream `dev` (workflow.md §0.1), keeping
`nigig-dev-reexports` intact.
- **Makepad test enablement (fork work, prerequisite for every UI test below).**
Upstream `dev` makepad_test only runs through the Studio hub: headless mode
still needs a Studio websocket, and Android is unsupported. This blocks
automated UI verification of nigig-pdf. The fork must gain:
- **Terminal/standalone mode**: `makepad_test` runs a suite with no Studio —
the harness spawns the app binary directly, drives the event loop, and
asserts on widgets (already partially present in the WIP runtime +
`examples/counter/tests/ui.rs` pattern: `Selector::id(..).click()` +
`.wait_text(..)`).
- **Android mode**: run the same `ui.rs` suites against a device via adb
(launch APK, port-forward the test channel, run asserts in-code) using the
staged WIP in the local fork checkout
(`makepad-native-glue/makepad`): `TestConfig.android/device_serial/adb_path`,
`start_android_app()`, native-activity platform module, cargo_makepad
install/launch.
- Reconcile the WIP with upstream `dev` (currently ~2 commits behind), resolve
conflicts, commit, and push to the Gitdab fork so the workspace pin uses it.
- Bump `rev =` in `pdf-makepad/Cargo.toml` + `map/Cargo.toml`; reconcile any
API breaks; full workspace build + 571 tests green.
- Baseline the gap list in this document against the freshly-synced
`libs/pdf_parse` + `widgets/src/pdf_view.rs` so we can demonstrate nigig-pdf
has superseded them.
Exit: workspace green on the new rev; `cargo test -p nigig-pdf-makepad --test ui`
runs on the desktop (no Studio) and on an Android device; this document's
inventory updated.
### Phase 2 — Complete content-stream coverage (pdf-cos / pdf-graphics)
Close the interpreter operator gaps dart-pdf handles and nigig does not:
- Inline images `BI/ID/EI`.
- Image XObjects `Do` in content streams (currently placeholder in
pdf-makepad).
- Form XObjects `Do` (nested content streams, resource dictionaries).
- Type 3 fonts (char-procs, `/FontMatrix`, `/CharProcs`, `/Encoding`).
- Shading operators `sh`/`sh1` + `/Pattern`/`/Shading` resources.
- Full text-object state (Tc/Tw/TL/Tz/Ts/Tstar/Td/TD/Tm/Tf/Tfs).
- **Read-side xref modernisation** (currently traditional xref + `/Prev` chains
only): xref **streams** (`/Type /XRef`, object streams `/ObjStm`), **hybrid
refs** (`/XRefStm`), and corrupt-file **recovery** (dart-pdf `xref.dart`
recovery + `parser.dart` robustness, `xref_test.dart`/`parser_test.dart`
coverage). nigig `parser.rs` is only 67 LOC; this is the biggest parse-side
gap.
- Lexer/parser error position tracking for diagnostics (dart-pdf token-level
lexer keeps source positions; nigig errors carry a position but the lexer is
simpler).
- Object model: lazy object resolution for very large documents (dart-pdf
`object.dart` lazy loading / type inference) — confirm nigig's `object.rs`
object-store is adequate or grow it.
- Streaming content interpretation (dart-pdf `streaming_interpreter.dart`
semantics: parse+interpret a content stream incrementally instead of
materializing every op) — covers `streaming_interpreter_test.dart`.
Exit: `pdf-graphics` tests cover each operator family; golden corpus grows;
`pdf-makepad` `ui.rs` smoke tests render pages exercising each new op family
(image/form XObject `Do`, Type 3 text) and assert non-empty draw output.
**Status: complete** (ADR 0013, ADR 0014). Audited item by item rather than
worked top-down, because several entries were already done:
| Item | State |
|---|---|
| Inline images `BI/ID/EI` | already implemented and tested |
| Image/Form XObject `Do` | already implemented; `PdfPage::xobjects` fixed in ADR 0011 |
| Full text state `Tc/Tw/TL/Tz/Ts/Td/TD/Tm/Tf` | already implemented |
| Shading `sh` | operator parsed and reported |
| **Xref streams, object streams, hybrid `/XRefStm`** | **ADR 0013** — was the biggest gap: *no PDF 1.5+ file would open at all* |
| **Type 3 fonts** | **ADR 0014**`/CharProcs`, `/FontMatrix`, `d0`/`d1` |
| **Streaming interpretation** | **ADR 0014**`ContentStreamIter`, `interpret_streaming` |
| Lexer error positions | errors already carry a byte position |
| Lazy object resolution | `resolve_num` already caches per object; adequate |
Three latent bugs were found and fixed along the way that the plan did not
list: `d1` read four of its six operands, `PdfPage::xobjects` was empty for
every document, and `BMC` corrupted the colour of every following operator.
The `ui.rs` half of the exit criterion is blocked on the Makepad fork
needing a headless backend — see `REVIEWS/PDF_PARITY_PHASE1_STATUS.md`.
### Phase 3 — Filters and image codecs (pdf-cos)
- Lossless: `RunLengthDecode`, `ASCIIHexDecode`, `ASCII85Decode`, `LZWDecode`
(+ EarlyChange predictor handling, CW-branching).
- Lossy/bitonal: CCITT G4 (and G3), JBIG2 (arith coding, huffman, refinement),
JPX/JPEG2000. Decide crate strategy (e.g. `ccitt`, `jbig2`, `jpeg2k`/`openjpeg`
or vendored ports from dart-pdf).
- Keep the `filter.rs` facade; add a codec registry for round-trip tests.
- Image decode surface: PNG/JPEG image decoding for raster image XObjects and
inline images (covers `png_test.dart`); image downsampling for render
performance (`image_downsample_test.dart`, `image_pixels.dart` surface).
Exit: corpus files using these filters decode; round-trip encode tests where
the format allows; `pdf-makepad` `ui.rs` test renders a page whose image uses
the new codec and asserts the decoded image is drawn (no blank region).
**Status: complete** (ADR 0015, ADR 0020). The lossless half landed under
ADR 0015; the three
image codecs it deferred — CCITT, JBIG2 and JPX — are now implemented, in
that order, each with its own tranche and mutation record.
| Item | State |
|---|---|
| `RunLengthDecode`, `ASCIIHexDecode`, `ASCII85Decode` | already worked; edge cases now covered |
| **`LZWDecode`** | **was broken outright** — dictionary indexing was two slots off, so the spec's own worked example failed |
| **`/EarlyChange`** | **was ignored** — a file setting 0 decoded to garbage, not an error |
| **Predictors on LZW** | **were gated to Flate only** |
| **Filter chains** | **`decode_stream` could not read an array `/Filter` at all**, so every chained stream in every document failed |
| **CCITT G3/G4** | **implemented** — T.4/T.6, all three `/K` schemes, decoded in the filter facade. Writing it found three defects: zero-length runs recorded no transition, trailing byte-padding was decoded as data, and a row of zero-length runs looped forever |
| **JBIG2** | **implemented** — MQ coder, generic regions (templates 0-3, TPGDON, MMR). Symbol dictionary, text/halftone/refinement regions and `/JBIG2Globals` are **refused by name**: those carry the composition machinery and shipping them means shipping an interpreter over untrusted input |
| **JPX** | **implemented** — pure Rust, no C dependency, so the Android cross-compile is unaffected. EBCOT tier-1, tag trees, packet headers, 5/3 and 9/7 wavelets, RCT/ICT. Verified against OpenJPEG-produced fixtures, exactly. Multiple tiles, custom precincts, code-block style options and `COC`/`QCC` are refused by name |
| `DCTDecode`/`JPXDecode` pass-through | **removed**: returned compressed bytes as though decoded |
All three are now done. The two open questions that gated JBIG2 and JPX
were answered rather than dropped:
- **JBIG2's CVE record.** The exploit surface is the composition machinery
— symbol dictionaries, text regions, refinement — not the arithmetic
coder. Those segment types are refused by name; the generic-region path
that every PDF-embedded JBIG2 image actually uses is implemented. Every
file-controlled offset is bounds-checked before a buffer is indexed, and
the region-to-page composition check has its own test naming what it
guards.
- **JPX's C dependency.** Avoided entirely: the decoder is pure Rust and
shares its MQ arithmetic coder with JBIG2, since T.800 and T.88 specify
the same one. No new crate, no cross-compile risk.
The remaining honest gap is coverage of the *refused* JPX and JBIG2
features, which is a deliberate scope boundary rather than an omission.
The `ui.rs` half of the exit criterion stays blocked on the Makepad headless
backend — see `REVIEWS/PDF_PARITY_PHASE1_STATUS.md`.
### Phase 4 — Document creation / writing (pdf-document + pdf-cos)
- Full `PdfWriter`/builder: outlines (outline tree + viewer prefs),
page labels, named destinations, document-level metadata (title, author,
keywords, XMP), viewer preferences, page mode/layout.
- `content_writer.rs` operators to match dart-pdf's `ContentWriter` (full
text/path/color/image emission).
- AcroForm **creation**: field tree (text, check, radio, list/combo, signature),
field flags, `NeedAppearances`, and correct AcroForm/Fields wiring. Include
field-value reconciliation (`form_reconcile_test.dart`) and appearance
generation (`generated_appearance_test.dart`, `text_box_appearance_test.dart`).
- Document attachments: `/Names /EmbeddedFiles` tree, file specs, attachment
add/read/remove (`attachment_test.dart`).
- Header/footer and image stamping on pages (`header_footer_test.dart`,
`image_stamp_test.dart`, `image_pdf_test.dart`).
- Font embedding: subset TrueType via `sfnt.rs` (glyph subsetting, cmap
remap, `repair-cmap`), embed Type1/CFF if feasible.
Exit: generated PDFs from nigig open cleanly in dart-pdf and external viewers;
`pdf-makepad` `ui.rs` form tests create a field in the UI, fill it via
`Selector::id(..).click()`/keyboard input, and assert the field value + label
state in code.
**Status: complete, except the `ui.rs` interaction tests, which are
written and blocked on the Makepad headless backend.** (ADR 0019 covers
the writer; ADR 0021 covers stamping, reconciliation, CFF and cmap.)
Two earlier revisions of this line were wrong in opposite directions: the
first said "complete" while three named items were unimplemented, the
second listed them as gaps. All three are now done, and the history is
left visible rather than tidied away — a status line that has been
overstated once should show its working.
| Item | State |
|---|---|
| Outlines, page labels, named destinations, metadata, XMP, viewer prefs, page mode/layout | done |
| `content_writer.rs` operator coverage | done |
| AcroForm creation, field flags, `NeedAppearances`, appearance generation | done |
| Attachments (`/Names /EmbeddedFiles`) | done |
| TrueType subsetting and embedding | done |
| **Field-value reconciliation** (`form_reconcile_test.dart`) | **done**`reconcile.rs`. Classifies how `/V` and `/AP` disagree and resolves it against a caller-declared `Intent`, because the right answer differs: a viewer must render `/AP` to match other viewers, an extractor must read `/V`, an editor must regenerate. `/NeedAppearances` overrides all three, which is the one case §12.7.3.3 actually settles |
| **Type1/CFF embedding** | **done**`embed_opentype_whole`. Subsetting CFF stays refused by name; embedding the whole program does not, and that is what "if feasible" allows. Writes `/FontFile3` with `/Subtype /OpenType` under a `CIDFontType0` descendant. `EmbeddedFont::is_subsetted` reports which path ran, so a caller with a size budget or a licence constraint can tell |
| **`repair-cmap`** | **done**`glyph_index` now falls back to a `(3,0)` symbol subtable at `0xF000 + low byte`, and reads format 0 as well as 4, 6 and 12. A symbol font's glyphs were previously unreachable by their plain characters: the lookup returned `None` and the character silently vanished |
| **Header/footer and image stamping** | **done**`stamp.rs`: image XObject embedding (JPEG passed through as `/DCTDecode` without re-encoding, raw samples as Flate), header/footer banners with alignment, and stamp composition |
The stamping work closed a gap of exactly the shape ADR 0017 describes.
`ContentWriter::draw_image` had existed since Phase 2 and was tested, but
nothing could *create* the image XObject it names — so every `Do` operator
referred to a resource that did not exist, and no error was raised anywhere.
It also uncovered a live defect in `PdfDocBuilder`'s object numbering:
`add_object` derived its number from `pages.len()`, so adding a page *after*
embedding an image — the natural order, since the page content has to name
the image — shifted a number already handed out, and the page's `/XObject`
entry pointed at the page object itself. This is the same positional
numbering bug previously fixed for fonts, one layer out; both are now fixed
at the root by freezing the base when the first number is issued.
### Exit criterion, verified where it can be
**"Generated PDFs open cleanly in external viewers" — now actually
tested, and it passes.** `pdf-graphics/examples/generate_sample.rs`
output was run through two implementations this codebase shares no code
with:
| Check | Result |
|---|---|
| `qpdf --check` | *"No syntax or stream encoding errors found"* |
| `pdfinfo` (poppler) | title, author, subject, keywords, dates, 2 pages, `Form: AcroForm` |
| `pdftotext` (poppler) | all page text, **including the embedded DejaVu subset and its em-dash** |
| `qpdf --list-attachments` | `readme.txt` extracted by name, with its description |
| Catalogue keys | `/Outlines`, `/Names`, `/EmbeddedFiles`, `/PageLabels`, `/Dests`, `/PageMode`, `/ViewerPreferences`, `/AcroForm` all present |
| `pdffonts` (poppler) | the subset TrueType as `CID TrueType` (`sub yes`) **and** the whole CFF as `CID Type 0C (OT)` (`sub no`), both `emb yes` |
| XMP (`set_xmp_metadata`) | `pdfinfo` reports `Metadata Stream: yes` |
Two things that audit turned up:
- **The sample never exercised XMP**, so the one Phase 4 feature most
likely to be silently absent was also the one nothing checked. Probed
separately; it works.
- **A `Banner` naming an unregistered font produces a structurally valid
PDF that draws no text.** `qpdf --check` passes; poppler says
`Unknown font tag 'F1'`. The API cannot register the font itself —
fonts belong to the document — so this is now documented on `Banner`
and pinned by
`a_banner_font_must_be_registered_or_the_page_lacks_the_resource`.
`pdffonts` is the check that earns its place for the font work: it is the
only one that inspects a font *program* rather than the file structure.
A CFF font written into `/FontFile2`, or given a `CIDFontType2`
descendant, still produces a file qpdf accepts — and a font that loads as
the wrong type or not at all. That shows up here and nowhere else.
**The `ui.rs` half remains blocked** on the Makepad headless backend. The
two form tests exist and are `#[ignore]`d — see
`REVIEWS/PDF_PARITY_PHASE1_STATUS.md`. This is the honest remaining gap:
the *engine* half of Phase 4 is verified end to end against third-party
readers, the *interaction* half is written but unrun. It is a fork
capability, not a nigig-pdf one.
### Phase 5 — Editing (pdf-document)
- Content-stream editing: insert/delete/replace operator runs, page content
mutation with xref/revision-safe save (extend `save.rs` + `incremental.rs`).
- Text-run rewriting (font/size/position-aware) — dart-pdf
`content_run_rewriter.dart`.
- Annotation editing: create/modify/delete annotations + appearance
regeneration (`appearance.rs`), annotation sync + clipboard semantics,
annotation metadata/restyle, and sub-type coverage: callout, comment/note,
ink, free-text (`annotation_align_test.dart`, `annotation_metadata_test.dart`,
`annotation_restyle_test.dart`, `callout_test.dart`, `comment_test.dart`,
`ink_slice_test.dart`, `sync_test.dart`).
- Page management: insert, reorder, duplicate, delete pages
(`page_ops_test.dart`, `page_index_map_test.dart`), plus **page import/merge
from another document** (`import_source_test.dart`).
- **Flatten** annotations/forms into page content (`flatten_test.dart`).
- Object compaction for incremental save: cos `compactor.dart` equivalent
(object-stream compaction, `compactor_test.dart`, `updater_test.dart`).
- Redaction: content analysis, redaction annotations, content removal.
- Outlines editing, page labels editing, struct-tree editing.
Exit: edit-anything round-trip tests (modify → save → re-parse → verify);
`pdf-makepad` `ui.rs` annotation tests add/move/delete an annotation through
the UI and assert the annotation count/state via the test code.
**Status: complete** (ADR 0022, ADR 0023), except the `ui.rs` interaction
half, which is blocked on the same missing Makepad headless backend as
Phase 4's.
| Item | State |
|---|---|
| Content-stream editing (insert/delete/replace operator runs) | done — `content_edit.rs`, gated by a corpus-wide round-trip property |
| Text-run rewriting (`content_run_rewriter.dart`) | done — preserves the operator *kind*, so a `'` keeps its line advance and a `TJ` keeps its kerning |
| Annotation editing + subtypes | already present (`annotation_edit.rs`); every subtype dart-pdf names was already modelled |
| Page insert/reorder/duplicate/delete (`page_ops_test.dart`) | done — `page_ops.rs`, page tree flattened to one level |
| Page index map (`page_index_map_test.dart`) | done — `PageIndexMap` |
| Page import/merge (`import_source_test.dart`) | done — deep-copies the transitive object graph, resolving inheritable attributes first |
| **Flatten** (`flatten_test.dart`) | done — `flatten.rs`, §12.5.5 placement, plus `/AcroForm` removal when no field survives |
| Object compaction (`compactor_test.dart`, `updater_test.dart`) | done — `compact.rs`, reachability from `/Root`; refuses a signed document by default |
| Redaction | done — `redact.rs`, removes operators rather than covering them; `pdftotext` cannot extract the redacted text |
| Outline / page label / struct-tree editing | done — `catalog_edit.rs` |
### What the round trips found
Every item is tested by saving, re-parsing, and asserting on what a reader
gets. That found **six defects in code that already existed**, none of
which raised an error:
- **`PdfWriter` wrote dictionary keys unordered**, so every generated PDF
differed run to run. `PdfDict` is a `HashMap` and Rust seeds its hasher
per process.
- **A short `/Length` silently truncated a stream**, losing the rest of its
content. The reader guarded the too-long case and trusted the too-short
one.
- **Two stream readers disagreed by one byte** over the EOL before
`endstream`, so a write-read-write cycle grew every stream by a newline.
- **`((nested))` truncated a string to nothing** and left the reader
mid-stream, so every operator after it was parsed from the wrong offset.
- **`#` escapes in names were never decoded**, so `/My#20Font` never
matched the resource it named.
- **`PdfOp::Unknown` was declared and never constructed**, so an
unrecognised operator vanished — survivable for a renderer, fatal for an
editor.
The `ui.rs` half of the exit criterion stays blocked on the Makepad
headless backend — see `REVIEWS/PDF_PARITY_PHASE1_STATUS.md`.
### Phase 6 — Signing and security (pdf-document + pdf-cos)
- Digital signature **writing**: document-byte-range signing, CMS/PKCS#7
structure, detached/attached signatures, PAdES basics
(`pades_test.dart`, `signature_test.dart`).
- Signing identities and algorithms: RSA and **ECC/Ed25519**
(`ec_identity_test.dart`, `ec_signing_test.dart`, `external_signing_test.dart`),
PKIX certificate-chain validation for verification (`pki_test.dart`,
`pkix_test.dart`), optional Fulcio-style identity (`fulcio_test.dart`).
- Signature appearance generation (reuse Phase 5 appearance work).
- Revocation checking: OCSP/CRL lookup for verification, per dart-pdf
crypto handlers (Rust crates; optional but listed for verify-path parity).
- Permission flags (`DocMdpPermission` already exists) + encryption applied on
save for AES-128/256 (writer side, not just parser side)
(`standard_security_handler_test.dart`, `encryption_test.dart`).
Exit: sign → verify round-trip; encrypted-write round-trip.
**Status: every bullet implemented.** Both exit criteria pass and each item
in the list above is ticked or explicitly deferred below.
This status line has been wrong twice, in the same direction, so it is now
written as the plan's own bullets rather than as prose. A summary written
from what was built cannot show what was not built; only the spec, item by
item, can (the process note in ADR 0021, which I wrote and then failed to
follow here).
| Spec item | State |
|---|---|
| Document-byte-range signing | done — `sign_document`, ADR 0026 |
| CMS/PKCS#7 structure | done — ADR 0025, corrected by ADR 0026 |
| **Detached** signatures | done — `adbe.pkcs7.detached` |
| **Attached** signatures | **refused, not deferred** — see below |
| PAdES basics | done — `SignatureProfile::Cades`, ADR 0027 |
| RSA identities | done |
| ECC / Ed25519 identities | done — P-256 and Ed25519 |
| `external_signing_test.dart` | done — `ExternalSigner` trait, ADR 0027 |
| PKIX chain validation | done — issuer signatures verified, ADR 0026 |
| Fulcio-style identity | **deferred** — see below |
| Signature appearance generation | done — ADR 0026 |
| Revocation: CRL | done — stapled, offline, ADR 0026 |
| Revocation: OCSP | done — stapled, offline, ADR 0027 |
| Permission flags | done — `DocMdpPermission` read and reported |
| Encryption on save, AES-128/256 | done — ADR 0024 |
**Attached signatures are refused rather than implemented.** `adbe.pkcs7.sha1`
and `adbe.x509.rsa_sha1` are the two attached profiles, and both are
SHA-1 based. SHA-1 is broken for signature purposes, and a library that
offers a broken profile will have it selected by somebody who does not know
that — the same reasoning that keeps RC4 readable but not writable
(ADR 0024). They are parsed, so documents using them can be read and
reported; they cannot be written. `SubFilter::Pkcs7Sha1` and
`SubFilter::X509RsaSha1` have no corresponding `SignatureProfile`.
**Fulcio is deferred.** The plan marks it optional. It is a keyless-signing
identity that requires an OIDC round trip and a call to a certificate
authority, which is network access the engine crates are gated against.
Three things remain before this is a product feature. **None is a code gap**,
and all three are recorded in the ADRs rather than implied:
- **Independent review.** ADR 0026 is a self-review. It found a critical
vulnerability — a forgery verified as valid — which is evidence the method
works, and not evidence that nothing remains.
- **Acrobat interoperability.** qpdf and poppler accept the documents;
Acrobat is stricter than the specification and is untested here.
- **Signing a document that already has an AcroForm.** `sign_document`
replaces the form rather than merging into it.
### Phase 7 — Advanced rendering (pdf-graphics + pdf-makepad)
- Shading: axial/radial/function shading + mesh (free-form/lattice/Coons)
`shading.rs`/`mesh.rs` ports (`shading_test.dart`).
- Blend modes + overprint compositor (`overprint_compositor.dart` equivalent,
`overprint_test.dart`).
- Transparency groups with soft masks already present; extend to
blend-mode compositing in the Makepad device.
- Embedded-font rendering: glyph outlines from embedded TrueType **and CFF**
(`cff_test.dart`, `embedded_font_render_test.dart`, `truetype_test.dart`).
- **Glyph-aware text runs** through the device trait — extend the nigig
text-run type to carry per-glyph pen offsets and (when the font is embedded)
outlines, matching dart-pdf `PdfGlyphPlacement`/`PdfTextRun`; enables
hit-testing, copy, and font-driven layout (`render_command_test.dart`).
- Type 3 font rendering (Phase 2 parsing + glyph runs).
- Form/image XObject rendering through the device trait.
- **RenderCommand wire codec**: binary serialize/deserialize of `RenderCommand`
for the worker-thread boundary (`render_command_codec_test.dart`,
`page_text_codec_test.dart`), and tiled rendering sink
(`PdfTiledCellSink` equivalent, `tiling_record_replay_test.dart`).
Exit: golden render corpus covers shading/mesh/overprint/image-XObject pages;
`pdf-makepad` `ui.rs` suite adds render smoke tests for each new rendering
feature (shading page, overprint page, image-XObject page) asserting non-empty
draw + correct element counts.
**Status: all eight bullets addressed; the golden-corpus exit criterion is
met, the `ui.rs` one remains blocked on the headless backend.**
Written as a table from the start, per the process note in ADR 0021, so an
unfinished item is a visible row rather than a sentence nobody wrote. Rows
marked **partial** name what is missing rather than rounding up.
| Spec item | State |
|---|---|
| Shading: axial / radial / function | **done** — ADR 0028 |
| Shading: mesh (free-form, lattice, Coons) | **done** — ADR 0029. Five corpus fixtures; writing them found three real bugs (type 5 read a phantom flag, types 6/7 read a colour per control point, flag 0 cleared the strip). Coons patches are still flattened to two triangles and report `is_approximate` |
| Blend modes + overprint compositor | **done** — ADR 0030. `composite.rs` composites all sixteen modes against a real backdrop; `/OP`, `/op`, `/OPM` parsed and honoured per ink |
| Transparency groups → blend-mode compositing in the Makepad device | **partial** — ADR 0030. Group compositing exists and is tested in `composite.rs`; the **Makepad** device still reports `Unsupported` because it has no render-to-texture. Knockout groups refused by name |
| Embedded-font glyph outlines (TrueType **and CFF**) | **done** — ADR 0031. `outline.rs` reads `glyf`/`loca` and Type 2 charstrings, asserted against fontTools ground truth. CID-keyed CFF (`FDArray`/`FDSelect`) and `seac` accents deferred |
| Glyph-aware text runs (per-glyph pen offsets) | **done** — ADR 0031. `GlyphPlacement` on `ShowTextWithMetrics`; hit-testing, selection and search highlighting now use real offsets instead of dividing the run advance by the character count. Outlines are reachable by glyph id, not attached to each placement |
| Type 3 font rendering | **done** — ADR 0032. Glyph procedures are interpreted through the device with the font matrix and pen composed correctly. Per-glyph caching deferred |
| Form/image XObject rendering through the device trait | **done** — ADR 0032. `render_form` resolves and runs the stream with `/Matrix`, the `/BBox` clip and a save/restore wrapper; image XObjects refused by name; recursion bounded. Wiring the Makepad **widget** to call it is UI work and is not done |
| `RenderCommand` wire codec + tiled sink | **done** — ADR 0033. Round-trips every variant; truncation is an error rather than a short list; tiles are pixel-identical to the whole page. Soft-mask groups do not cross the wire, by design and stated |
Exit criteria:
| Criterion | State |
|---|---|
| Golden render corpus covers **shading** pages | ✅ `shading_axial`, `shading_radial` — golden *pixels*, not command text |
| … **mesh** pages | ✅ `shading_mesh` |
| … **overprint** pages | ✅ `overprint_on` / `overprint_off`, as CMYK plates, because overprint cannot be shown in RGB |
| … **image-XObject** pages | ✅ `xobject_form` — a form XObject rendered to pixels, with its `cm` offset, its `/BBox` clip and the clipped-away overspill all visible in the golden. The golden caught a real bug the colour assertions missed (see ADR 0033) |
| `pdf-makepad` `ui.rs` render smoke tests | ❌ **blocked** — the Makepad headless backend, as since Phase 1. `windowing_backend.rs` has only X11/Wayland; 6 tests remain `#[ignore]`. Not this phase's work and not claimed as done |
### Phase 8 — Text intelligence (pdf-graphics)
- Text search: Unicode-aware search with hit rects over `text.rs` extraction.
- Selection improvement: layout-aware (multi-line, multi-column) hit-testing,
matching `pdf_view.rs` behaviour and beyond.
- Content reflow (`content_reflow.dart` equivalent) — layout-friendly text
export and re-layout (`content_reflow_test.dart`, `reflow_render_test.dart`).
- Text diff/cache (`text_cache.dart`, `text_diff.dart`) for cheap re-renders,
plus page-level disk/page cache (`disk_cache_test.dart`, `page_cache_test.dart`).
- Document AI surface (`document_ai.dart`): structure/layout understanding on
top of extraction (`document_ai_test.dart`).
Exit: search + selection integration tests; reflow unit tests.
**Status: two of five bullets done.** A table from the start, per ADR 0021.
| Spec item | State |
|---|---|
| Text search: Unicode-aware, with hit rects | **done** — ADR 0034. Cross-run matches, NFD normalisation, case *folding* (not lowercasing), opt-in diacritic folding, whole-word, one rectangle per run |
| Selection: layout-aware multi-line, multi-column hit-testing | **done** — ADR 0034. Columns detected before lines, because two columns share their baselines; reading order proven to be a permutation of the runs |
| Content reflow — layout-friendly export and re-layout | not started |
| Text diff/cache for cheap re-renders, plus disk/page cache | partial — `cache.rs` has an in-memory `PageCache` with an LRU budget and generation guards (Phase 3); no text diff, no disk cache |
| Document AI surface: structure/layout understanding | not started — `detect_lines`/`detect_columns_from_segments` from ADR 0034 are the substrate this would build on |
Exit criteria:
| Criterion | State |
|---|---|
| Search integration tests | ✅ `text_search.rs`, driven from `basic/two_columns.pdf` through parse → interpret → extract → index → search |
| Selection integration tests | ✅ hit-testing every run of a real page, plus the gutter |
| Reflow unit tests | ❌ **not started** — the reflow bullet is not done |
### Phase 9 — Conformance, metadata and OCR (pdf-document)
- PDF/A validation helpers (conformance checks against PDF/A-1b/2b,
`pdf_a_test.dart`).
- PDF/UA accessibility checks (struct-tree completeness, tag map, alt text,
`pdf_ua_test.dart`, `struct_text_test.dart`).
- XMP metadata read/write (Phase 4 covers writing; here full XMP packet
handling, `xmp.dart` port).
- OCR layer (`ocr_layer_test.dart`): **decision required** — dart-pdf binds an
external OCR engine. For Rust, decide between a Rust OCR crate
(e.g. `tr`/`rust-tesseract`/onnx) or defer.
Exit: conformance test suite over generated + corpus documents.
### Phase 10 — Editor/UX parity (pdf-makepad)
- Form filling UI: field focus, combo/list popup, check/radio toggling,
submit + JS-action wiring (extend `interaction.rs`).
- Annotation UI: add/hit-test/edit/delete annotation overlays.
- Signing UI: signature field creation, sign flow, verification badge.
- Search UI: toolbar, hit navigation, selection copy.
- Save/export entry points wired from UI to Phase 46 writers.
Exit: `pdf-makepad` can open, edit, sign, and save a document end-to-end —
parity with `dart_pdf_editor` core workflows — **proved by `ui.rs` suites**
that mirror each workflow (fill a form field and verify the value;
add an annotation and verify it persists; sign and verify the badge; search
and verify hit navigation; save and verify the file), all running headless in
a terminal and on Android via the Phase-1 makepad_test capability. No Studio
hub required for any of these.
## 2. dart-pdf test-inventory mapping (test transfer)
Every dart-pdf test suite below maps to one of:
- **DONE** — equivalent nigig coverage already exists;
- **P##** — port/adapt the test suite (and its feature) in that phase;
- **SKIP** — N/A for Rust or out of scope (recorded with reason);
- **DECIDE** — needs an explicit decision (external dependency, scope).
This is a living table; update it as suites land. The goal is **coverage
parity**: for every dart-pdf suite, a nigig equivalent test exists and passes.
### pdf_cos/test (31 suites)
| dart-pdf suite | status | notes |
|----------------|--------|-------|
| builder_test | DONE | nigig `writer.rs` doc-builder tests |
| byte_source_test | DONE | nigig operates on `&[u8]` |
| ccitt_test / ccitt_golden_test / ccitt_corpus | P3 | CCITT codec |
| compactor_test | P5 | object-stream compaction |
| content_parser_test | P2 | operator coverage |
| crypto_test | DONE/P6 | nigig `encrypt.rs` AES/RC4 |
| decoded_cache_test | P8 | cache tests |
| document_test | DONE | nigig `document.rs` |
| ec_identity_test | P6 | Ed25519 identity |
| encryption_test | P6 | encrypt-on-write |
| filter_test | P3 | codec registry round-trips |
| incremental_update_test | P5 | nigig `incremental.rs` + save |
| jbig2_test / jbig2_roundtrip_test / jpx_test | P3 | JBIG2/JPX |
| lexer_test | DONE | nigig `lexer.rs` |
| matrix_test | DONE | nigig `matrix` |
| parser_test | P2 | parser robustness + xref-stream parsing |
| perf_test | P8 | perf gate on decode paths |
| pki_test / pkix_test / pkix_extra_test | P6 | cert-chain validation |
| serializer_test | P4 | writer/serializer parity |
| standard_security_handler_test | P6 | encrypt-on-write |
| string_text_test | P2 | text handling |
| updater_test | P5 | incremental updater |
| xref_test / xref_writer_test | P2 | nigig `xref.rs` traditional only; add xref streams/hybrid/recovery |
### pdf_document/test (66 suites)
| dart-pdf suite | status | notes |
|----------------|--------|-------|
| annotation_test / annotation_editor_test | P5 | nigig `annotation_edit.rs` to grow |
| annotation_align_test / annotation_restyle_test / annotation_metadata_test | P5 | sub-types |
| annotation_clipboard_test / annotation_sync_test | P5 | sync semantics |
| attachment_test | P4 | embedded files |
| blank_document_test | P4 | doc creation |
| callout_test / comment_test | P5 | callout + note |
| check_mark_test | P4 | form check marks |
| color_processing_test | P2/P7 | color pipelines |
| compress_test | P3 | codecs |
| content_edit_test / content_edit_type0_test | P5 | stream editing + Type0 |
| content_reflow_test | P8 | reflow |
| disk_cache_test / page_cache_test | P8 | page cache |
| document_test | DONE | nigig `document.rs` |
| ec_signing_test / external_signing_test | P6 | ECC/Ed25519 signing |
| edit_impact_test | P5 | edit → re-render deltas |
| editor_test | P5 | editing aggregate |
| flatten_test | P5 | flatten to content |
| font_embed_test / font_used_in_test / fonts/ | P4 | font subsetting |
| form_test / form_fill_test | P4/P10 | creation + fill |
| form_admin_test | P4 | field admin |
| form_reconcile_test | P4 | value reconciliation |
| form_styling_test | P4 | field styling |
| fulcio_test | P6 | DECIDE (Fulcio) |
| header_footer_test | P4 | header/footer |
| image_pdf_test / image_stamp_test | P4 | image stamping |
| import_source_test | P5 | page import/merge |
| incremental_update_test | P5 | nigig `incremental.rs` |
| ink_slice_test | P5 | ink annotation |
| line_ending_test | P8 | text line handling |
| matrix_geometry_test | DONE | nigig geometry |
| measure_test | P4/P10 | measurement |
| office_conversion_test | SKIP | office → PDF; external tooling in Dart, out of scope |
| open_source_test | P4 | opening sources |
| outline_test | P4 | outlines |
| pades_test | P6 | PAdES |
| page_index_map_test / page_ops_test | P5 | page ops |
| page_labels_test | P4 | page labels |
| pdf_a_test / pdf_ua_test | P9 | conformance |
| perf_gate_test | P8 | perf gates |
| png_test | P3 | PNG decode |
| redaction_test | P5 | redaction |
| signature_test | P6 | signature write |
| struct_tree_test / struct_tree_editor_test | P5/P9 | structure |
| sync_test | P5 | annotation sync |
| takeoff_test | SKIP | construction-measurement niche, out of scope |
| text_box_appearance_test | P4 | appearance gen |
| text_run_rewriter_test | P5 | text-run rewrite |
| type0_font_test | P2/P8 | Type0 fonts |
| vector_snapshot_test | P5 | vector snapshots |
### pdf_graphics/test (37 suites)
| dart-pdf suite | status | notes |
|----------------|--------|-------|
| cff_test | P7 | CFF glyph outlines |
| cjk_cmap_test | P2 | CJK CID/CMap |
| color_space_test / colorant_buffer_test | DONE/P2 | nigig `colorspace.rs` |
| document_ai_test | P8 | document AI |
| embedded_font_render_test | P7 | embedded glyphs |
| font_cache_test / image_decode_cache_test | P8 | caches |
| font_info_test | P2/P4 | font engine |
| generated_appearance_test | P4 | appearance gen |
| ghent_corpus_test / ghent_jpx_indexed_test | P3 | **Ghent corpus** (print production) — nigig corpus must add these |
| icc_test | DONE | nigig `icc.rs` |
| image_colorants_test / image_scan_parity_test | P3 | image colorants |
| image_downsample_test / image_pixels_test | P3 | downsample/pixels |
| interpreter_test / streaming_interpreter_test | P2 | operator + streaming |
| ocr_layer_test | P9 | DECIDE (external OCR engine) |
| overprint_test | P7 | overprint compositor |
| page_text_codec_test | P7 | text wire codec |
| paste_rotation_render_test | P7 | paste/rotation render |
| path_test | DONE | nigig path |
| pdfjs_corpus_test | P2+ | **PDF.js corpus (171 files)** — nigig corpus must add |
| raster/ | P7 | raster pixel tests |
| reflow_render_test | P8 | reflow render |
| render_command_test | DONE | nigig `recording.rs` |
| render_command_codec_test | P7 | binary wire codec |
| shading_test | P7 | shading/mesh |
| struct_text_test | P9 | struct text |
| text_cache_test / text_diff_test | P8 | text caches |
| text_extraction_test | P8 | extraction |
| tiling_record_replay_test | P7 | tiled sink |
| truetype_test | DONE/P4 | nigig `sfnt.rs` |
| vector_print_test | SKIP | vector-print format, out of scope |
### Corpus parity
- nigig corpus: `crates/apps/pdf/tests/corpus/` (small, hand-picked).
- Target additions by phase:
- P2/P3: PDF.js corpus subset (171 files, `pdfjs_corpus_test.dart`) covering
the operators/codecs each phase adds;
- P3: Ghent PDF Workgroup corpus (print-production, `ghent_corpus_test.dart`)
once CCITT/JPX land;
- P7: render-golden subsets of both corpora through `golden_render.rs`.
## 3. Cross-cutting concerns
- **Testing**: every phase adds integration tests; golden corpus
(`pdf-graphics/tests/golden_render.rs`) extended; fuzz targets extended
(Phase 3 codecs are a fuzz hotspot). Keep the "no feature without an
integration test" rule (workflow.md §9).
- **Robustness parity**: dart-pdf runs robustness fuzzing (100K+ iterations).
nigig has 12 libFuzzer targets (`pdf-cos/fuzz/`); add a CI robustness gate
that runs corpus + fuzz seed inputs against every phase's new parser/codec
surface, and grow the fuzz corpus with the PDF.js/Ghent additions in §2.
- **UI-test parity (makepad_test)**: every pdf-makepad feature ships a
`tests/ui.rs` suite written in the `makepad_test` style (counter-example
pattern: `app.locator(Selector::id(..)).click()` then
`.wait_text(..)`/`.wait_visible()`, so a click that a human would do by hand
is asserted in code — label/count/state changes must be tracked by the test).
Rules:
- Tests run **headless in a terminal** (no Studio) and **on Android** via the
Phase-1 fork capability; they must not require the Studio hub.
- `#[ignore]`-gated Studio-only tests are not acceptable substitutes — a
feature is done only when its `ui.rs` suite passes on both targets.
- The existing `pdf-makepad/tests/ui.rs` (currently `#[ignore]`-gated because
it needs Studio) is the seed; migrate it off the hub in Phase 1 and keep it
green in CI.
- UI suites mirror `dart_pdf_editor` workflows (form fill, annotation add/
edit/delete, signing, search), ported as nigig-authored Rust tests since
dart-pdf's UI tests are Flutter-specific and not portable directly.
- **Provenance**: ported patterns from dart-pdf must keep `THIRD_PARTY_NOTICES.md`
accurate (Apache-2.0 attribution). Ported code should be re-implemented in
Rust idioms, not transliterated.
- **Performance**: re-check `cache.rs`/`worker.rs` after Phases 3, 7, 8
(decoders and text are the hot paths). Extend `BENCH_BASELINE.md`. Object
lazy-loading (Phase 2) and the wire codec (Phase 7) also gate large-doc
memory behaviour.
- **Fork dependency**: any makepad-API-touching phase (1, 7, 10) must first
satisfy workflow.md §0.1 fork sync.