Skip to content

v3.6.0

Choose a tag to compare

@shbernal shbernal released this 28 Aug 06:57
· 732 commits to main since this release

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 recomputes, and SmartArt is the case that proves it. A deck stores
    every drawn string twice, in the dgm:dataModel PowerPoint reads and in the
    dsp:drawing cache every renderer without a layout engine paints, and PowerPoint
    rebuilds the cache from the data model on open. So a deck whose cache was never
    written and one whose cache was written correctly render identically in PowerPoint,
    test:com cannot separate them, and the drawing-cache mirror shipped in 3.5.0 was
    proven only by the bytes of ppt/diagrams/drawing1.xml — real evidence, but evidence
    about a part rather than about a pixel. The new gate renders through LibreOffice,
    which has no SmartArt layout engine and therefore paints the cache and nothing else,
    via soffice --convert-to pdf plus pdftotext. Three cases, each with its own
    sensitivity check: mirrored proves DiagramPoint.text reaches the renderer, stale
    edits the data model alone and proves the renderer keeps painting the old string,
    and every case asserts the ten sibling nodes it did not touch are still painted, so a
    mirror that writes the right string into the wrong point fails on its own sentinel.
    Text is read through PDF rather than PNG because LibreOffice's PNG export writes the
    first slide only and ignores a PageRange filter option. SKIPs cleanly when either
    tool is missing (TSPPTX_SOFFICE / TSPPTX_PDFTOTEXT override the search, and a
    set-but-wrong path errors rather than silently falling back).

    It covers more than SmartArt, and it runs in CI. Three of the constructs the
    byte-identity corpus never emits — a:buBlip, a:prstTxWarp and numCol/spcCol —
    are now asserted against a second implementation rather than against their own bytes,
    each as a construct deck diffed with an otherwise identical control deck and each made
    to fail on purpose before it was kept. rtl="1" and altLang were probed and dropped
    as unobservable to any renderer; a:buClr changes the raster but not the extracted
    text. Unlike test:com there is a CI leg (ubuntu-latest, ~1m6s off the critical
    path), running under TSPPTX_RENDER_ORACLE=required so a runner missing a tool fails
    instead of skipping green.

  • CommonObjectDescriptor, the six key-tagged object descriptors a slide master and a
    group child both accept — image, line, rect, roundRect, shape and text.
    SlideMasterObject and GroupChildProps are now that type plus what is genuinely their
    own (a master adds chart and placeholder, a group child adds group). Both unions
    accept exactly what they accepted before, so nothing to migrate; the new name is what
    you will see in an editor hover, and it is exported so it can be named directly.

Fixed

  • addText no longer writes its own state onto the options object you hand it. The
    definer normalized options in place — a _bodyProp record, the assigned objectName,
    defaulted color and line — and the emitters then wrote more of it (_lineIdx,
    paragraph properties inherited from the shape). Reusing a style literal, the ordinary
    way to give several shapes one look, was enough to corrupt them: the first addText
    hung a _bodyProp on the literal, every { ...STYLE } after it aliased that same
    record, and one box asking for columns: 2 silently columnized the boxes before and
    after it. objectName leaked by the same route, spreading the first box's assigned
    name onto its siblings and colliding in the Selection Pane. Options are now copied on
    the way in, so the object a caller passes comes back exactly as it was written.
    Emitted bytes are unchanged (183/183 baseline parts): sharing within one addText
    call is preserved, because the string shorthand's shape and run genuinely are one
    object and the second normalization pass over it emits <a:ln> defaults the first
    cannot. Nested option objects (bullet, shadow, fill) are still shared by
    reference, which is how their relationship ids reach the emitters.

  • addShape and addTable no longer write their own state onto the options object
    you hand them either.
    3.5.0 named addText as the entry point that leaked; it was
    not the only one. addShape stamped the assigned objectName and the normalized
    line onto the caller's literal, so three shapes built from one spread came out named
    Shape 0, Shape 0, Shape 0 and warned about the collision. addTable wrote nine
    keys back — objectName, fontSize, margin, color and the five autoPage* ones —
    and handed the caller's object to every plain string cell as that cell's options, so
    the cell emitters wrote onto it too. autoPage is the one with teeth: the auto-pager
    clears it once it has shredded the rows, so a literal reused for a second table
    silently lost its paging. Both now copy on the way in, along with addTable's
    border array, whose slots were normalized in place. Emitted bytes are unchanged
    (183/183 baseline parts) — the options each definer owns still share identity within a
    single call, which is what the table's string cells and the text shorthand rely on.
    Nested caller objects follow addText: fill stays shared by reference for its rel
    id, and so does shadow, whose in-place normalization is idempotent and is what lets
    one shadow object give two shapes the same <a:effectLst>.

  • Shape.shadow and Shape.innerShadow read the shadow colour whatever model it
    uses.
    The decode named a:srgbClr and a:schemeClr explicitly and dropped the other
    four DrawingML colour models on the floor, so a shadow coloured with a:sysClr,
    a:prstClr, a:hslClr or a:scrgbClr read as color: null. The colour element is the
    shadow's only child and it is required (a:EG_ColorChoice, a single-member group), so
    it now takes the first element child — exactly what Shape.glow alongside it has always
    done. a:sysClr was the live case: it resolves everywhere else in the read model,
    through its lastClr snapshot. a:prstClr reaches the resolver, which now carries the
    preset table — see the colour-model entry above — so a preset-coloured shadow reports a
    colour rather than being silently swallowed by the accessor.

  • A chart point cache can no longer size an allocation straight from the file. Both
    chart readers turned a cached series into a dense array by trusting two numbers the deck
    supplies: c:ptCount/@val (cx:lvl/@ptCount on the 2016 side) and each point's @idx.
    @ptCount is xsd:unsignedInt, so 4294967295 is schema-valid, and
    new Array(4294967295).fill(null) is not a slow path — V8 answers FATAL ERROR: invalid table size and the host process dies with no exception to catch. It was reachable
    through the public API: Presentation.load() on such a deck returned normally and the
    first chart.series access killed the process. A single <c:pt idx="900000000"/> did
    the same on its own. Both readers now share one bounded decode: the array is sized by the
    points that are really present, and a point indexed past a worksheet's 1,048,576 rows —
    more than any workbook-backed cache could reference — is dropped. Two new diagnostics,
    chart/point-count-mismatch and chart/point-index-out-of-range, report either case
    rather than trimming silently. One consequence on legitimately sparse data: a cache that
    declares four points but carries three (the last cell blank) now reads three long instead
    of four. No value is lost — the dropped slots are the ones that read null — and the
    disagreement is warned about.

  • A table cell's colspan/rowspan can no longer size an allocation straight from the
    caller.
    The merge-grid builder trusted them: new Array(colspan - 1).fill(undefined)
    at colspan: 4294967295 is not a slow path but a process abort with no exception to
    catch, and a negative or fractional span shifted every column after it while emitting a
    gridSpan PowerPoint cannot make sense of. This is the write-side twin of the chart
    point-cache fix above, and less serious for the same reason — the caller is the program,
    not a hostile file — but the project's line is to warn rather than emit a degenerate
    result. A span that is not a whole number in 1..1000 now falls back to 1 and reports
    the new table/span-out-of-range diagnostic. The ceiling is a sanity bound, not a schema
    one (a:tc@gridSpan is a bare xsd:int); it sits an order of magnitude past
    PowerPoint's own maximum table, whose Insert Table dialog stops at 75 × 75. There were two
    allocations to guard, not one: the auto-pager sizes a per-column depth array from a column
    count that is a sum of colspans, so autoPage: true hit the same abort without reaching
    the merge grid at all. Both are fed by addTableDefinition, which is where the check runs
    — once, before anything reads a span, so the paged and unpaged paths agree on the grid and
    one bad cell warns once rather than several times. Emitted bytes are unchanged (183/183
    baseline parts): a cell whose spans are fine passes through untouched.

Changed

  • The bundle-size budget watches every published entry point, not just the browser
    one.
    bundle-size:check always measured an entry's whole transitive closure, shared
    chunks included — that part was never the gap. The gap was that only browser.js had a
    number, so a dependency landing in a chunk ts-pptx/read pulls in cost every consumer of
    that subpath and tripped nothing until it also reached the browser entry. All ten entries
    package.json publishes are budgeted now, which between them reach every .js file the
    build emits. Keying on entries rather than on chunks is what keeps it stable: an entry
    file name carries no content hash. Switching the other nine on immediately found two
    defects in the ratchet itself — a {@link import('./x.js')} in a doc comment counted as
    an import and made the closure demand a file no build emits, and the "you are far enough
    under to re-freeze" nag could not be satisfied on an entry small enough that --freeze's
    rounding to a whole kB exceeded the slack threshold.

  • Reflection.distPt is now Reflection.offsetPt (ts-pptx/read). Sibling accessors
    on one class spelled the same @dist attribute two ways: Shape.shadow and
    Shape.innerShadow reported offsetPt, Shape.reflection reported distPt. The three
    now agree. Migration is a rename at the call site — reflection.distPt →
    reflection.offsetPt; the value, the unit (points) and the omit-when-absent behaviour
    are unchanged. offsetPt won over distPt because the read API is meant to read well,
    not to mirror OOXML attribute spelling.

  • NotesPlaceholder now extends Placeholder (ts-pptx/read). It was a copy of it:
    the same twelve identity, geometry and escape-hatch members, read off the same p:sp,
    down to p:ph being located the same way — plus a flattened text convenience and a
    textFrame that threads the notesMaster inheritance context onto the body frame. Those
    two are now the only things it declares. Nothing about any member's behaviour changed;
    what changed is that notesPlaceholder instanceof Placeholder is true where it was
    false. That is a widening, and the answer a caller writing one type guard over "any
    placeholder in the deck" would want, but it is worth knowing if you were relying on the
    instanceof to tell a notes placeholder apart from a layout one — use
    instanceof NotesPlaceholder for that, which still reports only notes placeholders.