Skip to content

v3.0.0

Choose a tag to compare

@shbernal shbernal released this 09 Aug 11:07
· 928 commits to main since this release

Added

  • Shape.fillNoFill reads an explicit <a:noFill/> on a shape's fill — the fill-side
    counterpart of lineNoFill, and the only accessor that separates a deliberately
    transparent shape from one that inherits its fill through p:style/a:fillRef. Every other
    fill accessor (fillColor, fillSchemeColor, resolvedFill, gradientFill,
    patternFill, pictureFill) reports null for both, so a consumer honouring the read
    model painted an a:noFill rectangle in the theme's accent colour. The same class had
    ChartFill.noFill, ChartLine.noFill and CellBorder.noFill already; the shape fill was
    the omission. Shape.noFill() has always been able to write this state.

  • Shape.lineCap and Shape.lineAlign read a:ln/@cap and a:ln/@algn. @cap was a
    write/read asymmetry inside this library: ShapeLineProps.cap authors 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
    way lineDash reports @val — 'flat' / 'rnd' / 'sq' and 'ctr' / 'in' — and
    null when 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's stroke-linecap is the exact equivalent.

  • GroupShape.childFrame reads a group's own child coordinate space
    (p:grpSpPr/a:xfrm/a:chOff and a:chExt), as a ChildFrame of offsetX / offsetY /
    extentX / extentY in EMU, or null when 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's left/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. A ClipShape
    is data — a named silhouette plus its options — and clipPath(shape, w, h) resolves it to
    the freeform points path addImage emits 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 },
    })

    flat names the edge the straight side sits on; preset picks the proportion, 'deep'
    (the default) or 'shallow'. Paired with sizing: 'cover' this is a standalone
    reproduction of what a picture placeholder does — a layout custGeom clipping an
    inherited blipFill — with no placeholder, and no layout, involved.

    The box size is an argument for a reason. A custGeom point 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. clipPath multiplies 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. See docs/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-missing warning. Same bargain as addOleObject()'s cover.
    • Set meterPerModelUnit. The am3d scene 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 emits 0.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 to 1 / <largest bounding-box dimension>.

    camera overrides the viewpoint (pos/lookAt/up in metres, fov in 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 through ts-pptx/read surfaces as an
    inert graphicFrame and survives load → save and importSlide byte-intact.

  • Browser support is now proven in CI, not assumed. A Playwright lane
    (pnpm run test:browser, CI job browser) runs the package in headless Chromium
    against demos/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 module pnpm demos:build quarterly-review builds, and src/zip.ts pins
    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 the fflate the 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.md rather than
    left to inference:

    • Runtime support is not layout fidelity. Nothing in the lane depends on a rendered
      page. Real offsetWidth, 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 .pptx a browser builds differently from Node is.
    • tableToSlides measurement is still unavailable without a layout engine, exactly as
      documented — offsetWidth is 0, widths fall back to computed CSS and then an equal
      split, and data-pptx-width / data-pptx-min-width pin them.

    The explode/normalize/diff machinery the byte-identity gate has always used moved to
    scripts/pptx-parts.mjs so both gates share one definition of "the same bytes"; the
    refactor was verified byte-identical against a baseline frozen with the pre-refactor
    script.

  • The whole RuntimeAdapter now runs in a real browser, not just the download path.
    A second Playwright fixture serves the shipped dist/browser.js unbundled over a
    static server and drives decks written to reach the three loaders demos/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 a FileReader data
      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 same fontScale and 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.js turns 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.

  • tableToSlides is tested against a table a browser actually laid out — a third
    Playwright project, html-table, on the same unbundled harness server.

    pickColWidthBasis chooses between three column-width bases, and its first arm — the
    rendered offsetWidth — had never executed anywhere. The Node suite drives happy-dom,
    where offsetWidth is 0 for every cell, so it always took a fallback arm; the unit
    suite reached the function by handing it numbers directly, which proves the if and not
    the pipeline behind it. So the primary path of the feature, including the arrColSrc
    arithmetic that fixed the spanning-data-pptx-width defect, was covered only at its own
    function boundary.

    The fixture is built so the measured basis and the computed-CSS basis disagree
    (offsetWidth is the border box, computed width the 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, with data-pptx-width still 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 .pptx a 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.json are 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.mjs folds 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.mjs is 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 left functions at 0.35 of slack while an acceptance criterion of
    "thresholds still pass" was satisfied.

    The four numbers in vitest.config.ts remain 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 the browser condition; nothing asked the
    same of the node entry, 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 with platform: '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.bullet is replaced by Paragraph.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 literal n bullet on every converted deck, silently.

    bulletDetail returns a discriminated union with no parsing left to get wrong, and carries
    what the string could not — a:buAutoNum/@startAt, and the bullet's own a:buFont /
    a:buSzPct / a:buSzPts / a:buClr. It also reports a fourth kind the old accessor
    dropped to null: 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
    .char rather than after the colon; bullet.slice('autoNum:'.length) → .scheme.
    A paragraph that inherits its bullet still reports null.

    Numbering is content rather than styling: a list continuing "5. Deploy" that came back as
    "1. Deploy" was a different slide, and numberStartAt was a pure write/read asymmetry —
    addText accepted it and nothing could produce it.

  • BREAKING (output): <a:buSzPct/> is emitted only when bullet.size is given. It used
    to be written unconditionally, pinned to val="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:buNone an omitted bullet emits, and invisible until bulletDetail gave the
    round-trip check something to see it with. An out-of-range bullet.size now warns and
    emits nothing rather than warning and pinning to 100%. Decks that want the old behaviour
    can pass bullet: { size: 100 } explicitly.

  • sizing.w / sizing.h are optional, and sizing: { 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 every cover/contain call restated w/h it 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.

    stretch emits 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-square viewBox — 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 through sizing: '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 a viewBox,
    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 neither viewBox nor width/height stretches
    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 after src/media/image-size.ts learned to read a viewBox:

    • { 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.ts no longer excludes anything of this repo's own from coverage.
    The dist/browser.js / dist/browser-*.js entries 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, FileReader and a canvas.

  • @shbernal/ts-pptx/math is Node-only by decision, not by accident. It loads its
    optional peers through createRequire, which is what keeps latexToOmml() and
    mathmlToOmml() synchronous; the browser-compatible alternative is a dynamic import()
    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/async subpath, not a change to this one. Recorded in
    docs/runtime-and-package-support.md so 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
    /html entry'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:
    offsetWidth is the border box and a computed width is the content box, so padding
    alone can put the two bases in different proportions. The html-table fixture 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 with data-pptx-width where 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, turning table-widths.spec.mjs back into a test that passes whether or not the
    measured arm ran. The reasoning is recorded on pickColWidthBasis itself.

  • 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

  • readModelToIr now emits its table.rowAuto note 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 has a:tr/@h="0" on
    every row — and a real loss: no rowH is emitted at all, so addTable divides 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.

  • autoPage let every continuation slide take one row more than fitted, so the last row
    hung off the bottom edge.
    Affects addTable(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 against tableToSlides and 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.mjs pages the
    same table in headless Chromium and on a DOM that renders nothing and asserts the two
    produce the same slides.