Skip to content

convert: a first-party pptx importer in the kernel - #351

Merged
nyblnet merged 3 commits into
mainfrom
convert-engine
Aug 29, 2026
Merged

convert: a first-party pptx importer in the kernel#351
nyblnet merged 3 commits into
mainfrom
convert-engine

Conversation

@nyblnet

@nyblnet nyblnet commented Aug 23, 2026

Copy link
Copy Markdown
Owner

The first half of the convert engine: a first-party .pptxbento/slides importer in kernel/src/convert/, with no dependencies, running in both node and the browser.

What this is

  • The ZIP container moves to the kernel (kernel/src/convert/zip.ts, lifted verbatim from dash, which now re-exports through a six-line facade — the charts pattern). Both dash rigs pass unchanged (34/34, 133/133).
  • A small namespace-resolving XML parser — dash's regex scanner is explicitly safe only while no element self-nests, and p:grpSp nests. DOCTYPE is refused outright, which kills the entity-expansion attack class by construction.
  • The resolver chain: theme (clrScheme/clrMap/fmtScheme with the full transform set), placeholder inheritance (slide → layout → master, idx-first matching, nine-level list styles), text, shapes, media, and the orchestrator.
  • A fidelity report with provenance — every resolved value records which cascade level supplied it, so a reviewer checks the class ("1,700 fills came from the theme") rather than the instance.

Why the correctness budget went where it did

A structural census of six real business decks measured 91% of colour and 71% of run properties as indirect — resolved through a cascade where every hop fails silently. A backwards clrMap still renders, just dark-on-dark. So the traps are pinned by rigs whose negative controls have each been seen to fail against a deliberately wrong implementation:

  • a:shade/a:tint compute in linear-light RGB while lumMod/lumOff are HSL — the rig hand-computes shade50(#808080) = #5D5D5D and pins that the naive sRGB multiply's #404040 is not produced
  • a present-but-empty a:effectLst cancels an inherited effect; an absent one defers
  • group chOff/chExt scaling composes recursively (hand-computed leaf frame through two nesting levels)
  • buNone at a nearer level silences an inherited buChar
  • every emitted slide carries transition:'none' explicitly — the model's own constructors default to 'fade'
  • svg arrives as image + data:image/svg+xml, never an inline svg element (url(#id) is document-global)

The gate that matters

Fixtures are built in code with our own writeZip (no binaries in the repo), and load.ts imports the real slides parseDoc + validateDoc: every emitted document must parse and report zero errors. The engine emits documents the app itself accepts, or CI fails.

7 rigs, 378 checks, wired into CI. Verified by an adversarial pass that mutated six implementations and confirmed every mutation caught.

What this deliberately does not do

Layouts are not imported (500 layout parts per 79 census slides — flattening is the M0 answer; doc.layouts arrives with the theme/layout work). Tables and charts are reported honestly as not-yet rather than converted badly. No UI, no host — the standalone app and tray hosts consume this next, and the export direction (PR #88, reworked) builds on the same foundation.

…arser dash could not reuse, and the fidelity contract

The ZIP reader/writer moves verbatim from dash/src/zip.ts to
kernel/src/convert/zip.ts, with dash re-exporting through a six-line facade
(the charts pattern); both dash rigs pass unchanged (34/34, 133/133).

The XML parser is new because dash's regex scanner is explicitly safe only
while no element nests inside itself, and PresentationML's p:grpSp nests.
Tokenizing, namespace-resolving, node-and-browser. DOCTYPE is refused
outright, which kills the entity-expansion attack class by construction.
mc:AlternateContent resolution lives here too, since an mc-blind reader
double-imports.

report.ts is the fidelity spine (carried/approximated/dropped with per-code
folding, and provenance tallies for the silent-cascade problem); types.ts is
the structural output contract, declared in the kernel rather than imported
from slides, the same dependency inversion the charts engine uses.
Seven modules on the foundation commit: theme.ts (the colour/font resolver),
inherit.ts (the placeholder chain), media.ts, text.ts, shapes.ts, pptx.ts
(the orchestrator), and a seven-rig suite (378 checks) under
scripts/test-convert/.

The resolver IS the importer -- the spike measured 91% of colour and 71% of
run properties as indirect in real decks -- so that is where the correctness
budget went, and the traps it names are pinned by rigs whose negative
controls have each been SEEN to fail against a deliberately wrong
implementation:

  - a:shade/a:tint compute in linear-light RGB while lumMod/lumOff/satOff
    are HSL; mixing the spaces yields plausible wrong colours (rig recomputes
    shade50(#808080) = #5D5D5D by hand, and pins that the sRGB multiply's
    #404040 is NOT produced)
  - clrMap applies forward (bg1 -> lt1), with raw slot names still resolving
    directly, spec-correct
  - a PRESENT-but-empty a:effectLst cancels an inherited effect; an absent
    one defers up the chain
  - group chOff/chExt scaling composes recursively (nested groups double-
    scale; the rig pins a hand-computed leaf frame through two levels)
  - buNone at a nearer level silences an inherited buChar; bullets resolve
    as a unit, never merged across sources
  - every emitted slide carries transition:'none' explicitly -- the model's
    own constructors default to 'fade', and 71 of 79 census slides carry no
    transition at all
  - svg arrives as image + data:image/svg+xml, never an inline svg element
    (url(#id) resolves document-globally; 386 icons sharing ids would all
    paint as the first)

Fixtures are built in code with the kernel's own writeZip -- no binaries in
the repo -- and the gate that matters most is load.ts: every fixture's
emitted document must pass the REAL slides parseDoc and report zero
validateDoc errors. The engine emits documents the app itself would accept,
or the rig fails.

Unit arithmetic is pinned by hand-computed values (EMU/9525, centipoints,
60000ths of a degree), and provenance tallies survive into the report so a
reviewer checks the class ("1,700 fills came from the theme") rather than
the instance.

xml.ts loses its one parameter property: node's native type stripping
refuses non-erasable TS syntax, and it made every rig unrunnable.

Layouts are deliberately NOT imported (500 layout parts per 79 census
slides = 85% dead weight; flattening is the M0 answer), tables and charts
are reported honestly as not-yet rather than converted badly, and dash's
rigs (34/34, 133/133) confirm the zip lift disturbed nothing.
@nyblnet
nyblnet merged commit 2df2b9d into main Aug 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant