Repository navigation
Releases: shbernal/pptx-ts
Release list
v4.1.0
Added
- Guide pages run their own examples. A code sample marked
ts liveshows 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-reviewand
field-notestocost-transformationandseries-a, the files from
Kestrel_Q3_Business_Review.pptxandField_Notes_Four_Cities.pptxto
AIP_Lodestar_Cost_Transformation.pptxandNightjar_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 itsoutputType.write({ outputType: 'uint8array' })now resolves to
Uint8Array,'arraybuffer'toArrayBuffer,'base64'and'binarystring'tostring, and
nooutputTypetoBlob, instead of the union of all four.'nodebuffer'is typed
Uint8Array, the class Node'sBufferextends, so the declaration needs no Node types. Casts
such as(await pptx.write({ outputType: 'uint8array' })) as Uint8Arraycan go. Code that
annotated'nodebuffer'output asBufferneeds the cast kept, oroutputType: 'uint8array'.
WritePropstakes the output type as an optional type parameter, and the newWriteOutputMap
names each type's result. -
outerBorderaccepts theundefinedholes its own example shows. Under
exactOptionalPropertyTypes,outerBorder: [rule, undefined, rule, undefined]failed to compile,
because the tuple's optional elements did not admit an explicitundefined. Each element now
does. No migration is needed. -
Chart
valuesadmitnullfor a gap.OptsChartData.valuesis now
Array<number | null>. Anullhas always been the gap spelling at run time (an empty workbook
cell, drawn perdisplayBlanksAs), 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.pointandOpcPackage.part
now acceptnulland 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 typednumber | nullor
string | nulland the lookups onlynumberorstring. At run timeshapeById(null)also
matched the first shape whosep:cNvPrhas no@id; it now returnsundefined. No migration
is needed. -
Slide.newAutoPagedSlidesis typed as always present. The interface declared it optional,
though every slide carries the list, empty until an auto-paging table spills. Under
strictNullChecksthe documentedfor (const made of slide.newAutoPagedSlides)did not compile.
Code that guarded with?.or?? []still works; a class implementingSlideitself now has to
provide the property. -
The default branch is now
main, renamed frommaster. The library is unchanged. A
dependency ongithub:shbernal/ts-pptx#mastershould switch to#main, or better, to a pinned
commit sha. GitHub redirects oldblob/master/links, and the repository's own links now point
atmain. -
demos/is gone; demos live on the site. The showcase decks moved towww/showcases/, and
pnpm demos:buildis nowpnpm 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 totest/assets/. None of this was in the
published package. -
Runtime strings name the project
pptx-ts, notts-pptx. Console warnings are prefixed
pptx-ts:, and theInternalErrormessage calls it a bug in pptx-ts. A deck that sets no
document properties is written with author and companypptx-tsand title and subject
pptx-ts Presentation. A generated script's header readsGenerated from a .pptx by pptx-ts/script.Code that matched a warning line onts-pptx:, or detected this library's
decks by their default author, should matchpptx-tsinstead; asetDiagnosticHandlerhandler
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 throwsInvalidOptionError
with codepattern-fill/invalid-preset. Use one of thePatternPresetnames. - A
tableStylethat 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
InvalidOptionErrorwith codetable/invalid-style-id. Pass theTableStylemember
(TableStyle.MEDIUM_STYLE_2_ACCENT_1) instead of its key name. - A table cell's text shadow takes its
transparency. Ashadowon a cell, or on a run inside
a cell'stext, painted at the default 75% opacity whatevertransparencyor RGBA colour it
stated, because the table path skipped the normalizationaddTextruns.TableCellPropsnow
declaresshadow, 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
[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, andcleanOpts
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, throughRUN_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'sshadow, 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 constructtext.color.inheritedmoving 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
[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.bytesis renamedPart.originalBytes. To read what a part holds now, call
part.serialize(). KeeporiginalBytesonly where you mean the bytes as loaded.- Every read value that carries a colour carries one
ColorRef, undercolorRef. - The read model's text, table and diagram proxies take the context they are read in.
PptxSlideElement.idis typednumber. It wasstring | number.- A
NaNoption is refused, an infinite length clamps, and a coordinate outside the DrawingML
range throws.NaNthrowscoord/non-finiteorpercent/non-finite; the range bounds are
exported asMIN_COORDINATE_EMUandMAX_COORDINATE_EMU. - A zero extent is kept on every object and reported, and a
NaNone is refused.w: 0is
now a stated extent that warnsframe/zero-extent, not a silent default. chartColorsOpacity: 0paints a fully transparent series, andNaNthrows. 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,
slideoraction. - 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
createPresentationcomposes 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. pptxToScriptkeeps 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.appPropertiesreadsdocProps/app.xml, including thecompanythe write API
has always been able to set.StrokePropsgives 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
[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.mjsstages a copy of the build the
release gates just proved, and.github/workflows/publish.ymlpublishes that copy after
the canonical publish has succeeded. Three things differ from@shbernal/ts-pptxat
the same version, and nothing else does:package.json#name, aREADME.mdbanner
naming the scoped package as the canonical one, and the absence of thescriptsblock,
which is dev-only metadata in a published manifest and whoseprepackwould delete the
stageddist/if a publish ever ran without--ignore-scripts.version,exports,
files,dependenciesand thedist/andskills/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-pptxon npm belongs to an
unrelated project. -
slide.objects: the authored objects on a slide, bottom-to-top in z-order. The
read-back half ofgroupObjects(). Each entry is aSlideObjectInfocarryingtype,
objectName,isPlaceholder,canGroup, andchildren(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.objectNamecomes back in the caller's spelling, not the stored one. Every
add*Definitionstores the name attribute-escaped andgroupObjects()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 namedQ&A
reads back asQ&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
&decodes to&and re-encodes to the stored&amp;.objectNameis always a string. An object authored without one still carries the
generatedShape 3/Text 1/Group 2identity PowerPoint shows in the Selection
Pane, and that name addresses it as well as an authored one does — reportingnullthere
would have withheld a usable handle to signal a distinction nothing downstream of
authoring records anyway.canGroupis the predicategroupObjects()throws on, not a restatement of it.
GROUPABLE_TYPESand the placeholder exclusion now live behind oneisGroupableObject
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 madeworkflow_dispatchuseless 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
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 recompute...
v3.5.0
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.textis settable.Run.text,TextFrame.text,Shape.textand
TableCell.textcould 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'sa:rPrand
the paragraph's owna:pPr(level, alignment, bullet), and leaves sibling paragraphs
untouched — the same rule the SmartArt drawing-cache mirror already ran on internally. -
DiagramPoint.texthas a setter, and it keeps the drawing cache honest. A SmartArt
diagram stores every string twice: as authored nodes inppt/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 throughtextFramewrote 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.textFramestill edits the data model alone and now says so; it is the escape hatch for
run-level formatting. Seedocs/reference/pptx-read.md. -
Diagram.nodesgives the data model its tree, andDiagram.point()resolves an id.
connectionshands back the rawparOfedges, 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. ADiagramNodecarries itspoint, itschildren(in@srcOrd
order), itsparentand itslevel. 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 throughDiagramConnection.parentTransitionId/siblingTransitionId. A
parOfcycle raisesdiagram/parent-edge-cyclerather than hanging or silently dropping
the points it swallows. -
DiagramPoint.drawnShapeandDiagramPoint.presentationIdlink a node to what is drawn
for it.drawnShaperesolves thedsp:spof the fallback drawing part that carries the
point's text, plus the paragraph index inside it, as aTextFramebound to the drawing
part. The mapping is not the obvious one and is many-to-one in both directions: a
dsp:sp/@modelIdis always a generatedprespoint'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.nullis a defined
outcome, covering a package with no drawing part, a point with nopresOfedge (every
unlabelled transition point), aprespoint that draws nothing, and a drawn shape with no
text body. Measured over five layout families and 90 authored points.
Changed
-
importSlidebinds to chrome the destination already holds instead of copying it in
again.copyPartwalked the source slide'sslideLayout -> 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 (fromTemplatekeeps
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 threwlayout/ambiguous-namebecause 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 inp:sldMasterIdLstand 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) withimportSlide, 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 everyappendSlidesbinding in the emitted script from
the name to a gallery position. WithimportSlidereusing 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. -
inspectPptxreports graphic frames. Ap:graphicFrame(table, chart, SmartArt, or a
payload this library does not model) was skipped outright and consumed nozIndex, so a
deck whose slides are SmartArt or tables inspected aselements: []withwordCount: 0,
indistinguishable from a deck of blank slides. It also put the two read surfaces in flat
disagreement:Slide.texton 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, azIndex
in the shape-tree walk, and a newgraphicKindfield ('table' | 'chart' | 'chartEx' | 'diagram' | 'other'). Itstextis what a reader sees on the slide, so it counts toward
the slide'stextandwordCount; a chart contributes none, matchingSlide.text, which
treats data labels as chart data rather than slide body text. The structure is still not
flattened:textRunsandparagraphsstay empty, and cells, series, and nodes are reached
throughts-pptx/read.Migration.
PptxSlideElementKindgains'graphicFrame', so an exhaustiveswitchover
it must handle the new member;PptxSlideElementgainsgraphicKind,nullon every other
kind. A consumer that counted elements, summedwordCount, or readzIndexvalues sees
different numbers on decks containing graphic frames. Filter onkind !== 'graphicFrame'to
get the old element set back (the oldzIndexnumbering 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/scriptdecides 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
importSlidenow 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 animportSlidebetween twoappendSlidescalls moves the gallery
underneath them.Migration: a deck whose SmartArt, OLE or ink slides previously came out
authorednow comes
outcarried. 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
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.hasDiagramandGraphicFrame.diagram. A
p:graphicFramewhosea:graphicData/@uriis the DrawingML diagram namespace answered
falseto all three host predicates andnullto 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".Diagramdecodes the
dgm:dataModelpart:points(everydgm:ptin document order, typed
node/asst/doc/pres/parTrans/sibTrans),connections(thedgm:cxnedges that
give the points their tree), andtext(the authored node text, generatedprespoints,
thedocroot and unfilled placeholders excluded). A point'stextFrameis an ordinary
TextFrameover itsdgm:t, so runs, formatting and theresolved*getters all apply.
The layout, quick-style, colours and fallback drawing parts resolve asParts, and the
docpoint'slayoutTypeIdnames the SmartArt kind. Authoring SmartArt remains out of
scope. Seedocs/reference/pptx-read.md. -
GraphicFrame.graphicDataUrireports what an undecoded frame holds. Everyhas*
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 fourfalses. 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 —setDiagnosticHandlerowns that, and the
two reset independently because a host usually installs its handler once. See
docs/diagnostics.md.
Changed
-
Slide.textnow 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.textlengths to detect empty slides, or diffing that
string across versions, a deck containing SmartArt will now report more text. Read
GraphicFrame.diagramdirectly if you need the diagram separated back out. -
A converted script now distinguishes
diagram.allfromgraphicFrame.unknown. SmartArt
used to be reported under the latter, whose stated cause isunread. With a reader in front
of it, the loss is the write leg's:pptxToScriptemitsdiagram.all(unwritable,
dropped), the same shape aschartEx.all.graphicFrame.unknownkeeps 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 acustGeomconnection-site or
adjust-handle angle all threwInfinitystraight 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 anInvalidOptionErrorwith code
coord/non-finiteat 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
Infinityor
NaN(a division by a zero row count is the usual one), guard it at the call site. ANaN
is unchanged on the size options — every caller reads them for truthiness, so aNaNhas
always meant "absent" there. -
A rotation past a full turn now reduces correctly.
convertRotationDegreessubtracted a
single turn, sorotate: 800became 440 degrees rather than 80, androtate: 370became 10
whilerotate: -400was 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
-45and315are validST_Angleand 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'sminAng/maxAng, anangleRangeguide, and the
chart label rotations. Those now go through a non-wrapping converter, so an adjust handle
declared withmaxAng: 540keeps its full travel instead of silently collapsing to 180. -
An empty
chartColorsnow 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 isPIECHART_COLORS. Nothing chose that: normalization defaulted a missing
chartColorsper 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
legendFontSizeis no longer coerced from a string. It was the one font-size
option whose emitter wrapped the value inNumber(), solegendFontSize: '12'from untyped
JS worked there while the same string threw atcatAxisLabelFontSize,dataLabelFontSize
and the eight other spellings of the same option. The coercion is gone; a non-number now
throwsInvalidOptionErrorwith codecoord/non-finitewherever it is passed.Migration: pass a number, which is what the type has always said.
-
A
nullside in a cell's border tuple is now omitted rather than erased. A four-side
TRBL tuple replaced every falsy side with{ type: 'none' }, soborder: [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".
Anullside now leaves its element absent, which is the distinctionnormalizeOuterBorder
already documents for the perimeter ("a sparse side is not{ type: 'none' }"), and the
tuple type acceptsnullso 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
nullto erase an edge should say{ type: 'none' }. -
A table with a
tableStylekeeps 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 aMEDIUM_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.outerBordercomposes 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 figureautoPagewraps on comes from the column's width in points, and
the conversion went through EMU:(colWidth / EMU_PER_POINT) * EMU_PER_INCHis the same 72
ascolWidth * POINTS_PER_INCH, but not exactly, and theMath.flooraround it took the
hit. Of the 200,000 widths from 0.001in to 2...
v3.3.0
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
-
isChartTypenarrows a string to the chart catalog. The companion toisChartExType,
which reported only which half of the catalog a type belongs to and answeredfalsefor
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 reachesaddChart, which now
throws on one it does not recognise (see Changed). -
importSlidescarries speaker notes, per request.ImportSlidesRequestgains
importNotes?: boolean, the batch spelling of the optionimportSlidehas 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 onenotesMaster, so the
destination's own is reused when it has one and the first carried master is installed
when it has none, exactly asimportSlide({ importNotes: true }),appendSlidesand
Slide.addNotesdo. 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.checkSelectionCopyabletakes the opted-in pages and walks each
one's notes subgraph under exactly the rulescarryNoteswill 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.
carryNotesopens an
ownership scope (page-owned.ts), so a page named twice — reachable in one call for
the first time here, and already reachable through twoimportSlidecalls — 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.
- The up-front dry run now walks the notes graph. Notes are copied after the pages
-
Slide.addNotes(text)on the read model — the write counterpart to the
notesText/notesTextFrame/notesSlidegetters, and the only way to annotate a
slide with no notes part at all. That state is reachable in normal use — it is what
importSlidewithout{ importNotes: true }leaves behind — and it was a dead end: the
notes accessors are getters, and the settablenotesTextFrameisnullprecisely 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-sideaddNotes; 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, thesldImg/sldNumplaceholders — 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 ruleimportSlide({ importNotes: true })andappendSlidesalready follow, so mixing the three cannot produce a second
notes master.The part it builds is the generator's, not a second design:
makeXmlNotesSlidewas split
so its fixed three-placeholder frame (makeXmlNotesSlideSkeleton) is shared with the read
side instead of copied into it, andtest/read/add-notes.test.jsasserts the two paths
emit the same notes body — modulo empty-element spelling, which the read model's
serializer normalizes. This is the firstsrc/read/→src/gen/import; it buys the
anti-drift guarantee thatsrc/ooxml/exists to give the constants both halves share.
Fixed
-
A chartEx chart appended onto a loaded deck arrived empty.
Presentation.extractSlides()
— the bridgets-pptx/read'sappendSlidesserializes 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.
extractSlidesbuilds a chartEx chart
withmakeXmlChartExand hands its two mandatory style sidecars along in the descriptor's new
chartExslot;appendSlidesinjects it as its ownchartEx{N}.xmlpart under the Microsoft
chartex content type, wires the slide to it through the MSchartExrelationship rather than
the ECMAchartone, and writesstyle{N}.xml/colors{N}.xmlat 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 onisChartExTypeand always has. Classic charts
throughappendSlidesare unchanged, byte for byte. The interim
UnsupportedFeatureError(chart/chartex-not-extractable) that refused these charts is gone
fromUnsupportedFeatureErrorCode— 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, andembedFontswere absent from
docs/reference/pptx-read.md—importNotesappeared nowhere indocs/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,importNoteshas a prose section
covering the notes-master policy, and the neighbouring claim that "deliberate re-branding
(arestylemode) is not yet implemented" — untrue sincerestyleshipped, and
contradicted by the section above it — is corrected. No behaviour change: the option has
worked in all threethememodes since it shipped.ImportSlidesRequestwas 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
addChartinstead of emitting a chart with
no plot.addChart(data, { type })took the type on trust: theCHART_NAMEunion
constrains TypeScript callers, but nothing enforced it for JavaScript ones, and the two chart
emitters partitionChartTypebetween 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
ChartMultientry 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 throwsInternalError
(chart/type-not-routed), which no public input can now produce. Every catalog type builds
exactly the bytes it did before. -
toBytes()replacesstream(). The old method did not stream: it assembled the
complete archive, then copied fflate'sUint8Arrayinto a NodeBuffer. It therefore
failed in browsers without aBuffershim, returned an unnecessarily broad type, and
ignoredonMediaError.toBytes({ compression, onMediaError })now returns exactly
Promise<Uint8Array>in Node, browsers, and workers without the extra archive-sized
copy. Migrateawait pptx.stream()toawait pptx.toBytes(); callers that need another
representation should continue usingwrite({ outputType }). -
pnpm run verifyno 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
OOXMLValidatorCLIprocess, so the real ceiling wasworkers × 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.jsbatches validation. `OOXMLValidatorCL...
v3.2.0
Added
-
bullet: 'inherit', the state omission cannot spell (#15).TextBaseProps.bullethad
two states and three meanings: a bullet, orfalse/omitted — and both of the latter emitted
an explicit<a:buNone/>plusindent="0" marL="0". There was no way to author a paragraph
that states nothing about its bullet. That matters because bullet properties resolve down
thea:lstStyle→ placeholder → layout → master chain, anda:buNoneoverrides that
chain rather than deferring to it: a paragraph inheriting a bullet from its layout's
a:lvl1pPrlost it, and one inheriting no bullet still stopped tracking the master, so a
later edit there no longer reached the slide. Theindent="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 nora:buNonenor 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
bulletfor 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 thea:buChar) would have eaten a real
character from text that emits no bullet to duplicate. Both now ask whether bullet markup is
actually emitted. -
paraMarginLeftandparaIndent, the other half of that element. #15's report named
theindent="0" marL="0"beside thea:buNoneas "a second, separate loss on the same
element", andbullet: 'inherit'closed it only for the state that says nothing. The
attributes themselves still belonged to the bullet:a:pPr/@marLand@indenthad no option
of their own, sobullet: falsecould 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.indentLevelis a different fact — it writesa:p/@lvl, the
discrete outline level — andbullet.indentreached@marLonly 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 thea:lstStyle→ placeholder → layout → master chain. Omission keeps
meaning what it has always meant — whatever thebulletstate 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
toST_TextMargin(0–4032pt, unsigned) andST_TextIndent(−4032–4032pt) with a warning,
under the newtext/paragraph-margin-out-of-rangeandtext/paragraph-indent-out-of-range
diagnostic codes, since PowerPoint reports an out-of-range value as needing repair. -
ts-pptx/scriptcarries a paragraph's own margins, andtext.indentis retired. It was
5/44 on the corpus and the largest note left ona:pPronce 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.marginLeftPtandParagraph.indentPtalready 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 retriedgenXmlParagraphPropertieson each run of a paragraph until one produced
non-empty XML. That was unreachable while everybulletstate wrote something, and became
wrong the moment an emptya:pPrwas 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 verya:buNonethe first run asked to leave out, appended after that
run's<a:r>, where apPris not allowed. No deck the byte-identity corpus authors moves. -
ts-pptx/scriptcarries inherited bullets instead of declaring them lost. A paragraph
read as having no bullet child of its own now maps tobullet: 'inherit'rather than to an
absent option, andtext.bullet.inheritedis retired — it was the largest fidelity note
on the corpus at 34/44 fixtures, along withlayout.text.bullet.inheritedat 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.bulletDetailisnullfor a paragraph with no bullet child and{ kind: 'none' }
for one statinga:buNone— so this was a missing write option rather than an unreadable
construct, which is worth noting because the note filed it asunread. Consumers diffing
printed scripts will seebullet: 'inherit'appear on most paragraphs. -
ts-pptx/script's standalone tier rebuilds a layout's decoration instead of dropping
it. A source layout became adefineSlideMastercall 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.decorationand 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 itsShapePropscarries 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, becauseabsoluteFramecomposes the
group's offset, rotation, flips and child-space scaling into each child, and noted as
layout.groupbecause one selectable object becomes several. A connector has no
variant either and is re-authored as alinepreset: it paints the identical stroke,
keeps its rotation (which the slide-sideaddConnectormapping loses), and matters
more than it sounds — PowerPoint's line tool authors ap:cxnSp, so connectors are 18
of the 45 shapes the fixture corpus's layouts actually draw. A table is dropped,
with alayout.decorationnote 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.decorationsurvives for a master's own shapes and moves from a reading
problem to a structural one —defineSlideMastercreates 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 fromts-pptx/script, because the shared
mapper speaks the slide vocabulary:layout.line.widthisline.widthseen 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.
isKnownNoteConstructis exported alongside it and resolves a prefixed construct to
its slide entry in the coverage table. -
SlideMaster.shapes/SlideLayout.shapesreach a template's own content, and
showMasterSpsays whether to draw it (#12). Both classes exposedplaceholders
and nothing else, so thep:sp/p:pic/p:graphicFrameunder 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; onlypart.domreached 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 walkingslide.shapes
reproduces th...
v3.1.0
Added
-
The package ships a
ts-pptx-upstreamskill, andInternalErrorsays 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.InternalErrornow 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
ErrorCodeTSDoc — and therefore the.d.tsan agent innode_modulesactually 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 aPackageReadErroron a file PowerPoint opens
cleanly is our gap, not bad input. Andskills/ts-pptx-upstream/is published in the tarball, so
npx skills add ./node_modules/@shbernal/ts-pptxworks 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-pptxon everyghcall, sinceghin 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.messagegained a trailing pointer, which is not a contract: the class and
thecodeare API and the message never was. -
TableCell.fillNoFillreads an explicit<a:noFill/>on a cell (#7) — the cell-side
counterpart of 3.0.0'sShape.fillNoFill, and whatTableCell.noFill()has always been
able to write.hasOwnFillis not this question: it istruefor any
EG_FillPropertieschild, so it cannot separate a suppressed fill from a gradient, a
pattern, a picture or ana:grpFill, and every colour accessor (resolvedFill,
fillColor,fillSchemeColor) reportsnullfor 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 onsrc/, 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-promisesnow also coverscripts/andtest/,
where the old configuration could not afford them. Seedocs/development.md. -
Published
.d.tsfiles 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) | undefinedbecomes
fontRef?: StyleFontRef | null | undefined). Chunk filename hashes shift as a
consequence. No declaration, signature or export moved, andpublint,
@arethetypeswrong/cliand 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 privatetools/api-docsworkspace 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:genXmlColorSelectionhad nononecase, so the fill child was
omitted entirely and the interior fell back top:style/a:fillRefor 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 reportedfalsebefore. The one behaviour that
changes for existing decks is thattype: '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
filloption instead. -
readModelToIrcarries a line's@cap, and declares a dropped@algn(#8). Both
legs of the cap mapping already existed —ShapeLineProps.capauthors the attribute and
3.0.0'sShape.lineCapreads it back — but the script tier'slineOptionnever 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.@capextends 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.@algnis readable and
has no write option, so it now records aline.alignnote (dropped/unwritable) —
foralgn="in"only, sincectris what an omitted@algnalready renders as. -
latexToOmmlemits accents as<m:acc>rather than<m:limUpp>(#6).\hat,
\bar,\vec,\dot,\ddot,\tilde,\acute,\grave,\check,\breve,
\mathring,\H,\dddotand 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 offaccent="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 anaccPrcharacter should be — so\vec{v}
gets an arrow accent instead of a full-size arrow hung over the base.Scoped to
latexToOmml:mathmlToOmmlpasses hand-written MathML through unchanged,
because thereaccentis the caller's to set. Constructs that were already mapping well
are untouched (\widehat/\overbracestaym:groupChr,\overline/\underlinestay
m:borderBox,\stackrelstaysm: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 singlem:chr. -
A table cell's explicit
a:noFillsurvives read → script → write. Previously a
suppressed cell fell out ofcellFillas "no fill option" and the copy took the table
style's banding — the opposite of what the source showed. Enabled by the
TableCell.fillNoFillreader above and thefill: { type: 'none' }writer fix above.