Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/render-image-bg-valid-svg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@textcortex/slidewise": patch
---

fix(render): emit a valid SVG `<image>` 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 `<image>` 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.
52 changes: 52 additions & 0 deletions packages/slidewise/src/lib/render/__tests__/render-deck.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -146,6 +147,57 @@ describe("renderDeckToSvg / renderDeckToImages", () => {
expect(w).toBe(480);
});

it("emits a real <image> 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 <image>, 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(`<image`);
expect(svg).toContain(`xlink:href="${IMG_SRC}"`);
expect(svg).toContain(`preserveAspectRatio="xMidYMid slice"`); // cover
// The malformed shorthand-as-fill must NOT appear.
expect(svg).not.toContain("no-repeat");
expect(svg).not.toMatch(/fill="[^"]*url\(/);
});

it("renders `contain` image backgrounds with preserveAspectRatio=meet", async () => {
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");
Expand Down
21 changes: 17 additions & 4 deletions packages/slidewise/src/lib/render/renderDeck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
`<rect x="0" y="0" width="${SLIDE_W}" height="${SLIDE_H}" fill="#FFFFFF"/>` +
`<image x="0" y="0" width="${SLIDE_W}" height="${SLIDE_H}" preserveAspectRatio="xMidYMid slice" xlink:href="${escAttr(href)}"/>`
`<image x="0" y="0" width="${SLIDE_W}" height="${SLIDE_H}" preserveAspectRatio="${preserve}" xlink:href="${escAttr(href)}"/>`
);
}
return `<rect x="0" y="0" width="${SLIDE_W}" height="${SLIDE_H}" fill="${fill ?? "#FFFFFF"}"/>`;
Expand Down Expand Up @@ -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
Expand Down
Loading