Repository navigation
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 the deck's content and none of its identity. Both getters return the same
AnyShapeunionSlide.shapesdoes, from the samebuildShapesdispatch, so
shape-walking code applies unchanged — and the members carry the full paint surface
(resolvedFill,resolvedLine,presetGeometry,rotation,absoluteFrame), which
the smallerPlaceholderclass never had. Tokens resolve against the owning part's
context: a master shape'sschemeClr accent2goes through the master's ownp:clrMap
and theme, not a slide's. Groups recurse and compose to slide-absolute frames as at
slide level, and both classes getshapeByIdDeep.placeholdersis unchanged, and is now documented as the filtered view of the same
tree — both hand out the same livep:spelements. Read a placeholder there to
place it, throughshapesto draw it.Shipping the accessor alone would have traded one wrong answer for another, so
Slide.showMasterSpandSlideLayout.showMasterSpship with it:@showMasterSp
(ECMA-376 attributeGroupAG_ChildSlide,xsd:booleandefaulting totrue, so absent
means shown) is how a slide or a layout suppresses the master's decorative shapes —
PowerPoint writes it on section dividers and full-bleed layouts. Without it a consumer
that gained access to master shapes would paint them onto slides that deliberately hid
them. Both are read-only; the write API authors neither. The layout arm has a genuine
oracle inmixed.pptxandread-stress.pptx, which each carryshowMasterSp="0"on
their title layout. -
fill: { type: 'inherit' }authors a shape whose interior comes from the style
reference or the placeholder (#10). 3.1.0 gavetype: 'none'its<a:noFill/>back
(#9) and, in doing so, left the inherit state with no spelling on the shape and text-box
path: that emitter defaults a missingfillto<a:noFill/>, so omitting the option
is an explicit transparent interior, not silence. The two arms were therefore both
no-fill, and 3.1.0's own migration advice — "omitfillinstead" — pointed at the wrong
one.'inherit'emits noEG_FillPropertiesmember at all, which is the state that lets
p:style/a:fillRefor the placeholder paint the shape. The default is untouched: every
existingaddShape/addTextcall without afillkeeps emitting<a:noFill/>, which is
why this is additive rather than a fix to the ternary.ShapeLinePropsinherits the new
member, where it means the same thing the stroke side already got for free — omit the
paint child, keep the<a:ln>. On a table cell and a slide background, where omitting
the option already meant inherit,'inherit'is simply the explicit spelling of that. -
Any commit is installable straight from GitHub:
npm i github:shbernal/ts-pptx#<sha>.
It looked like this already worked, and it never did.dist/is gitignored andprepare
only installed git hooks, so a git-URL install packed a tarball in which everyexports
entry named a file that had never been built — the install succeeded and the first import
failed.preparenow also runsscripts/ensure-dist.mjs --if-missing, a new mode that
builds an absentdist/and leaves a stale one alone. That distinction is the whole
design: the existing freshness check would be wrong inprepare, where it would make
pnpm run buildandpnpm run watchbuild twice over, and every script that needs a
current build already front-loads its own unconditionalensure-dist. Absent means
build; stale is somebody else's question. Two things had to move with it, both only
reachable once consumers could run this path at all:ensure-distinvokes the build
throughnpm_execpath— the package manager actually running it — rather than a
hardcodedpnpm, which is declared here aspackageManagerbut never installed as a
dependency and so resolved to a shim a plain-npm consumer does not have; and
install-hooks.mjsnow skips whenINIT_CWDplaces the caller outside the checkout,
which is what a consumer's install looks like. Without that it ranlefthook install
inside the package manager's throwaway clone and propagated lefthook's exit status into
someone else'snpm install. This is for trying an unreleased fix, not for production
dependencies: the install builds from source, so it pulls this package's
devDependenciesand takes minutes.
Changed
-
BREAKING (write):
TextPropsOptions.strikeadmits'noStrike', andunderline.style
matchesST_TextUnderlineType(#14).strikewasboolean | 'sngStrike' | 'dblStrike',
which had no spelling for the explicit off even though the serializer passes any truthy
string straight to the attribute — sostrike: 'noStrike' as 'sngStrike'already produced
the right XML and only the published type refused it. It is now
boolean | 'noStrike' | 'sngStrike' | 'dblStrike'. The breaking half isunderline.style:
'dotDashHeave'was a typo for'dotDashHeavy'and is corrected, and the missing
'words'is added, so the union is the enumeration's full 18 members. Migration: replace
underline: { style: 'dotDashHeave' }with'dotDashHeavy'— the old spelling was not a
legalST_TextUnderlineTypevalue, so any deck written with it carried an invalid@u.The doc comments now also say which state silence is an alias for, on all three
decorations.falseand an omittedstrikeboth write no attribute and therefore state
nothing, leaving the run with whatever it inherits — the same asbold/italic, whose
falsy arm is likewise an omission.'noStrike',underline: { style: 'none' }and
caps: 'none'are the spellings that state "off" and override an inherited decoration. -
BREAKING (read):
Shape.slideis nowShape.host, typedShapeHost. A shape proxy
no longer belongs to a slide specifically — the samep:spcan sit in a slide's,
a layout's, or a master'sp:spTree— so the back-reference names what it actually
is.ShapeHostis the small contract all three classes satisfy (part,partName,
opc,relationships,themeContext(),shapeByIdDeep()) and is exported from
ts-pptx/read. Migration:shape.slide→shape.host; where you genuinely need the
Slide, narrow withshape.host instanceof Slide.Slidegains anopcgetter
(=== presentation.opc) andSlideMaster/SlideLayoutgain publicopcand
relationshipsgetters, all to satisfy that contract. Nothing else about a shape
changed, andSlide.shapesis untouched. -
The
ts-pptx-upstreamskill covers the far end of the cycle, not just the filing.
It ended at "write the workaround", which is the half that happens on its own — the
half that rots is the release landing and nobody finding the stopgaps it retired. A
new step 7 is that sweep:npm viewfor what is out, closed issues,rg 'ts-pptx#'
for every marked stopgap here, then per stopgap bump, run the check the comment names,
delete, leave a test behind, and comment upstream with the check that now passes. It
rests on the marker actually saying something, so step 6 no longer prescribes
remove once fixed upstream— a line that cannot be checked at bump time and sends
the reader back to re-derive the reproduction — but the wrong output as an observable,
the exact check that proves the fix, and the code the stopgap becomes.ts-pptx#is
named as the literal token to grep for, which is what makes step 7 a command rather
than a memory. Two other gaps a downstream consumer found by working through it: step
4 now checksnpm view @shbernal/ts-pptx versionbefore the tracker, since a report
against a stale pin costs a maintainer a full triage and ends in a close; and step 1
carries theFidelityNote.causetriage —unreadandunwritableare gaps worth
filing,unsupportedis the format's own limit and is not — which is our own
machine-readable verdict on whose bug a silent loss is, and was being re-derived in
each consumer's notes instead of read off the note. -
The README installs the skill the way an agent will actually run it. The documented
line was the interactive one, in a section addressed to agents: it prompts for the
skill and the runtimes, which unattended is a hang rather than an install. The
non-interactive form is now the default shown, with the interactive one as the aside,
and--allis called out — it installs into every runtime the CLI knows, around
seventy, and leaves anagent/directory at the consumer's repository root. Also
written down: the installed copy is a copy, so a version bump does not update it, and
skills experimental_installrestores the file fromskills-lock.jsonwithout
creating any runtime link, so it is a record rather than a restore command. -
docs/RELEASING.mdcloses the loop the skill now depends on. Issues here close
when the fix merges, which is right for this repo and the wrong signal for a consumer:
merged-and-unreleased lasts weeks, and a workaround deleted on the strength of a closed
issue breaks against the version actually installed. Post-publish now comments the
carrying version on each issue the release closed.AGENTS.mdgains the rule #10 was:
when a fix gives an option value a meaning it did not have, work out what its absence
now means, because an emitter that defaults through a ternary has already assigned one
state to silence.
Fixed
-
readModelToIrcarries a text decoration's explicit off (#14). A run stating
a:rPr/@u="none",@strike="noStrike"or@cap="none"was read correctly by
Run.underline/Run.strike/Run.capsand then mapped toundefined, so the
resultingCallIrcarried no option and re-emitting wrote no attribute. All three now
carry —underline: { style: 'none' },strike: 'noStrike',caps: 'none'— and only an
absent attribute maps to an absent option.none/noStrikeare not the same fact as stating nothing. Each is a member of its own
enumeration (ECMA-376 §20.1.10.81, §20.1.10.78,ST_TextCapsType) and would be redundant
with omission if omission were the only way to be off. It is not, because run properties
resolve down thea:lstStyle→ placeholder → layout → master chain: a run that would take
u="sng"from its list style and statesu="none"is not underlined, and the same run
with the attribute dropped is. So the loss was invisible on a deck with no inherited
decoration and a visibly wrong answer on one that has any.It was undeclared either way — the same shape as #13.
DeckIr.fidelitynamed neither
construct, andcanonicalDeckIrdid not carry the field, sodiffDeckIrcompared two
models that were both missing it and reported the deck clean; a consumer's round-trip
harness structurally could not see this. Carrying the tokens closes both at once, and
neither state notes. Two PowerPoint-authored fixtures state these tokens:mixed.pptxand
table.pptxhold 132 runs statingu="none"andstrike="noStrike", 100 of which also
statecap="none". No new note fires, soscript:censusis unmoved. -
readModelToIrkeeps a bakednormAutofit'sfontScaleandlnSpcReduction(#13).
The mapper flattened everynormAutofitframe tofit: 'shrink', so both numbers were
gone by the time a consumer held theDeckIrand everything downstream re-emitted a bare
<a:normAutofit/>. Neither end of the round trip was at fault —TextFitShrinkProps
already carries both fields andTextFrame.autofitFontScalealready reads them back — so
this was a mapping omission with an exact fix available: a frame that bakes either
attribute now emits the object formfit: { type: 'shrink', fontScale, lnSpcReduction },
and one with neither keepsfit: 'shrink'.The two spellings are two states, not one value at two precisions. ECMA-376 §21.1.2.1.3
defaults each attribute to 100%/0% only when it is omitted; PowerPoint recomputes an
unbaked scale on edit and draws a baked one exactly as written until then. A deck baked at
fontScale="40000"therefore came back painting its text two and a half times too large,
in a file valid either way, which is why nothing caught it.It was also silent, which is the worse half.
printScriptnamed no fidelity note, so a
consumer following the documented rule — trust the tier's own notes — was told the tier
lost nothing; andcanonicalDeckIrdid not carry the fields either, sodiffDeckIr
compared two models that were both missing them and reported the deck clean. A round-trip
oracle built ondiffDeckIr, which is what the docs recommend building, could not detect
this class of loss at all. Carrying the numbers through the IR closes all three at once.One arm still loses something, and now declares it: the write path rejects a percentage
outside 0–100 and drops the attribute with a warning, so a source outside that range falls
back to bare'shrink'with atext.autofit.fontScale/text.autofit.lnSpcReduction
note (dropped/unwritable) rather than passing through a number that would vanish. No
corpus fixture is malformed, so both read 0/44 and the census is unmoved. -
Chart area and plot area fills honour
typeinstead of onlycolor(#11).
ChartPropsFillLine.fillis typedShapeFillProps, so both areas looked like they took
every fill kind a shape does — andc:spPrreally isa:CT_ShapeProperties, the same
optionalEG_FillPropertiesgroup, so nothing in OOXML said otherwise. But both emitters
gated onfill?.color, so every spelling that carries no colour fell to the<a:noFill/>
arm and did nothing:type: 'gradient',type: 'pattern', andtype: 'inherit'the
moment it was added above. They now go through the shared fill dispatch, so a chart area
can take a gradient or a pattern,'none'states a transparent area explicitly, and
'inherit'emits no fill child at all and leaves the area to the chart style.Two spellings deliberately still mean no-fill, because the gate is on a fill being
stated rather than merely present.normalizeChartOptionsdefaultsplotArea.fillto
{}, so every chart ever authored arrives at the emitter carrying a fill object — a
presence check would have painted all of them a default grey. And{ transparency: 50 }
with no colour is not a fill: there is nothing for the alpha to apply to. That was a
documented@exampleon the option and has never worked; it now reads
{ color: '696969', transparency: 50 }.type: 'image'remains unavailable on a chart and now warns
(image-fill/unresolved-media) instead of silently doing nothing: a blip fill needs a
media relationship on the chart part, and only shape and slide-level fills register one.
No existing deck changes — the only inputs whose output moved are ones that used to emit
<a:noFill/>, an invisible area nobody asked for.
Full changelog: https://github.com/shbernal/ts-pptx/blob/v3.2.0/CHANGELOG.md