Skip to content

Releases: shbernal/pptx-ts

v4.1.0

Choose a tag to compare

@shbernal shbernal released this 11 Oct 08:54

Added

  • Guide pages run their own examples. A code sample marked ts live shows the slide it builds
    under the code, built in the reader's browser, with a button that downloads the deck. The code on
    the page is the code that runs, and the test suite runs every live sample against the Node build
    and fails on a throw, a warning or a schema error. Fills and
    gradients
    is the first page with them.
  • The demos page shows both showcase decks. The startup pitch deck, with its photographs and
    video, now builds in the browser beside the consulting deck.

Changed

  • The showcase decks are a strategy consulting deck and a startup pitch deck. The quarterly
    review becomes Project Lodestar, a twelve-slide cost-transformation deck by the fictional
    Actionable Insights Partners in navy: action titles, a linked section tracker, a waterfall, an
    issue tree, a 2x2 and Harvey balls, still with nothing loaded. The Field Notes photo essay is
    replaced by Nightjar, an eleven-slide Series A pitch on the Tokyo Night palette that keeps the
    photographs, scrims and video. More of each deck is drawn than charted, because the demos page's
    preview shows a native chart as a placeholder.

    This is only visible if you build the showcases. The slugs change from quarterly-review and
    field-notes to cost-transformation and series-a, the files from
    Kestrel_Q3_Business_Review.pptx and Field_Notes_Four_Cities.pptx to
    AIP_Lodestar_Cost_Transformation.pptx and Nightjar_Series_A.pptx. In
    www/demos/decks/media/, the photographs only Field Notes used, the subway maps, the stock
    video with its clapperboard poster, and the cube model are gone. Two CC0 photographs and a
    rendered recording of the deck's own terminal session replace them.

  • write() is typed by its outputType. write({ outputType: 'uint8array' }) now resolves to
    Uint8Array, 'arraybuffer' to ArrayBuffer, 'base64' and 'binarystring' to string, and
    no outputType to Blob, instead of the union of all four. 'nodebuffer' is typed
    Uint8Array, the class Node's Buffer extends, so the declaration needs no Node types. Casts
    such as (await pptx.write({ outputType: 'uint8array' })) as Uint8Array can go. Code that
    annotated 'nodebuffer' output as Buffer needs the cast kept, or outputType: 'uint8array'.
    WriteProps takes the output type as an optional type parameter, and the new WriteOutputMap
    names each type's result.

  • outerBorder accepts the undefined holes its own example shows. Under
    exactOptionalPropertyTypes, outerBorder: [rule, undefined, rule, undefined] failed to compile,
    because the tuple's optional elements did not admit an explicit undefined. Each element now
    does. No migration is needed.

  • Chart values admit null for a gap. OptsChartData.values is now
    Array<number | null>. A null has always been the gap spelling at run time (an empty workbook
    cell, drawn per displayBlanksAs), but the type rejected it, so typed callers needed a cast.
    No migration is needed.

  • The read model's lookups take the nullable ids its getters return. Slide.shapeById,
    shapeByIdDeep (on slides, layouts, masters and notes), Diagram.point and OpcPackage.part
    now accept null and miss on it, so a value read back can be passed straight in under
    strictNullChecks: slide.shapeById(shape.id), diagram.point(connection.destinationId),
    opc.part(picture.imagePartName). Before, the getters typed number | null or
    string | null and the lookups only number or string. At run time shapeById(null) also
    matched the first shape whose p:cNvPr has no @id; it now returns undefined. No migration
    is needed.

  • Slide.newAutoPagedSlides is typed as always present. The interface declared it optional,
    though every slide carries the list, empty until an auto-paging table spills. Under
    strictNullChecks the documented for (const made of slide.newAutoPagedSlides) did not compile.
    Code that guarded with ?. or ?? [] still works; a class implementing Slide itself now has to
    provide the property.

  • The default branch is now main, renamed from master. The library is unchanged. A
    dependency on github:shbernal/ts-pptx#master should switch to #main, or better, to a pinned
    commit sha. GitHub redirects old blob/master/ links, and the repository's own links now point
    at main.

  • demos/ is gone; demos live on the site. The showcase decks moved to www/showcases/, and
    pnpm demos:build is now pnpm showcases:build, writing to .tmp/showcases/. The Express
    streaming demo became a dependency-free Serving a deck over
    HTTP

    section. The images the test suite reads moved to test/assets/. None of this was in the
    published package.

  • Runtime strings name the project pptx-ts, not ts-pptx. Console warnings are prefixed
    pptx-ts: , and the InternalError message calls it a bug in pptx-ts. A deck that sets no
    document properties is written with author and company pptx-ts and title and subject
    pptx-ts Presentation. A generated script's header reads Generated from a .pptx by pptx-ts/script. Code that matched a warning line on ts-pptx: , or detected this library's
    decks by their default author, should match pptx-ts instead; a setDiagnosticHandler handler
    receives the message without the prefix and is unaffected.

Fixed

  • An unknown pattern preset throws instead of writing a corrupt part.
    fill: { type: 'pattern', pattern: { preset } } with a name outside the 54 presets was written
    verbatim as <a:pattFill prst>, which fails the schema. It now throws InvalidOptionError
    with code pattern-fill/invalid-preset. Use one of the PatternPreset names.
  • A tableStyle that is not a style GUID throws. addTable({ tableStyle }) wrote any string
    into <a:tableStyleId>, so an enum key such as 'MEDIUM_STYLE_2_ACCENT_1' produced a
    schema-invalid table. A value that is not an upper-case GUID in braces now throws
    InvalidOptionError with code table/invalid-style-id. Pass the TableStyle member
    (TableStyle.MEDIUM_STYLE_2_ACCENT_1) instead of its key name.
  • A table cell's text shadow takes its transparency. A shadow on a cell, or on a run inside
    a cell's text, painted at the default 75% opacity whatever transparency or RGBA colour it
    stated, because the table path skipped the normalization addText runs. TableCellProps now
    declares shadow, which the cell's runs inherit, so a typed caller no longer needs a cast to set
    it.

Full changelog: https://github.com/shbernal/pptx-ts/blob/v4.1.0/CHANGELOG.md

v4.0.1

Choose a tag to compare

@shbernal shbernal released this 21 Sep 08:19

[4.0.1] - 2026-09-21

A same-day patch for one colour bug. If you build decks that use layout or master placeholders,
rebuild them with this version.

Corrected after publishing. The first version of these notes said 4.0.0 introduced this. It
did not: it widened an older defect. The two call forms and their histories are set out below.

Fixed

  • Text targeting a placeholder no longer has black written into it. addText(…, { placeholder: 'title' }) emitted an explicit <a:solidFill><a:srgbClr val="000000"/></a:solidFill> on the run,
    so text whose whole purpose is to take its colour from the layout or master stopped following the
    theme: a recoloured master left the slide's own text black. The colour default has always been
    suppressed for placeholder-targeting text, but it tested the bag it was handed, and cleanOpts
    runs once for the shape and again for each of its runs, where a run carries only what the caller
    wrote on that run. So the shape was spared and the run inside it was not. The question asked is
    now whether the text targets a placeholder, which is the object's answer as much as the run's. A
    colour the caller does state still arrives, through RUN_INHERITABLE_OPTIONS, and nothing else
    about the default changes.

    The two call forms were affected at different times. addText([{ text }], { placeholder }), the
    array and multi-paragraph form, has been baking black for as long as the guard has read one bag.
    addText('x', { placeholder }), the string form, was spared by accident until 4.0.0: it handed
    ONE options object to both the shape and its lone run, so the run's bag was the shape's and
    carried the key. Removing that aliasing, so a run no longer inherits the shape's shadow, took
    the key this default was reading and the string form started baking black too. So 4.0.0 widened
    an older defect rather than introducing one, and this release fixes both forms.

    Downstream impact: a deck built with 4.0.0 carries the baked colour wherever it targets a
    placeholder, and one built earlier carries it wherever it did so through the array form;
    rebuilding with this version restores inheritance in both cases. Every byte-identity gate stayed
    green throughout, because no showcase deck states an inherited placeholder colour, which is also
    why the new coverage goes through the emitted package rather than the corpus. It was caught by
    pptx-html's fidelity oracle, which reported the construct text.color.inherited moving on its
    corpus decks: first disappearing from four of them on the 4.0.0 bump, then appearing on two more
    than its baseline once this fix landed, which is what showed the older half.

Full changelog: https://github.com/shbernal/ts-pptx/blob/v4.0.1/CHANGELOG.md

v4.0.0

Choose a tag to compare

@shbernal shbernal released this 21 Sep 07:41

[4.0.0] - 2026-09-21

405 commits and 280 changelog entries since 3.7.0, which is too much to paste here. The full
section is linked at the bottom; this is what a reader upgrading needs to know.

The theme of the release is that the library stopped answering quietly wrong. A NaN option was
falsy, so it was dropped or replaced by a default without a word; a 0 extent meant one thing to
addShape and another to addChart; a coordinate past the DrawingML range was written into the
part as scientific notation. All three now warn or throw, which is the rule
docs/contributing/development.md states under "Warn or throw?" and which the option surface now
actually follows. The second theme is the read model: a colour reaching it arrives as one
ColorRef wherever it is read, the text, table and diagram proxies carry the context they were
read in, and Part.bytes is renamed Part.originalBytes because the old name hid the fact that
every copy and import in the library was reading the bytes a part was loaded with and dropping
the session's edits.

Nine entries are breaking, and each carries its migration in the changelog.

Breaking changes

  • Part.bytes is renamed Part.originalBytes. To read what a part holds now, call
    part.serialize(). Keep originalBytes only where you mean the bytes as loaded.
  • Every read value that carries a colour carries one ColorRef, under colorRef.
  • The read model's text, table and diagram proxies take the context they are read in.
  • PptxSlideElement.id is typed number. It was string | number.
  • A NaN option is refused, an infinite length clamps, and a coordinate outside the DrawingML
    range throws.
    NaN throws coord/non-finite or percent/non-finite; the range bounds are
    exported as MIN_COORDINATE_EMU and MAX_COORDINATE_EMU.
  • A zero extent is kept on every object and reported, and a NaN one is refused. w: 0 is
    now a stated extent that warns frame/zero-extent, not a silent default.
  • chartColorsOpacity: 0 paints a fully transparent series, and NaN throws. Leave the
    option out for an opaque series.
  • A hyperlink is validated the same way wherever it is authored. Give each link one of url,
    slide or action.
  • Chart axis, error-bar and legend-layout options are checked before they reach the writer.

Removed surface, all of it either dead or superseded: masterSlide, CONNECTOR_PRESETS,
ChartPropsBase.axisPos, ChartOpts's OptsChartGridLine base, the five series-axis unit
options, the three *AxisLineShow defaults, image on ShapeLineProps, TableCellProps.autoPageLineWeight,
TableToSlidesProps's colW / autoPage / autoPageHeaderRows, PresLayout._sizeW / ._sizeH,
TableCell._tableCells and SlideMasterChartProps.opts.

Highlights

  • createPresentation composes a deck from the construct families it needs, so a program that
    writes text boxes no longer links the chart, table and diagram emitters.
  • A measured comparison with pptxgenjs, with a gate that fails the build when the published
    page drifts from the snapshot it was rendered from.
  • pptxToScript keeps far more of what it reads: a shape's click hyperlink, a picture's
    border, all six Table Style Options, a pattern fill's theme tokens, a solid background's theme
    token, and the freeform a picture is clipped to.
  • The chart read model reads scatter, bubble and multi-level data, and the labels a series
    declares.
  • Presentation.appProperties reads docProps/app.xml, including the company the write API
    has always been able to set.
  • StrokeProps gives a stroke one vocabulary across every surface that draws one.
  • Three measured speedups, none of which moves a byte: six memoized read-model answers, the
    table auto-pager's covered-column count, and package assembly decoding a shared media part once.

Full changelog: https://github.com/shbernal/ts-pptx/blob/v4.0.0/CHANGELOG.md

v3.7.0

Choose a tag to compare

@shbernal shbernal released this 29 Aug 22:26

[3.7.0] - 2026-08-30

groupObjects() has always addressed objects by objectName, and there has never been a
way to learn those names. That is fine while one caller authored everything and useless
the moment a slide is assembled by independent renderers, which is exactly the case
groupObjects() was added for: nobody kept the descriptors, so the only handle on what is
on the slide is a name nothing reports. The two ways out were both bad — make every
renderer surrender its internals, or keep a parallel ledger that is wrong the first time a
renderer adds an object it did not announce. slide.objects is the accessor that was
missing, and it reports the groupable-kinds rule alongside each object so a consumer does
not pin a second copy of it.

This is also the first release to go out under two names. pptx-ts is the same build
published a second time, for people who cannot or will not type a scope; @shbernal/ts-pptx
stays canonical, and installing both at once is the one thing not to do.

Added

  • The package is published under a second, unscoped name: pptx-ts. npm has one
    package per name and no notion of a redirect, so an alias on the registry is not a
    pointer at another package: it is a second publish of the same content under a second
    name. That is what this is. scripts/alias-package.mjs stages a copy of the build the
    release gates just proved, and .github/workflows/publish.yml publishes that copy after
    the canonical publish has succeeded. Three things differ from @shbernal/ts-pptx at
    the same version, and nothing else does: package.json#name, a README.md banner
    naming the scoped package as the canonical one, and the absence of the scripts block,
    which is dev-only metadata in a published manifest and whose prepack would delete the
    staged dist/ if a publish ever ran without --ignore-scripts. version, exports,
    files, dependencies and the dist/ and skills/ payloads are the same bytes, so
    the alias is the package the gates measured rather than a near-copy of it.

    Install one or the other, never both. Two copies of this library in one dependency
    tree are two module registries, and per-copy state such as the diagnostic handler stops
    behaving as one library. The scoped name stays canonical: the issue tracker, this
    changelog and every example in the docs use it, and nothing about cutting a release
    changes because the second name exists.

    The name is reversed rather than scope-stripped because ts-pptx on npm belongs to an
    unrelated project.

  • slide.objects: the authored objects on a slide, bottom-to-top in z-order. The
    read-back half of groupObjects(). Each entry is a SlideObjectInfo carrying type,
    objectName, isPlaceholder, canGroup, and children (a group's members, nested to
    any depth). It is a snapshot rather than a live handle: a fresh array on every access,
    inert, and the way to act on it is to call the authoring API with the names it reports.

    objectName comes back in the caller's spelling, not the stored one. Every
    add*Definition stores the name attribute-escaped and groupObjects() escapes the
    caller's spelling before comparing, so reporting the stored form would hand back a name
    that escapes a second time on the way in and resolves to nothing — the same mismatch
    1.0.0 fixed on the input side, arriving from the other direction. A shape named Q&A
    reads back as Q&A. The promise is the round trip rather than invertibility, and it
    holds even where decoding is not a true inverse: a name authored as the literal string
    &amp; decodes to &amp; and re-encodes to the stored &amp;amp;.

    objectName is always a string. An object authored without one still carries the
    generated Shape 3 / Text 1 / Group 2 identity PowerPoint shows in the Selection
    Pane, and that name addresses it as well as an authored one does — reporting null there
    would have withheld a usable handle to signal a distinction nothing downstream of
    authoring records anyway.

    canGroup is the predicate groupObjects() throws on, not a restatement of it.
    GROUPABLE_TYPES and the placeholder exclusion now live behind one isGroupableObject
    that both the accessor and the throw path call. A consumer that had to re-state the rule
    would be pinning a copy of a list it does not own: it would keep refusing the day
    grouping learns a new kind and go on offering the day one is withdrawn, with nothing
    failing in either direction. It answers about the object and not the call — an
    unresolved, duplicated or ambiguous name still throws, and no single object can speak
    for a selection.

Changed

  • The publish workflow is re-runnable when only one of the two names went out.
    Publishing two packages is not atomic, so the canonical publish can succeed and the
    alias fail after it. The unpublished-version guard therefore fails only when both
    names already carry the version, and each publish step is skipped when its own name
    already does. Re-dispatching the workflow on the same tag finishes the missing half and
    touches nothing else. Previously the guard failed whenever the version existed under the
    one name it knew about, which would have made workflow_dispatch useless in precisely
    the state it exists for. The alias is published last for the same reason: a failure in
    it costs a re-dispatch, never the release that already went out.

[3.6.0] - 2026-08-28

v3.6.0

Choose a tag to compare

@shbernal shbernal released this 28 Aug 06:57

This release finishes a colour job the read model had been doing half of. a:prstClr and
a:hslClr resolve, so five of the six DrawingML colour models report a colour wherever one
is read, and the three accessors that were hunting for a:srgbClr and a:schemeClr by tag
name — a gradient stop, a shadow, a cell border — take the colour element by position
instead and get all of them. A gradient stop and a cell border also carry the whole
ResolvedColor now, transform list included, so a consumer can tell "this source stated no
transforms" from "this reader could not see them", and Run.resolvedItalic closes the last
of the inherited character properties. Three definers stop writing their own state onto the
options object you hand them: 3.5.0 named addText, but addShape and addTable did it
too, which is what made one style literal spread across three shapes come out sharing a
name and silently losing its paging. Two allocations sized straight from numbers nothing
checked — a chart's cached point count, a table cell's spans — aborted the host process
with no exception to catch, and are bounded now. And test:com gains a sibling:
LibreOffice has no SmartArt layout engine, so it paints the drawing cache PowerPoint
recomputes, which is the only way to show 3.5.0's mirror reaching a pixel rather than a
part.

Added

  • A gradient stop reads every colour model the reader resolves, and reports a preset
    name
    (ts-pptx/read). readGradientStops hunted for a:srgbClr and a:schemeClr by
    tag name, so a stop written as a:prstClr, a:sysClr or a:hslClr came back blank in
    every field — color, schemeColor, effectiveHex all null — even though
    resolveColorElement resolves five of the six models everywhere else, and even though
    the a:prstClr/a:hslClr work above claims gradients among the places it reaches. It
    did not reach this one.

    a:CT_GradientStop is a sequence of exactly one a:EG_ColorChoice, so the stop's colour
    is whichever element sits in that slot; it is read by position now rather than by name,
    and every model the resolver handles arrives.

    GradientStop.presetColor now exists. Three places already documented it — the
    presetColorHex export note above, and the RecolorColor cross-references that describe
    themselves as mirroring "the {@link GradientStop} split
    (color/schemeColor/presetColor)" — against a type that had no such field. It is the
    raw a:prstClr/@val, matching the raw/resolved split the rest of the read model uses. A
    stop written as a:sysClr or a:hslClr has no raw field of its own and is reported
    through resolvedColor alone, which the interface now states rather than leaving to be
    inferred from three nulls.

    Downstream, the script converter drops a stop it cannot resolve and falls back to no
    gradient below two stops, so a deck whose gradient used any of these models silently lost
    it; those stops now convert.

  • GradientStop.resolvedColor and CellBorder.resolvedColor (ts-pptx/read). A
    ResolvedColor keeps three things: the base hex, the raw transforms list, and the
    effectiveHex after applying them. Two other places read a colour through the same
    resolver and kept only the last two, so the transform list was computed and then dropped
    on the floor. A gradient stop reported position/color/schemeColor/effectiveHex
    and a table cell border reported a resolved hex plus a raw schemeColor, with nothing
    between them: on a deck whose accent carries <a:lumMod val="75000"/>, all three of a
    solid fill, a gradient stop and a cell border resolved to the same 2F5597, and only the
    solid fill could say what the transform had been.

    Why it matters to a consumer: a reader could not tell "this stop stated no transforms"
    from "this reader could not see them". Anything re-authoring a deck against a different
    theme needs the first fact, because effectiveHex alone is the theme baked in, and a
    lumMod-darkened accent carried forward as a literal hex stops tracking the theme it
    came from with nothing saying so.

    Both now carry the full ResolvedColor the resolver already built, under resolvedColor.
    The existing flat fields are unchanged and stay the convenience for painting:
    GradientStop.effectiveHex and CellBorder.color are both exactly
    resolvedColor?.effectiveHex ?? null. An empty transforms means the source stated
    none; a null resolvedColor means there was no resolvable colour to read. Nothing has
    to migrate.

    ResolvedColor.transforms is now typed as the already-exported ColorTransform[]
    rather than a structurally identical inline literal, so the three places that hand one
    around name the same type.

  • Run.resolvedItalic (ts-pptx/read). Run resolved four character properties
    through the placeholder / list-style / master chain — colour, size, face and @b — and
    not @i, so a run inside a placeholder that inherits i="1" from the master's
    p:txStyles reported italic: null with no way to answer what it actually renders as.
    @b and @i are siblings on CT_TextCharacterProperties and PowerPoint writes them as
    a pair (every level a:defRPr in a stock master carries both); the write API already
    treats them as one, since MasterTextStyleLevel has bold and italic — so a deck
    could be authored with an inherited italic that could not then be read back. Anything
    painting from the read model as props.X ?? resolved.X had to render such a subtitle
    upright. It resolves the same chain resolvedBold documents (paragraph a:defRPr →
    slide a:lstStyle → layout → master placeholder a:lstStyle → master p:txStyles →
    p:defaultTextStyle) and reports null when the run states no @i and inherits none.

    Both flags now share one walker, which corrects a smaller thing on the way past: an
    unparseable @b (b="yes") used to resolve to false, because the old code tested
    === '1' || === 'true' and let everything else fall through as not-bold. It is parsed by
    boolValue now — the same xsd:boolean parser Run.bold/Run.italic use — so a value
    the schema does not allow reports null (unknown) rather than a confident false.

  • a:prstClr and a:hslClr resolve, so five of the six DrawingML colour models now
    report a colour
    (ts-pptx/read). resolveColor handled a:srgbClr, a:sysClr and
    a:schemeClr; a colour written in any of the other three read as null everywhere in
    the read model — fills, lines, gradients, effects, table styles, slide backgrounds and
    the theme: 'preserve' flatten path alike. a:prstClr is the one that mattered, because
    this library emits it itself (a notes-page frame, a zoom tile's border) and because
    Picture's duotone stops already surfaced the raw preset name with nothing to turn it
    into. src/read/oxml/preset-color.ts now holds the ECMA-376 §20.1.10.47 table: 190
    enumerated names, 140 distinct colours, since the abbreviated dk/lt/med prefixes,
    both spellings of grey, and case are spelling rules rather than separate entries.
    a:hslClr went in beside it through the sRGB-HSL conversion the colour-transform module
    already carries and already validates against PowerPoint for lumMod/satMod. The
    preset resolves to a hex and nothing else: ResolvedColor gains no presetColor field,
    because the read model reports the raw reference separately from the resolved one
    everywhere else, and the write API has no preset-name option for a round trip to reach.

    presetColorHex is exported from ts-pptx/read, so a caller holding a raw preset
    name — GradientStop.presetColor, RecolorColor.presetColor — can make it literal the
    same way the reader does.

    a:scrgbClr is deliberately still unresolved. Its channels are percentages of a
    colour space the schema does not pin down, so whether 50% is linear-light or
    sRGB-encoded decides the answer and the two differ by a gamma curve. Reporting no colour
    is honest; reporting a guessed one is not. Settling it needs a render oracle, and until
    there is one that stays a decision rather than an oversight.

    One related repair on the way past: a:ST_Percentage is a union in the Transitional
    profile — the fixed-point integer Office writes and a %-suffixed decimal string — and
    the transform decoder read only the first, dropping a schema-legal alpha="50%" without
    a word. Both spellings are read now.

    It shows up downstream: three run colours in the table fixture move from
    text.color.default — the one fidelity note where the converter's output colour is not
    merely frozen but possibly wrong, because nothing resolved what the run inherits and
    the write path paints it black — to text.color.inherited, resolved and baked. The
    corpus note totals move with them (733 / 433).

  • objectLock on zooms, and honoured on 3D models (addSlideZoom /
    addSectionZoom / addSummaryZoom / addModel3d). SlideZoomProps had no such field
    at all, while Model3dProps inherited one that the definer never propagated — so a
    zoom's locks could not be expressed and a model's were silently dropped. Both now land
    on the a:graphicFrameLocks of the mc:Choice graphic frame, which is the object
    PowerPoint 2016+/2019+ actually selects, moves and locks, and take the graphic-frame flag
    set (noGrp, noDrilldown, noSelect, noChangeAspect, noMove, noResize). A zoom
    keeps noChangeAspect on by default and objectLock: { noChangeAspect: false } lifts
    it, as on an image. The mc:Fallback picture in both keeps its own fixed a:picLocks
    set: it is only what a pre-2016 consumer draws, and folding a graphic-frame flag set onto
    a picture-locks element would warn about every flag the two element types do not share.

  • A second render oracle: pnpm run test:lo. PowerPoint cannot be an oracle for
    markup PowerPoint recompute...

Read more

v3.5.0

Choose a tag to compare

@shbernal shbernal released this 26 Aug 21:53

This release makes a text edit land where the text is actually read. Paragraph.text and
DiagramPoint.text are settable, and the SmartArt setter writes the fallback drawing cache
that every renderer without a layout engine paints from, not just the data model PowerPoint
reads. The diagram data model gains its tree (Diagram.nodes, Diagram.point()) and a link
from a node to the shape drawn for it. importSlide stops copying chrome the destination
already holds, which takes a duplicate layout and master off every import between decks
templated from one file, and with it off the carried slides ts-pptx/script emits. And two
read surfaces stop losing graphic frames: inspectPptx reports them instead of skipping
them, and a SmartArt slide is copied rather than transcribed into a script with a hole in it.

Added

  • Paragraph.text is settable. Run.text, TextFrame.text, Shape.text and
    TableCell.text could all be assigned; the paragraph in between could not, so replacing
    one bullet of a list meant either collapsing the whole frame or rewriting runs by hand.
    Setting it replaces that paragraph with a single run, keeping the first run's a:rPr and
    the paragraph's own a:pPr (level, alignment, bullet), and leaves sibling paragraphs
    untouched — the same rule the SmartArt drawing-cache mirror already ran on internally.

  • DiagramPoint.text has a setter, and it keeps the drawing cache honest. A SmartArt
    diagram stores every string twice: as authored nodes in ppt/diagrams/data{N}.xml, which
    is what PowerPoint reads, and as a copy of every drawn string in
    ppt/diagrams/drawing{N}.xml, which is what every renderer without a SmartArt layout
    engine paints. Editing a node through textFrame wrote only the first, so the change was
    invisible in LibreOffice, Google Slides, thumbnailers and web previews until someone opened
    the deck in PowerPoint and saved it. The new setter writes both, replacing the one paragraph
    of the drawn shape that belongs to that node and leaving its siblings byte-identical (one
    shape commonly draws three nodes at once). Geometry is not recomputed, which is inherent
    rather than a defect: the drawn shape keeps its cached size, so a much longer string
    overflows its box in those renderers until PowerPoint re-lays the diagram out.

    textFrame still edits the data model alone and now says so; it is the escape hatch for
    run-level formatting. See docs/reference/pptx-read.md.

  • Diagram.nodes gives the data model its tree, and Diagram.point() resolves an id.
    connections hands back the raw parOf edges, so every consumer that wanted "the top-level
    nodes, in order" wrote the same index-and-walk, and a hierarchy read as a flat list with no
    indication of depth. A DiagramNode carries its point, its children (in @srcOrd
    order), its parent and its level. Assistant (asst) points are in the tree and keep
    their own type; transition points are not, because they label an edge rather than a node and
    are reached through DiagramConnection.parentTransitionId / siblingTransitionId. A
    parOf cycle raises diagram/parent-edge-cycle rather than hanging or silently dropping
    the points it swallows.

  • DiagramPoint.drawnShape and DiagramPoint.presentationId link a node to what is drawn
    for it.
    drawnShape resolves the dsp:sp of the fallback drawing part that carries the
    point's text, plus the paragraph index inside it, as a TextFrame bound to the drawing
    part. The mapping is not the obvious one and is many-to-one in both directions: a
    dsp:sp/@modelId is always a generated pres point's id rather than the authored point's,
    one shape draws several points at paragraph indices that can contradict document order, and
    one point has several presentations of which at most one holds text. null is a defined
    outcome, covering a package with no drawing part, a point with no presOf edge (every
    unlabelled transition point), a pres point that draws nothing, and a drawn shape with no
    text body. Measured over five layout families and 90 authored points.

Changed

  • importSlide binds to chrome the destination already holds instead of copying it in
    again.
    copyPart walked the source slide's slideLayout -> slideMaster -> theme
    subgraph and copied every part of it under a fresh partname, with no notion of what the
    destination arrived with. For the deck templated from its own source (fromTemplate keeps
    a package's chrome byte-identical and strips only its slides, which is exactly what
    ts-pptx/script's template-anchored tier emits) every one of those parts was already there
    under its own partname, so each imported slide grew the deck a duplicate layout and master:
    one extra entry in PowerPoint's layout picker per slide, and a later
    appendSlides({ layout: <name> }) that threw layout/ambiguous-name because two layouts
    answered to the name.

    A part is now reused when the destination holds it at the same partname with the same
    content type, the same bytes as they would be written, the same relationships, and the same
    test passing recursively; a master must also be registered in p:sldMasterIdLst and a
    layout must sit in a registered master's gallery. The decision is made at the page boundary
    and never below it, so a part reached from something this import copied is copied too and
    copied chrome stays self-contained. Anything short of identical still copies.

    Migration. Importing between two decks that share byte-identical chrome (the same file
    opened twice, or two decks made from one template) now adds fewer parts: no new layout,
    master or theme, and the imported page resolves to the destination's own. Rendering is
    unchanged, since reuse only happens where the bytes were the same. A consumer asserting on
    part counts after such an import sees the smaller numbers.

  • A converted script's carried slide no longer costs a layout, and batches bind by name
    again.
    ts-pptx/script's template-anchored tier copies a slide the write API cannot
    author (slide.carried) with importSlide, out of the very file it templated the deck
    from. That import used to duplicate the slide's layout and master, and because the duplicate
    repeated a layout name, it demoted every appendSlides binding in the emitted script from
    the name to a gallery position. With importSlide reusing chrome the destination already
    holds, layout names stay unambiguous and bindings print as { layout: 'Titelfolie' }. A
    multi-master deck that genuinely repeats a layout name still falls back to a position, which
    is the case the fallback was always for.

  • inspectPptx reports graphic frames. A p:graphicFrame (table, chart, SmartArt, or a
    payload this library does not model) was skipped outright and consumed no zIndex, so a
    deck whose slides are SmartArt or tables inspected as elements: [] with wordCount: 0,
    indistinguishable from a deck of blank slides. It also put the two read surfaces in flat
    disagreement: Slide.text on the deep model has always flattened table cells and SmartArt
    node text.

    A frame now comes back as one element with kind: 'graphicFrame', its own box, a zIndex
    in the shape-tree walk, and a new graphicKind field ('table' | 'chart' | 'chartEx' | 'diagram' | 'other'). Its text is what a reader sees on the slide, so it counts toward
    the slide's text and wordCount; a chart contributes none, matching Slide.text, which
    treats data labels as chart data rather than slide body text. The structure is still not
    flattened: textRuns and paragraphs stay empty, and cells, series, and nodes are reached
    through ts-pptx/read.

    Migration. PptxSlideElementKind gains 'graphicFrame', so an exhaustive switch over
    it must handle the new member; PptxSlideElement gains graphicKind, null on every other
    kind. A consumer that counted elements, summed wordCount, or read zIndex values sees
    different numbers on decks containing graphic frames. Filter on kind !== 'graphicFrame' to
    get the old element set back (the old zIndex numbering is not recoverable, and was itself
    a walk with holes in it).

Fixed

  • A slide holding SmartArt is no longer emitted as a script with a hole in it.
    ts-pptx/script decides per slide whether the emitted script can describe it or whether the
    printer must copy the source slide verbatim, and that test named extended charts and nothing
    else. But three graphic-frame payloads produce no call, not one: an extended chart, a
    SmartArt diagram, and a frame the reader does not decode at all (an OLE object, ink, a 3-D
    model). So a chartEx slide round-tripped correctly by being copied while a SmartArt slide
    was transcribed and silently lost its diagram, with only a per-shape fidelity note recording
    the loss. Both sites now share one predicate, so they cannot drift apart again.

    The copy costs nothing in the layout gallery, because importSlide now binds to chrome the
    destination already holds (see above) and the template-anchored tier copies out of the very
    file it templated the deck from. Layout handles are still resolved up front rather than
    where first used, since an importSlide between two appendSlides calls moves the gallery
    underneath them.

    Migration: a deck whose SmartArt, OLE or ink slides previously came out authored now comes
    out carried. The emitted script imports those slides from the template instead of
    rebuilding them, which needs the source deck present (it already did for the template
    itself). The standalone tier is unchanged: it has no source package to copy from, so it
    still transcribes such a slide and still reports the per-shape note.

v3.4.0

Choose a tag to compare

@shbernal shbernal released this 26 Aug 07:22

This release makes SmartArt readable through the read model, and names what a frame the
reader still cannot decode actually holds. It refuses two option values that used to reach
the emitted XML as nonsense — a non-finite size or angle, a stringly-typed
legendFontSize — and gives three others the meaning their absence already carried: an
empty chartColors, a null side in a border tuple, and a table that named a built-in
style only to have the writer's own grid painted over it. It also takes Math.random() out of
the chart palette, so a chart with more series than colours emits the same bytes on every
build.

Added

  • SmartArt is readable: GraphicFrame.hasDiagram and GraphicFrame.diagram. A
    p:graphicFrame whose a:graphicData/@uri is the DrawingML diagram namespace answered
    false to all three host predicates and null to all three accessors, so its content was
    unreachable through the read model, and a consumer could not distinguish "this frame holds
    text I cannot get at" from "this frame holds a chart with no labels". Diagram decodes the
    dgm:dataModel part: points (every dgm:pt in document order, typed
    node/asst/doc/pres/parTrans/sibTrans), connections (the dgm:cxn edges that
    give the points their tree), and text (the authored node text, generated pres points,
    the doc root and unfilled placeholders excluded). A point's textFrame is an ordinary
    TextFrame over its dgm:t, so runs, formatting and the resolved* getters all apply.
    The layout, quick-style, colours and fallback drawing parts resolve as Parts, and the
    doc point's layoutTypeId names the SmartArt kind. Authoring SmartArt remains out of
    scope. See docs/reference/pptx-read.md.

  • GraphicFrame.graphicDataUri reports what an undecoded frame holds. Every has*
    predicate is a comparison against this URI, so a frame that matches none of them is one the
    read model does not decode (an OLE object, an ink annotation). Reading it lets a consumer
    say which construct it could not reach instead of inferring loss from four falses. The
    class comment had described this accessor since the frame reader was written; it had never
    actually been public.

  • resetDiagnosticState() clears the once-per-process warning record. Conditions that
    would flood a log are emitted once per distinct code and message, and that record was
    process-global with no way to clear it. So a service building a second deck got no warning
    for anything the first deck already tripped, which reads as "no problems found" rather than
    as "already mentioned"; and since most of those messages interpolate the offending value,
    the record grew by one entry per distinct bad value with nothing to release them. Call it
    between builds. It does not touch the handler — setDiagnosticHandler owns that, and the
    two reset independently because a host usually installs its handler once. See
    docs/diagnostics.md.

Changed

  • Slide.text now includes SmartArt node text. A slide whose whole message is a diagram
    flattened to the empty string, or to its title alone. Diagram nodes are body text that
    PowerPoint itself searches and spell-checks, so they contribute a block each, in shape-tree
    order, exactly as table cells already did. Chart data labels and speaker notes are still
    excluded, unchanged.

    Migration: if you were summing Slide.text lengths to detect empty slides, or diffing that
    string across versions, a deck containing SmartArt will now report more text. Read
    GraphicFrame.diagram directly if you need the diagram separated back out.

  • A converted script now distinguishes diagram.all from graphicFrame.unknown. SmartArt
    used to be reported under the latter, whose stated cause is unread. With a reader in front
    of it, the loss is the write leg's: pptxToScript emits diagram.all (unwritable,
    dropped), the same shape as chartEx.all. graphicFrame.unknown keeps its meaning and now
    names only genuinely undecoded frames.

  • A non-finite size or angle is now refused rather than serialized. fontSize,
    charSpacing, lineSpacing, paraSpaceBefore/After, rotate, a chart's
    catAxisLabelRotate/valAxisLabelRotate/titleRotate, and a custGeom connection-site or
    adjust-handle angle all threw Infinity straight through their converter and into the
    attribute — sz="Infinity", rot="Infinity" — which is not a legal value and made
    PowerPoint offer to repair the package. Each now throws an InvalidOptionError with code
    coord/non-finite at the point of conversion. A finite out-of-range value is unaffected:
    it still clamps into the schema range and warns, as before.

    Migration: if you were computing one of these from data that can produce Infinity or
    NaN (a division by a zero row count is the usual one), guard it at the call site. A NaN
    is unchanged on the size options — every caller reads them for truthiness, so a NaN has
    always meant "absent" there.

  • A rotation past a full turn now reduces correctly. convertRotationDegrees subtracted a
    single turn, so rotate: 800 became 440 degrees rather than 80, and rotate: 370 became 10
    while rotate: -400 was not reduced at all. It is now % 360, sign-preserving: a rotation
    already within a turn — including a negative one — is byte-for-byte unchanged, since both
    -45 and 315 are valid ST_Angle and the read side reports back what was authored.

    The same function was also being used for angles that are not modular: a custGeom
    connection site, a polar adjust handle's minAng/maxAng, an angleRange guide, and the
    chart label rotations. Those now go through a non-wrapping converter, so an adjust handle
    declared with maxAng: 540 keeps its full travel instead of silently collapsing to 180.

  • An empty chartColors now means the same as omitting it. chartColors: [] used to give
    every chart the bar palette, including a pie or doughnut, whose default when the option is
    omitted is PIECHART_COLORS. Nothing chose that: normalization defaulted a missing
    chartColors per chart type, Array.isArray([]) is true, so an explicit empty array walked
    through that pass untouched and met a later fallback that did not know the chart type. An
    empty array names no colours, which is what omitting the option means, so both now resolve to
    the built-in palette for the chart's own type. A combo subchart's palette resolves the same
    way for the first time — its options never went through that normalization pass at all.

    Migration: if you passed chartColors: [] to a pie or doughnut and wanted the bar palette,
    pass it explicitly. Every other chart type is unaffected: its default already was that
    palette.

  • A chart's legendFontSize is no longer coerced from a string. It was the one font-size
    option whose emitter wrapped the value in Number(), so legendFontSize: '12' from untyped
    JS worked there while the same string threw at catAxisLabelFontSize, dataLabelFontSize
    and the eight other spellings of the same option. The coercion is gone; a non-number now
    throws InvalidOptionError with code coord/non-finite wherever it is passed.

    Migration: pass a number, which is what the type has always said.

  • A null side in a cell's border tuple is now omitted rather than erased. A four-side
    TRBL tuple replaced every falsy side with { type: 'none' }, so border: [solid, null, solid, null] emitted four <a:lnX> elements — two drawn, two carrying an explicit
    <a:noFill/>. On <a:tcPr> those are not the same state: an absent edge inherits from the
    table style, theme banding and the master chain, while a present <a:noFill/> overrides
    that inheritance. So a hole in the tuple meant the opposite of the omission it reads as, and
    there was no spelling left for "draw the edges I name, leave the rest to the style".
    A null side now leaves its element absent, which is the distinction normalizeOuterBorder
    already documents for the perimeter ("a sparse side is not { type: 'none' }"), and the
    tuple type accepts null so the sparse form is authorable from TypeScript. An explicit
    { type: 'none' } still emits <a:noFill/>, so both states stay reachable.

    Migration: a tuple that relied on null to erase an edge should say { type: 'none' }.

  • A table with a tableStyle keeps that style's grid. Every cell with no border authored
    used to receive four explicit <a:lnX><a:noFill/></a:lnX> edges. That is direct formatting,
    so it beat the built-in style the caller had just selected, and a MEDIUM_STYLE_2_ACCENT_1
    table came out with the style's fills and banding but none of its rules — with no way to ask
    for them, since the only spelling for "leave this edge alone" was the one being overwritten.
    The default is now applied only to a table that named no style, which is where it earns its
    keep: PowerPoint's no-style look is a black hairline grid, and suppressing that is what the
    force-fill was for. A styled table emits no <a:tcPr> edges unless you author some, and
    border: { type: 'none' } still erases the grid explicitly. outerBorder composes as it
    always did: on a styled table it now draws the perimeter and leaves the interior rules to
    the style, rather than blanking them.

    Migration: a styled table that relied on the force-fill for a gridless look should author
    border: { type: 'none' }. Unstyled tables are byte-identical.

Fixed

  • Auto-paged tables no longer lose a point of column width to floating-point rounding.
    The chars-per-line figure autoPage wraps on comes from the column's width in points, and
    the conversion went through EMU: (colWidth / EMU_PER_POINT) * EMU_PER_INCH is the same 72
    as colWidth * POINTS_PER_INCH, but not exactly, and the Math.floor around it took the
    hit. Of the 200,000 widths from 0.001in to 2...
Read more

v3.3.0

Choose a tag to compare

@shbernal shbernal released this 25 Aug 19:45

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

Added

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

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

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

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

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

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

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

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

Fixed

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

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

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

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

Changed

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

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

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

    • test/validator.js batches validation. `OOXMLValidatorCL...
Read more

v3.2.0

Choose a tag to compare

@shbernal shbernal released this 10 Aug 15:08

Added

  • bullet: 'inherit', the state omission cannot spell (#15). TextBaseProps.bullet had
    two states and three meanings: a bullet, or false/omitted — and both of the latter emitted
    an explicit <a:buNone/> plus indent="0" marL="0". There was no way to author a paragraph
    that states nothing about its bullet. That matters because bullet properties resolve down
    the a:lstStyle → placeholder → layout → master chain, and a:buNone overrides that
    chain rather than deferring to it: a paragraph inheriting a bullet from its layout's
    a:lvl1pPr lost it, and one inheriting no bullet still stopped tracking the master, so a
    later edit there no longer reached the slide. The indent="0" marL="0" written beside it
    flattened an inherited hanging indent to zero in the same stroke — a second, separate loss on
    the same element. bullet: 'inherit' emits neither a bullet child nor a:buNone nor the
    margins.

    Omission keeps meaning a:buNone, and that is deliberate: it has meant that since the writer
    existed and every deck authored against it depends on the default. Same resolution as
    fill: { type: 'inherit' } (#10) — name the state that had no name, leave alone the one that
    has. This is that fix one element down, and #14's mirror image: there the explicit off
    needed a spelling distinct from silence, here the explicit off is the only thing that could
    be said.

    Two sites downstream of the emitter tested bullet for truthiness, which a truthy string
    that draws nothing would have broken: the line grouping (a bullet starts a new paragraph)
    would have split every run into its own paragraph, and the leading-glyph strip (which removes
    a literal • so it is not drawn twice beside the a:buChar) would have eaten a real
    character from text that emits no bullet to duplicate. Both now ask whether bullet markup is
    actually emitted.

  • paraMarginLeft and paraIndent, the other half of that element. #15's report named
    the indent="0" marL="0" beside the a:buNone as "a second, separate loss on the same
    element", and bullet: 'inherit' closed it only for the state that says nothing. The
    attributes themselves still belonged to the bullet: a:pPr/@marL and @indent had no option
    of their own, so bullet: false could not suppress a bullet without also flattening an
    inherited hanging indent to zero, a drawn bullet always re-hung the first line by the
    writer's 27pt default, and a first-line indent (a positive @indent, the prose form) was
    unauthorable in every state. indentLevel is a different fact — it writes a:p/@lvl, the
    discrete outline level — and bullet.indent reached @marL only by drawing a bullet with
    it.

    Both options take points and both admit 'inherit', which writes no attribute at all and so
    leaves the margin to the a:lstStyle → placeholder → layout → master chain. Omission keeps
    meaning what it has always meant — whatever the bullet state writes — so no existing deck
    moves, and the third state is spelled rather than assumed, exactly as one element up. An
    explicit value wins over the bullet default in every state. Out-of-range values are clamped
    to ST_TextMargin (0–4032pt, unsigned) and ST_TextIndent (−4032–4032pt) with a warning,
    under the new text/paragraph-margin-out-of-range and text/paragraph-indent-out-of-range
    diagnostic codes, since PowerPoint reports an out-of-range value as needing repair.

  • ts-pptx/script carries a paragraph's own margins, and text.indent is retired. It was
    5/44 on the corpus and the largest note left on a:pPr once the bullet one closed; the
    standalone tier's note count falls from 713 to 705 and the template-anchored tier's from 419
    to 411. The mapper emits the read margin as a number, and 'inherit' where the paragraph
    states none but its bullet state would otherwise write one — which is what makes an inherited
    margin survive rather than being replaced by a default. The reader did not move here either:
    Paragraph.marginLeftPt and Paragraph.indentPt already separated a stated margin from an
    absent one, so this was a missing write spelling, the same shape as the bullet note above.
    The retired note's exclusion entry was empty — neither IR carried the field, so the round
    trip compared two models both missing it — which means the margins are now verified rather
    than merely declared; a build that ignores the new options reports 36 undeclared differences
    across the corpus.

  • Fixed in the same pass: a paragraph's properties are the first run's, decided once. The
    emitter retried genXmlParagraphProperties on each run of a paragraph until one produced
    non-empty XML. That was unreachable while every bullet state wrote something, and became
    wrong the moment an empty a:pPr was possible: a paragraph whose first run said 'inherit'
    took its properties from a continuation run instead, which by convention states no bullet —
    so it got back the very a:buNone the first run asked to leave out, appended after that
    run's <a:r>, where a pPr is not allowed. No deck the byte-identity corpus authors moves.

  • ts-pptx/script carries inherited bullets instead of declaring them lost. A paragraph
    read as having no bullet child of its own now maps to bullet: 'inherit' rather than to an
    absent option, and text.bullet.inherited is retired — it was the largest fidelity note
    on the corpus at 34/44 fixtures, along with layout.text.bullet.inherited at 5/44. Across
    the 44-fixture corpus the standalone tier's note count falls from 1018 to 713 and the
    template-anchored tier's from 712 to 419. The distinction always survived the read leg —
    Paragraph.bulletDetail is null for a paragraph with no bullet child and { kind: 'none' }
    for one stating a:buNone — so this was a missing write option rather than an unreadable
    construct, which is worth noting because the note filed it as unread. Consumers diffing
    printed scripts will see bullet: 'inherit' appear on most paragraphs.

  • ts-pptx/script's standalone tier rebuilds a layout's decoration instead of dropping
    it.
    A source layout became a defineSlideMaster call carrying a title and a
    background, and nothing else: the bands, rules, wordmarks, triangles and quote marks
    that make a deck recognisable as somebody's template were declared lost as
    master.decoration and thrown away, so a standalone script produced a deck that
    rendered its slides faithfully and wore a different suit. Each of a layout's
    non-placeholder shapes is now transcribed into that layout's
    defineSlideMaster({ objects }) array, by the same mapper the slides go through —
    so a rectangle on a layout is decided by the code that decides one on a slide, and the
    two cannot drift. Fills, gradients, scheme tokens, custom geometry, adjust handles,
    rotation, effects and text all come across on the shape mapper's existing terms.

    The note claiming this was unwritable was wrong about which half was holding it, and
    measuring it is what showed that: { shape: { type } } accepts any preset, custGeom
    included, and its ShapeProps carries fill, line, shadow and adjust handles, so most
    of the supposed write-side ceiling was already reachable. Three kinds genuinely are
    not, and each is handled rather than assumed away. A group has no variant, so it is
    flattened into its children — visually lossless, because absoluteFrame composes the
    group's offset, rotation, flips and child-space scaling into each child, and noted as
    layout.group because one selectable object becomes several. A connector has no
    variant either and is re-authored as a line preset: it paints the identical stroke,
    keeps its rotation (which the slide-side addConnector mapping loses), and matters
    more than it sounds — PowerPoint's line tool authors a p:cxnSp, so connectors are 18
    of the 45 shapes the fixture corpus's layouts actually draw. A table is dropped,
    with a layout.decoration note naming the shape.

    Layout placeholders are still not emitted, and that is unchanged and deliberate:
    the write path seeds every slide with each layout placeholder it did not populate, so
    re-declaring one would put an empty ghost shape on every slide bound to the layout.
    master.decoration survives for a master's own shapes and moves from a reading
    problem to a structural one — defineSlideMaster creates a layout under the single
    shared master, so a master's shape tree has no write-side counterpart to receive them.

    Fidelity notes recorded against a layout shape are namespaced under a new
    LAYOUT_NOTE_PREFIX (layout.), exported from ts-pptx/script, because the shared
    mapper speaks the slide vocabulary: layout.line.width is line.width seen from the
    chrome. The prefix is load-bearing twice over — the template-anchored tier suppresses
    every note under it (it rebuilds no layout, so none of them describes its output), and
    the round-trip check refuses to let one excuse a difference on a slide, which a
    shape name repeated between a layout and the slides bound to it would otherwise do.
    isKnownNoteConstruct is exported alongside it and resolves a prefixed construct to
    its slide entry in the coverage table.

  • SlideMaster.shapes / SlideLayout.shapes reach a template's own content, and
    showMasterSp says whether to draw it
    (#12). Both classes exposed placeholders
    and nothing else, so the p:sp/p:pic/p:graphicFrame under a master's or layout's
    p:cSld/p:spTree — the bands, rules, logos and footer furniture a deck is recognized
    by — had no modeled path out of the read API at all; only part.dom reached them,
    which is the raw-XML hatch rather than the model. On a corpus of PowerPoint-authored
    decks this was the single largest read gap: a consumer walking slide.shapes
    reproduces th...

Read more

v3.1.0

Choose a tag to compare

@shbernal shbernal released this 09 Aug 21:06

Added

  • The package ships a ts-pptx-upstream skill, and InternalError says where to send a
    report.
    Most code that calls this library is written by an agent working in a repository
    that is not this one, and an agent that hits a library defect writes a workaround instead of
    filing — which is the rational move from where it stands, since the workaround unblocks its
    user today and the tracker belongs to a repo it is not in. So the report never happens and the
    next consumer rediscovers the same defect. Three layers answer three different reasons the
    report dies. InternalError now appends the tracker URL from its constructor rather than from
    its throw sites, so a site added later cannot forget it; it is the only class that does, because
    it is the only one that already declares whose bug it is, and a "report this" banner on every
    malformed package would train callers to skip the line that always means something. The
    ErrorCode TSDoc — and therefore the .d.ts an agent in node_modules actually reads — now
    states the test for the other four: the supported bar is "the output opens cleanly in Microsoft
    PowerPoint"
    , and it reads in both directions, so a PackageReadError on a file PowerPoint opens
    cleanly is our gap, not bad input. And skills/ts-pptx-upstream/ is published in the tarball, so
    npx skills add ./node_modules/@shbernal/ts-pptx works offline and always matches the installed
    version. The skill's load-bearing instruction is the one about the deck: presentations carry
    client names, unreleased strategy and pricing, and the tracker is public, so it spends most of
    its length on reducing a failure to a script that builds its own deck — and passes
    --repo shbernal/ts-pptx on every gh call, since gh in a consumer repo would otherwise file
    our bug into theirs. A third issue form, agent-report.yml, is where the error message's URL
    lands; its attachment dropdown deliberately has no option for a file containing real data, and
    "what should have happened" asks for the reason — an ECMA-376 clause, PowerPoint's own behaviour,
    or the docs — since that is what separates an actionable report from a matter of opinion. No API
    changed. InternalError.message gained a trailing pointer, which is not a contract: the class and
    the code are API and the message never was.

  • TableCell.fillNoFill reads an explicit <a:noFill/> on a cell (#7) — the cell-side
    counterpart of 3.0.0's Shape.fillNoFill, and what TableCell.noFill() has always been
    able to write. hasOwnFill is not this question: it is true for any
    EG_FillProperties child, so it cannot separate a suppressed fill from a gradient, a
    pattern, a picture or an a:grpFill, and every colour accessor (resolvedFill,
    fillColor, fillSchemeColor) reports null for a no-fill cell exactly as it does for
    one inheriting the style's shading. Deriving it as "has an own fill that no accessor
    recognises" also changes meaning silently the day a further fill kind gets an accessor.

Changed

  • Development toolchain: ESLint + Prettier → oxlint + oxfmt, and TypeScript 6 → 7.
    This is a contributor-facing change with no runtime effect on the published package —
    no API was added, removed or altered, and the generated OOXML is byte-identical across
    all 183 parts of the reference decks. Enforcement was held level rather than relaxed: of
    the 90 lint rules previously enabled on src/, 89 carry over (the missing one, no-octal,
    is already a syntax error in an ES module), type-aware linting stays on, and
    no-floating-promises / no-misused-promises now also cover scripts/ and test/,
    where the old configuration could not afford them. See docs/development.md.

  • Published .d.ts files are textually different, though no type changed. TypeScript 7
    prints declarations slightly differently: string literal types use single quotes
    (readonly shapeType: 'autoShape' rather than "autoShape") and redundant parentheses
    are dropped (fontRef?: (StyleFontRef | null) | undefined becomes
    fontRef?: StyleFontRef | null | undefined). Chunk filename hashes shift as a
    consequence. No declaration, signature or export moved, and publint,
    @arethetypeswrong/cli and the packed-package smoke test all pass — but a consumer
    diffing shipped declarations between versions will see churn, which is why it is recorded
    here rather than left as an implementation detail.

    Note that documentation generation deliberately stays on TypeScript 6: TypeScript 7 is
    the native Go compiler and ships no JavaScript compiler API for TypeDoc to import. That
    copy is confined to the private tools/api-docs workspace package and reaches nothing
    that is published.

Fixed

  • fill: { type: 'none' } emits <a:noFill/> instead of nothing at all (#9). The
    option's own name states the shape is transparent, and it was the one call that did not
    produce that state: genXmlColorSelection had no none case, so the fill child was
    omitted entirely and the interior fell back to p:style/a:fillRef or the placeholder —
    a shape carrying a style reference rendered in the theme's accent colour rather than
    transparent. line: { type: 'none' } on the same options object has always emitted its
    a:noFill; the two now agree. This reaches every caller of the shared fill dispatch, so
    a table cell (TableCellProps.fill) can author a transparent cell the same way.
    Round-trip consequence: addShape({ fill: { type: 'none' } }) → save → load now reports
    Shape.fillNoFill === true, where it reported false before. The one behaviour that
    changes for existing decks is that type: 'none' no longer produces the inherit state
    by accident — if you were relying on it to mean "leave the fill to the style", omit the
    fill option instead.

  • readModelToIr carries a line's @cap, and declares a dropped @algn (#8). Both
    legs of the cap mapping already existed — ShapeLineProps.cap authors the attribute and
    3.0.0's Shape.lineCap reads it back — but the script tier's lineOption never consumed
    it, so a deck this library wrote could not survive its own converter. The loss was
    undeclared, which is the part that mattered: the round-trip gate excludes exactly what
    a fidelity note names, so it passed green while the deck changed. @cap extends every
    dash by the stroke width and decides whether each draws as a rectangle or a lozenge, so
    on a thick dashed rule the before and after are visibly different. @algn is readable and
    has no write option, so it now records a line.align note (dropped/unwritable) —
    for algn="in" only, since ctr is what an omitted @algn already renders as.

  • latexToOmml emits accents as <m:acc> rather than <m:limUpp> (#6). \hat,
    \bar, \vec, \dot, \ddot, \tilde, \acute, \grave, \check, \breve,
    \mathring, \H, \dddot and their short-form aliases (\^, \`, \', \",
    \., \=, \u, \v, \r) all landed as over-limits, with limit spacing and
    semantics, because temml emits a bare <mover> (correct for a browser — MathML renderers
    derive accent positioning from the operator dictionary) and mathml2omml has no dictionary
    and keys strictly off accent="true". The pipeline now carries the small dictionary
    subset that closes the gap, and while it is there it swaps temml's spacing modifier for
    the combining mark ECMA-376 §22.1.2.20 says an accPr character should be — so \vec{v}
    gets an arrow accent instead of a full-size arrow hung over the base.

    Scoped to latexToOmml: mathmlToOmml passes hand-written MathML through unchanged,
    because there accent is the caller's to set. Constructs that were already mapping well
    are untouched (\widehat/\overbrace stay m:groupChr, \overline/\underline stay
    m:borderBox, \stackrel stays m:limUpp), and two stay limits by necessity: \utilde
    and other under-accents, since OMML has no under-accent object and the symmetric
    accentunder="true" makes mathml2omml emit an over-accent; and \ddddot, whose
    two-character operator has no single m:chr.

  • A table cell's explicit a:noFill survives read → script → write. Previously a
    suppressed cell fell out of cellFill as "no fill option" and the copy took the table
    style's banding — the opposite of what the source showed. Enabled by the
    TableCell.fillNoFill reader above and the fill: { type: 'none' } writer fix above.