Repository navigation
v3.6.0
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).readGradientStopshunted fora:srgbClranda:schemeClrby
tag name, so a stop written asa:prstClr,a:sysClrora:hslClrcame back blank in
every field —color,schemeColor,effectiveHexallnull— even though
resolveColorElementresolves five of the six models everywhere else, and even though
thea:prstClr/a:hslClrwork above claims gradients among the places it reaches. It
did not reach this one.a:CT_GradientStopis a sequence of exactly onea: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.presetColornow exists. Three places already documented it — the
presetColorHexexport note above, and theRecolorColorcross-references that describe
themselves as mirroring "the {@link GradientStop} split
(color/schemeColor/presetColor)" — against a type that had no such field. It is the
rawa:prstClr/@val, matching the raw/resolved split the rest of the read model uses. A
stop written asa:sysClrora:hslClrhas no raw field of its own and is reported
throughresolvedColoralone, which the interface now states rather than leaving to be
inferred from threenulls.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.resolvedColorandCellBorder.resolvedColor(ts-pptx/read). A
ResolvedColorkeeps three things: the basehex, the rawtransformslist, and the
effectiveHexafter 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 reportedposition/color/schemeColor/effectiveHex
and a table cell border reported a resolved hex plus a rawschemeColor, 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 same2F5597, 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, becauseeffectiveHexalone 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
ResolvedColorthe resolver already built, underresolvedColor.
The existing flat fields are unchanged and stay the convenience for painting:
GradientStop.effectiveHexandCellBorder.colorare both exactly
resolvedColor?.effectiveHex ?? null. An emptytransformsmeans the source stated
none; anullresolvedColormeans there was no resolvable colour to read. Nothing has
to migrate.ResolvedColor.transformsis now typed as the already-exportedColorTransform[]
rather than a structurally identical inline literal, so the three places that hand one
around name the same type. -
Run.resolvedItalic(ts-pptx/read).Runresolved 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 inheritsi="1"from the master's
p:txStylesreporteditalic: nullwith no way to answer what it actually renders as.
@band@iare siblings onCT_TextCharacterPropertiesand PowerPoint writes them as
a pair (every levela:defRPrin a stock master carries both); the write API already
treats them as one, sinceMasterTextStyleLevelhasboldanditalic— so a deck
could be authored with an inherited italic that could not then be read back. Anything
painting from the read model asprops.X ?? resolved.Xhad to render such a subtitle
upright. It resolves the same chainresolvedBolddocuments (paragrapha:defRPr→
slidea:lstStyle→ layout → master placeholdera:lstStyle→ masterp:txStyles→
p:defaultTextStyle) and reportsnullwhen the run states no@iand 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 tofalse, because the old code tested
=== '1' || === 'true'and let everything else fall through as not-bold. It is parsed by
boolValuenow — the samexsd:booleanparserRun.bold/Run.italicuse — so a value
the schema does not allow reportsnull(unknown) rather than a confidentfalse. -
a:prstClranda:hslClrresolve, so five of the six DrawingML colour models now
report a colour (ts-pptx/read).resolveColorhandleda:srgbClr,a:sysClrand
a:schemeClr; a colour written in any of the other three read asnulleverywhere in
the read model — fills, lines, gradients, effects, table styles, slide backgrounds and
thetheme: 'preserve'flatten path alike.a:prstClris 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.tsnow holds the ECMA-376 §20.1.10.47 table: 190
enumerated names, 140 distinct colours, since the abbreviateddk/lt/medprefixes,
both spellings of grey, and case are spelling rules rather than separate entries.
a:hslClrwent in beside it through the sRGB-HSL conversion the colour-transform module
already carries and already validates against PowerPoint forlumMod/satMod. The
preset resolves to a hex and nothing else:ResolvedColorgains nopresetColorfield,
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.presetColorHexis exported fromts-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:scrgbClris deliberately still unresolved. Its channels are percentages of a
colour space the schema does not pin down, so whether50%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_Percentageis 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-legalalpha="50%"without
a word. Both spellings are read now.It shows up downstream: three run colours in the
tablefixture 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 — totext.color.inherited, resolved and baked. The
corpus note totals move with them (733 / 433). -
objectLockon zooms, and honoured on 3D models (addSlideZoom/
addSectionZoom/addSummaryZoom/addModel3d).SlideZoomPropshad no such field
at all, whileModel3dPropsinherited 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 thea:graphicFrameLocksof themc:Choicegraphic 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
keepsnoChangeAspecton by default andobjectLock: { noChangeAspect: false }lifts
it, as on an image. Themc:Fallbackpicture in both keeps its own fixeda: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 thedgm:dataModelPowerPoint reads and in the
dsp:drawingcache 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:comcannot separate them, and the drawing-cache mirror shipped in 3.5.0 was
proven only by the bytes ofppt/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,
viasoffice --convert-to pdfpluspdftotext. Three cases, each with its own
sensitivity check:mirroredprovesDiagramPoint.textreaches 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 aPageRangefilter option. SKIPs cleanly when either
tool is missing (TSPPTX_SOFFICE/TSPPTX_PDFTOTEXToverride 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:prstTxWarpandnumCol/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"andaltLangwere probed and dropped
as unobservable to any renderer;a:buClrchanges the raster but not the extracted
text. Unliketest:comthere is a CI leg (ubuntu-latest, ~1m6s off the critical
path), running underTSPPTX_RENDER_ORACLE=requiredso 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,shapeandtext.
SlideMasterObjectandGroupChildPropsare now that type plus what is genuinely their
own (a master addschartandplaceholder, a group child addsgroup). 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
-
addTextno longer writes its own state onto the options object you hand it. The
definer normalized options in place — a_bodyProprecord, the assignedobjectName,
defaultedcolorandline— 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 firstaddText
hung a_bodyPropon the literal, every{ ...STYLE }after it aliased that same
record, and one box asking forcolumns: 2silently columnized the boxes before and
after it.objectNameleaked 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 oneaddText
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. -
addShapeandaddTableno longer write their own state onto the options object
you hand them either. 3.5.0 namedaddTextas the entry point that leaked; it was
not the only one.addShapestamped the assignedobjectNameand the normalized
lineonto the caller's literal, so three shapes built from one spread came out named
Shape 0,Shape 0,Shape 0and warned about the collision.addTablewrote nine
keys back —objectName,fontSize,margin,colorand the fiveautoPage*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.autoPageis 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 withaddTable's
borderarray, 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 followaddText:fillstays shared by reference for its rel
id, and so doesshadow, whose in-place normalization is idempotent and is what lets
one shadow object give two shapes the same<a:effectLst>. -
Shape.shadowandShape.innerShadowread the shadow colour whatever model it
uses. The decode nameda:srgbClranda:schemeClrexplicitly and dropped the other
four DrawingML colour models on the floor, so a shadow coloured witha:sysClr,
a:prstClr,a:hslClrora:scrgbClrread ascolor: 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 whatShape.glowalongside it has always
done.a:sysClrwas the live case: it resolves everywhere else in the read model,
through itslastClrsnapshot.a:prstClrreaches 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/@ptCounton the 2016 side) and each point's@idx.
@ptCountisxsd:unsignedInt, so4294967295is schema-valid, and
new Array(4294967295).fill(null)is not a slow path — V8 answersFATAL ERROR: invalid table sizeand 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
firstchart.seriesaccess 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-mismatchandchart/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 readnull— and the
disagreement is warned about. -
A table cell's
colspan/rowspancan no longer size an allocation straight from the
caller. The merge-grid builder trusted them:new Array(colspan - 1).fill(undefined)
atcolspan: 4294967295is 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
gridSpanPowerPoint 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 to1and reports
the newtable/span-out-of-rangediagnostic. The ceiling is a sanity bound, not a schema
one (a:tc@gridSpanis a barexsd: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, soautoPage: truehit the same abort without reaching
the merge grid at all. Both are fed byaddTableDefinition, 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:checkalways measured an entry's whole transitive closure, shared
chunks included — that part was never the gap. The gap was that onlybrowser.jshad a
number, so a dependency landing in a chunkts-pptx/readpulls in cost every consumer of
that subpath and tripped nothing until it also reached the browser entry. All ten entries
package.jsonpublishes are budgeted now, which between them reach every.jsfile 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.distPtis nowReflection.offsetPt(ts-pptx/read). Sibling accessors
on one class spelled the same@distattribute two ways:Shape.shadowand
Shape.innerShadowreportedoffsetPt,Shape.reflectionreporteddistPt. 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.offsetPtwon overdistPtbecause the read API is meant to read well,
not to mirror OOXML attribute spelling. -
NotesPlaceholdernow extendsPlaceholder(ts-pptx/read). It was a copy of it:
the same twelve identity, geometry and escape-hatch members, read off the samep:sp,
down top:phbeing located the same way — plus a flattenedtextconvenience and a
textFramethat 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 thatnotesPlaceholder instanceof Placeholderistruewhere 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
instanceofto tell a notes placeholder apart from a layout one — use
instanceof NotesPlaceholderfor that, which still reports only notes placeholders.