Skip to content

Invariant R and the round-trip oracle

Round-tripping is the centre of the project, not a deferred extra:

text
.pptx  ──import──►  IR  ──render──►  HTML   (what a human sees / edits)
  ▲                  ▲                 │
  └────emit──────────┴─────parse───────┘   (what a machine reads back)

Invariant R. For any deck this pipeline can write, import → render → parse → emit produces a deck equal under the normalized read model to the input. Slides whose features the IR does not model are carried across byte-identical rather than approximated.

Equality is normalized, not byte-level: zip order, timestamps, rIds and cNvPr ids all vary legally, so both decks are canonicalized before diffing. Byte determinism is explicitly not a goal and is not an upstream ask.

Two things this serves, in priority order:

  1. Web previews and web editing. HTML is the medium a deck is looked at and edited in; this library is what makes that page redeemable as a real .pptx.
  2. AI-authored decks. Agents emit HTML well and OOXML badly. This is the adapter that makes agent-emitted HTML land as editable slides.

The heuristic DOM → IR lane stays, but it is the secondary path: it is for HTML that carries no IR of its own. HTML produced by this library's own renderer carries its IR in the document, and the return path parses that IR rather than re-deriving it from getComputedStyle: computed style is lossy and has no representation for placeholder inheritance, colour transforms, autofit mode or geometry adjust values.

The oracle

test/oracle/roundtrip.ts decides whether a deck survived the loop, and it is what makes Invariant R a claim rather than a hope. A fidelity change is not done until the oracle covers it.

  • Equality is upstream's, not ours. readModelToIrcanonicalDeckIrdiffDeckIr. Do not write a normalizer or a differ; zip order, timestamps, rIds and cNvPr ids are canonicalized away by design.
  • undeclared is the only field a gate may read. declared is the fidelity contract working, added is the write path being explicit where the source was implicit, and unmatchedNotes are usually constructs the read model cannot see at all: report them, never gate on them, never "fix" one by deleting a note.
  • A lane declares its own losses. diffDeckIr's notes argument must be the notes of the tier that produced the output. DeckIr.fidelity is the wrong set (it describes the source, not the output), and printScript's notes are right only for the printed-script tier: scriptTierNotes() exists so that case has a name and nothing else reaches for it by accident. A lane returning no notes is claiming to lose nothing, and is held to it.
  • The corpus is generated, never committed as binaries. test/corpus/decks.ts must stay deterministic: no clock, no filesystem, no randomness. Every deck is generated by @shbernal/ts-pptx itself because that is the v1 input domain; decks authored in PowerPoint are a deferred second tier. Add a deck when you add a construct.
  • The coverage snapshot is the progress metric. Notes counted by Disposition × Cause, plus authored-vs-carried slides. It is expected to change; it must never change silently, which is why it is snapshotted. Update it deliberately, never with a blind -u.
  • A green run on an empty deck proves nothing, so assertNonTrivial guards every corpus entry, and the harness is mutation-tested against itself in roundtrip.test.ts. Keep both when touching the harness.

The lanes the oracle runs

The import → emit lane (test/oracle/script-lane.ts) replays DeckIr through the write API with no HTML involved. Both legs are upstream code, so a failure there is an issue to file rather than a local fix, and a defect it catches can never be misattributed to the renderer.

The full lane (test/oracle/html-lane.ts) runs all four legs. test/oracle/loop.test.ts covers what an unedited corpus deck cannot reach: the edit path, the integrity refusals and the lane decision, each tested by constructing the case rather than by observing its absence.

What a green oracle does not prove

Every lane test is a statement of the form diffDeckIr found nothing, and that sentence is only as strong as the set of constructs DeckIr models. There is a gap it cannot cover, and it is not a matter of corpus breadth:

A loss in the intersection of "the writer can author it" and "DeckIr does not carry it" is invisible to the whole harness, by construction.

Three things have to be true at once, and when they are, they conspire: readModelToIr flattens the value on the read leg, nothing declares a note for it, and canonicalDeckIr omits it, so the differ compares two models that are both missing the field and reports clean. Adding decks does not help; both sides lose the same thing the same way.

It has happened twice. A baked <a:normAutofit fontScale="70000"> re-emitted as a bare <a:normAutofit/>, and an explicit u="none" / strike="noStrike" re-emitted as silence, a visible wrong answer on any deck whose runs inherit a decoration. Both were found by asking what the write API does with a value, not by a failing test. Both are fixed upstream and both are now carried by the canonical model, so the oracle can see a regression in either on its own.

The response, when you find one, is a pinning test that asserts the loss rather than the absence of one. It fails on the release that fixes it, which is what stops the fix from arriving unnoticed and being rediscovered later by someone re-deriving the same workaround. Both of the above did exactly that, and now assert survival.

The preview is not affected by any of this: RenderIr is built from the read model directly and never passes through DeckIr. That is the two-IR split paying for itself in the direction it was not designed for.

Assert on the lane, not just the file

Three bugs in the return path each made the system quietly do nothing and each produced a plausible, working document:

  • the parser handing back the island as the edited model, so every edit was invisible;
  • a colour compared by reference, so every untouched slide read as edited;
  • a colour validated as a string, so every colour-bearing run raised an anomaly.

A lane that degrades gracefully hides its own bugs; the output alone will not tell you. Assert on the lane a slide took.

Released under the MIT License.