diff --git a/.changeset/render-image-bg-valid-svg.md b/.changeset/render-image-bg-valid-svg.md new file mode 100644 index 0000000..0a9f051 --- /dev/null +++ b/.changeset/render-image-bg-valid-svg.md @@ -0,0 +1,20 @@ +--- +"@textcortex/slidewise": patch +--- + +fix(render): emit a valid SVG `` for image-fill backgrounds + +`renderDeckToSvg` rendered a slide's image-fill background as a CSS `background` +shorthand inside an SVG `fill="…"` attribute — `fill="center / cover no-repeat +url("data:image…")"`. That is not valid SVG (a non-paint value plus nested +unescaped quotes); browsers tolerate it, but strict rasterisers +(`@resvg/resvg-js`, librsvg, batik) reject it, blocking a Chromium-free +`parsePptx → renderDeckToSvg → resvg → PNG` path. + +The pptx importer stores image backgrounds as a CSS shorthand, but the +renderer's image-ref detection only inspected the *start* of the value, so the +shorthand fell through to the `fill` path. The renderer now recognises a +`url(...)` anywhere in the value and emits a real `` element +(`preserveAspectRatio` = `slice` for `cover`, `meet` for `contain`). A lock-in +test asserts every rendered slide is valid SVG a strict XML parser accepts, +with an image-background slide as the regression case. diff --git a/packages/slidewise/src/lib/render/__tests__/render-deck.test.ts b/packages/slidewise/src/lib/render/__tests__/render-deck.test.ts index 2f5edef..4618c58 100644 --- a/packages/slidewise/src/lib/render/__tests__/render-deck.test.ts +++ b/packages/slidewise/src/lib/render/__tests__/render-deck.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import path from "node:path"; +import { XMLValidator } from "fast-xml-parser"; import { renderDeckToSvg, renderDeckToImages, @@ -146,6 +147,57 @@ describe("renderDeckToSvg / renderDeckToImages", () => { expect(w).toBe(480); }); + it("emits a real for an image-fill background, not a CSS-shorthand fill", async () => { + // The pptx importer stores image backgrounds as a CSS `background` + // shorthand: `center / cover no-repeat url("data:image…")`. The renderer + // must turn that into a valid SVG , never `fill="…url(data:…)…"` + // (nested quotes + a non-paint value that strict rasterisers reject). + const bg = `center / cover no-repeat url("${IMG_SRC}")`; + const deck = { + version: 1, + title: "ImageBg", + slides: [{ id: "s1", background: bg, elements: [] }], + } as Deck; + + const [svg] = await renderDeckToSvg(deck); + expect(svg).toContain(` { + const bg = `center / contain no-repeat url("${IMG_SRC}")`; + const deck = { + version: 1, + title: "ContainBg", + slides: [{ id: "s1", background: bg, elements: [] }], + } as Deck; + const [svg] = await renderDeckToSvg(deck); + expect(svg).toContain(`preserveAspectRatio="xMidYMid meet"`); + }); + + it("every rendered slide is valid SVG a strict XML parser accepts", async () => { + // Lock-in for the resvg/librsvg path: a strict (non-browser) parser must + // accept every slide. An image-background slide is the regression case. + const deck = { + version: 1, + title: "Strict", + slides: [ + { id: "s1", background: `center / cover no-repeat url("${IMG_SRC}")`, elements: [] }, + ...buildDeck().slides, + ], + } as Deck; + const svgs = await renderDeckToSvg(deck); + for (const svg of svgs) { + const result = XMLValidator.validate(svg); + expect(result, typeof result === "object" ? JSON.stringify(result.err) : "") + .toBe(true); + } + }); + it("stays browser-free (no Playwright/Puppeteer/jsdom in the source)", () => { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const src = readFileSync(path.resolve(__dirname, "../renderDeck.ts"), "utf8"); diff --git a/packages/slidewise/src/lib/render/renderDeck.ts b/packages/slidewise/src/lib/render/renderDeck.ts index f64a6b4..6956866 100644 --- a/packages/slidewise/src/lib/render/renderDeck.ts +++ b/packages/slidewise/src/lib/render/renderDeck.ts @@ -163,9 +163,10 @@ function renderBackground(background: string | undefined): string { const fill = background ? solidFrom(background) : "#FFFFFF"; if (isImageRef(background)) { const href = imageHref(background!); + const preserve = imageFitPreserve(background); return ( `` + - `` + `` ); } return ``; @@ -499,12 +500,24 @@ async function defaultRasterize( // -- colour / string helpers ------------------------------------------------- function isImageRef(s: string | undefined): boolean { - return !!s && (s.startsWith("data:image") || /^url\(/i.test(s) || /^https?:\/\//i.test(s)); + if (!s) return false; + const v = s.trim(); + // A bare data/http URL, OR a CSS `background` shorthand that embeds a + // `url(...)` anywhere (e.g. `center / cover no-repeat url("data:image…")`, + // as produced by the pptx importer for image-fill backgrounds). + return v.startsWith("data:image") || /url\(/i.test(v) || /^https?:\/\//i.test(v); } function imageHref(s: string): string { - const m = /^url\(["']?(.*?)["']?\)$/i.exec(s); - return m ? m[1] : s; + // Pull the URL out of a `url(...)` wherever it appears in the value; data + // URLs use a `)`-free base64 alphabet, so non-greedy-to-first-`)` is safe. + const m = /url\(\s*["']?(.*?)["']?\s*\)/i.exec(s); + return m ? m[1] : s.trim(); +} + +/** `cover` → `slice`, `contain` → `meet`, for an image-fill shorthand. */ +function imageFitPreserve(s: string | undefined): string { + return s && /\bcontain\b/i.test(s) ? "xMidYMid meet" : "xMidYMid slice"; } /** Best-effort single colour for SVG: pass hex through, pull the first hex out