Skip to content

v3.2.0

Choose a tag to compare

@shbernal shbernal released this 10 Aug 15:08
· 891 commits to main since this release

Added

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

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

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

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

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

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

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

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

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

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

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

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

  • SlideMaster.shapes / SlideLayout.shapes reach a template's own content, and
    showMasterSp says whether to draw it
    (#12). Both classes exposed placeholders
    and nothing else, so the p:sp/p:pic/p:graphicFrame under a master's or layout's
    p:cSld/p:spTree — the bands, rules, logos and footer furniture a deck is recognized
    by — had no modeled path out of the read API at all; only part.dom reached them,
    which is the raw-XML hatch rather than the model. On a corpus of PowerPoint-authored
    decks this was the single largest read gap: a consumer walking slide.shapes
    reproduces the deck's content and none of its identity. Both getters return the same
    AnyShape union Slide.shapes does, from the same buildShapes dispatch, so
    shape-walking code applies unchanged — and the members carry the full paint surface
    (resolvedFill, resolvedLine, presetGeometry, rotation, absoluteFrame), which
    the smaller Placeholder class never had. Tokens resolve against the owning part's
    context: a master shape's schemeClr accent2 goes through the master's own p:clrMap
    and theme, not a slide's. Groups recurse and compose to slide-absolute frames as at
    slide level, and both classes get shapeByIdDeep.

    placeholders is unchanged, and is now documented as the filtered view of the same
    tree — both hand out the same live p:sp elements. Read a placeholder there to
    place it, through shapes to draw it.

    Shipping the accessor alone would have traded one wrong answer for another, so
    Slide.showMasterSp and SlideLayout.showMasterSp ship with it: @showMasterSp
    (ECMA-376 attributeGroup AG_ChildSlide, xsd:boolean defaulting to true, 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 in mixed.pptx and read-stress.pptx, which each carry showMasterSp="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 gave type: '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 missing fill to <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 — "omit fill instead" — pointed at the wrong
    one. 'inherit' emits no EG_FillProperties member at all, which is the state that lets
    p:style/a:fillRef or the placeholder paint the shape. The default is untouched: every
    existing addShape/addText call without a fill keeps emitting <a:noFill/>, which is
    why this is additive rather than a fix to the ternary. ShapeLineProps inherits 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 and prepare
    only installed git hooks, so a git-URL install packed a tarball in which every exports
    entry named a file that had never been built — the install succeeded and the first import
    failed. prepare now also runs scripts/ensure-dist.mjs --if-missing, a new mode that
    builds an absent dist/ and leaves a stale one alone. That distinction is the whole
    design: the existing freshness check would be wrong in prepare, where it would make
    pnpm run build and pnpm run watch build twice over, and every script that needs a
    current build already front-loads its own unconditional ensure-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-dist invokes the build
    through npm_execpath — the package manager actually running it — rather than a
    hardcoded pnpm, which is declared here as packageManager but never installed as a
    dependency and so resolved to a shim a plain-npm consumer does not have; and
    install-hooks.mjs now skips when INIT_CWD places the caller outside the checkout,
    which is what a consumer's install looks like. Without that it ran lefthook install
    inside the package manager's throwaway clone and propagated lefthook's exit status into
    someone else's npm install. This is for trying an unreleased fix, not for production
    dependencies: the install builds from source, so it pulls this package's
    devDependencies and takes minutes.

Changed

  • BREAKING (write): TextPropsOptions.strike admits 'noStrike', and underline.style
    matches ST_TextUnderlineType
    (#14). strike was boolean | 'sngStrike' | 'dblStrike',
    which had no spelling for the explicit off even though the serializer passes any truthy
    string straight to the attribute — so strike: '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 is underline.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
    legal ST_TextUnderlineType value, 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. false and an omitted strike both write no attribute and therefore state
    nothing, leaving the run with whatever it inherits — the same as bold/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.slide is now Shape.host, typed ShapeHost. A shape proxy
    no longer belongs to a slide specifically — the same p:sp can sit in a slide's,
    a layout's, or a master's p:spTree — so the back-reference names what it actually
    is. ShapeHost is 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 with shape.host instanceof Slide. Slide gains an opc getter
    (=== presentation.opc) and SlideMaster/SlideLayout gain public opc and
    relationships getters, all to satisfy that contract. Nothing else about a shape
    changed, and Slide.shapes is untouched.

  • The ts-pptx-upstream skill 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 view for 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 checks npm view @shbernal/ts-pptx version before the tracker, since a report
    against a stale pin costs a maintainer a full triage and ends in a close; and step 1
    carries the FidelityNote.cause triage — unread and unwritable are gaps worth
    filing, unsupported is 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 --all is called out — it installs into every runtime the CLI knows, around
    seventy, and leaves an agent/ 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_install restores the file from skills-lock.json without
    creating any runtime link, so it is a record rather than a restore command.

  • docs/RELEASING.md closes 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.md gains 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

  • readModelToIr carries 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.caps and then mapped to undefined, so the
    resulting CallIr carried 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 / noStrike are 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 the a:lstStyle → placeholder → layout → master chain: a run that would take
    u="sng" from its list style and states u="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.fidelity named neither
    construct, and canonicalDeckIr did not carry the field, so diffDeckIr compared 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.pptx and
    table.pptx hold 132 runs stating u="none" and strike="noStrike", 100 of which also
    state cap="none". No new note fires, so script:census is unmoved.

  • readModelToIr keeps a baked normAutofit's fontScale and lnSpcReduction (#13).
    The mapper flattened every normAutofit frame to fit: 'shrink', so both numbers were
    gone by the time a consumer held the DeckIr and everything downstream re-emitted a bare
    <a:normAutofit/>. Neither end of the round trip was at fault — TextFitShrinkProps
    already carries both fields and TextFrame.autofitFontScale already reads them back — so
    this was a mapping omission with an exact fix available: a frame that bakes either
    attribute now emits the object form fit: { type: 'shrink', fontScale, lnSpcReduction },
    and one with neither keeps fit: '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. printScript named no fidelity note, so a
    consumer following the documented rule — trust the tier's own notes — was told the tier
    lost nothing; and canonicalDeckIr did not carry the fields either, so diffDeckIr
    compared two models that were both missing them and reported the deck clean. A round-trip
    oracle built on diffDeckIr, 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 a text.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 type instead of only color (#11).
    ChartPropsFillLine.fill is typed ShapeFillProps, so both areas looked like they took
    every fill kind a shape does — and c:spPr really is a:CT_ShapeProperties, the same
    optional EG_FillProperties group, so nothing in OOXML said otherwise. But both emitters
    gated on fill?.color, so every spelling that carries no colour fell to the <a:noFill/>
    arm and did nothing: type: 'gradient', type: 'pattern', and type: '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. normalizeChartOptions defaults plotArea.fill to
    {}, 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 @example on 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