Repository navigation
v3.3.0
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
-
isChartTypenarrows a string to the chart catalog. The companion toisChartExType,
which reported only which half of the catalog a type belongs to and answeredfalsefor
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 reachesaddChart, which now
throws on one it does not recognise (see Changed). -
importSlidescarries speaker notes, per request.ImportSlidesRequestgains
importNotes?: boolean, the batch spelling of the optionimportSlidehas 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 onenotesMaster, so the
destination's own is reused when it has one and the first carried master is installed
when it has none, exactly asimportSlide({ importNotes: true }),appendSlidesand
Slide.addNotesdo. 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.checkSelectionCopyabletakes the opted-in pages and walks each
one's notes subgraph under exactly the rulescarryNoteswill 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.
carryNotesopens an
ownership scope (page-owned.ts), so a page named twice — reachable in one call for
the first time here, and already reachable through twoimportSlidecalls — 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.
- The up-front dry run now walks the notes graph. Notes are copied after the pages
-
Slide.addNotes(text)on the read model — the write counterpart to the
notesText/notesTextFrame/notesSlidegetters, and the only way to annotate a
slide with no notes part at all. That state is reachable in normal use — it is what
importSlidewithout{ importNotes: true }leaves behind — and it was a dead end: the
notes accessors are getters, and the settablenotesTextFrameisnullprecisely 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-sideaddNotes; 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, thesldImg/sldNumplaceholders — 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 ruleimportSlide({ importNotes: true })andappendSlidesalready follow, so mixing the three cannot produce a second
notes master.The part it builds is the generator's, not a second design:
makeXmlNotesSlidewas split
so its fixed three-placeholder frame (makeXmlNotesSlideSkeleton) is shared with the read
side instead of copied into it, andtest/read/add-notes.test.jsasserts the two paths
emit the same notes body — modulo empty-element spelling, which the read model's
serializer normalizes. This is the firstsrc/read/→src/gen/import; it buys the
anti-drift guarantee thatsrc/ooxml/exists to give the constants both halves share.
Fixed
-
A chartEx chart appended onto a loaded deck arrived empty.
Presentation.extractSlides()
— the bridgets-pptx/read'sappendSlidesserializes 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.
extractSlidesbuilds a chartEx chart
withmakeXmlChartExand hands its two mandatory style sidecars along in the descriptor's new
chartExslot;appendSlidesinjects it as its ownchartEx{N}.xmlpart under the Microsoft
chartex content type, wires the slide to it through the MSchartExrelationship rather than
the ECMAchartone, and writesstyle{N}.xml/colors{N}.xmlat 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 onisChartExTypeand always has. Classic charts
throughappendSlidesare unchanged, byte for byte. The interim
UnsupportedFeatureError(chart/chartex-not-extractable) that refused these charts is gone
fromUnsupportedFeatureErrorCode— 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, andembedFontswere absent from
docs/reference/pptx-read.md—importNotesappeared nowhere indocs/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,importNoteshas a prose section
covering the notes-master policy, and the neighbouring claim that "deliberate re-branding
(arestylemode) is not yet implemented" — untrue sincerestyleshipped, and
contradicted by the section above it — is corrected. No behaviour change: the option has
worked in all threethememodes since it shipped.ImportSlidesRequestwas 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
addChartinstead of emitting a chart with
no plot.addChart(data, { type })took the type on trust: theCHART_NAMEunion
constrains TypeScript callers, but nothing enforced it for JavaScript ones, and the two chart
emitters partitionChartTypebetween 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
ChartMultientry 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 throwsInternalError
(chart/type-not-routed), which no public input can now produce. Every catalog type builds
exactly the bytes it did before. -
toBytes()replacesstream(). The old method did not stream: it assembled the
complete archive, then copied fflate'sUint8Arrayinto a NodeBuffer. It therefore
failed in browsers without aBuffershim, returned an unnecessarily broad type, and
ignoredonMediaError.toBytes({ compression, onMediaError })now returns exactly
Promise<Uint8Array>in Node, browsers, and workers without the extra archive-sized
copy. Migrateawait pptx.stream()toawait pptx.toBytes(); callers that need another
representation should continue usingwrite({ outputType }). -
pnpm run verifyno 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
OOXMLValidatorCLIprocess, so the real ceiling wasworkers × 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.jsbatches validation.OOXMLValidatorCLIaccepts 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.tsderivesmaxWorkersfrom 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_WORKERSpins
it explicitly.docs:buildmoved fromverifytoverify: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.ymlalready built the
site on every pull request, socheck:staticwas building it a second time.
verifyis ~97s → ~44s andverify:full~77s, both on a 12-core box. Composites are
now assembled byscripts/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
verifywas paying 13 times over.package.jsonremains the single definition of each
step. Seedocs/testing.md.One latent test bug surfaced and is fixed: seven schema fixtures assert on warnings by
swappingconsole.warnacross anawait, which is unsound underdescribe.concurrent
— a neighbour'sfinallyrestores 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-validateoracle. This repo and
ts-xlsxeach 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-validatorscript with it.ooxml-validatefetches 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.jsshrank to an adapter. Batching, the one-child-at-a-time queue and
the CI gate belong to the package now; the suite'svalidateBuf/
validatorAvailablesurface is unchanged, so no fixture moved.- Every input file now comes back with an explicit
validflag. 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 nowPackageOpenError/
Packagerather than a null id and anOpenXmlPackageExceptiontype; the two
self-check fixtures intest/schema-cases.jspin 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 stillMicrosoft365, andpnpm run schema:versionsstill
prints the coverage table it printed at 3.2.0. - Set
OOXML_VALIDATE_NO_BATCH=1(wasTSPPTX_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
/demoson the project site, which builds the same deck in the tab and then shows you
the slides, by reading the package back with
pptx-htmland 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-levelwww/, sodocs/stays what the docs kit
governs: markdown content. Seewww/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
demofixture, 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. - Vite + Bootstrap app whose only output was a download — is deleted. Its replacement is
-
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, becausepptx-htmlalready 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.pptxtoKestrel_Q3_Business_Review.pptx, and every key in
the deck's exportedBRANDis renamed rather than merely re-valued — a constant called
navyholding 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-*.jsalone is 610 KB, 78 test files import
dist/node.jsand 64 importdist/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 atVITEST_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 fullverifynow reports 14.7s of import.
Two module-level caches written to span files finally do:test/validator.jsjoins
validation requests across files instead of spawning one .NET child per file, and
test/read/corpus.js'sirFormemo 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
resetssetDiagnosticHandlerafter every test, the one process-global the library owns,
and the deliberate leak/detector pair in
test/regression/api/global-state-reset.test.jsfails when that setup file is removed.
sequence.shuffle.filesrandomizes 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, becausecaptureDiagnostics()and the
warn-capturing schema fixtures rest on it. The other module-level state undersrc/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.jsowns the read fixture corpus,
test/read/opc.jsthe relationship assertions,test/regression/chart/chart-parts.js
the chart part readers, andtest/helpers.jsbytesEqual(),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 definedopenFixtureinline, 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 theMIN_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.defineRegressionSuitetakes 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.
Itsfngoes to vitest as-is, which takeshelpers.jsoff the top of every regression
failure's stack, and per-caseskipIf/runIf/skip/only/todo/fails/
concurrent/timeoutfields 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 inanimations-transitions.test.jswiden while
passing through, having filtered toppt/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.jsnamed its decks in a five-name literal, anddocs/testing.mdasked
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 readsfixtureNamesnow, 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.pptxfails "saved output passes
the validator" as committed, before this library touches it, with three Microsoft365
errors inside PowerPoint's own chartc:extLstthat 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.jsgains 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 inverifynoticing. One
case perturbs a freshly built deck with an undeclared attribute onp:spand demands
exactly oneSch_UndeclaredAttributeat 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 withvalidateBufstubbed 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.mdanddocs/testing.md:verify
alone no longer covers the script round trip over the corpus.script-roundtrip.test.js
andscript-standalone.test.jseach carried a byte-equivalent copy of what
script:roundtrip:allalready runs, same corpus and samediffDeckIr, soverify: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. Runpnpm run script:roundtrip:allbefore pushing changes to
src/script/. -
The commit-message gates come from
shbernal/lefthook-rules, not a local script.
scripts/check-commit-msg.mjsencoded 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 tov1
with a 24h refetch.no-shell-quoting-leakis 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 thegit commit -v
scissors line first. Over this history it rejects those five commits and none of the
other 4356.no-ai-attributionarrives 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, sopreparesurvives an offline install and the rules simply
do not run. Aconfigs: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-ooxmlrather than the hostedhttps://api.ooxml.dev/mcpendpoint. 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 gainedooxml_values,ooxml_diff_profilesandooxml_explain, and it lost the
prose half entirely: no spec PDFs, noooxml_section, and no OPC part, content-type or
relationship catalogue.AGENTS.mdanddocs/ooxml-agent-context.mdnow say outright
that those questions fall through tomicrosoft_learnand then to web search, rather
than leaving an agent to discover the dead end.ooxml_searchalso 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 tonoImplicitAny, 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 "migratescripts/to TypeScript" question:
the annotations buy the checking, and the file extension buys nothing on top of it while
costing 49 path citations indocs/and every glob in the lint, format and tsconfig
lists. Types come from the library itself,import('../dist/read.js').Sliderather than
a restatement of its shape.The flag was not bookkeeping. It surfaced six silent defects.
scripts/append-ceiling.mjsreported a false loss, its bullet-glyph probe reading
paragraph.bullet, an accessor the read model replaced withbulletDetail, so it
comparedundefinedand called the construct lost; it reports 23 of 24 surviving now,
the one failure being the documentedplaceholder idxloss.
scripts/read-blindness-census.mjs --allnever censused a notes slide, because it
reached for aNotesSlide.element_that does not exist and its guard was therefore
always false, whiledocs/testing.mdalready advertised--allas 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.mjscould render every template withundefinedas the project
name, from a.split('/').pop()TypeScript knows can miss. And
scripts/powerpoint-com-smoke.mjsdropped a signal kill:closepassesnullfor a
signalled child, which flowed into acode !== -1retry test and made a killed cscript
look like a clean run.@types/istanbul-lib-coverage,-lib-reportand-reportsare new devDependencies,
sincescripts/coverage-merge.mjsimports 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.noPropertyAccessFromIndexSignaturestays off, with
its reason recorded: it is house style aboutobj.fooagainstobj['foo'], not
correctness, and these scripts read a lot of JSON that isRecord<string, ...>by
nature..oxlintrc.jsoncandscripts/README.mdboth 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 stillno-unsafe-*. The residue isJSON.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.
parseFontMetricsandpptx.registerFontMetricspreviously threwUnsupported OpenType signature ttcfon 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;registerFontMetricsadditionally uses its ownface
argument as the selector whenfontis 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.ttfunder anyfacename is unchanged: only a collection is
name-selected.ts-pptx/measuregainslistFontFaces(bytes)(every font in a file, with itsname
table identity; a plain.ttfis a one-entry list) andisFontCollection(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 oneglyf. 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. Seedocs/measured-text-fit.md("Font collections"). -
A font the parser rejects now raises a classified
MediaErrorwith the new
font/parse-failedcode, instead of opentype.js's bareErrorescaping the taxonomy.
A consumer catching library failures by class andcodecould 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
uncoveredCodepointsarray (sorted, deduplicated), andapplyMeasuredFitwarns once
per export with the newmeasure/uncovered-codepointsdiagnostic, 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.notdefis 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 afit:'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 inapproximatedFaces. The audit (collectUncoveredCodepointsin
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.importSlidein a loop can leave a half-stitched deck when request three
of five fails on a size mismatch;importSlidesvalidates 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 torequests. It also gives the
batch'sslide → slidelinks 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 andrescalehave no batch spelling yet, so reach forimportSlidewhen
you need one of those. -
compose()on the showcase modules, beside the existingbuild(outFile): it
assembles the deck and returns the presentation, having written nothing. The preview needs
the bytes;pnpm demos:buildneeds a file.buildis nowcomposeplus a destination. -
pnpm run typecheck:site, anddocs/tsconfig.jsongrew a runner. It covered
docs/.vitepress/**on paper and was executed by nothing; it now also coverswww/**/*.ts
and runs inverify,check:staticand pre-push..vuefiles stay outside it —tsc
does not read single-file components — which is why the demos page's logic is a plain
.tsmodule 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.charcheckis that gate, wired into both hooks
andcheck:static, andcharcheck.config.jscarries 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/**/*.vueis 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-warningswhile they were worked off. They are worked off, so the second
severity and itsDOCS_CLEANallowlist are gone, every surface errors, and
--max-warnings 0is on bothlint:charsand the pre-commit hook so that a rule added
atwarnlater 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:--fixbroke 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 asshbernal/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. -
checkDocsJsongates 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 withdocs:buildgreen, and finding them meant diffing
the tree againstdocs.jsonby hand. Page to nav is checked now, with two exemption
mechanisms, because the two exempt things are not the same shape.NAV_EXEMPTis by
exact key, currentlydoc-index, whichscripts/docs-index.mjsgenerates 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 inscripts/path-refs.mjs.
GENERATED_TREESis by prefix, currentlyreference/api/, where TypeDoc emits a few
hundred pages behind one sidebar link, which is why it cannot be an exact-key exemption:
reference/api/indexstays 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
rundocs: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,importSlideof one
source page twice, and (once it allowed the same page twice) animportSlidesbatch — as
wasimportShape, 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 andchartUserShapesdrawing 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 insrc/read/api/ops/page-owned.tsas 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 thecloneSlidedocs used to carry). AndimportSlide/importSlidesno longer
dedupe a chart or diagram across repeated imports of the same page, so such a deck grows
by one chart subtree per copy. -
cloneSlidelost every relationship of a page imported in the same session. The
clone copied the source's.relspart bytes, and a page brought in byimportSlide
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 whoser: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. -
importSlidetwice 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 secondimportSlide(source, i)with the same
loadedsourcereturned the first copy's partname, and the deck ended up with two
p:sldIdentries 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.lengthcounted the entry that had no part behind it — so the failure
surfaced only when someone opened the deliverable. Onlytheme: 'copy', the
default, was affected;preserveandrestylealways allocated a fresh part. A
page animportSlidesbatch had already brought across hit the same registry, so
batch-then-import failed the same way.The page now goes through the selection plan
copyPartalready 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:sldIdLsta 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
fontScalePowerPoint would never have chosen, andmeasureText/overflowsBox
reported vertical overflows that were not there.src/measure/text-fit.tsnow 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 bytest/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 throughuncoveredCodepointsand a warning (see Added) rather than only
documented. Seedocs/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.tsand
www/demos/deck-preview.tseach held a literalU+0000byte 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 asBin 0 -> 4739 bytesandBin 4938 -> 6949 bytesin their own commits rather than as readable diffs.The bytes are now written as the
\0escape. Same value at runtime, and the files are
text again: they diff, they blame,git log -Sreaches them, and — the part that
matters —grepstops 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.tsstill opened with
PptxGenJS: Write-side font metrics, the last upstream branding anywhere insrc/,
which the rebrand content scrub could not see; and it still cited
PLAN-measured-text-fit.md, consolidated intodocs/measured-text-fit.mdback in
604ff7cf, whichpath-refs:checkdoes 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 throughcd demos/node && node demo.js Alland a per-feature-groupnode 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:buildanddemos/showcases/output/, and the bisect step no longer promises
a feature-group split there is no generator for.path-refs:checkdoes not reach
.agents/, so nothing had flagged it. -
Two config comments described files that had been renamed.
.oxfmtrc.jsonc
justified ignoringdocs/docs.jsonby citingscripts/docs-new.pyand
scripts/docs-init.pyand "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 ignoreddemos/browser/js/*, a tree not in this repository. -
src/types/had never been formatted..oxfmtrc.jsonccarriedtypes/among a
group of build-output directories, next todist/andbuild/, 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,
whileformat: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.tscarried a 129-column line
against aprintWidthof 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,typecheckis
clean, and the emitted.d.tsis unaffected. The header already recorded this hazard in
its other direction,tsconfig*.jsonmatching at any depth and reaching workspaces the
repo does not format, caught byformat:checkfailing 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:checkcannot
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;SHOWCASESmoved tolib/showcases.mjswhen 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.jpgby four regression suites and
by a fixture-authoring script,logo_square.pngandlock-green.svgby 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 thanshowcases/. A<deck>/data.mjsis 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}.PresentationCoreis 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 acrossindex,nodeand
browser. They point atTsPptxnow, 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"#"inexternalSymbolLinkMappings, silences the warning and
leaves a dead anchor.Eight more were symbols a documented type references but that are not documented
themselves, plus one staleintentionallyNotExportedentry forImageBaseProps, which
is exported now. For thest-enums.tstuples this is deliberate:types/table.ts
re-exports the foura:cell3Dtype 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@exampleon a member of theSlideMasterObjectunion, where TypeDoc allows no
block tags; it is prose now, and says why.docs:apireports zero warnings. A fulldocs:devstill 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, andscripts/browser-harness-server.mjsserves.images/image2.jpgwas the last one out, and it survived the first sweep because the
grep that found the other forty matched it:src/script/print/common.tsdocuments 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. -
tsliband@arethetypeswrong/corefromdevDependencies. No tsconfig sets
importHelpersand nothing indist/imports tslib; it arrived in a bulk dependency
bump and was never used.@arethetypeswrong/coreis a direct dependency of
@arethetypeswrong/cliat 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, andformat:run's globs do not reach it. It configured nothing. -
yamlandfast-xml-parserfromdevDependencies.yamlarrived 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.mjsexplaining that the hand-rolled frontmatter parser
deliberately does not use it now states the standing constraint instead, since "the
yamldevDependency 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
turndocs:checkfrom a lint into a gate on YAML pedantry.fast-xml-parserhad exactly one consumer,
test/read/fixtures/authoring/extract-autofit-calibration.mjs, and@xmldom/xmldomis
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.jsonis an oracle, held against the shrink
solver insrc/measure/text-fit.tsbytest/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 makedist/a prerequisite for regenerating a fixture, and this
is the one recipe inauthoring/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:spPrthena:xfrmthena:off); the one place recursion
is correct is thep:spsweep, 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.jsonis absent from that diff rather than buried in a 15,374-line
whitespace churn the pre-commit formatter used to flatten back. -
tools/data2chart.htmland its stylesheet. A gitbrent-era standalone page: paste
tab-separated data, get a snippet building a line chart, optionallyeval()it in the
tab to produce a deck. Every load-bearing piece of it is gone. It loads
dist/pptxgen.bundle.jsfrom a CDN, an artifactdocs/runtime-and-package-support.md
lists as something this package deliberately does not ship and
scripts/package-smoke.mjsasserts 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 apptx.chartsenum bag
where the API isaddChart(data, { type }). Itsimages/favicon.pnghas 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, andgit status --untracked-files=allreports nothing newly
visible. The reason to bother with patterns that cost nothing to run is thesrc/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.