# ADR 0022: editing — content streams, page operations, flatten, and catalogue edits - **Status:** Accepted - **Date:** 2026-08-17 - **Review item:** `NIGIG_PDF_FEATURE_PARITY_PLAN.md` §1 Phase 5, "Editing (pdf-document)" - **Supersedes:** nothing - **Related:** ADR 0019 (creation — this is its inverse), ADR 0023 (redaction and compaction, the remaining Phase 5 items) - **Written retrospectively.** The work landed in `b8bd718`, `e0aab74`, `e0d8627` and `b870dc4` without an ADR. ## Context Phases 1–3 read PDFs; Phase 4 wrote them from scratch. Neither could change an existing document without rewriting it wholesale, which loses everything the original file contained that the model does not represent — and that is always a lot. Editing needs a different property from creation: whatever the editor does not understand must survive untouched. ## Decision Four modules, following the crate boundary: content-stream work sits in `pdf-graphics` because it needs the operator model; document-structure work sits in `pdf-document`. ### `content_edit.rs` — the serialiser (1,257 lines, 45 tests) The foundation, and the piece that had to exist first. `parse_content_stream` had always been one-way: operators in, nothing out. `write_op` and `write_ops` complete the round trip, and `ContentEditor` provides range-addressed insert, delete and replace over an operator list. `write_real` is separate and exported for the same reason `format_real` exists in the writer (ADR 0019): PDF's number grammar has no exponent form, so `format!("{}", 1e21)` produces something no parser accepts. A content stream is a second place that mistake can be made, so it has its own tested function rather than a second ad-hoc one. Edits are addressed by `OpRange`, not by byte offset. Byte offsets shift under every preceding edit; operator indices are stable within one editing session, which is what a caller can actually reason about. ### `page_ops.rs` — insert, reorder, duplicate, delete, import (852 lines, 16 tests) The design decision worth recording is `PageIndexMap`. Every operation returns one, mapping original index to new index, with `was_deleted` for pages that are gone. This exists because *everything else in a document refers to pages*: outlines, named destinations, link annotations, structure trees. Reordering pages without a mapping silently breaks all of them, and breaks them in the way this project keeps finding — the link still resolves, to the wrong page. Import merges pages from another document, carrying the resources those pages depend on. ### `flatten.rs` — annotations and forms into page content (837 lines, 11 tests) Flattening draws each annotation's appearance stream into the page and removes the annotation, producing a file that looks identical in a viewer that does not render annotations at all. The subtle part is `appearance_matrix`: a form XObject's `/BBox` is in its own space with its own `/Matrix`, and it must be mapped onto the annotation's `/Rect`. Getting that wrong scales or offsets every flattened annotation — the failure looks like a layout bug, not a maths bug, so it gets its own exported and separately tested function. `remove_acroform_if_empty` cleans up `/AcroForm` once its fields are gone, because a form dictionary with no fields makes a viewer offer form behaviour for a document that no longer has any. ### `catalog_edit.rs` — outlines, page labels, struct tree (533 lines, 7 tests) The write side of what ADR 0019 created and `catalog.rs` reads. `remove_structure_tree` exists for flatten and redaction: a structure tree that still describes removed content is worse than none, because assistive technology reads it and announces text that is no longer on the page. ## Verification Re-run against the tree at `f75c1cc`: ``` content_edit 45 #[test] (pdf-graphics lib: 289 passed) page_ops 16 passed flatten 11 passed catalog_edit 7 passed TEST_TARGET=pdf 1187 passed TEST_TARGET=pdf-ui 1232 passed ``` ## Merge criteria Taken from the Phase 5 plan text, then marked — not derived from what was built (see ADR 0021's process note): - [x] Content-stream editing: insert/delete/replace operator runs - [x] Page content mutation with xref/revision-safe save - [x] Page management: insert, reorder, duplicate, delete - [x] Page import/merge from another document - [x] Flatten annotations and forms into page content - [x] Outlines editing - [x] Page labels editing - [x] Struct-tree editing - [x] Object compaction — **ADR 0023** - [x] Redaction — **ADR 0023** - [x] Annotation editing and appearance regeneration — pre-existing (`annotation_edit.rs`, `appearance.rs`, ADR 0004) - [x] Text-run rewriting (`content_run_rewriter.dart`) — `ContentEditor::set_text` and `text_op_indices`. Preserves the operator *kind*, so a `'` keeps its implicit line advance and a `TJ` keeps its kerning numbers while its strings are replaced - [x] Annotation sub-type coverage — every subtype PDF defines is parsed (`Text`, `Link`, `FreeText`, `Square`, `Circle`, `Line`, `Polygon`, `PolyLine`, `Highlight`, `Underline`, `StrikeOut`, `Squiggly`, `Ink`, `Stamp`, `Popup`, `Widget`, `Redact`). "Callout" and "comment/note" are dart-pdf's names for a `FreeText` with a `/CL` callout line and a `Text` annotation respectively, not distinct `/Subtype` values - [ ] **`ui.rs` annotation interaction tests** — blocked on the Makepad headless backend, as they have been since Phase 1. The only item in this phase that is genuinely outstanding. ## Consequences **Positive.** A document can be modified rather than regenerated, so everything the model does not represent survives an edit. `PageIndexMap` makes the cross-reference problem explicit instead of leaving each caller to discover it. **Negative.** Four modules, 3,479 lines, and the invariant that edits must preserve unknown structure is not expressible as a type — it is a property each function has to maintain by hand, and a reviewer has to check. **Risk.** `OpRange` indices are stable only within one editing session. A caller that holds a range across a mutation is addressing something else, and nothing in the type system prevents it. This is documented; it would be better enforced by a generation counter on the editor. **A correction to this ADR's own first draft.** It initially listed text-run rewriting and annotation sub-types as deferred, on the strength of a grep for `rewrite` finding nothing. Both are implemented — the rewriter is `ContentEditor::set_text`, and the "missing" annotation types are dart-pdf's names for shapes PDF spells differently. Checking the plan's claim against the code was right; concluding from a failed grep was not. The lesson is ADR 0021's in reverse: a name that does not appear is not a feature that does not exist. The only genuinely outstanding Phase 5 item is the `ui.rs` interaction half, blocked since Phase 1 on the Makepad headless backend.