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} 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, 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,