From ca98677c548ca3872f3771d4ee446791e6892204 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 8 Oct 2026 16:24:44 +1100 Subject: [PATCH] fix: carry canvas pixels into html5 frame capture --- src/components/canvas/players/html5-player.ts | 22 +++++++++++++++++++ tests/html5-player.test.ts | 17 ++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/components/canvas/players/html5-player.ts b/src/components/canvas/players/html5-player.ts index df0f1dfb..a44159b5 100644 --- a/src/components/canvas/players/html5-player.ts +++ b/src/components/canvas/players/html5-player.ts @@ -385,6 +385,28 @@ export class Html5Player extends Player { const bodyClone = doc.body.cloneNode(true) as HTMLElement; // Scripts never run in an SVG image, and their source can hold characters XML forbids, which fail the frame. bodyClone.querySelectorAll("script").forEach(script => script.remove()); + // A cloned canvas has no pixels. Painting them as its own background keeps every style that targets the canvas. + // An SVG image runs without scripting, so it lays a canvas out as fallback content: an inline box with no + // size of its own. Pinning display and the used size inline restores the layout of the live canvas. + const sourceCanvases = doc.body.querySelectorAll("canvas"); + const clonedCanvases = bodyClone.querySelectorAll("canvas"); + for (let i = 0; i < clonedCanvases.length; i += 1) { + const { style } = clonedCanvases[i]; + const used = doc.defaultView?.getComputedStyle(sourceCanvases[i]); + if (used) { + style.display = used.display === "inline" ? "inline-block" : used.display; + style.width = used.width; + style.height = used.height; + } + try { + style.backgroundImage = `url("${sourceCanvases[i].toDataURL()}")`; + style.backgroundSize = "100% 100%"; + style.backgroundOrigin = "content-box"; + style.backgroundRepeat = "no-repeat"; + } catch { + // a tainted canvas can't be read, so it stays blank + } + } const existingStyle = bodyClone.getAttribute("style") ?? ""; bodyClone.setAttribute("style", `width:${width}px;height:${height}px;margin:0;overflow:hidden;${existingStyle}`); const bodyXml = serializer.serializeToString(bodyClone); diff --git a/tests/html5-player.test.ts b/tests/html5-player.test.ts index f3f6a287..4fe6e3f5 100644 --- a/tests/html5-player.test.ts +++ b/tests/html5-player.test.ts @@ -167,6 +167,23 @@ it("leaves scripts out of the capture so their source can't break the frame", () expect(doc.body.querySelector("script")).not.toBeNull(); }); +it("paints canvas pixels into the capture as the canvas's own background", () => { + const { harness, iframe } = createPlayer(); + const doc = iframe.contentDocument!; + const canvas = doc.createElement("canvas"); + canvas.setAttribute("style", "border: 4px solid gold;"); + doc.body.replaceChildren(canvas); + jest.spyOn(Object.getPrototypeOf(canvas) as HTMLCanvasElement, "toDataURL").mockReturnValue("data:image/png;base64,AAAA"); + + const parsed = new DOMParser().parseFromString(harness.captureIframeAsForeignObjectSvg(1080, 1920), "image/svg+xml"); + const style = parsed.querySelector("canvas")?.getAttribute("style") ?? ""; + + expect(style).toMatch(/background-image: url\("?data:image\/png;base64,AAAA"?\)/); + expect(style).toContain("background-size: 100% 100%"); + expect(style).toContain("border: 4px solid gold"); + expect(canvas.getAttribute("style")).toBe("border: 4px solid gold;"); +}); + it("keeps a failed capture inside its canvas layer and retries only after the asset changes", async () => { const { player, harness, iframe, events, edit } = createPlayer(); jest.spyOn(console, "warn").mockImplementation(() => {});