freepdfeditor/docs/adr/0002-surgical-re-emission.md

74 lines
3.7 KiB
Markdown

# ADR-0002 — Surgical re-emission with splice-point invariants
* **Status**: Accepted
* **Date**: 2025-07-25
* **Plan reference**: §4.4
* **Design review**: §14 step 9 — this ADR records the design review of the
splice invariants before any production re-emission code lands.
## Context
Naïvely regenerating a page's content stream on every edit loses everything we
didn't model: obscure operators, `BDC` marked-content nesting, transparency
groups, printer-specific `DP` properties, comments. A full-rebuild emitter
either has to model the entire PDF operator set losslessly (infeasible before
1.0) or silently drops what it doesn't understand (a fidelity regression per
§1.1). The decision shapes the L3 content model: every `DisplayItem` carries
its `SourceSpan`, and the emitter splices rather than rebuilds.
This is the M0 design review that §14 step 9 calls for, recorded as an ADR.
## Decision
Re-emission is **surgical**. For a page whose edits touch regions B and C of
`[A][B][C][D]`, the emitter copies A and D verbatim and writes a new `B'C'`
in place of `[B][C]`. Rules:
1. Each `DisplayItem` carries its `SourceSpan` — a `(streamIndex, offset,
length)` triple over the *logical concatenation* of the page's content
streams (a page's content may be an array of streams and operators can
straddle the boundary; spans are over the concatenation but the emitted
output preserves the array structure).
2. Dirty items' spans are coalesced into replacement regions.
3. Graphics state at a region boundary is reconstructed and **explicitly
re-established** inside the replacement (`q ... Q` wrapping), so the
following verbatim bytes still see the state they expect. This is verified
by an assertion pass that re-interprets the emitted stream and diffs the
graphics state at every splice point against the original. **Zero
violations is a CI gate** (§8.2).
4. Newly created objects append after the last region.
5. If a page's structure is too tangled to splice safely — deeply interleaved
marked content across a dirty region, an unrecognised operator inside it,
or a `q`/`Q` imbalance — fall back to a full re-emit of that page and
record it as a fidelity event. **We never guess.**
6. Resources added by an edit are merged into the page's resource dictionary
with fresh, non-colliding names. A resource dictionary inherited from an
ancestor `Pages` node is *copied down to the page* before mutation — never
edited in place, it is shared.
## Consequences
**Positive.** Untouched content is byte-identical to the input — the round-trip
gate (§8.2) is realistically achievable. Unknown operators are preserved by
construction rather than dropped. The splice-point assertion gives a
mechanical, property-based check on the trickiest invariant.
**Negative.** The L3 interpreter must record `SourceSpan` on every item,
which is bookkeeping cost throughout. The fall-back full re-emit path must
also exist and be correct, which is roughly the work of a full emitter anyway
— but it only runs on tangled pages, so its fidelity budget is per-page
rather than corpus-wide.
**Neutral.** A page that needed a full re-emit is reported in the fidelity
panel; the user is told, not lied to.
## Alternatives considered
* **Full rebuild always** — rejected; cannot preserve unmodelled operators
without modelling the whole operator set.
* **Full rebuild with a "pass-through unknown" hack** — rejected; the
graphics state at the rebuild boundary is the failure mode, and a hack
doesn't address it. Splicing with explicit state re-establishment does.
* **Splice without the assertion pass** — rejected; the splice-point invariant
is the one thing that, if violated, silently corrupts complex pages. A
property-based check is cheap insurance.