Skip to content

feat(export): editable PPTX export with native shapes and text - #2722

Open
stn1slv wants to merge 36 commits into
slidevjs:mainfrom
stn1slv:feat/pptx-editable-export
Open

feat(export): editable PPTX export with native shapes and text#2722
stn1slv wants to merge 36 commits into
slidevjs:mainfrom
stn1slv:feat/pptx-editable-export

Conversation

@stn1slv

@stn1slv stn1slv commented Aug 26, 2026

Copy link
Copy Markdown

Closes the request in #2721, which has the full reasoning. This is the implementation.

Try it before reading any code

# once CI has run on this PR and pkg.pr.new has published
npm i https://pkg.pr.new/@slidev/cli@2722 playwright-chromium
slidev export --format pptx-editable

Then click on a heading in the result. It is text.

The workflows on this PR are held at action_required, so nothing has been published yet. Approving them is what produces that build.

If you would rather not approve workflows first, the same thing from a checkout, no CI involved:

gh pr checkout 2722
pnpm install && pnpm build
node packages/slidev/bin/slidev.mjs export demo/starter/slides.md --format pptx-editable

What this does not do

  • No template or .potx loading, no slide master synthesis, no font embedding.
  • No gradients: pptxgenjs has none, so they rasterize.
  • --per-slide errors for this format. It navigates one slide at a time and never renders the print page the measurement depends on.
  • Decorations a theme draws with ::before or ::after in normal flow are left out, and named at the end of the export. Code block line numbers are the case a user is most likely to meet: they come from a CSS counter, which has neither text nor a box a computed style can report.
  • PowerPoint draws a dashed underline as a solid one, so a theme that rules its links with border-bottom: 1px dashed, as Slidev's own do, gets a solid rule. The style is in the file as u="dash" and other renderers honor it.
  • A long paragraph can wrap onto a different number of lines, because PowerPoint's text metrics are not Chromium's.

What it does

--format pptx-editable walks the rendered DOM at export time and emits native shapes and text boxes rather than one picture per slide. On demo/starter, unmodified:

--format pptx --format pptx-editable
slide1.xml 770 bytes 5,134 bytes
shapes 0 640
text runs 0 1,554

--format pptx is untouched. The two ship side by side because pptx is pixel-exact on every theme and this cannot be.

Shape of the change

export.ts changes by twenty-eight lines: one entry in the format union, one dispatch branch, the format cast, withClicks ?? format === 'pptx' becoming format.startsWith('pptx'), and a generator that guards --per-slide, delegates, and hands the result to a reporter. One line in cli.ts for choices. The rest is new files under packages/slidev/node/commands/pptx/:

  • walker.ts runs in the page and extracts only facts that need live layout. It makes no decisions.
  • normalize.ts holds every judgement as pure functions over plain JSON.
  • color.ts parses computed colors in every syntax Chromium leaves in a computed style.
  • capture.ts is the only Playwright glue.
  • build.ts turns the result into a .pptx through pptxgenjs, already a catalog:prod dependency. Also pure.

115 tests, over hand-written fixtures and unzipped OOXML rather than over the calling code. plans/022 asks for the first tests the export pipeline has ever had, is P1 and still TODO, and plans/023 is blocked on it. The split here is the shape 023 wants, so the other exporters can be extracted later without touching this one.

One dev dependency, jszip, added through the catalog. It is used only to unzip and assert on the generated XML in tests.

The safety valve

A slide that throws, times out, loses all its text, or comes out more than 60% picture with nothing recovered over it falls back to exactly today's --format pptx output, for that slide alone, and says why. None of the decks I have run needs it. It is there so that a theme this heuristic cannot handle degrades to current behavior, per slide and loudly, instead of producing a broken file.

On whether it survives real themes

It did not, at first. I built it against demo/starter, then ran it over peacock0803sz/slides, lyqht/intro-to-svg-slides and two decks on custom themes of my own, written to match internal corporate templates, which I cannot share. Four distinct themes in all, none of which I wrote the exporter against. Every slide was compared to its rendered reference. Twenty-six distinct defects came out of that, each now a named test: paint order is not array order, an inline background is painted once per line fragment rather than once over their union, locator.screenshot() returns the wrong region on a page far taller than its viewport, KaTeX cannot be walked as text, a table cell is not a layout container, and so on.

So when a theme breaks it, normalize.ts is pure and the report becomes a fixture plus a test. Nobody has to reproduce it inside a browser first.

Questions I would rather settle in review

  1. Is --format pptx-editable the surface you want, or would you rather this arrived as a pluggable export-format API with this as its first consumer? Everything already sits behind a PptxExportContext and is a plugin in all but name.
  2. Is the per-slide image fallback the right degradation policy?

I am happy either way, and would rather rework the surface now than after review.

stn1slv added 30 commits August 25, 2026 15:07
`--format pptx` screenshots every slide and assigns the PNG as
`slide.background`, so each `slideN.xml` is 770 bytes with an empty
`<p:spTree>`. Nothing in the file can be selected, let alone edited.

This adds `--format pptx-editable`, which measures the rendered print
page and rebuilds each slide as native PowerPoint shapes. On the starter
deck it produces 940 shapes and 1605 text runs across 40 slides where
the image export produces none. `--format pptx` is untouched and stays
the most visually faithful option.

Four modules, split so the parts worth testing need no browser:

  walker.ts     runs in the page, extracts geometry and interned
                computed styles, makes no decisions
  normalize.ts  pure, holds every judgement: inline grouping, colour
                parsing, what must be rasterized, the fallback valve
  capture.ts    the only Playwright glue: element screenshots and
                image fetching
  build.ts      pure, IR to OOXML through pptxgenjs

`export.ts` gains one dispatch branch and a delegating closure;
`cli.ts` gains one entry in the `--format` choices.

Anything DrawingML cannot express becomes a picture of that element
alone: SVG (so Mermaid and icons), canvas, iframes, gradients, filters,
blend modes and clip paths. A slide that cannot be rebuilt safely, or
that ends up mostly pictures anyway, falls back to the image export for
that slide alone and says why.

Three things worth knowing, each found the hard way:

  - pptxgenjs writes IMG_BROKEN as the raster fallback for SVG in Node,
    so all SVG is rasterized rather than embedded.
  - `rectRadius` is documented as a 0.0-1.0 fraction but is really
    inches; the documented reading gives square corners.
  - A .pptx names fonts, it does not embed them, so the export reports
    which families it referenced.

56 unit tests, over hand-written IR fixtures and unzipped OOXML rather
than over the calling code, since every conversion bug here looks
correct in the source until you read what it wrote.

`jszip` is added as a dev dependency to read the generated package in
tests; it was already in the tree via pptxgenjs.
The whole-slide fallback counted every leaf raster against its area
budget, so a cover slide built on a full-bleed photo or a decorative
`<svg>` tripped the 60% limit and was replaced by a picture, throwing
away a title that vectorized perfectly.

An isolated backdrop was already exempt, but that only covered
`background-image` and the filter family. A leaf picture is just as
capable of having content on top of it.

Now a raster counts toward the budget only when no recovered text
overlaps it, which states the underlying rule directly: a picture with
editable text over it is not wasted vectorization.

Found by running the exporter against two decks on custom corporate
themes rather than on the bundled starter. Both had lost their cover
and closing slides to this. After the fix, both export with no
fallbacks at all and recover every word the reference implementation
does: 801/801 on one, 755 against its 752 on the other.
Four defects found by exporting two decks on custom corporate themes
and comparing the render against the design reference, rather than by
reading the output of the bundled starter.

Doubling. Every line of the cover and closing slides printed twice, at
slightly different positions. `locator.screenshot()` clips the page to
the element's box rather than isolating the element, so anything
overlapping that box lands in the picture and is then drawn again as a
shape. Isolation was decided from the CSS reason, backdrops and the
filter family, which was only ever a proxy for "something is painted on
top of this". It now tests the real thing, so a leaf picture such as a
full-bleed `<svg>` is covered too.

Missing chrome. Isolating those leaf pictures then emptied the theme's
decorative frame out of the deck, because hiding an element's children
is only safe when they are walked and redrawn as shapes. That is true
for a backdrop and false for an `<svg>`, whose children ARE its artwork.
The two meanings are now separate flags.

Re-wrapping. Wrapped text was handed the width of its longest line,
which guarantees a second, tighter wrap: a caption reading "one bad
pattern" over two lines came back over three and overflowed its box.
Multi-line text now wraps against the container's content box, which is
what the browser wrapped against. Single-line text keeps its exact
glyph bounds.

Chip drift. An inline element with its own background is a chip: its
decoration is an absolutely positioned shape while the text around it
flows, so any difference between PowerPoint's metrics and the browser's
accumulates across the earlier runs and slides the label off its own
chip. A decorated run now gets its own box at its own glyph bounds, so
the two cannot drift apart.

Both decks export with no fallbacks and recover every word the
reference implementation does: 801 on one, 755 on the other.
An inline element with its own background is a chip, and its label was
positioned from its own glyph bounds. That anchors the text to the ink
rather than to the decoration, so as soon as PowerPoint sets the string
even slightly wider than the browser did, the label ends up hard
against one edge instead of evenly inset.

The label is now pinned to the decoration's own box and centred in it
both ways, which holds however the font measures.

Measured against the design reference at 140 DPI, chip and label:

  before  padding L=6  R=18  T=14 B=17
  after   padding L=14 R=14  T=14 B=17
  ref     padding L=13 R=14  T=15 B=16

Horizontally exact, vertically within about a point.

This needed a `valign` on the text IR. It defaults to the top, because
an ordinary box is measured from its glyphs and centring it inside a
height taken from those same glyphs would move it. A chip is the one
case where the box is the decoration rather than the ink.
A `::before` or `::after` has no DOM node, so a walk over the tree
cannot see it and every decoration drawn that way was silently absent
from the export. The case that surfaced it was a corporate wordmark
placed as a background image on an `::after`, missing from the footer
of every content slide.

Only absolutely positioned pseudos are placeable: their box resolves
against the originating element when that element is itself positioned.
Anything else takes part in inline or block flow, where the geometry
cannot be recovered from computed style alone. Those are counted and
reported, so a missing decoration is a line in the log rather than a
mystery.

A pseudo also has no element for a locator to point at, so its capture
clips the page to the box the walker computed instead of targeting a
selector.

Both kinds are handled: a background image becomes a picture, a
`content` string becomes a text run.
Code blocks lost their line structure. Shiki renders each line as an
inline span separated by a bare newline text node, and `white-space:
pre` text was folded into a space, so every multi-line block exported as
one re-wrapped paragraph. Those newlines are now real line breaks, and
the walker keeps a text node that has no boxes of its own, which is
exactly what such a newline is.

A rasterized backdrop then had its own box painted over its picture, so
an element with both a background colour and a background image lost
the image entirely, and a translucent one composited twice.

Isolation was a silent no-op where it mattered most. `querySelector`
does not pierce shadow DOM, which is where Mermaid renders, and a
pseudo-element has no id attribute to find at all. `page.locator` in
the screenshot path DOES pierce, so the capture succeeded while the
isolation quietly did nothing and the doubling came back. It now
searches through shadow roots, isolates a pseudo against its
originating element, and counts the misses.

Pseudo-element clips used viewport coordinates read at measurement
time, but the print route is far taller than the viewport once every
click step has a container, and earlier captures scroll the page.
Playwright answered "clipped area is empty", the catch swallowed it and
the decoration vanished. They are document coordinates now.

`shoot()` could throw, and two of its three call sites are the fallback
paths, so a failure crashed the export at the moment it was trying to
degrade gracefully.

Also: boxes emitted twice for a layout container nested in an inline
run; the chip rule firing on inline `<code>` and cutting sentences into
three overlapping boxes; ancestor opacity not compounded, so a child of
a half-transparent wrapper exported solid; the font fallback putting
`system-ui` back into the file after the walker went out of its way to
exclude it; text the only IR kind not clipped to the slide; leading and
consecutive `<br>` losing their blank lines; a backdrop's direct text
child baking into its picture; warnings fighting the progress bar for
the terminal; `lineSpacing` applied to boxes measured from glyph ink;
and `unparsedColors` leaking across runs as module state.

One more found while verifying: an `<img>` whose source is a
URL-encoded SVG data URI made pptxgenjs print "Image `data` value lacks
a base64 header!" and emit nothing. Those now fall through to a
screenshot like every other SVG.

70 unit tests. Both reference decks still export with no fallbacks and
no word loss, 801 and 755.
A centred statement block sizes its container to its own text, so the
box carried no room for PowerPoint setting the same string slightly
wider than Chromium does. The longest line wrapped and the block gained
a line that is not in the design.

Multi-line text now keeps 2% of its width free, or four pixels,
whichever is larger, and only when the container does not already
provide that much. A paragraph constrained by a column is left alone,
because widening it pushes the text into whatever sits beside it. The
box grows about its own anchor so the text does not slide sideways.

Also trims the whitespace that opens and closes each LINE, not just
each box. Markup puts a sentence on its own source line, so the text
node begins with a newline that collapses to a space, and a centred
line then sits half a space off centre. Every line boundary counts,
since a run after a break opens a new line.

On the reference deck the statement box goes from 487px, which is
exactly its own ink, to 496px, and its three lines set as one line each
as the design has them. Both decks still export with no fallbacks and
no word loss.
Screenshotting an element that runs far past the slide asks Chromium
for a bitmap it cannot allocate, and the renderer dies mid-export.
Slidev's own starter deck carries one measuring tens of millions of
pixels, so `--format pptx-editable` failed outright on it.

The failure was miserable to read: the screenshot was already wrapped
in a catch, which swallowed the crash and returned no data, and the
error surfaced later from `restore()` as "Target page, context or
browser has been closed", pointing at neither the slide nor the
element responsible.

An overflowing element is now placed at its clipped rectangle and
captured at that same rectangle, so the picture is neither squashed nor
enormous. Its geometry was already clamped for the shape tree; only the
capture still used the full element.

Two supporting changes. Every element now carries its document-space
rect, not just pseudo-elements, since a clip needs page coordinates.
And a clip screenshot returns the page to the scroll origin first
rather than asking for `fullPage`, which on a forty slide deck would
request a bitmap of some twenty-two thousand pixels squared at
`deviceScaleFactor: 2` and fail the same way.

The starter deck now exports with no whole-slide fallbacks at all,
where it previously lost that slide to one. 40 slides, 879 shapes,
1749 text runs. Both reference decks are unchanged at 801 and 755
words with nothing missing.
…line

A paragraph greyed by `opacity` exported solid black. Opacity is
compounded down the tree, but only onto element records, and a text
node carries no style of its own, so every run lost it. Slidev's own
stylesheet greys this kind of paragraph with `opacity: 0.5` rather than
a colour, so it shows on the bundled starter deck: the standfirst under
each heading came out at full strength.

Text nodes now carry the compounded value, and the export writes
`alpha: 50000` for that paragraph, which is exactly the 0.5 the
stylesheet asks for.

List markers were anchored to the top of their box and rode above their
item's first line. They are centred in the line box now, which is where
a browser puts them.

Found by rendering slide 2 of `demo/starter` against the image export
and comparing pixels: the standfirst measured #000000 where the
reference measured #717171.
Everything downstream treated array order as paint order, and the array
was built in document order. CSS does not work that way: a positioned
element paints above in-flow content whatever the tree says.

A slide's page counter is the first child of its container, so the
full-bleed background further down the tree was drawn straight over it
and the counter disappeared from every slide that had one.

Nodes are now sorted into two tiers, in-flow then positioned, with
z-index inside each, and a stable sort keeps document order within a
tier as CSS does. Not the full stacking algorithm, which also has
negative layers and stacking contexts, but it covers what a slide deck
does.

The layer comes from the nearest POSITIONED ancestor rather than the
nearest styled one. The counter is a positioned `<footer>` wrapping a
plain `<div>`, and reading the div alone still called the whole thing
in-flow.

Found on a third-party deck, which is the first one this exporter has
seen that neither I nor the Slidev repository wrote.

Four decks now export with no whole-slide fallbacks: 35, 18, 16 and 16
slides.
The print route sizes its viewport to the whole deck, and Chromium can
only rasterize a surface so large. Past roughly twenty thousand CSS
pixels the capture is silently truncated, so every `page.screenshot`
clip below that point answered "Clipped area is either empty or outside
the resulting image".

On a fifty slide deck that is the last third of the presentation. A
third-party deck lost its corner decorations, an inline image and a
background from five separate slides, and nothing in the output said so:
`rastersFailed` was counted and then never reported.

Clips now go through a two thousand pixel viewport that is scrolled to
the region, rebasing on the scroll position the browser actually reached
rather than the one it was asked for, which differ at the very bottom of
the page. The resize happens once around all of them, because it reflows
the page. Element captures keep the full-height viewport they were
measured through.

Failed captures are now reported, so a picture that goes missing says so
instead of leaving a hole.
Runs of different sizes on the same line have different rect tops,
because a browser aligns them on the shared baseline. Grouping by top
counted a heading reading `Needed a **Pros-Cons comparison** in now?` as
four lines where it has two.

Two rects share a line when they overlap by more than half the shorter
one, which separates a small run sitting inside a large one from the
couple of pixels adjacent lines can share when leading is tight.

The line spacing now comes from the measured distance between line
boxes. The computed `line-height` belongs to the element while a line
box is as tall as its largest run, so a heading that mixes sizes
reported far too little leading and PowerPoint set its two lines on top
of each other.
CSS paints an inline element's background and borders once per line
FRAGMENT, while `getBoundingClientRect` reports the union of them.

A theme styling `<code>` as an inline element with a dark background
therefore came out as one solid rectangle covering the ragged space at
the end of every line, instead of the per-line strips a browser draws.
On a code-heavy deck this was the single largest source of visual error,
worth several times any other defect on the same slide.

The walker now records `getClientRects()` for an inline element whose
box spans more than one line, and each fragment becomes its own shape.
Only `display: inline` proper: an inline-block is a single box that
merely sits in a line, and always has one rect.
An element chosen for rasterization was treated as rasterized whether or
not a picture came out of it. When its rect had no area on the slide,
nothing was emitted for the element AND its whole subtree was skipped.

A theme rotating a corner decoration does exactly this: the transform
sits on a wrapper with no size of its own, while the artwork inside it
is absolutely positioned and does have one. Every slide lost its corner
decoration, silently, because the raster that replaced them was never
placed either.

`emitRaster` now reports whether it emitted, and the three call sites
fall through to ordinary handling when it did not.
`emitImage` clipped the box to the slide but still drew the whole image
into it. The slide container has `overflow: hidden`, so a browser shows
the top of an oversized image and cuts the rest off; scaling it to fit
instead came out vertically compressed and showed content the audience
never saw.

A pair of photos in a grid, one 772px tall on a 552px slide, was the
case that surfaced it.

The picture now keeps its full display size and takes a window out of it
through `sizing: { type: 'crop' }`, which reads `w`/`h` as the full size
and the visible region from `sizing`, the opposite way round from every
other option.
Measurement and capture are two separate passes over the page, so
anything still moving between them is measured at one frame and
photographed at another. A theme spinning a logo forever came out
cropped against its own box, because the box was read at one angle and
the picture taken at a later one.

Pausing rather than cancelling: `animation: none` would rewind a fade-in
to opacity zero and lose the element, while pausing keeps a reveal at
the end state the deck had already settled on, which is what the
audience saw.
The export lists flow-positioned `::before` and `::after` decorations it
could not place, but the guide did not say what that means. Code block
line numbers are the case an author is most likely to meet: they come
from a CSS counter, which has neither text nor a box that a computed
style can report.
`locator.screenshot()` is the obvious call for an element picture and is
wrong on a print page far taller than its viewport, which every deck
with click steps is. It returns a region from somewhere else on the page
entirely: Slidev's own starter deck came back with a picture of slide
one pasted into the bottom of slide four, and again into slide ten.

Reading the element's live box and clipping the page at it returns the
right pixels, and is the same primitive a pseudo-element already used.
Both capture paths are now one, so the short scrolled viewport that
clips already needed covers every picture in the export rather than only
those the walker had measured itself.

A clip is also clamped to the page. An absolutely positioned decoration
can hang off the left edge, and Chromium rejects a negative origin
outright, so the picture went missing instead of being trimmed.

Also rasterizes a rendered formula. KaTeX sets one as dozens of
separately positioned spans in its own metric fonts, with radicals,
braces and fraction rules drawn as bare boxes; walked as text it came
apart, glyphs landing off their baselines and every rule disappearing.
Its root is a `<span>`, so the walker marks it by class, which is what
KaTeX emits and what Slidev's own MathML output is hidden behind.
`omitBackground` only drops the browser's DEFAULT backdrop. A slide
container paints its own white, and an ancestor's background is not a
sibling, so hiding siblings alone still captured it.

A mostly transparent element then came out as an opaque rectangle. On
Slidev's starter deck a `v-drag-arrow` spanning half the slide became a
white block that covered the title and two thirds of the body text, and
because it is positioned it painted in front of them.

The target keeps its own background: for a backdrop that is the thing
being captured.
An `<img>` whose source cannot be embedded falls back to a picture of
the element, and that path alone skipped isolation. An `<img>` is mostly
transparent whenever it points at an SVG, so the picture carried
whatever the slide painted behind the icon, and every word of it was
then drawn a second time as a shape on top.

On Slidev's starter deck the arrow pointing at the navigation bar
brought a keyboard shortcut and a caption along with it.
`addEdge` passes `transparency` for a per-side border, and the uniform
path set only `color`, `width` and `dashType`. `ShapeLineProps extends
ShapeFillProps`, so a line takes the same transparency a fill does.

A card ruled `border: 2px solid rgba(0, 0, 0, 0.1)` therefore exported
as a solid black hairline, while the SAME border with one side a
different width went through `addEdge` and came out correct, which made
it look like anything but a missing property.

Also guards the `pptxgenjs` import against a CJS interop layer handing
back the constructor rather than a namespace, and corrects a comment:
`go` puts the range in the query and `PrintContainer` renders only
`printRange`, so the `--range` filter here is defensive, not the
load-bearing step it claimed to be.
`backgroundOf` matched only the literal `rgba(0, 0, 0, 0)` to decide a
background paints nothing. `rgba(255, 255, 255, 0)`, and the
`oklch(... / 0)` a theme authored in modern syntax computes to, both
ended the walk and were returned as the slide background, so the real
painted colour further up was never found. It now asks the browser what
the colour composites to, which is robust across every syntax the
browser accepts because the browser produced the string.

`Number.parseFloat(pseudo.right || '0')` guarded the empty string but
not `auto`, which parses to NaN and spreads through the whole
`pageRect`, losing the decoration to a failed clip with nothing in the
log naming it.

The self-containedness sandbox ran the walker over zero containers, so
`walk` and everything under it never executed and a free variable there
went unnoticed. `window` was one, referenced for `scrollX`, absent from
the allowlist, and the suite passed anyway. It now walks a real
container, with the leak test pinned on a path only that reaches.
`emitRaster` set a page rectangle for every element, so every raster
request carried a `clip` and the capture path that reads an element's
box live was dead code. That quietly contradicted the contract
`RasterRequest.clip` states, and meant every crop used coordinates
measured before the capture phase resizes the viewport, when the whole
point of reading the box live is that it cannot be stale.

Only a pseudo-element genuinely has no node to point a screenshot at.
An element that runs past the slide still needs its clipped region, so
that case keeps its rectangle.

`unparsedColors` was module-level mutable state in a module whose header
claims purity. Resetting it per run is correct for the export, but
`parseColor` is exported, and a call made outside a run accumulated into
whatever export came next in the same process. It is now a parameter.
`isolate()` climbed with `while (node.parentElement)`, which is null at
the top of a shadow tree. For a target inside one it hid nothing at all
and still returned true, so `isolationMissed` stayed at zero: a Mermaid
diagram with a title over it kept the title in its picture, and the
title was then drawn again as a shape. That is precisely the doubling
this mechanism exists to prevent, reported as success.

`restore()` was the mirror image. It used `document.querySelectorAll`,
which does not cross a shadow boundary, while `isolate()`'s finder does,
so anything hidden inside a shadow tree stayed hidden for the rest of
the export, including the whole-slide fallback screenshots taken at the
end.

Both now work from one list of roots: the document plus every open
shadow root, found by a single tree walk and cached for the export. That
also removes a full `querySelectorAll('*')` scan per captured element,
which was a whole-document walk per picture on the great majority of
decks, which have no shadow roots at all.
The short viewport that clip screenshots go through was a fixed two
thousand pixels, which is shorter than one slide as soon as a deck sets
`canvasWidth: 3840`: 16:9 makes the slide 2160 tall. Every full-bleed
picture and every whole-slide fallback would then ask for a clip taller
than the viewport and fail, silently, which is the exact failure the
short viewport was introduced to fix. It is now derived from the tallest
slide and the tallest clip actually requested.

`shootClip` clamped a negative x and not a negative y, though Chromium
rejects either one and the caller swallows the rejection, so a
decoration positioned above the page vanished with no picture and no
message.

An unfetchable image was isolated and screenshotted once per IR node
rather than once per element, so the same pixels were captured again for
every click step the image appeared on.

The module header claimed no geometry is ever read from a page this
module has touched, which stopped being true when the viewport resize
arrived. It now says what the module actually guarantees: only
visibility, background colour and viewport HEIGHT change, none of which
move a print page whose slide containers are fixed-size and whose scale
follows the width.
The whole argument for a `pptx/` directory is that `export.ts` stays
thin, and the generator had grown to fifty lines of which most were the
warning block. It is now about fifteen: guard `--per-slide`, delegate,
stop the progress bar, hand the result to `reportEditableExport`.

The change to `export.ts` is twenty-eight lines against main, not the
sixty-one it had reached. A comment claiming five is gone; so is the
argument it made for the design, which belongs in a pull request rather
than in the source.
`SlideWalker` was the only class in `packages/slidev/node/` and
`packages/parser/`, and its name collided with `walker.ts`, which is a
different phase, so "the walker" in a comment was ambiguous. It is now
`buildSlideIr`, a function whose helpers close over the per-slide index
instead of reaching through `this`.

Two names had to change with it. The emitter's `push` collided with a
local of the same name inside `emitTextGroup`, and the node index
`children` with a local array; both are renamed rather than left to
shadowing. Verified by diffing generated slide XML across five decks
before and after: 162 of 163 slides byte-identical, the one difference
being a deck whose logo spins forever and was paused on a different
frame.

Color parsing moves to `color.ts` with the two helpers that only it
uses, taking `normalize.ts` from 1336 lines to 1164.
`build.ts` took `slide`, `shapeType` and the constructor as `any`, which
is where a silently renamed option would hurt most, in the one module
whose whole job is writing a library's option objects. A type-only
import costs nothing at runtime and keeps the dynamic
`import('pptxgenjs')` that holds the library off the CLI's startup path,
mirroring `importPlaywright`. The file now has no `any` at all.

The walker's four became the IR types they were always producing.

Its two style interners were the same twelve lines twice, and the
element path called `getComputedStyle` a second time for an element it
already had one for.
The pptx modules ran at 23 to 52 percent comment lines against 2 to 4
percent in Slidev's own `node/` code. The facts were right; the volume
and the voice were not, and a reviewer reads that as noise.

Deleted: development history, arguments addressed at a reviewer, banner
dividers, and claims about how many lines another file changes by.
Rewritten: capitalized emphasis and multi-paragraph explanations, into
short impersonal statements. Kept, compressed: every unit and
coordinate-space statement, and the browser and OOXML facts a reader
cannot derive, such as `rectRadius` being in inches, `sizing.crop`
reading its dimensions the opposite way round from every other option,
and Chromium's capture ceiling.

Also switches to American spelling, including the user-facing warning
about color values, and drops the "trap" and "finding" numbers from test
names, which indexed a document that is not in this repository.

Verified comment-only where it claims to be: with comments stripped, all
seven modules are byte-identical before and after, and the generated
slide XML matches across five decks.
pptxgenjs writes `u="sng"` on any run carrying a hyperlink unless
`underline` is set explicitly. Slidev's own themes rule links with a
dashed `border-bottom` and `text-decoration: none`, and that border is
already emitted as its own shape, so every link in `demo/starter` came
out with two lines under it: the theme's dashed rule and PowerPoint's
solid one.

Setting `{ style: 'none' }` when the run has a link and the computed
style asked for no underline leaves the theme's own rule alone.
Gradient text is two things at once: a `linear-gradient` background
clipped to the glyphs by `background-clip: text`, with `color` left as a
flat fallback for anything that cannot do the clip. Testing
`background-image` first reported it as a backdrop, so the heading was
rasterized correctly and then drawn again as text on top of its own
picture, in the fallback color, hiding the gradient it stood in for.

`demo/starter` has one on every slide title.
A per-side border is drawn as a filled rectangle, because a line's
stroke centers on the geometry and half of it would fall outside the
box. A filled rectangle cannot carry a dash pattern, so `dashed` and
`dotted` came out solid.

Slidev rules its links with `border-bottom: 1px dashed`, so every link
in every deck had a solid bar under it where the theme drew a dashed
rule. Dashed and dotted edges are now lines with the matching
`dashType`; for a hairline the half stroke that escapes the box is well
under a pixel. Solid edges keep the rectangle.
`fill: { type: 'none' }` looks explicit and is the opposite.
`genXmlColorSelection` returns an empty string for it, so the shape went
out with no fill element at all and PowerPoint resolved one from its
default shape style. An ABSENT fill is what makes pptxgenjs write
`<a:noFill/>`.

A box with no fill and no uniform border is also no longer emitted. Its
only border is drawn as its own edge, so the box carried nothing: it was
a rectangle with neither fill nor outline stated, left for PowerPoint to
fill in. Slidev rules its links with a single `border-bottom`, so there
was one behind every link in every deck.

Sixty-six of them on `demo/starter`, twenty-four on the corporate deck.
A `border-bottom` on inline text is an underline, and DrawingML can dash
one: `dash`, `dotted` and a dozen more are valid `u` values. Drawing it
as a separate line meant positioning a shape against text PowerPoint may
re-lay, it could not follow an edit, and it was a second thing capable
of drawing a line under a link that PowerPoint may underline itself.

Slidev rules every link this way, so the deck carried one such shape per
link. It is now the run's own underline, with the border's dash style,
and there is only one mechanism that can put a line under a link.

Only when the bottom is the only border and there is no fill: a chip is
more than a rule and stays a shape, and a bottom border on a block is a
section rule rather than an underline.
Three things went wrong in one table cell.

`display: table-cell` matched the layout-container test, which is a
prefix match on `table`. A table lays out its rows and a row its cells,
but the contents of a cell flow like any block, so
`<kbd>right</kbd> / <kbd>space</kbd>` became three boxes.

The separator was then positioned from glyph bounds measured across the
spaces either side of it while its own text had them trimmed, so it drew
where the leading space had been: on the key beside it. Trimming is
right at a line boundary, where the browser collapses the space, and
wrong mid-line, where it rendered it.

Flowing the cell as one box instead runs the labels together, because a
chip is padded and spaced by its own box and text cannot reproduce that:
`<kbd>shift</kbd><kbd>space</kbd>` has no text between the keys at all.
So a single-line row of chips places every part on its own glyph bounds.
Only single-line: fragments can be positioned exactly while they do not
wrap, and that is also what keeps a sentence carrying one inline `<code>`
in one editable box.
The self-containedness guard ran against Vitest's transform of the
source, which is not what reaches a browser: `exportPptxEditable` hands
Playwright the function from `dist`, and a bundler is free to rewrite a
body with helpers of its own. A leak introduced by the build would have
passed every test and failed inside someone else's browser, which is the
one place this cannot be debugged.

The same sandbox now also runs the function extracted from the built
bundle, and the same two static checks read it. Verified by injecting
`__toESM` into the real artifact: three tests fail.

`/* @__PURE__ */` annotations are stripped before the identifier check.
They are comments, so they never execute, and the bundle carries them.
@netlify

netlify Bot commented Aug 26, 2026

Copy link
Copy Markdown

Deploy Preview for slidev ready!

Name Link
🔨 Latest commit 29b316b
🔍 Latest deploy log https://app.netlify.com/projects/slidev/deploys/6a8e7cde6edfcd0008c43cb2
😎 Deploy Preview https://deploy-preview-2722--slidev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant