Skip to content

v3.3.0

Choose a tag to compare

@shbernal shbernal released this 25 Aug 19:45
· 823 commits to main since this release

This release includes a breaking cleanup to the in-memory byte export API, speaker notes on
both read-model paths that lacked them (authoring onto a loaded deck, and the batch import),
a chart type that is now refused rather than emitted as a chart with nothing in it, the
Office-2016 chart family carrying across that same append bridge, plus repository and
project-site changes.

Added

  • isChartType narrows a string to the chart catalog. The companion to isChartExType,
    which reported only which half of the catalog a type belongs to and answered false for
    a string that is in neither. Useful for validating a chart type that arrives as data — from
    a config file, a spreadsheet column, a CLI flag — before it reaches addChart, which now
    throws on one it does not recognise (see Changed).

  • importSlides carries speaker notes, per request. ImportSlidesRequest gains
    importNotes?: boolean, the batch spelling of the option importSlide has had all
    along; the default is unchanged, so a request that does not ask still arrives without
    notes. Closes the half of #17 the previous change left open.

    Per request rather than per batch because a stitch mixes sources: the notes of a
    library's cover page are worth carrying where a scratch deck's are not. What the flag
    does not decide is the styling — a presentation holds at most one notesMaster, so the
    destination's own is reused when it has one and the first carried master is installed
    when it has none, exactly as importSlide({ importNotes: true }), appendSlides and
    Slide.addNotes do. The four paths cannot between them produce a second notes master.

    Two things had to move for the batch's own guarantees to survive the addition:

    • The up-front dry run now walks the notes graph. Notes are copied after the pages
      are, so a batch that could not finish carrying them was the one remaining way back into
      a half-stitched deck. checkSelectionCopyable takes the opted-in pages and walks each
      one's notes subgraph under exactly the rules carryNotes will follow — including
      skipping the source notes master when the destination has one of its own, since that
      master is then never read and a check that walked it anyway would reject a batch the
      copy would have completed. A rejected batch still leaves the deck byte-identical.
    • A notes slide is now copied as a part its page owns. carryNotes opens an
      ownership scope (page-owned.ts), so a page named twice — reachable in one call for
      the first time here, and already reachable through two importSlide calls — gets notes
      of its own each time, and so do the parts those notes own. Sharing one of those between
      two pages is a package PowerPoint refuses to open (0x80070570); media stays shared,
      as everywhere else.
  • Slide.addNotes(text) on the read model — the write counterpart to the
    notesText / notesTextFrame / notesSlide getters, and the only way to annotate a
    slide with no notes part at all. That state is reachable in normal use — it is what
    importSlide without { importNotes: true } leaves behind — and it was a dead end: the
    notes accessors are getters, and the settable notesTextFrame is null precisely when
    there is no part, so a consumer that imported a page could not give it notes of its own
    through any API. Reported as #17.

    A starts a new paragraph, matching the write-side addNotes; runs carry no
    formatting of their own, so per-run colour/size/hyperlinks are set afterwards through
    notesTextFrame. Called on a slide that already has notes it replaces the body text and
    leaves the rest of the part — geometry, the sldImg/sldNum placeholders — alone.

    Creating the part means creating what a notes slide must bind to, and a presentation may
    hold at most one notes master. The deck's own is reused when it has one; otherwise one is
    installed, bound to a clone of the deck's own theme rather than to the slide master's
    theme part, so no two masters claim one part and the notes resolve against the
    destination palette. That is the same single-master rule importSlide({ importNotes: true }) and appendSlides already follow, so mixing the three cannot produce a second
    notes master.

    The part it builds is the generator's, not a second design: makeXmlNotesSlide was split
    so its fixed three-placeholder frame (makeXmlNotesSlideSkeleton) is shared with the read
    side instead of copied into it, and test/read/add-notes.test.js asserts the two paths
    emit the same notes body — modulo empty-element spelling, which the read model's
    serializer normalizes. This is the first src/read/ → src/gen/ import; it buys the
    anti-drift guarantee that src/ooxml/ exists to give the constants both halves share.

Fixed

  • A chartEx chart appended onto a loaded deck arrived empty. Presentation.extractSlides()
    — the bridge ts-pptx/read's appendSlides serializes generated slides through — built every
    chart with the classic <c:chartSpace> builder, which has no arm for the Office-2016 family
    (waterfall, funnel, treemap, sunburst, histogram, pareto, boxWhisker,
    regionMap). A waterfall came out as a 2.6 kB chart part with axes and no plot element at
    all, registered under the classic content type, behind a slide still pointing at it through
    <mc:AlternateContent><cx:chart> — a chart-shaped hole PowerPoint opens and shows empty.

    Both halves of the bridge now know the second shape. extractSlides builds a chartEx chart
    with makeXmlChartEx and hands its two mandatory style sidecars along in the descriptor's new
    chartEx slot; appendSlides injects it as its own chartEx{N}.xml part under the Microsoft
    chartex content type, wires the slide to it through the MS chartEx relationship rather than
    the ECMA chart one, and writes style{N}.xml / colors{N}.xml at the chart part's rId3 /
    rId2. The sidecars are not decoration: PowerPoint reports a chartEx part without them as
    corrupt (0x80070570), which the schema validator does not see. The generator builds them, so
    the read subsystem still imports nothing from the emitters at runtime.

    write() was never affected: it routes on isChartExType and always has. Classic charts
    through appendSlides are unchanged, byte for byte. The interim
    UnsupportedFeatureError (chart/chartex-not-extractable) that refused these charts is gone
    from UnsupportedFeatureErrorCode — it was added and removed inside this same unreleased
    cycle, so no released version can throw it.

  • The read reference documented three of ImportSlideOptions' seven fields.
    importNotes, rescale, remapLiterals, and embedFonts were absent from
    docs/reference/pptx-read.md — importNotes appeared nowhere in docs/ at all — so the
    documented way to carry a slide's speaker notes across an import was invisible to anyone
    reading the docs rather than the .d.ts, and #17 was filed as a missing feature on that
    basis. The interface block now matches the interface, importNotes has a prose section
    covering the notes-master policy, and the neighbouring claim that "deliberate re-branding
    (a restyle mode) is not yet implemented" — untrue since restyle shipped, and
    contradicted by the section above it — is corrected. No behaviour change: the option has
    worked in all three theme modes since it shipped. ImportSlidesRequest was likewise
    absent from the interface block entirely, and the batch section is now explicit about
    what notes do there rather than saying only that they are dropped.

Changed

  • A chart type outside the catalog is refused at addChart instead of emitting a chart with
    no plot.
    addChart(data, { type }) took the type on trust: the CHART_NAME union
    constrains TypeScript callers, but nothing enforced it for JavaScript ones, and the two chart
    emitters partition ChartType between them and each treats the other's members as not its
    own — so an off-catalog string matched no arm anywhere and fell through to an empty-string
    default. addChart(data, { type: 'nonsence' }) was accepted, and the typo arrived in the
    deck as a chart frame with axes and nothing inside it. Both the single-type form and every
    ChartMulti entry of a combo are now checked against the catalog, throwing
    InvalidOptionError (chart/unknown-type) naming the valid types. The two emitters no longer
    have a silent default at all: reaching one with a type the other owns throws InternalError
    (chart/type-not-routed), which no public input can now produce. Every catalog type builds
    exactly the bytes it did before.

  • toBytes() replaces stream(). The old method did not stream: it assembled the
    complete archive, then copied fflate's Uint8Array into a Node Buffer. It therefore
    failed in browsers without a Buffer shim, returned an unnecessarily broad type, and
    ignored onMediaError. toBytes({ compression, onMediaError }) now returns exactly
    Promise<Uint8Array> in Node, browsers, and workers without the extra archive-sized
    copy. Migrate await pptx.stream() to await pptx.toBytes(); callers that need another
    representation should continue using write({ outputType }).

  • pnpm run verify no longer sizes itself off the host's CPU, and no longer builds the
    site.
    The gate could take a developer machine down: Vitest sizes its fork pool from
    availableParallelism() - 1, and every concurrent fixture spawned its own 55 MB
    OOXMLValidatorCLI process, so the real ceiling was workers × maxConcurrency — a
    property of the developer's core count rather than of this repository, and the wrong
    one. A faster CPU made the spike bigger, never the run safer. Three changes, all in
    test/build tooling; nothing the published package does is affected.

    • test/validator.js batches validation. OOXMLValidatorCLI accepts a directory
      and validates every package in it in one process, and measured against the pinned
      release a run costs ~0.40s of startup plus ~0.048s per additional deck at ~55 MB
      regardless — so one deck per process was paying that startup, and that 55 MB, some
      500 times per suite. Each worker now holds at most one validator child. Full suite:
      55.1s / 4.03 GB / 17 concurrent validators → 48.4s / 3.40 GB / 7.
    • vitest.config.ts derives maxWorkers from memory free at startup rather than
      core count, since peak RSS is linear in the pool size. Idle machines still land on
      the CPU bound; loaded ones scale down instead of swapping. VITEST_MAX_WORKERS pins
      it explicitly.
    • docs:build moved from verify to verify:full. 19.7s of its 26.6s is
      vitepress build, which proves something about the site, not the library; docs:check
      keeps the docs validated in the loop. CI is unaffected — docs.yml already built the
      site on every pull request, so check:static was building it a second time.

    verify is ~97s → ~44s and verify:full ~77s, both on a 12-core box. Composites are
    now assembled by scripts/run-steps.mjs, which expands script names into leaf commands
    and runs them in one process tree, removing the ~0.7–1.3s package-manager relaunch that
    verify was paying 13 times over. package.json remains the single definition of each
    step. See docs/testing.md.

    One latent test bug surfaced and is fixed: seven schema fixtures assert on warnings by
    swapping console.warn across an await, which is unsound under describe.concurrent
    — a neighbour's finally restores the original mid-capture. It was a race, so it was
    always latent; changing how fixtures interleave is what exposed it. Those fixtures now
    run in a sequential sibling suite, and new ones are routed there automatically.

  • Schema validation moved to the shared ooxml-validate oracle. This repo and
    ts-xlsx each carried their own validator pinned to a different Open XML SDK version —
    3.2.0 here, 3.5.1 there — so the two enforced different rule sets while looking like
    they enforced the same one. Both now validate through one published package, and the
    SDK pin lives in that package rather than in either consumer. Test tooling only;
    nothing the published package does is affected.

    • tools/ooxml-validator/ is gone — installer, update checker, version pin and the
      check-validator script with it. ooxml-validate fetches its oracle from GitHub
      Releases on first use (never from a postinstall hook), verifies the download's
      checksum and its build provenance, and caches it under ~/.cache/ooxml-validate.
      CI caches that directory instead of running an install step.
    • test/validator.js shrank to an adapter. Batching, the one-child-at-a-time queue and
      the CI gate belong to the package now; the suite's validateBuf /
      validatorAvailable surface is unchanged, so no fixture moved.
    • Every input file now comes back with an explicit valid flag. The old batcher
      read "absent from the output" as "clean", which was safe only for as long as
      unreadable packages kept being reported. Nothing infers cleanliness from absence any
      more.
    • Diagnostics are { id, type, description, partUri, xpath } — the same five fields,
      renamed with the report contract. A non-package file is now PackageOpenError /
      Package rather than a null id and an OpenXmlPackageException type; the two
      self-check fixtures in test/schema-cases.js pin the new shape.
    • Validating at SDK 3.5.1 changed nothing about what this project emits. All 153
      generated decks plus the 44 static fixtures were run through both oracles before the
      switch: identical diagnostics, file for file, nothing new and nothing gone. The
      conformance target is still Microsoft365, and pnpm run schema:versions still
      prints the coverage table it printed at 3.2.0.
    • Set OOXML_VALIDATE_NO_BATCH=1 (was TSPPTX_VALIDATOR_NO_BATCH) to pin a batch
      failure to one fixture.
  • The browser demo is a page of the site, not a workspace. demos/vite-demo — a React

    • Vite + Bootstrap app whose only output was a download — is deleted. Its replacement is
      /demos on the project site, which builds the same deck in the tab and then shows you
      the slides
      , by reading the package back with
      pptx-html and painting them as SVG. A
      download button is still there, and still goes through the browser runtime's own
      writeFile.

    This removes React, Bootstrap, sass-embedded and a second ESLint configuration from the
    repository, and leaves one toolchain (VitePress + Vue) building one site. The site's
    application code lives in a new top-level www/, so docs/ stays what the docs kit
    governs: markdown content. See www/README.md.

    demos/ is now only what its name says — clone the repo, run a script, get a .pptx.

    The browser test lane did not shrink: that page is the Playwright demo fixture, so
    writeFile's object-URL path and the browser-vs-Node byte-identity comparison are still
    covered, now against the site's build rather than a second app's.

  • The quarterly-review showcase is Kestrel Analytics, in forest and brass. Same eleven
    slides, same five masters, same charts, table and timeline — a different fictional company
    and a different palette, because pptx-html already uses "Meridian" as its own example
    and two neighbouring projects demonstrating the same fictional company is confusing.

    This is only visible if you build the showcase. The output file name changes from
    Meridian_Q3_Business_Review.pptx to Kestrel_Q3_Business_Review.pptx, and every key in
    the deck's exported BRAND is renamed rather than merely re-valued — a constant called
    navy holding a green is the exact thing that file's own doc comment argues against.

  • The test suite shares one module registry per worker (isolate: false). dist/ is
    over 1 MB of JavaScript, text-*.js alone is 610 KB, 78 test files import
    dist/node.js and 64 import dist/read.js, and Vitest's default isolation had all 236
    files re-evaluating that graph from scratch. It was the single largest line in the run.
    Measured across three paired runs at VITEST_MAX_WORKERS=4, alternated so machine load
    could not favour one setting, import time goes from 136.0s / 114.5s / 84.8s to
    34.4s / 30.6s / 22.0s, and takes 22 to 37% of wall clock with it. The 3.5x to 4x on the
    import column is the honest number; the wall figures overlap because the box was getting
    quieter. On the single-worker path CI runs, a full verify now reports 14.7s of import.
    Two module-level caches written to span files finally do: test/validator.js joins
    validation requests across files instead of spawning one .NET child per file, and
    test/read/corpus.js's irFor memo stops being rebuilt per file.

    Isolation made cross-file state leakage impossible rather than merely absent, so two
    things stand in for it rather than a hope that nobody leaks. test/setup-globals.js
    resets setDiagnosticHandler after every test, the one process-global the library owns,
    and the deliberate leak/detector pair in
    test/regression/api/global-state-reset.test.js fails when that setup file is removed.
    sequence.shuffle.files randomizes file order, so an order dependence fails instead of
    hiding behind a stable one; four full shuffled runs pass 3337 tests each. Tests within
    a file stay in source order deliberately, because captureDiagnostics() and the
    warn-capturing schema fixtures rest on it. The other module-level state under src/ is
    idempotent caching, which is better shared than rebuilt.

  • Test helpers live in one module each, and four contracts widened on the way through.
    Every helper involved had been written out again in seven to twenty-six files, and the
    copies had drifted the way copies do. test/read/corpus.js owns the read fixture corpus,
    test/read/opc.js the relationship assertions, test/regression/chart/chart-parts.js
    the chart part readers, and test/helpers.js bytesEqual(), throws(), partBodies
    and the 67 bytes of 1x1 transparent PNG that had been pasted under six different names,
    which made a grep for "the tests' image" miss most of them. corpus.js importers go from
    30 to 52; twenty-eight files had defined openFixture inline, which corpus.js had
    exported all along with zero importers. That is not only tidying, because a file that
    builds its own fixture path never loads corpus.js and so sits outside the MIN_CORPUS
    floor there, the check that stops a corpus which has silently resolved somewhere else
    from turning every invariant into a loop over nothing. Twenty-six files were outside it.
    None are now.

    defineRegressionSuite takes exactly two arguments. The three-argument form's provenance
    tag was destructured out and then dropped, so thirty-six suites recorded where their
    regression came from in a string no reporter ever printed; those tags are in the suite
    name now, and a second positional argument is an error rather than something ignored.
    Its fn goes to vitest as-is, which takes helpers.js off the top of every regression
    failure's stack, and per-case skipIf / runIf / skip / only / todo / fails /
    concurrent / timeout fields let a case reach the modifiers it could not get at from
    inside a plain array.

    The shared untouched-parts assertion is stronger than the dozen longhand loops it
    replaces, in two ways that each closed a live hole. It fails when it compared nothing, a
    loop whose filter stops matching being indistinguishable from success in a reporter. And
    a part missing from the output is a failure rather than a skip: remove-slide.test.js
    skipped absent parts outright, so any part vanishing, including one the removal had no
    business touching, read as a pass. It names the four parts a removal is expected to take
    and asserts that set exactly. Four checks in animations-transitions.test.js widen while
    passing through, having filtered to ppt/slides/* and left the rest of each package
    unasserted; load then save is byte-identical across every part of all 44 committed
    fixtures, so the narrowing bought nothing and hid the remainder.

    roundtrip.test.js named its decks in a five-name literal, and docs/testing.md asked
    for a manual edit to that literal when promoting a fixture. Nobody made it. The corpus
    reached 44 while the OPC contract kept being proved against the same five, so the decks
    that actually stress it (chartEx, model3d, math-omml, embedded fonts, av-media, modern
    comments) were never round-tripped there at all. It reads fixtureNames now, promotion
    is the only step there is, and 30 cases become 274 at a cost of about 22s. The widening
    found something on its first run: bar-chart-data-labels.pptx fails "saved output passes
    the validator" as committed, before this library touches it, with three Microsoft365
    errors inside PowerPoint's own chart c:extLst that the SDK does not model. So the
    assertion is now "a round trip introduces no new validator errors", which stays
    strictly stronger everywhere else (an empty verdict before still demands an empty verdict
    after) and avoids the choice between excluding that fixture and implying the library
    caused what PowerPoint authored.

    test/schema-cases.js gains the only two cases in the file that expect a non-zero error
    count. Every other case asserted zero, so the tier could not tell "this deck is valid"
    from "the validator reported nothing", and a keying bug in the batcher, a spawn failure
    the JSON parse swallows, or an output-shape change on a version bump would have turned
    the whole tier green while proving nothing, with no other step in verify noticing. One
    case perturbs a freshly built deck with an undeclared attribute on p:sp and demands
    exactly one Sch_UndeclaredAttribute at the expected part and XPath; the other feeds
    bytes that are not an OPC package and demands the package-level row, which is the
    property the batcher's old "absent means clean" shortcut rested on. Both were confirmed
    to fail with validateBuf stubbed to return [], and both assert on the id and the
    XPath, never on the description, so an upstream rewording cannot break them. The failure
    formatter was also dropping two of the CLI's five fields, the XPath that names the
    offending element and the stable machine id; it prints all five now.

    One consequence to know about, recorded in AGENTS.md and docs/testing.md: verify
    alone no longer covers the script round trip over the corpus. script-roundtrip.test.js
    and script-standalone.test.js each carried a byte-equivalent copy of what
    script:roundtrip:all already runs, same corpus and same diffDeckIr, so verify:full
    paid for the identical 44-deck two-tier subprocess sweep twice. The script keeps that
    job, being the more capable copy, and the suites keep what the round trip rests on and
    cannot itself establish. Run pnpm run script:roundtrip:all before pushing changes to
    src/script/.

  • The commit-message gates come from shbernal/lefthook-rules, not a local script.
    scripts/check-commit-msg.mjs encoded a rule that is not about this project: a heredoc
    delimiter leaking into a commit subject when the shell dialect is wrong. ts-xlsx carries
    the same damage, two commits of it. Both gates are lefthook remotes now, pinned to v1
    with a 24h refetch. no-shell-quoting-leak is not a transcription of the script it
    replaces. It drops the leading-backtick test, which would reject three legitimate
    subjects here that open with a code span and never fired on a damaged one, and it adds
    the closer test the script lacked, since two of the five damaged commits end with a
    second delimiter line and the script caught them only because the opener leaked as well.
    It reads every line rather than the subject alone, truncating at the git commit -v
    scissors line first. Over this history it rejects those five commits and none of the
    other 4356. no-ai-attribution arrives with it, and a remote job costs about 3 ms
    against about 19 ms for the same logic in a Node process, which a commit-msg hook pays
    every time.

    Two properties of the mechanism are worth knowing. A remote that cannot sync prints a
    warning and still exits 0, so prepare survives an offline install and the rules simply
    do not run. A configs: entry the tag does not hold is silent. After a fresh clone,
    confirm both job names appear in a commit.

  • The OOXML schema MCP runs locally over stdio. Both MCP configs point at
    mcp-server-ooxml rather than the hosted https://api.ooxml.dev/mcp endpoint. The
    schema graph ships inside the package, so a lookup needs no account and no network round
    trip, and the same question returns the same answer every run. Codex gets a 60s startup
    allowance because the first run downloads the tarball.

    It is not the same tool surface, so the docs that route agents to it had to change with
    it. It gained ooxml_values, ooxml_diff_profiles and ooxml_explain, and it lost the
    prose half entirely: no spec PDFs, no ooxml_section, and no OPC part, content-type or
    relationship catalogue. AGENTS.md and docs/ooxml-agent-context.md now say outright
    that those questions fall through to microsoft_learn and then to web search, rather
    than leaving an agent to discover the dead end. ooxml_search also narrowed to a
    substring match on symbol names, which the Annex D note records: a semantic query would
    not have worked there even if the geometry addenda were indexed.

  • scripts/ is held to noImplicitAny, annotated in JSDoc. tsconfig.scripts.json
    relaxed the flag while the tree was unannotated. Paying what it wanted, 291 annotations
    across 24 files, is the cheaper half of the "migrate scripts/ to TypeScript" question:
    the annotations buy the checking, and the file extension buys nothing on top of it while
    costing 49 path citations in docs/ and every glob in the lint, format and tsconfig
    lists. Types come from the library itself, import('../dist/read.js').Slide rather than
    a restatement of its shape.

    The flag was not bookkeeping. It surfaced six silent defects.
    scripts/append-ceiling.mjs reported a false loss, its bullet-glyph probe reading
    paragraph.bullet, an accessor the read model replaced with bulletDetail, so it
    compared undefined and called the construct lost; it reports 23 of 24 surviving now,
    the one failure being the documented placeholder idx loss.
    scripts/read-blindness-census.mjs --all never censused a notes slide, because it
    reached for a NotesSlide.element_ that does not exist and its guard was therefore
    always false, while docs/testing.md already advertised --all as covering notes.
    Three dead ?? fallbacks sat on properties the types do not carry
    (hyperlink.slidePartName, GradientStop.hex, ResolvedColor.value).
    typeof slide.notesSlide === 'function' evaluates a getter and tests its result, so
    that arm was unreachable and the getter ran twice on the way to the arm taken.
    scripts/docs-init.mjs could render every template with undefined as the project
    name, from a .split('/').pop() TypeScript knows can miss. And
    scripts/powerpoint-com-smoke.mjs dropped a signal kill: close passes null for a
    signalled child, which flowed into a code !== -1 retry test and made a killed cscript
    look like a clean run.

    @types/istanbul-lib-coverage, -lib-report and -reports are new devDependencies,
    since scripts/coverage-merge.mjs imports three untyped Istanbul modules and the
    alternative was a hand-written shim that could mistype the API silently; with real types
    that file went from 23 errors to 2. noPropertyAccessFromIndexSignature stays off, with
    its reason recorded: it is house style about obj.foo against obj['foo'], not
    correctness, and these scripts read a lot of JSON that is Record<string, ...> by
    nature. .oxlintrc.jsonc and scripts/README.md both asserted this tree was
    deliberately unannotated, so both are rewritten against a fresh measurement rather than a
    guess: with the type-aware rules restored, scripts/** reports 289 findings against the
    recorded 1276, and 285 of the 289 are still no-unsafe-*. The residue is JSON.parse
    and untyped imports, not unannotated parameters, so the rules stay off for a different
    reason than before. test/** is unannotated and unmeasured since, and is a separate job.

Added

  • Font collections (.ttc/.otc) are now readable by the measured-fit metrics.
    parseFontMetrics and pptx.registerFontMetrics previously threw Unsupported OpenType signature ttcf on a collection, which is how most of the fonts a consumer
    would reach for to measure East Asian text ship on Windows: MS Gothic, Yu Gothic,
    SimSun, Microsoft YaHei, Microsoft JhengHei, MingLiU and Nirmala UI are all .ttc, and
    so is Cambria, so this was never only a CJK gap. Measured fit on those faces fell back
    to the cmap-less heuristic, or did not engage at all.

    A collection holds several fonts, so one has to be chosen. Both APIs take a new font
    option, either a 0-based index or a name matched case-insensitively against the family,
    full, and PostScript names; registerFontMetrics additionally uses its own face
    argument as the selector when font is omitted, so the common call needs nothing extra:

    await pptx.registerFontMetrics('MS PGothic', 'C:/Windows/Fonts/msgothic.ttc')
    await pptx.registerFontMetrics('Cambria Math', 'C:/Windows/Fonts/cambria.ttc', { font: 1 })

    A selector matching nothing throws (font/collection-face-not-found,
    font/collection-index-out-of-range) rather than falling back to the first font, and
    the message lists what the file holds. Falling back is the failure worth refusing:
    measuring the wrong member produces perfectly plausible numbers that nothing downstream
    can question, and MS Gothic against MS PGothic is a 26% difference on Latin advances.
    The selector means the same thing for a plain .ttf, which is a one-entry list, so a
    wrong index or an unmatched name is an error there too rather than being ignored.
    Registering a plain .ttf under any face name is unchanged: only a collection is
    name-selected.

    ts-pptx/measure gains listFontFaces(bytes) (every font in a file, with its name
    table identity; a plain .ttf is a one-entry list) and isFontCollection(bytes).

    Grounding: the unwrap rests on the claim that a member's table records carry offsets
    absolute to the file, which is how members share one glyf. A self-generated fixture
    cannot test that, since a builder and a reader can agree on a format real files do not
    follow, so the genuine Windows collections are checked against advances read by WPF's
    GlyphTypeface (38 faces across 15 collections, windows-collections.oracle.json).
    A collection synthesized from the repo's own Silkscreen files carries the same claims
    on every platform, asserting that a member's metrics equal exactly the same font parsed
    as a plain .ttf. See docs/measured-text-fit.md ("Font collections").

  • A font the parser rejects now raises a classified MediaError with the new
    font/parse-failed code, instead of opentype.js's bare Error escaping the taxonomy.
    A consumer catching library failures by class and code could not previously catch a
    corrupt or unsupported font file that way.

  • Measured fit now reports the code points a registered font cannot render, rather
    than measuring them silently. measureText() returns them in a new
    uncoveredCodepoints array (sorted, deduplicated), and applyMeasuredFit warns once
    per export with the new measure/uncovered-codepoints diagnostic, naming the face and
    the code points.

    This is the one approximation in the model that does not err safe. Everywhere else the
    numbers are deliberately conservative — raw advances with no kerning, the taller of two
    line heights, a width safety factor that wraps a hair early — so a wrong answer is a
    too-tall box. Font fallback is different: PowerPoint substitutes another face per code
    point and lays the run out in that face's advances, while this model has no fallback
    and charges the registered font's .notdef, a single flat number unrelated to the glyph
    that paints. Malgun Gothic's .notdef is 0.663 em — wider than the 0.5 em halfwidth
    Katakana it lacks, so those gain a harmless phantom line, and narrower than the 1.0 em
    Plane 2 ideographs it lacks, so a run of those measures short: 24 of them in a 150 pt
    box lay out on 2 lines here and 3 in PowerPoint. A short measurement is a fit:'resize'
    box baked smaller than its text, which is the overflow the resize path has no safety net
    for.

    Only faces that are registered are audited: an unregistered face measures through
    the cmap-less average-advance heuristic, which has no coverage to report and is already
    surfaced in approximatedFaces. The audit (collectUncoveredCodepoints in
    src/measure/font-metrics.ts) is shared by the layout-time query and the export pass,
    so the two cannot diverge. Nothing about the measured numbers changed — this is the
    signal that says when not to trust them.

  • Presentation.importSlides(requests), batch slide import across one or more loaded
    sources
    (#19). Stitching a deck from several sources needs each imported page at a
    specific position in the final slide list, and needs the whole stitch to succeed or
    fail as one. importSlide in a loop can leave a half-stitched deck when request three
    of five fails on a size mismatch; importSlides validates everything up front — pages
    exist, output positions are unique and within the final list, sizes match, and a
    read-only dry run of the copy proves every part it would reach is present — so a
    rejected batch leaves the target byte-identical whichever rule rejected it. The returned
    array is parallel to requests. It also gives the
    batch's slide → slide links a rule: a jump link on a selected page must target another
    selected page (or one an earlier import from that source already brought across) and is
    rewritten to the fresh partnames, so importing page 3 of 10 neither drags pages 1–2
    across as dependencies nor strands the link — appendSlides' import/unresolved-slide-link,
    enforced natively. One request is one output page, so naming the same source page in
    several requests asks for that many independent copies of it — the page part is the one
    thing an import never shares, and a duplicated page's jump links stay inside the batch
    (pages duplicated together are copied in lockstep, so a duplicated linked pair becomes
    two self-contained pairs). Pages come across under 'copy' theme semantics; notes,
    embedded fonts and rescale have no batch spelling yet, so reach for importSlide when
    you need one of those.

  • compose() on the showcase modules, beside the existing build(outFile): it
    assembles the deck and returns the presentation, having written nothing. The preview needs
    the bytes; pnpm demos:build needs a file. build is now compose plus a destination.

  • pnpm run typecheck:site, and docs/tsconfig.json grew a runner. It covered
    docs/.vitepress/** on paper and was executed by nothing; it now also covers www/**/*.ts
    and runs in verify, check:static and pre-push. .vue files stay outside it — tsc
    does not read single-file components — which is why the demos page's logic is a plain
    .ts module and the component is markup around it.

  • pnpm run lint:chars, which keeps em dashes out of what a reader sees. The README,
    the site and the docs are the only text a reader of this project ever meets, and nothing
    checked what characters went into them. charcheck is that gate, wired into both hooks
    and check:static, and charcheck.config.js carries one rule per surface, because the
    surfaces differ in what reader-visible text even means. Markdown prose is scoped so a
    dash inside a fenced or inline code sample is not a finding: it is part of the sample,
    not something to reword. www/**/*.vue is scanned as markup, which reads template text
    and allowlisted attributes while leaving the component's comments and its stylesheet
    alone. The VitePress config and theme are scanned as strings only, since the nav labels,
    the tagline and the meta description are the sole reader-visible text in those files.
    Generated trees are ignored by path, so a scan of a built checkout agrees with a scan of
    a clean one.

    The gate arrived on a tree that already held 684 em dashes in docs/, which warned under
    a frozen --max-warnings while they were worked off. They are worked off, so the second
    severity and its DOCS_CLEAN allowlist are gone, every surface errors, and
    --max-warnings 0 is on both lint:chars and the pre-commit hook so that a rule added
    at warn later fails rather than scrolling past in a hook's output. No hook passes
    --fix, because a fix is a guess about prose and rewriting a sentence on its way into a
    commit is not a guess a hook should make unsupervised. Reading that diff was not a
    formality either: --fix broke a bracketed label in three places, nested parentheses two
    deep in four more, converted only one half of five dash pairs, and turned a table cell
    whose entire content was a dash into |: |, twice over seven such cells. None of that is
    caught by a check.

    Clearing the backlog also caught the gate lying. Eleven genuine dashes were sitting
    behind a green run, every one of them the same shape: a dash ending a hard-wrapped line
    whose continuation began with an inline code span. A trailing \s* in the pattern
    matched the newline, carried the match off the end of the line and into the next node,
    and charcheck dropped the finding without a word. Filed as shbernal/charcheck#16,
    worked around with a horizontal-only [ \t] until the fix shipped, and back to \s* on
    0.2.3, which reports the longest match that fits inside the region. The pin is exact for
    that reason: below 0.2.3 this pattern under-reports, and it does so as a pass.

  • checkDocsJson gates both directions of the sidebar. It validated nav to page and
    stopped there, which catches a page that was deleted or renamed and says nothing about
    one that was added and never listed. That is the failure that actually happened: three
    pages sat outside the sidebar with docs:build green, and finding them meant diffing
    the tree against docs.json by hand. Page to nav is checked now, with two exemption
    mechanisms, because the two exempt things are not the same shape. NAV_EXEMPT is by
    exact key, currently doc-index, which scripts/docs-index.mjs generates for the
    llms.txt build rather than for anyone to navigate to; listing an exempt page in the
    nav is itself an error, so an exemption that stops being true reports rather than
    silently granting slack, the same reasoning as the allowlist in scripts/path-refs.mjs.
    GENERATED_TREES is by prefix, currently reference/api/, where TypeDoc emits a few
    hundred pages behind one sidebar link, which is why it cannot be an exact-key exemption:
    reference/api/index stays eligible for the nav direction. The prefix form is also what
    keeps the verdict a property of the repo rather than of the machine, since that tree is
    gitignored and so is absent in a fresh checkout and present in any working copy that has
    run docs:api. Skipped by prefix, both states agree.

Fixed

  • Two copies of one page shared the parts that page owned, and PowerPoint refused the
    deck.
    A page copy shares the deck-wide graph underneath it — layout, master, theme,
    images — and that sharing was applied to everything below the page, including the parts
    PowerPoint treats as belonging to exactly one slide. Duplicating a page with a chart or a
    SmartArt diagram therefore wrote a package with two slides resolving to one chart part or
    one diagram set, and PowerPoint would not open the file at all: 0x80070570, the whole
    deck rejected rather than the duplicate page. The schema validator accepts such a package
    — nothing in ECMA-376 says a chart part has one referrer — so CI was green the whole
    time. Every way of duplicating a page was affected: cloneSlide, importSlide of one
    source page twice, and (once it allowed the same page twice) an importSlides batch — as
    was importShape, one level down, where carrying the same chart or SmartArt frame onto
    two slides pointed both frames at one part.

    A page copy (or a carried shape) now takes its own copy of what it owns: charts and
    chartEx, the five SmartArt parts, OLE embeddings, tags, comments, the notes slide. Ownership is transitive
    — a chart's embedded workbook and chartUserShapes drawing come with it, measured
    against desktop PowerPoint, which refuses a shared workbook exactly as it refuses a
    shared chart — while media blobs, fonts and deck furniture stay shared, which is what
    PowerPoint itself does. The classification lives in src/read/api/ops/page-owned.ts as a
    list of what may be shared, so a relationship type nobody has classified is copied: a
    wrongly shared part is a deck nobody can open, a wrongly copied one is some duplicated
    bytes.

    Two behaviours change beyond the corruption. A cloned page now gets its own notes slide,
    wired back to the clone, where before the clone and the source shared one notes part (the
    caveat the cloneSlide docs used to carry). And importSlide/importSlides no longer
    dedupe a chart or diagram across repeated imports of the same page, so such a deck grows
    by one chart subtree per copy.

  • cloneSlide lost every relationship of a page imported in the same session. The
    clone copied the source's .rels part bytes, and a page brought in by importSlide
    holds its relationships in memory until the deck is saved: there was no such part yet, so
    the clone came out with none at all — a slide whose r:ids resolved to nothing, its
    chart with no chart part behind it and its layout unbound. The clone now copies the live
    relationship set, which is the same thing for a page loaded from disk.

  • importSlide twice from one loaded source wrote a package PowerPoint refuses to
    open
    (issue #18). Copying a part out of a source deck is idempotent per source
    package, which is right for a theme, master, layout or image — a second copy is
    waste. It was also being applied to the slide part, which is the one thing an
    import duplicates on purpose. So the second importSlide(source, i) with the same
    loaded source returned the first copy's partname, and the deck ended up with two
    p:sldId entries and two relationships naming a single slide part: 0x80070570,
    "the file or directory is corrupted and unreadable", with the whole package
    rejected rather than just the duplicate. Nothing in the API said so first —
    slides.length counted the entry that had no part behind it — so the failure
    surfaced only when someone opened the deliverable. Only theme: 'copy', the
    default, was affected; preserve and restyle always allocated a fresh part. A
    page an importSlides batch had already brought across hit the same registry, so
    batch-then-import failed the same way.

    The page now goes through the selection plan copyPart already honoured for a
    batch: it is materialized fresh on every call while everything under it (layout,
    master, theme, media) stays shared. Importing one source page twice yields two
    independent copies over one master, which is what makes a before/after pair of the
    same page possible without loading the source deck twice.

    Wiring a slide part into p:sldIdLst a second time now throws
    InternalError('slide/part-already-in-deck') rather than writing the deck. No
    caller can reach it with valid input — the guard exists because this class of
    defect is invisible until the file is opened, and a thrown error at the call site
    is worth more than a clean run and a corrupt deliverable.

  • Measured fit put Chinese and Japanese text on one line too many. The wrap
    simulator broke lines only at whitespace, so a CJK run tokenized as a single
    unbreakable word: it fitted beside nothing, moved to the next line whole, and left
    the rest of the current one empty. Harmless while such a run sat alone on its line
    (the over-long-token fallback packed it identically), wrong the moment anything
    preceded it. The inflated line count then propagated: fit:'shrink' baked a
    fontScale PowerPoint would never have chosen, and measureText/overflowsBox
    reported vertical overflows that were not there.

    src/measure/text-fit.ts now treats Han, Kana, Bopomofo, the fullwidth and halfwidth
    forms, the compatibility blocks and the Plane 2/3 ideograph extensions as per-character
    wrap opportunities, matching how PowerPoint lays those scripts out.

    Hangul is deliberately not in that set. UAX #14 permits breaking Korean between
    syllables; PowerPoint does not do it, because Korean is written with spaces between
    words. A Hangul run still breaks when it is longer than a line, but through the
    over-long-token character-wrap fallback rather than a break class. Including it
    would have under-reported the line count for Korean, which is the direction that
    overflows, and which the resize path has no safety net for.

    Both halves are pinned to a new PowerPoint-authored fixture,
    test/read/fixtures/autofit-cjk-wrap.pptx (11 boxes, Malgun Gothic, one claim
    each), read by test/read/cjk-line-breaking-oracle.test.js. Reverting the fix fails
    three of its cases; adding Hangul back fails a fourth. Two limitations are recorded
    there rather than fixed — PowerPoint's kinsoku rules (。/、 hang past the right
    inset instead of starting a line) and its font fallback for code points the named
    face lacks. Kinsoku costs nothing: same line count, narrower widest line, so the
    height stays conservative. Font fallback does not have that property, and is now
    reported through uncoveredCodepoints and a warning (see Added) rather than only
    documented. See docs/measured-text-fit.md.

    Thanks to @flyisland for the report and the original fix (#20, #21).

  • Four source files were being tracked as binary, and it had already cost us.
    src/diagnostics.ts, src/measure/font-metrics.ts, src/script/fidelity.ts and
    www/demos/deck-preview.ts each held a literal U+0000 byte inside a template
    literal used as a map-key separator — `${code}<NUL>${message}` and friends. Git
    classifies such a file as binary, which is why the last two showed up as Bin 0 -> 4739 bytes and Bin 4938 -> 6949 bytes in their own commits rather than as readable diffs.

    The bytes are now written as the \0 escape. Same value at runtime, and the files are
    text again: they diff, they blame, git log -S reaches them, and — the part that
    matters — grep stops skipping them silently.

    Two stale references had been hiding behind exactly that, both invisible to the passes
    that should have caught them. src/measure/font-metrics.ts still opened with
    PptxGenJS: Write-side font metrics, the last upstream branding anywhere in src/,
    which the rebrand content scrub could not see; and it still cited
    PLAN-measured-text-fit.md, consolidated into docs/measured-text-fit.md back in
    604ff7cf, which path-refs:check does not catch because a citation needs a / to be
    recognised as a path. Both now say what is true.

  • The desktop-smoke skill told agents to run a generator that no longer exists.
    .agents/skills/powerpoint-desktop-smoke/ walked through cd demos/node && node demo.js All and a per-feature-group node demo.js <Group>, against
    demos/node/output/TsPptx_Demo_*.pptx. That generator went with the upstream-era demos;
    demos/node/ is one HTTP-streaming example now. The workflow is rewritten around
    pnpm demos:build and demos/showcases/output/, and the bisect step no longer promises
    a feature-group split there is no generator for. path-refs:check does not reach
    .agents/, so nothing had flagged it.

  • Two config comments described files that had been renamed. .oxfmtrc.jsonc
    justified ignoring docs/docs.json by citing scripts/docs-new.py and
    scripts/docs-init.py and "both python writers"; they are .mjs, and write with
    JSON.stringify(config, null, 2). The reasoning was still correct, the evidence for it
    was not. It also still ignored demos/browser/js/*, a tree not in this repository.

  • src/types/ had never been formatted. .oxfmtrc.jsonc carried types/ among a
    group of build-output directories, next to dist/ and build/, mirroring the /types/
    in .gitignore. It lost the anchor on the way across, and oxfmt's patterns are
    gitignore-flavoured, so an unanchored directory name matches at any depth. There is no
    types/ at the root and never has been, so the pattern had only ever matched one thing:
    the library's entire public type surface. Seventeen files the formatter had never read,
    while format:run's "src/**/*.ts" glob says plainly that it covers them. The ignore
    list is documented as a safety net rather than the definition of what gets formatted;
    here it was quietly the definition, and it won.

    Four of the seventeen had drifted, and src/types/style.ts carried a 129-column line
    against a printWidth of 120, visible in any diff of that file for as long as it has
    existed and invisible to every gate. Reformatted here: three union types split one
    member per line, one import collapsed onto one. No semantic change, typecheck is
    clean, and the emitted .d.ts is unaffected. The header already recorded this hazard in
    its other direction, tsconfig*.json matching at any depth and reaching workspaces the
    repo does not format, caught by format:check failing on three untouched files. That
    one was loud. This one could not be, because an ignored file looks exactly like a
    well-formatted one.

  • Two claims in the demos READMEs had gone stale in a way path-refs:check cannot
    see
    , since the file each one cites exists and what the text says about it is what
    rotted. The showcases README told the next author to register a third deck in
    build.mjs; SHOWCASES moved to lib/showcases.mjs when the byte-identity harness
    started enumerating decks from it, so following that instruction would have built the
    deck by one list while leaving it out of the gate, the exact drift the move was made to
    prevent. It now says where the registry lives, and why that is not an arbitrary file
    choice.

    And demos/common/ is not read only by the Field Notes deck. Three of its twelve
    surviving assets are read by the test suite: cc_logo.jpg by four regression suites and
    by a fixture-authoring script, logo_square.png and lock-green.svg by both sides of
    the browser lane. The table called the directory "shared images and media the Field
    Notes deck draws on" one commit after that same directory was pruned on the rule "no
    showcase references it", a rule which, applied literally to what was left, takes the
    browser lane's fixtures with it. There is a table of who actually reads what now, and an
    instruction to grep the repository rather than showcases/. A <deck>/data.mjs is also
    the quarterly review's alone, not the shape both decks have; Field Notes carries its
    captions inline.

  • TypeDoc emitted nineteen warnings on every docs run. Nine came from
    {@link PresentationCore.measureText} and
    {@link PresentationCore.registerFontMetrics}. PresentationCore is the
    default-exported base class and is not itself an entry point, so the target resolved to
    a symbol absent from the docs, and the miss multiplied across index, node and
    browser. They point at TsPptx now, the subclass each entry point exports, which
    resolves to a documented member and renders a real link. TypeDoc's own suggestion,
    mapping the names to "#" in externalSymbolLinkMappings, silences the warning and
    leaves a dead anchor.

    Eight more were symbols a documented type references but that are not documented
    themselves, plus one stale intentionallyNotExported entry for ImageBaseProps, which
    is exported now. For the st-enums.ts tuples this is deliberate: types/table.ts
    re-exports the four a:cell3D type aliases and keeps the backing tuples internal, so
    the fix is to declare that intent rather than widen the public surface. The last warning
    was an @example on a member of the SlideMasterObject union, where TypeDoc allows no
    block tags; it is prose now, and says why.

    docs:api reports zero warnings. A full docs:dev still prints esbuild's
    Unrecognized target environment "es2024", which is not ours to fix: VitePress 1.6.4
    pins Vite 5.4, whose esbuild 0.21.5 predates ES2024 target support, and it reads the
    root tsconfig when bundling the VitePress config. TypeDoc still documents three of the
    ten published entry points, and covering the other seven surfaces about forty latent
    link warnings, so that wants its own pass.

Removed

  • 41 of the 53 files in demos/common/ — about 34 MB of 37 MB. They were the upstream
    demos' feature-checklist props (starlabs_*, title_bkgd*, cc_*, fediverse_*,
    krita_*, sample.{aif,avi,m4v,mov,mp3,mpg,wav}, earth-big.mp4, base64Images.js,
    and the rest), and nothing referenced them: not a showcase, not a test, not the browser
    harness. What is left is the 12 files the Field Notes deck draws, the regression suite
    loads, and scripts/browser-harness-server.mjs serves.

    images/image2.jpg was the last one out, and it survived the first sweep because the
    grep that found the other forty matched it: src/script/print/common.ts documents the
    read half's asset naming (image1.png, image2.jpg, and so on), so the name appears in
    the tree without anything loading the file. That is a collision, not a use. After the
    removal the only occurrence of the string anywhere in tracked source is that doc comment.

  • tslib and @arethetypeswrong/core from devDependencies. No tsconfig sets
    importHelpers and nothing in dist/ imports tslib; it arrived in a bulk dependency
    bump and was never used. @arethetypeswrong/core is a direct dependency of
    @arethetypeswrong/cli at an exact pin, so declaring it again here was a second,
    looser constraint on the same package whose only possible effect was to drift out of
    step with the pin the cli actually wants.

  • demos/.prettierrc.json. Prettier is gone; oxfmt does not read it, demos/** is
    oxlint-ignored, and format:run's globs do not reach it. It configured nothing.

  • yaml and fast-xml-parser from devDependencies. yaml arrived for
    scripts/upstream-signals-ledger.mjs; that script and its test are gone and upstream
    tracking is retired, so nothing in the repo imports it. The comment in
    scripts/docs-frontmatter.mjs explaining that the hand-rolled frontmatter parser
    deliberately does not use it now states the standing constraint instead, since "the
    yaml devDependency is deliberately not used here" stops parsing once there is no such
    devDependency: this repo carries no YAML library, and do not add one for that file. A
    real parser would reject frontmatter the hand-rolled one tolerates, which is what would
    turn docs:check from a lint into a gate on YAML pedantry.

    fast-xml-parser had exactly one consumer,
    test/read/fixtures/authoring/extract-autofit-calibration.mjs, and @xmldom/xmldom is
    already a runtime dependency, so the swap adds nothing and removes 11 packages from the
    lockfile. That script still parses XML itself rather than going through this library's
    read model, which covers every field it reads, and the reason is worth not
    re-litigating: autofit-calibration.json is an oracle, held against the shrink
    solver in src/measure/text-fit.ts by test/read/autofit-calibration-oracle.test.js,
    so deriving it through the reader would make the oracle a function of the code it exists
    to judge. It would also make dist/ a prerequisite for regenerating a fixture, and this
    is the one recipe in authoring/ that needs nothing but Node. The three replacement
    helpers walk direct children rather than searching descendants, which is what every
    field lookup here means (p:spPr then a:xfrm then a:off); the one place recursion
    is correct is the p:sp sweep, where a group nests shapes. The regenerated table is
    byte-identical to the committed one across all 149 cases and every field, so
    autofit-calibration.json is absent from that diff rather than buried in a 15,374-line
    whitespace churn the pre-commit formatter used to flatten back.

  • tools/data2chart.html and its stylesheet. A gitbrent-era standalone page: paste
    tab-separated data, get a snippet building a line chart, optionally eval() it in the
    tab to produce a deck. Every load-bearing piece of it is gone. It loads
    dist/pptxgen.bundle.js from a CDN, an artifact docs/runtime-and-package-support.md
    lists as something this package deliberately does not ship and
    scripts/package-smoke.mjs asserts the absence of. The code it emitted would throw even
    if the bundle loaded, since it wrote the three-argument
    addChart(pptx.charts.LINE, data, opts) signature against a pptx.charts enum bag
    where the API is addChart(data, { type }). Its images/favicon.png has no counterpart
    in the repository, and nothing anywhere referenced the page. Rebuilding it would have
    meant a second standalone browser app one commit after the last one moved onto the site,
    for a payload of about 35 lines of TSV transposition. Deleting it also makes
    runtime-and-package-support.md's "the legacy upstream browser demo for that workflow
    is not included in this repository" true, rather than contradicted by a file sitting in
    the tree.

  • Ignore patterns for seven paths nothing writes, from .oxfmtrc.jsonc
    (.docusaurus/, static/, libs/, build/, src/bld/) and .gitignore
    (bower_components/, .nyc_output/, /types/, src/bld/). The first two are the
    layout of a site generator this repository replaced with VitePress, the next two a
    package manager and a coverage tool it does not use, and the remainder match no
    directory at any depth. Verified inert rather than assumed: bare oxfmt considers 600
    files before and after, and git status --untracked-files=all reports nothing newly
    visible. The reason to bother with patterns that cost nothing to run is the src/types/
    fix above. A list that mixes live entries with speculative ones cannot be read, because
    a pattern's presence stops being evidence that it matches something, and the one entry
    that did match, and matched the wrong tree, read as unremarkable for as long as it sat
    among six that matched nothing. Both files now say to add a path when a tool writes it,
    rather than in case one might.