nigig-org/REVIEWS/adr/0022-pdf-editing.md
andodeki ad3fe19b90
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
docs(pdf): the four missing ADRs — codecs, Phase 4 completion, editing, redaction
Ten PDF feature commits landed without an ADR, covering three whole phases.
Every feature gets one; these are the four that were owed. Written against
the code as it stands and re-verified by running it, not transcribed from
the commit messages.

  0020  CCITT, JBIG2 and JPEG 2000 — the codecs ADR 0015 refused by name
  0021  Phase 4 completion — stamping, reconciliation, CFF, cmap, and the
        audit that corrected a false "complete" in ADR 0019
  0022  Editing — content_edit, page_ops, flatten, catalog_edit
  0023  Redaction and compaction, and the three reader defects they found

Verified rather than assumed, on the tree at f75c1cc:

  ccitt 30, jbig2 29, jpx 44 unit tests; jpx_roundtrip's 7 compare against
  OpenJPEG-generated pixels, exactly, because a JPEG 2000 bug produces a
  plausible image rather than an error.

  Redaction re-checked with a test written from outside the module. The
  middle line is the one worth keeping:

      after redact, secret present  = true    <- earlier revision
      after compact, secret present = false
      public text retained          = true

  Redaction alone leaves the secret in the bytes. That is why the two
  shipped together, and why the report carries
  earlier_revisions_retain_content.

  Determinism re-checked by generating the same document from four separate
  processes: byte-identical. The HashMap key-order defect really is fixed.

This ADR contains a correction to its own first draft. 0022 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, which preserves the operator kind
so a ' keeps its line advance and a TJ keeps its kerning; and "callout" and
"comment" are dart-pdf's names for a FreeText with a /CL and a Text
annotation, not distinct /Subtype values. Checking the plan's claim against
the code was right; concluding from a failed grep was not.

One criterion across the four is left unticked, and it is real: the ui.rs
interaction tests, blocked on the Makepad headless backend since Phase 1.

0021 also records a process note. ADR 0019's merge criteria were derived
from what had been built, so all of them ticked while three unbuilt items
stayed invisible. Criteria should come from the plan text first, then be
marked done or explicitly deferred — a deferral stated is fine, a deferral
unstated is a false claim.

pdf: 1187 passed. No code changed.
2026-08-18 05:53:36 +00:00

6.9 KiB
Raw Permalink Blame History

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 13 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):

  • Content-stream editing: insert/delete/replace operator runs
  • Page content mutation with xref/revision-safe save
  • Page management: insert, reorder, duplicate, delete
  • Page import/merge from another document
  • Flatten annotations and forms into page content
  • Outlines editing
  • Page labels editing
  • Struct-tree editing
  • Object compaction — ADR 0023
  • Redaction — ADR 0023
  • Annotation editing and appearance regeneration — pre-existing (annotation_edit.rs, appearance.rs, ADR 0004)
  • 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
  • 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.