Repository navigation
v3.0.0
Added
-
Shape.fillNoFillreads an explicit<a:noFill/>on a shape's fill — the fill-side
counterpart oflineNoFill, and the only accessor that separates a deliberately
transparent shape from one that inherits its fill throughp:style/a:fillRef. Every other
fill accessor (fillColor,fillSchemeColor,resolvedFill,gradientFill,
patternFill,pictureFill) reportsnullfor both, so a consumer honouring the read
model painted ana:noFillrectangle in the theme's accent colour. The same class had
ChartFill.noFill,ChartLine.noFillandCellBorder.noFillalready; the shape fill was
the omission.Shape.noFill()has always been able to write this state. -
Shape.lineCapandShape.lineAlignreada:ln/@capanda:ln/@algn.@capwas a
write/read asymmetry inside this library:ShapeLineProps.capauthors it and nothing read
it back, so a deck this writer produced could not round-trip through its own reader without
losing an attribute the writer put there on purpose. Both report the raw OOXML token, the
waylineDashreports@val—'flat'/'rnd'/'sq'and'ctr'/'in'— and
nullwhen unset rather than a defaulted value. On a thick dashed rule the cap decides
whether each dash reads as a rectangle or a lozenge and changes the drawn length of every
one; SVG'sstroke-linecapis the exact equivalent. -
GroupShape.childFramereads a group's own child coordinate space
(p:grpSpPr/a:xfrm/a:chOffanda:chExt), as aChildFrameofoffsetX/offsetY/
extentX/extentYin EMU, ornullwhen the group has no transform.absoluteFrame
reads these internally to compose slide-absolute geometry and remains the right answer for
anything that paints — this is for a consumer that rebuilds a group as OOXML, which
needs the source child space to reproduce its scaling and could otherwise only rebuild
groups whose child space is the identity. Named after the OOXML attributes rather than the
read model'sleft/top/width/height, because it is the source rectangle of a
mapping, not a frame on the slide. -
clipPath()names the clip silhouettes you would otherwise re-derive. AClipShape
is data — a named silhouette plus its options — andclipPath(shape, w, h)resolves it to
the freeformpointspathaddImageemits as its<a:custGeom>clip mask. The first
silhouette is the half-disc a cover-slide picture placeholder cuts. See
docs/image-in-shape.md.import { clipPath } from '@shbernal/ts-pptx' const w = 5.22, h = 7.5 slide.addImage({ path: 'cover.jpg', x: 0, y: 0, w, h, points: clipPath({ kind: 'half-disc', flat: 'right' }, w, h), sizing: { type: 'cover', w, h }, })
flatnames the edge the straight side sits on;presetpicks the proportion,'deep'
(the default) or'shallow'. Paired withsizing: 'cover'this is a standalone
reproduction of what a picture placeholder does — a layoutcustGeomclipping an
inherited blipFill — with no placeholder, and no layout, involved.The box size is an argument for a reason. A
custGeompoint written as%resolves
against the slide, not the picture, so a box-relative silhouette has to be emitted in
inches already scaled to its box.clipPathmultiplies its fractions out at build time,
which is what lets one silhouette scale to any region — and is why handing a path to a
picture of a different size puts the clip somewhere else entirely. That trap is the whole
reason this is worth shipping rather than leaving to each caller. -
slide.addModel3d()embeds a 3D model — PowerPoint's Insert ▸ 3D Models. A glTF
binary (.glb) travels inside the package, and PowerPoint 2019+ renders it live and lets
the viewer orbit it. Seedocs/3d-models.md.slide.addModel3d({ path: 'assets/engine.glb', preview: { path: 'assets/engine-render.png' }, meterPerModelUnit: 1 / 240, // the model's largest bounding-box dimension x: 1, y: 1, w: 6, h: 4, })
Two things are worth knowing before using it:
- Supply
preview. Everything that is not PowerPoint 2019+ draws that picture instead
of the model — including PowerPoint's own slide thumbnails, PDF export, and print. The
library has no 3D renderer, so omitting it embeds a gray placeholder and emits a
model3d/preview-missingwarning. Same bargain asaddOleObject()'scover. - Set
meterPerModelUnit. Theam3dscene is measured in metres. PowerPoint reads the
model's bounding box and normalizes its largest dimension to 1 metre; ts-pptx does not
parse glTF, so it emits0.5(correct for a model 2 units across) and leaves the rest to
you. Left at the default, a model 240 units across becomes a 120-metre object with the
camera inside it. Set it to1 / <largest bounding-box dimension>.
cameraoverrides the viewpoint (pos/lookAt/upin metres,fovin degrees); the
defaults are the ones PowerPoint wrote for a 2×2×2 cube. Out-of-range and non-finite values
throw rather than being coerced. Linked (non-embedded) models, animation scenes, and a typed
read accessor are out of scope for now — a model read throughts-pptx/readsurfaces as an
inertgraphicFrameand survives load → save andimportSlidebyte-intact. - Supply
-
Browser support is now proven in CI, not assumed. A Playwright lane
(pnpm run test:browser, CI jobbrowser) runs the package in headless Chromium
againstdemos/vite-demo. No library code changed — this converts an existing
claim into evidence.The assertion worth naming is cross-runtime byte identity. The demo imports the same
showcase modulepnpm demos:build quarterly-reviewbuilds, andsrc/zip.tspins
FIXED_MTIME, so the two packages are directly comparable: all 113 parts of the
browser-built deck are byte-identical to the Node-built one. Every serializer, the zip
writer, part ordering and relationship numbering are therefore runtime-invariant — by
comparison, not by inspection. A second spec reads the object-URL download back with
jszip (an implementation independent of thefflatethe library writes with) to confirm
it is a real OPC package.Two boundaries stay exactly where they were, and are now stated in
docs/runtime-and-package-support.mdrather than
left to inference:- Runtime support is not layout fidelity. Nothing in the lane depends on a rendered
page. RealoffsetWidth, the resolved cascade, and browser-chosen fonts remain out of
active scope; a layout difference between two browsers is not a defect in this
package's browser support, whereas a.pptxa browser builds differently from Node is. tableToSlidesmeasurement is still unavailable without a layout engine, exactly as
documented —offsetWidthis0, widths fall back to computed CSS and then an equal
split, anddata-pptx-width/data-pptx-min-widthpin them.
The explode/normalize/diff machinery the byte-identity gate has always used moved to
scripts/pptx-parts.mjsso both gates share one definition of "the same bytes"; the
refactor was verified byte-identical against a baseline frozen with the pre-refactor
script. - Runtime support is not layout fidelity. Nothing in the lane depends on a rendered
-
The whole
RuntimeAdapternow runs in a real browser, not just the download path.
A second Playwright fixture serves the shippeddist/browser.jsunbundled over a
static server and drives decks written to reach the three loadersdemos/vite-demo
cannot, because its showcase draws every asset rather than loading one:loadMedia— a fetched raster image lands in the package as the same bytes Node reads
off disk, and as the same bytes as the source file. The two implementations return
different strings for the same image (Node raw base64, the browser aFileReaderdata
URI) and everything downstream reconciles them, including the image sizer.createSvgPngPreview— the<canvas>rasterizer, whose branches nothing exercised
before: a real PNG where Node can only stub a placeholder, plus the undecodable-SVG and
zero-dimension arms, each of which must fail rather than ship a blank fallback.loadFontData— a font fetched over HTTP bakes the samefontScaleand embeds the
same/ppt/fonts/bytes as one read off disk.
The deck definitions are written once and built twice, once per runtime, so a divergence
in the fixture cannot read as a divergence in the runtime.Two things fell out of loading the shipped file unbundled.
opentype.jsturns out to be
a dynamic bare import inside the measure/fit chunk — invisible to every bundled
consumer, and now documented for anyone loading the entry over a plain
<script type="module">. And Node and the browser are expected to disagree on exactly
one part: the SVG PNG fallback, where Node has no rasterizer. The lane asserts the shape
of that disagreement so it cannot quietly become a different one. -
tableToSlidesis tested against a table a browser actually laid out — a third
Playwright project,html-table, on the same unbundled harness server.pickColWidthBasischooses between three column-width bases, and its first arm — the
renderedoffsetWidth— had never executed anywhere. The Node suite drives happy-dom,
whereoffsetWidthis0for every cell, so it always took a fallback arm; the unit
suite reached the function by handing it numbers directly, which proves theifand not
the pipeline behind it. So the primary path of the feature, including thearrColSrc
arithmetic that fixed the spanning-data-pptx-widthdefect, was covered only at its own
function boundary.The fixture is built so the measured basis and the computed-CSS basis disagree
(offsetWidthis the border box, computedwidththe content box), because a test that
only showed "the widths came out proportional" would be equally green if the measured arm
never ran. The spec re-derives both bases from the live page and fails if they ever
converge. Sensitivity-checked by disabling the arm: exactly the two arm-dependent
assertions go red, reporting the CSS ratio.This does not move the scope line, and the wording matters. What is asserted is that a
measurement is taken and honoured — proportionally, withdata-pptx-widthstill winning
outright. Nothing asserts that Chromium's numbers are the right numbers or that another
engine would agree; that is live-DOM layout fidelity, it has no oracle, and it stays out
of active scope. A layout difference between two browsers is still not a defect in this
package; a.pptxa browser builds differently from Node still is — which the lane pins
directly, by converting the same markup in both runtimes and asserting Node falls back to
the CSS basis where the browser measures.The new project contributes its V8 coverage to the merge like every other browser spec, so
the merged report moved up on all four axes — statements 93.91 → 94.03, branches
83.71 → 84.16, functions 98.29 → 98.58, lines 96.11 → 96.20. Two notches in
scripts/coverage-gates.jsonare ratcheted with it (statements 92 → 93, branches
82 → 83), which is what keeps a gate from carrying two points of slack. -
Coverage from both lanes is merged into one number (
pnpm run coverage:gate, CI job
coverage).scripts/coverage-merge.mjsfolds the browser lane's V8 coverage into the
Node report on one rule — the Node report defines the shape, the browser lane
contributes hits — so the merged denominator is identical to the Node report's and the
two percentages are directly comparable. Merged: statements 93.91, branches 83.71,
functions 98.29, lines 96.11.It also makes this repo's point-of-slack rule fail a build rather than live in prose:
scripts/coverage-gate.mjsis red both when a number falls below its notch and when it
clears it by less than a full point. Prose does not fail a build, which is how the
exclusion drop leftfunctionsat 0.35 of slack while an acceptance criterion of
"thresholds still pass" was satisfied.The four numbers in
vitest.config.tsremain the Node suite's own floor, and the browser
lane keeps its per-function gate — a percentage cannot say which adapter function
stopped running. -
The package is now bundled for Node and run, inside
pnpm run test:package. The
browser lane put a real bundler in front of thebrowsercondition; nothing asked the
same of thenodeentry, and the two are different questions — Node's resolver finds a
specifier on disk at call time, while a bundler must resolve every one of them, including
dynamic ones, at build time. A package can be perfectly importable and still be
unbundlable.bundleForNode()esbuild-bundles the installed tarball withplatform: 'node'across
every export subpath but/browser, then runs what it emitted. It fails if the build
warns, if anything other than a Node builtin stayed external, or if the emitted bundle
cannot write a.pptx. Both the npm and pnpm fixtures, since pnpm's symlinked store is a
different shape for a bundler to walk. -
A bundle-size budget for the browser entry (
pnpm run bundle-size:check, part of
check:package). Nothing measured shipped size before; a size promise nobody measures is
a promise that quietly stops being true. It fails only on a step change and asks for a
re-freeze only when a win is worth banking, because bytes move on every commit. The
figure is a growth detector, not a download size —dist/is unminified and every real
browser consumer minifies it.
Changed
-
BREAKING:
Paragraph.bulletis replaced byParagraph.bulletDetail. The old accessor
reported a tagged string —'none'/'char:•'/'autoNum:arabicPeriod'— which is
ambiguous when the glyph is itself a colon and reads as a bare glyph if you do not know
better. That is not hypothetical: this library's own script converter first consumed it as
one, and'none'.codePointAt(0)put a literalnbullet on every converted deck, silently.bulletDetailreturns a discriminated union with no parsing left to get wrong, and carries
what the string could not —a:buAutoNum/@startAt, and the bullet's owna:buFont/
a:buSzPct/a:buSzPts/a:buClr. It also reports a fourth kind the old accessor
dropped tonull: a picture bullet (a:buBlip), with its image part resolved.// before para.bullet // 'autoNum:arabicPeriod' — startAt, font, size and colour unreachable // after para.bulletDetail // { kind: 'autoNum', scheme: 'arabicPeriod', startAt: 5, // font: 'Wingdings', sizePct: 80, sizePt: null, // color: 'C00000', schemeColor: null, resolvedColor: { … } }
Migration:
bullet === 'none'→bulletDetail?.kind === 'none';
bullet?.startsWith('char:')→bulletDetail?.kind === 'char', with the glyph at
.charrather than after the colon;bullet.slice('autoNum:'.length)→.scheme.
A paragraph that inherits its bullet still reportsnull.Numbering is content rather than styling: a list continuing "5. Deploy" that came back as
"1. Deploy" was a different slide, andnumberStartAtwas a pure write/read asymmetry —
addTextaccepted it and nothing could produce it. -
BREAKING (output):
<a:buSzPct/>is emitted only whenbullet.sizeis given. It used
to be written unconditionally, pinned toval="100000", on every object-form bullet and on
bullet: true. An explicit 100% is not the same as leaving it out — it overrides
whatever bullet size the layout's or master's list style sets, so every bullet this path
wrote silently forced its glyph back to full size. The same class of bug as the explicit
a:buNonean omittedbulletemits, and invisible untilbulletDetailgave the
round-trip check something to see it with. An out-of-rangebullet.sizenow warns and
emits nothing rather than warning and pinning to 100%. Decks that want the old behaviour
can passbullet: { size: 100 }explicitly. -
sizing.w/sizing.hare optional, andsizing: { type: 'stretch' }names what used to
be nameless. The emitter has always defaulted the fit box to the picture's own extent; the
type demanded both anyway, so everycover/containcall restatedw/hit had already
supplied a line above.sizing: { type: 'cover' }is now the ordinary form, and passing them
still means what it always did — a fit box deliberately different from the picture.stretchemits the plain<a:stretch><a:fillRect/></a:stretch>a raster already gets. It
exists so the fill-the-box behaviour can be asked for, which is what makes the vector
default below opt-out-able rather than a trap. -
BREAKING: an SVG is placed at its own aspect ratio by default instead of being stretched
to its box.addImage({ svg, w, h })used to fill the box whatever the glyph's proportions
were, so any icon with a non-squareviewBox— a minority in every real icon set, and never
the one you check — came out squashed. Every consumer's answer was the same wrapper that
routes each call throughsizing: 'contain'; that wrapper is now the library's default.slide.addImage({ svg: icon, x: 1, y: 1, w: 3, h: 1 }) // letterboxed, centered slide.addImage({ svg: band, x: 0, y: 0, w: 13.33, h: 0.4, sizing: { type: 'stretch' } }) // opt out
Scope, deliberately narrow. Rasters are untouched: a photo's box is chosen for it, filling
it is what PowerPoint does, and letterboxing every existing deck's pictures would be a change
of a different order. A vector is different in kind — it states its ratio in aviewBox,
and disagreeing with that statement is a defect rather than a layout choice. Nothing is
emitted when the ratios already agree, so a square glyph in a square box produces the same
bytes it always did, and an SVG carrying neitherviewBoxnorwidth/heightstretches
silently — no sizing was requested, so there is nothing to warn about.Two related SVG fixes fall out of the same root cause — the write path treated vector
sources as unmeasurable long aftersrc/media/image-size.tslearned to read aviewBox:{ svg, w: 4 }derives its height from the intrinsic ratio (a 2:1 viewBox → 4in × 2in),
where before an omitted dimension silently became 1 inch. Rasters have always done this.{ svg }with neither dimension still falls back to 1 inch, and that is not an
oversight. An SVG's user units are dependable relative to each other and merely
conventional in absolute terms; treating them as 96-DPI pixels would insert a 24-unit icon
as a quarter-inch object. The ratio is trusted, the magnitude is not.
If you have a wrapper that adds
sizing: 'contain'to every icon, delete it — the emitted
XML is identical either way. If you were relying on a stretched vector, name it:sizing: { type: 'stretch' }. -
vitest.config.tsno longer excludes anything of this repo's own from coverage.
Thedist/browser.js/dist/browser-*.jsentries are gone; the second never matched
anything, because tsdown bundles the adapter into the entry. Dropping them took the
measured functions figure 98.33 → 97.35 while actual tested-ness went up, which is the
shape of an honest denominator: the Node suite cannot execute an adapter that needs
fetch,FileReaderand a canvas. -
@shbernal/ts-pptx/mathis Node-only by decision, not by accident. It loads its
optional peers throughcreateRequire, which is what keepslatexToOmml()and
mathmlToOmml()synchronous; the browser-compatible alternative is a dynamicimport()
that would make both async — a breaking change to every existing caller, for a use case
nobody has raised. If a browser consumer turns up, the answer is an additional
/math/asyncsubpath, not a change to this one. Recorded in
docs/runtime-and-package-support.mdso it is not
re-litigated per release. -
The docs called
tableToSlides's no-browser width path a degradation. It is a
fallback, and the difference is not cosmetic. Every user-facing statement of it — the
README,docs/project-target.md,docs/runtime-and-package-support.md,AGENTS.md, the
/htmlentry's own doc comment — said column widths "degrade to computed CSS", which
reads as the same answer, less precisely. They do not measure the same box:
offsetWidthis the border box and a computedwidthis the content box, so padding
alone can put the two bases in different proportions. Thehtml-tablefixture is
built to demonstrate exactly that — 1:1 measured against 2:1 from CSS on one table — so
the repo had the fact and the docs contradicted it. Now stated wherever the fallback is
described, with the remedy: pin the column withdata-pptx-widthwhere both runtimes
have to agree.No behaviour changed, and deliberately so. Normalizing the CSS basis to the border box
would need computed padding and border widths, which the DOMs that reach that arm need
not resolve (a%padding computes to nothing usable without layout) — so it would
converge the two only sometimes, and it would collapse the one discriminator the browser
lane has, turningtable-widths.spec.mjsback into a test that passes whether or not the
measured arm ran. The reasoning is recorded onpickColWidthBasisitself. -
The browser lane stays Chromium-only, deliberately. The APIs in play (
fetch,
FileReader, canvas, object URLs,<a download>) are not where engines are known to
disagree, and no divergence has been reported or observed. An engine gets added when
there is something concrete to add it for — also written down rather than left as a
default.
Fixed
-
readModelToIrnow emits itstable.rowAutonote when every row is auto-height, not
only when some are. The guard excluded the all-auto case explicitly, and that case is both
the more common one — a table authored with no explicit row heights hasa:tr/@h="0"on
every row — and a real loss: norowHis emitted at all, soaddTabledivides the frame
height evenly and three auto rows come back pinned to a third of the frame each. The table
still looks the same; what is lost is the implicitness, which matters the moment someone
edits a cell and expects the row to grow. A round-trip oracle gated on "nothing undeclared"
is only as good as its note set, and this was a difference passing through undeclared. -
autoPagelet every continuation slide take one row more than fitted, so the last row
hung off the bottom edge. AffectsaddTable(rows, { autoPage: true })and
tableToSlides()alike, and only tables whose cells carry top/bottom margins — which is
every table converted from HTML, since cell padding becomes a cell margin.The pager charges each row its cells' top and bottom margins before deciding whether the
row fits. On a page break it did that and then zeroed the accumulator, so the first row of
each new page — and only that row — was placed for free. The page then filled to its budget
as if it had that space, and it did not. The deeper the padding, the further the overflow:
at 8px of cell padding a 60-row table paged[10, 11, 11, 11, 11, 7]where every full page
had room for 10.The symptom is easy to miss because the error is constant: every generated page overflows
by the same amount, so the pages agree with each other and disagree only with the first
one. That is also why the existing continuation-slide regression stayed green — it compared
continuation pages to each other.This closes upstream
gitbrent/PptxGenJS#1200, filed againsttableToSlidesand long
assumed to be a browser-layout question. It is not: the browser supplies column widths, and
nothing the vertical arithmetic reads.test/browser/table-autopage.spec.mjspages the
same table in headless Chromium and on a DOM that renders nothing and asserts the two
produce the same slides.