feat(export): editable PPTX export with native shapes and text - #2722
Open
stn1slv wants to merge 36 commits into
Open
feat(export): editable PPTX export with native shapes and text#2722stn1slv wants to merge 36 commits into
stn1slv wants to merge 36 commits into
Conversation
`--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.
✅ Deploy Preview for slidev ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the request in #2721, which has the full reasoning. This is the implementation.
Try it before reading any code
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:
What this does not do
.potxloading, no slide master synthesis, no font embedding.pptxgenjshas none, so they rasterize.--per-slideerrors for this format. It navigates one slide at a time and never renders the print page the measurement depends on.::beforeor::afterin 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.border-bottom: 1px dashed, as Slidev's own do, gets a solid rule. The style is in the file asu="dash"and other renderers honor it.What it does
--format pptx-editablewalks the rendered DOM at export time and emits native shapes and text boxes rather than one picture per slide. Ondemo/starter, unmodified:--format pptx--format pptx-editableslide1.xml--format pptxis untouched. The two ship side by side becausepptxis pixel-exact on every theme and this cannot be.Shape of the change
export.tschanges by twenty-eight lines: one entry in the format union, one dispatch branch, the format cast,withClicks ?? format === 'pptx'becomingformat.startsWith('pptx'), and a generator that guards--per-slide, delegates, and hands the result to a reporter. One line incli.tsforchoices. The rest is new files underpackages/slidev/node/commands/pptx/:walker.tsruns in the page and extracts only facts that need live layout. It makes no decisions.normalize.tsholds every judgement as pure functions over plain JSON.color.tsparses computed colors in every syntax Chromium leaves in a computed style.capture.tsis the only Playwright glue.build.tsturns the result into a.pptxthroughpptxgenjs, already acatalog:proddependency. Also pure.115 tests, over hand-written fixtures and unzipped OOXML rather than over the calling code.
plans/022asks for the first tests the export pipeline has ever had, is P1 and still TODO, andplans/023is blocked on it. The split here is the shape023wants, 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 pptxoutput, 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 overpeacock0803sz/slides,lyqht/intro-to-svg-slidesand 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.tsis 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
--format pptx-editablethe 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 aPptxExportContextand is a plugin in all but name.I am happy either way, and would rather rework the surface now than after review.