From ac22806f73c42c55fc07eba9c4abe7aa46e2f868 Mon Sep 17 00:00:00 2001 From: dfliess Date: Wed, 5 Aug 2026 13:37:31 +0200 Subject: [PATCH 1/3] fix(canvas): capture canvas blocks twice where WebKit needs it html-to-image clones a into an nested inside the it serializes, and WebKit paints that SVG before the nested image has decoded, so the first capture of a node holding a canvas comes back blank. captureCanvasBlocks rasterizes each block exactly once, which means that in Safari every chart in an exported PDF is empty; only the KPI sparklines survive, because they are inline SVG rather than Vega's canvas renderer. Probe once per capture with a tiny canvas and, where it comes back blank, capture canvas-backed blocks twice and discard the first result. Probing rather than matching the user agent keeps the extra pass off the browsers that do not need it, and lets the workaround retire itself once WebKit changes. --- .../src/features/exports/pdf/capture.spec.ts | 60 ++++++++- .../src/features/exports/pdf/capture.ts | 114 ++++++++++++++++-- 2 files changed, 162 insertions(+), 12 deletions(-) diff --git a/web-common/src/features/exports/pdf/capture.spec.ts b/web-common/src/features/exports/pdf/capture.spec.ts index 47b74d5da4f..83de25218a3 100644 --- a/web-common/src/features/exports/pdf/capture.spec.ts +++ b/web-common/src/features/exports/pdf/capture.spec.ts @@ -1,6 +1,62 @@ // @vitest-environment jsdom -import { describe, expect, it } from "vitest"; -import { captureTargetsIn, inlineSvgStyles, rowIndexFor } from "./capture"; +import { toJpeg } from "html-to-image"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + captureTargetsIn, + inlineSvgStyles, + rasterizeNode, + rowIndexFor, +} from "./capture"; + +vi.mock("html-to-image", () => ({ + toJpeg: vi.fn(() => Promise.resolve("data:image/jpeg;base64,")), +})); + +describe("rasterizeNode", () => { + beforeEach(() => vi.mocked(toJpeg).mockClear()); + + function cardWith(inner: string): HTMLElement { + const card = document.createElement("div"); + card.innerHTML = inner; + return card; + } + + // WebKit hands back a blank raster the first time it captures a , so + // affected browsers capture those nodes twice and discard the first result. + it("captures a canvas-backed node twice when the warm-up is required", async () => { + await rasterizeNode(cardWith(""), { + backgroundColor: "#fff", + warmUpCanvas: true, + }); + expect(vi.mocked(toJpeg)).toHaveBeenCalledTimes(2); + }); + + it("captures once when the browser does not need the warm-up", async () => { + await rasterizeNode(cardWith(""), { + backgroundColor: "#fff", + warmUpCanvas: false, + }); + expect(vi.mocked(toJpeg)).toHaveBeenCalledTimes(1); + }); + + // Only charts render to a canvas; the other blocks must not pay for the pass. + it("captures a node without a canvas once even on affected browsers", async () => { + await rasterizeNode(cardWith(""), { + backgroundColor: "#fff", + warmUpCanvas: true, + }); + expect(vi.mocked(toJpeg)).toHaveBeenCalledTimes(1); + }); + + it("captures both passes with identical options", async () => { + await rasterizeNode(cardWith(""), { + backgroundColor: "#fff", + warmUpCanvas: true, + }); + const [first, second] = vi.mocked(toJpeg).mock.calls; + expect(first[1]).toStrictEqual(second[1]); + }); +}); describe("inlineSvgStyles", () => { it("restores original SVG style attributes", () => { diff --git a/web-common/src/features/exports/pdf/capture.ts b/web-common/src/features/exports/pdf/capture.ts index 0adabb5082b..d271e18a7a6 100644 --- a/web-common/src/features/exports/pdf/capture.ts +++ b/web-common/src/features/exports/pdf/capture.ts @@ -47,19 +47,103 @@ const PIXEL_RATIO = 2; // crisp for dashboard charts/text. JPEG has no alpha, so we supply a background. const JPEG_QUALITY = 0.85; -// Rasterizes a single element to a JPEG data URL. +// Side length of the probe canvas: the blank-first-capture bug reproduces at any +// size, so keep it as cheap as possible. +const PROBE_SIZE_PX = 8; + +// html-to-image clones a into an nested inside the +// it serializes, and WebKit paints that SVG before the nested image is ready, so +// the first capture of a node containing a canvas comes out blank (Safari 26 on +// macOS and iOS; Chrome and Firefox are unaffected). A second pass over the same +// node is correct. The behaviour is known upstream and still unfixed, so the +// workaround lives here until a html-to-image release carries one. +// +// Rather than pay the extra pass everywhere, or key it off the user agent, +// capture a tiny canvas once and see whether it survives. +let canvasWarmupProbe: Promise | undefined; + +function needsCanvasWarmup(): Promise { + canvasWarmupProbe ??= probeCanvasWarmup(); + return canvasWarmupProbe; +} + +async function probeCanvasWarmup(): Promise { + const host = document.createElement("div"); + host.setAttribute("aria-hidden", "true"); + host.style.cssText = "position:fixed;left:-99999px;top:0;pointer-events:none"; + + const canvas = document.createElement("canvas"); + canvas.width = PROBE_SIZE_PX; + canvas.height = PROBE_SIZE_PX; + canvas.style.display = "block"; + const ctx = canvas.getContext("2d"); + if (!ctx) return true; + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, PROBE_SIZE_PX, PROBE_SIZE_PX); + + host.appendChild(canvas); + document.body.appendChild(host); + try { + // White on black: any bright pixel means the canvas reached the raster. + return await isBlank( + await toJpeg(host, { pixelRatio: 1, backgroundColor: "#000" }), + ); + } catch { + // Assume the warm-up is needed: guessing "no" ships blank charts, guessing + // "yes" only costs a second pass. + return true; + } finally { + host.remove(); + } +} + +async function isBlank(dataUrl: string): Promise { + const img = new Image(); + img.src = dataUrl; + await img.decode(); + + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) return true; + ctx.drawImage(img, 0, 0); + + const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height); + for (let i = 0; i < data.length; i += 4) { + if (data[i] > 128) return false; + } + return true; +} + +export interface RasterizeOptions { + backgroundColor: string; + // Comes from needsCanvasWarmup(). Both fields are required: a caller that + // forgot the warm-up would ship blank charts on WebKit with nothing to show + // for it, no error and no failed capture. + warmUpCanvas: boolean; +} + +// Rasterizes a single element to a JPEG data URL. On browsers that need it, a +// node holding a is captured twice and the first result discarded; the +// warm-up has to run at the real pixel ratio, as a smaller one does not prime +// the second pass. export async function rasterizeNode( node: HTMLElement, - backgroundColor: string, + { backgroundColor, warmUpCanvas }: RasterizeOptions, ): Promise { const restoreSvgStyles = inlineSvgStyles(node); + const options = { + cacheBust: true, + pixelRatio: PIXEL_RATIO, + quality: JPEG_QUALITY, + backgroundColor, + }; try { - return await toJpeg(node, { - cacheBust: true, - pixelRatio: PIXEL_RATIO, - quality: JPEG_QUALITY, - backgroundColor, - }); + if (warmUpCanvas && node.querySelector("canvas")) { + await toJpeg(node, options); + } + return await toJpeg(node, options); } finally { restoreSvgStyles(); } @@ -111,6 +195,10 @@ export async function captureCanvasBlocks( const targets = captureTargetsIn(rowContainer); + // Probed once per capture rather than per block: the answer is a property of + // the browser, and the probe itself rasterizes. + const warmUpCanvas = await needsCanvasWarmup(); + const blocks: CapturedBlock[] = []; const total = targets.length + (opts.includeFilters ? 1 : 0); let done = 0; @@ -129,7 +217,10 @@ export async function captureCanvasBlocks( header.style.width = `${contentWidthPx}px`; if (header.scrollHeight > 0) { try { - const dataUrl = await rasterizeNode(header, backgroundColor); + const dataUrl = await rasterizeNode(header, { + backgroundColor, + warmUpCanvas, + }); blocks.push({ id: FILTER_BAR_ID, dataUrl, @@ -151,7 +242,10 @@ export async function captureCanvasBlocks( for (const target of targets) { const rect = target.getBoundingClientRect(); try { - const dataUrl = await rasterizeNode(target, backgroundColor); + const dataUrl = await rasterizeNode(target, { + backgroundColor, + warmUpCanvas, + }); blocks.push({ id: target.id, dataUrl, From b2b8640c129c075ea8a9b5b01c6123368790c2d2 Mon Sep 17 00:00:00 2001 From: dfliess Date: Wed, 5 Aug 2026 13:37:31 +0200 Subject: [PATCH 2/3] fix(canvas): stop the PDF export magnifying narrow captures paginate scaled the capture up to the page content width, so a phone-width canvas was inflated by about 40%. That pushed rows past the page height, which sliced whole charts across pages and left most of each following page empty. Captures narrower than the page now keep their size and are centred. --- .../src/features/exports/pdf/layout.spec.ts | 44 +++++++++++++++++++ web-common/src/features/exports/pdf/layout.ts | 17 +++++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/web-common/src/features/exports/pdf/layout.spec.ts b/web-common/src/features/exports/pdf/layout.spec.ts index ebac7287b39..e2010204480 100644 --- a/web-common/src/features/exports/pdf/layout.spec.ts +++ b/web-common/src/features/exports/pdf/layout.spec.ts @@ -50,6 +50,50 @@ describe("paginate", () => { expect(p.yPt).toBeCloseTo(result.marginPt, 1); }); + // A phone-width capture used to be stretched to the page, which inflated every + // row past the page height and sliced whole charts across pages. + it("does not magnify a capture narrower than the page", () => { + const result = paginate([block({ id: "a", widthPx: 390, heightPx: 300 })], { + ...A4, + contentWidthPx: 390, + }); + + expect(result.pageCount).toBe(1); + const p = result.placements[0]; + expect(p.wPt).toBeCloseTo(390, 1); + expect(p.hPt).toBeCloseTo(300, 1); + }); + + it("centres a capture that is narrower than the page", () => { + const result = paginate([block({ id: "a", widthPx: 390, heightPx: 300 })], { + ...A4, + contentWidthPx: 390, + }); + + const p = result.placements[0]; + const contentWidthPt = result.pageWidthPt - 2 * result.marginPt; + expect(p.xPt).toBeCloseTo(result.marginPt + (contentWidthPt - 390) / 2, 1); + // Equal gutters either side. + expect(result.pageWidthPt - (p.xPt + p.wPt)).toBeCloseTo(p.xPt, 1); + }); + + // Three phone-width charts fit one page at 1:1; magnified they would not. + it("fits several narrow rows on one page instead of slicing them", () => { + const result = paginate( + [ + block({ id: "a", yPx: 0, widthPx: 390, heightPx: 240, rowIndex: 0 }), + block({ id: "b", yPx: 250, widthPx: 390, heightPx: 240, rowIndex: 1 }), + block({ id: "c", yPx: 500, widthPx: 390, heightPx: 240, rowIndex: 2 }), + ], + { ...A4, contentWidthPx: 390 }, + ); + + expect(result.pageCount).toBe(1); + expect(result.placements.every((p) => p.srcHeightPx === undefined)).toBe( + true, + ); + }); + it("keeps two columns of one row on the same page side by side", () => { const result = paginate( [ diff --git a/web-common/src/features/exports/pdf/layout.ts b/web-common/src/features/exports/pdf/layout.ts index 6a74eea8005..bb715facfd1 100644 --- a/web-common/src/features/exports/pdf/layout.ts +++ b/web-common/src/features/exports/pdf/layout.ts @@ -63,7 +63,8 @@ export function resolveOrientation( } // Groups blocks into canvas rows (preserving DOM order within a row) and walks -// them top-to-bottom, scaling the on-screen layout to the page content width. +// them top-to-bottom, fitting the on-screen layout to the page content width: +// wider captures are scaled down, narrower ones keep their size and are centred. // A row that would overflow the current page moves wholesale to the next page; // a single-block row taller than a full page is sliced across pages. export function paginate( @@ -78,8 +79,16 @@ export function paginate( const contentWidthPt = pageWidthPt - 2 * marginPt; const contentHeightPt = pageHeightPt - 2 * marginPt; + // Never magnify. Blocks are a fixed-resolution raster, so stretching a capture + // narrower than the page (a phone-width dashboard) both softens it and inflates + // every row past the page height, which slices charts across pages: a doughnut + // ends up halved, and the rest of the page is left empty. At 1:1 the content + // keeps its natural size and is centred in the content box. const scale = - opts.contentWidthPx > 0 ? contentWidthPt / opts.contentWidthPx : 1; + opts.contentWidthPx > 0 + ? Math.min(contentWidthPt / opts.contentWidthPx, 1) + : 1; + const contentOffsetPt = (contentWidthPt - opts.contentWidthPx * scale) / 2; const rows = groupIntoRows(blocks); @@ -141,7 +150,7 @@ export function paginate( placements.push({ block, page, - xPt: marginPt + block.xPx * scale, + xPt: marginPt + contentOffsetPt + block.xPx * scale, yPt: pageTopPt(page) + (sliceTopPx - rowSrcYPx) * scale, wPt: block.widthPx * scale, hPt: srcHeightPx * scale, @@ -168,7 +177,7 @@ export function paginate( placements.push({ block, page, - xPt: marginPt + block.xPx * scale, + xPt: marginPt + contentOffsetPt + block.xPx * scale, yPt: cursorYPt + (block.yPx - rowTopPx) * scale, wPt: block.widthPx * scale, hPt: block.heightPx * scale, From ef2abe270e59ba15bb14d453b5eebc34a402d946 Mon Sep 17 00:00:00 2001 From: dfliess Date: Wed, 5 Aug 2026 13:37:31 +0200 Subject: [PATCH 3/3] fix(canvas): export the PDF at the canvas's design width The off-screen export render took its width from the width the dashboard happened to occupy on screen, so the same canvas produced a different document depending on the window it was exported from, and a phone produced a narrow capture stacked into a single column. Render it at the canvas's max_width instead. --- .../src/features/canvas/CanvasDashboardWrapper.svelte | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/web-common/src/features/canvas/CanvasDashboardWrapper.svelte b/web-common/src/features/canvas/CanvasDashboardWrapper.svelte index cab84a40e07..e3538fed692 100644 --- a/web-common/src/features/canvas/CanvasDashboardWrapper.svelte +++ b/web-common/src/features/canvas/CanvasDashboardWrapper.svelte @@ -66,11 +66,12 @@ class="pointer-events-none absolute" style="left: -99999px; top: 0;" > - + + {/if}