Skip to content

Commit 94e348b

Browse files
fix(pptx/render): emit valid SVG <image> for image-fill backgrounds (#100)
* fix(pptx/render): emit valid SVG <image> for image-fill backgrounds renderDeckToSvg rendered a slide's image-fill background as a CSS background shorthand inside fill="…" (center / cover no-repeat url("data:image…")) — invalid SVG that strict rasterisers (resvg, librsvg) reject, blocking a Chromium-free render path. The renderer now recognises a url(...) anywhere in the value and emits a real <image> element (slice for cover, meet for contain). Adds a strict-XML-parser lock-in test over an image-background fixture. * test(render): rasterise image-background slide through real @resvg/resvg-js XMLValidator proves well-formedness only; resvg is the actual consumer. Add @resvg/resvg-js as a devDependency and drive an image-background slide through the package's default rasteriser (no injected hook), asserting a valid PNG of the expected dimensions (320x180). resvg threw on the old fill="...url(data:...)..." output, so this is a true guard. * Revert "test(render): rasterise image-background slide through real @resvg/resvg-js" This reverts commit 3f9d393. Keep the resvg renderability check on the consumer side (the host serializer already ships @resvg/resvg-js) rather than dragging a platform-native binary into the package's CI. The bug was an SVG-validity (well-formedness) regression, which the pure-JS XMLValidator lock-in already catches — it was confirmed to reject the old malformed output. The package CI stays native-dep-free.
1 parent 6c38b79 commit 94e348b

3 files changed

Lines changed: 89 additions & 4 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@textcortex/slidewise": patch
3+
---
4+
5+
fix(render): emit a valid SVG `<image>` for image-fill backgrounds
6+
7+
`renderDeckToSvg` rendered a slide's image-fill background as a CSS `background`
8+
shorthand inside an SVG `fill="…"` attribute — `fill="center / cover no-repeat
9+
url("data:image…")"`. That is not valid SVG (a non-paint value plus nested
10+
unescaped quotes); browsers tolerate it, but strict rasterisers
11+
(`@resvg/resvg-js`, librsvg, batik) reject it, blocking a Chromium-free
12+
`parsePptx → renderDeckToSvg → resvg → PNG` path.
13+
14+
The pptx importer stores image backgrounds as a CSS shorthand, but the
15+
renderer's image-ref detection only inspected the *start* of the value, so the
16+
shorthand fell through to the `fill` path. The renderer now recognises a
17+
`url(...)` anywhere in the value and emits a real `<image>` element
18+
(`preserveAspectRatio` = `slice` for `cover`, `meet` for `contain`). A lock-in
19+
test asserts every rendered slide is valid SVG a strict XML parser accepts,
20+
with an image-background slide as the regression case.

packages/slidewise/src/lib/render/__tests__/render-deck.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
22
import { readFileSync } from "node:fs";
33
import { fileURLToPath } from "node:url";
44
import path from "node:path";
5+
import { XMLValidator } from "fast-xml-parser";
56
import {
67
renderDeckToSvg,
78
renderDeckToImages,
@@ -146,6 +147,57 @@ describe("renderDeckToSvg / renderDeckToImages", () => {
146147
expect(w).toBe(480);
147148
});
148149

150+
it("emits a real <image> for an image-fill background, not a CSS-shorthand fill", async () => {
151+
// The pptx importer stores image backgrounds as a CSS `background`
152+
// shorthand: `center / cover no-repeat url("data:image…")`. The renderer
153+
// must turn that into a valid SVG <image>, never `fill="…url(data:…)…"`
154+
// (nested quotes + a non-paint value that strict rasterisers reject).
155+
const bg = `center / cover no-repeat url("${IMG_SRC}")`;
156+
const deck = {
157+
version: 1,
158+
title: "ImageBg",
159+
slides: [{ id: "s1", background: bg, elements: [] }],
160+
} as Deck;
161+
162+
const [svg] = await renderDeckToSvg(deck);
163+
expect(svg).toContain(`<image`);
164+
expect(svg).toContain(`xlink:href="${IMG_SRC}"`);
165+
expect(svg).toContain(`preserveAspectRatio="xMidYMid slice"`); // cover
166+
// The malformed shorthand-as-fill must NOT appear.
167+
expect(svg).not.toContain("no-repeat");
168+
expect(svg).not.toMatch(/fill="[^"]*url\(/);
169+
});
170+
171+
it("renders `contain` image backgrounds with preserveAspectRatio=meet", async () => {
172+
const bg = `center / contain no-repeat url("${IMG_SRC}")`;
173+
const deck = {
174+
version: 1,
175+
title: "ContainBg",
176+
slides: [{ id: "s1", background: bg, elements: [] }],
177+
} as Deck;
178+
const [svg] = await renderDeckToSvg(deck);
179+
expect(svg).toContain(`preserveAspectRatio="xMidYMid meet"`);
180+
});
181+
182+
it("every rendered slide is valid SVG a strict XML parser accepts", async () => {
183+
// Lock-in for the resvg/librsvg path: a strict (non-browser) parser must
184+
// accept every slide. An image-background slide is the regression case.
185+
const deck = {
186+
version: 1,
187+
title: "Strict",
188+
slides: [
189+
{ id: "s1", background: `center / cover no-repeat url("${IMG_SRC}")`, elements: [] },
190+
...buildDeck().slides,
191+
],
192+
} as Deck;
193+
const svgs = await renderDeckToSvg(deck);
194+
for (const svg of svgs) {
195+
const result = XMLValidator.validate(svg);
196+
expect(result, typeof result === "object" ? JSON.stringify(result.err) : "")
197+
.toBe(true);
198+
}
199+
});
200+
149201
it("stays browser-free (no Playwright/Puppeteer/jsdom in the source)", () => {
150202
const __dirname = path.dirname(fileURLToPath(import.meta.url));
151203
const src = readFileSync(path.resolve(__dirname, "../renderDeck.ts"), "utf8");

packages/slidewise/src/lib/render/renderDeck.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,10 @@ function renderBackground(background: string | undefined): string {
163163
const fill = background ? solidFrom(background) : "#FFFFFF";
164164
if (isImageRef(background)) {
165165
const href = imageHref(background!);
166+
const preserve = imageFitPreserve(background);
166167
return (
167168
`<rect x="0" y="0" width="${SLIDE_W}" height="${SLIDE_H}" fill="#FFFFFF"/>` +
168-
`<image x="0" y="0" width="${SLIDE_W}" height="${SLIDE_H}" preserveAspectRatio="xMidYMid slice" xlink:href="${escAttr(href)}"/>`
169+
`<image x="0" y="0" width="${SLIDE_W}" height="${SLIDE_H}" preserveAspectRatio="${preserve}" xlink:href="${escAttr(href)}"/>`
169170
);
170171
}
171172
return `<rect x="0" y="0" width="${SLIDE_W}" height="${SLIDE_H}" fill="${fill ?? "#FFFFFF"}"/>`;
@@ -499,12 +500,24 @@ async function defaultRasterize(
499500
// -- colour / string helpers -------------------------------------------------
500501

501502
function isImageRef(s: string | undefined): boolean {
502-
return !!s && (s.startsWith("data:image") || /^url\(/i.test(s) || /^https?:\/\//i.test(s));
503+
if (!s) return false;
504+
const v = s.trim();
505+
// A bare data/http URL, OR a CSS `background` shorthand that embeds a
506+
// `url(...)` anywhere (e.g. `center / cover no-repeat url("data:image…")`,
507+
// as produced by the pptx importer for image-fill backgrounds).
508+
return v.startsWith("data:image") || /url\(/i.test(v) || /^https?:\/\//i.test(v);
503509
}
504510

505511
function imageHref(s: string): string {
506-
const m = /^url\(["']?(.*?)["']?\)$/i.exec(s);
507-
return m ? m[1] : s;
512+
// Pull the URL out of a `url(...)` wherever it appears in the value; data
513+
// URLs use a `)`-free base64 alphabet, so non-greedy-to-first-`)` is safe.
514+
const m = /url\(\s*["']?(.*?)["']?\s*\)/i.exec(s);
515+
return m ? m[1] : s.trim();
516+
}
517+
518+
/** `cover` → `slice`, `contain` → `meet`, for an image-fill shorthand. */
519+
function imageFitPreserve(s: string | undefined): string {
520+
return s && /\bcontain\b/i.test(s) ? "xMidYMid meet" : "xMidYMid slice";
508521
}
509522

510523
/** Best-effort single colour for SVG: pass hex through, pull the first hex out

0 commit comments

Comments
 (0)