diff --git a/web-common/src/features/canvas/CanvasDashboardWrapper.svelte b/web-common/src/features/canvas/CanvasDashboardWrapper.svelte index cab84a40e072..e3538fed692f 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/assemble.ts b/web-common/src/features/exports/pdf/assemble.ts index 9f692e23f3c6..6ef80848a03e 100644 --- a/web-common/src/features/exports/pdf/assemble.ts +++ b/web-common/src/features/exports/pdf/assemble.ts @@ -97,7 +97,10 @@ function drawTitle( doc.setFontSize(TITLE_FONT_SIZE_PT); setTextColor(doc, TITLE_COLOR); - const maxWidthPt = result.pageWidthPt - 2 * result.marginPt; + // Content narrower than the page is centred (see paginate), and the title + // follows it rather than sitting at the page margin. + const contentLeftPt = result.marginPt + result.contentOffsetPt; + const maxWidthPt = result.pageWidthPt - 2 * contentLeftPt; let title = meta.title; if (doc.getTextWidth(title) > maxWidthPt) { while (title.length > 1 && doc.getTextWidth(`${title}…`) > maxWidthPt) { @@ -107,7 +110,7 @@ function drawTitle( } // Baseline near the bottom of the reserved band, leaving a gap before content. - doc.text(title, result.marginPt, result.marginPt + TITLE_FONT_SIZE_PT); + doc.text(title, contentLeftPt, result.marginPt + TITLE_FONT_SIZE_PT); doc.setFont("helvetica", "normal"); } @@ -116,6 +119,7 @@ function drawFooter( result: PaginationResult, meta: AssembleMeta, ): void { + const contentLeftPt = result.marginPt + result.contentOffsetPt; const yPt = result.pageHeightPt - 10; const generatedText = `Generated ${meta.generatedAt}`; const linkPrefix = "Open the live dashboard: "; @@ -123,11 +127,11 @@ function drawFooter( doc.setFontSize(8); setTextColor(doc, FOOTER_TEXT_COLOR); - doc.text(generatedText, result.marginPt, yPt); + doc.text(generatedText, contentLeftPt, yPt); const linkXPt = result.pageWidthPt - - result.marginPt - + contentLeftPt - doc.getTextWidth(`${linkPrefix}${linkText}`); doc.text(linkPrefix, linkXPt, yPt); setTextColor(doc, FOOTER_LINK_COLOR); diff --git a/web-common/src/features/exports/pdf/capture.spec.ts b/web-common/src/features/exports/pdf/capture.spec.ts index 47b74d5da4f0..40a9c4922840 100644 --- a/web-common/src/features/exports/pdf/capture.spec.ts +++ b/web-common/src/features/exports/pdf/capture.spec.ts @@ -1,6 +1,99 @@ // @vitest-environment jsdom -import { describe, expect, it } from "vitest"; -import { captureTargetsIn, inlineSvgStyles, rowIndexFor } from "./capture"; +import { getFontEmbedCSS, toJpeg } from "html-to-image"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + captureCanvasBlocks, + captureTargetsIn, + inlineSvgStyles, + rasterizeNode, + rowIndexFor, +} from "./capture"; + +vi.mock("html-to-image", () => ({ + toJpeg: vi.fn(() => Promise.resolve("data:image/jpeg;base64,")), + getFontEmbedCSS: vi.fn(() => Promise.resolve("")), +})); + +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", + fontEmbedCSS: "", + 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", + fontEmbedCSS: "", + 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", + fontEmbedCSS: "", + warmUpCanvas: true, + }); + expect(vi.mocked(toJpeg)).toHaveBeenCalledTimes(1); + }); + + it("captures both passes with identical options", async () => { + await rasterizeNode(cardWith(""), { + backgroundColor: "#fff", + fontEmbedCSS: "", + warmUpCanvas: true, + }); + const [first, second] = vi.mocked(toJpeg).mock.calls; + expect(first[1]).toStrictEqual(second[1]); + }); + + // The warm-up's result is discarded, so a failure in it must not cost the + // block the real pass would have captured. + it("captures for real even when the warm-up pass throws", async () => { + vi.mocked(toJpeg) + .mockRejectedValueOnce(new Error("warm-up failed")) + .mockResolvedValueOnce("data:image/jpeg;base64,real"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const dataUrl = await rasterizeNode(cardWith(""), { + backgroundColor: "#fff", + fontEmbedCSS: "", + warmUpCanvas: true, + }); + + expect(dataUrl).toBe("data:image/jpeg;base64,real"); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("hands the caller's font CSS to every pass", async () => { + const fontEmbedCSS = "@font-face{src:url(data:font/woff2;base64,AA)}"; + await rasterizeNode(cardWith(""), { + backgroundColor: "#fff", + fontEmbedCSS, + warmUpCanvas: true, + }); + for (const [, options] of vi.mocked(toJpeg).mock.calls) { + expect(options).toMatchObject({ fontEmbedCSS }); + } + }); +}); describe("inlineSvgStyles", () => { it("restores original SVG style attributes", () => { @@ -108,3 +201,79 @@ describe("captureTargetsIn", () => { expect(indexOf("#tab-a")).toBe(2); }); }); + +describe("captureCanvasBlocks", () => { + // needsCanvasWarmup memoizes at module scope, so the probe runs once for the + // whole file: take a single capture run and assert on what it did. + let probeFills: number[][]; + let header: HTMLElement; + let fontNode: HTMLElement; + let probeNode: HTMLElement; + let probeOptions: Record; + + beforeAll(async () => { + // jsdom cannot rasterize, so hand the probe a context it can paint on and + // let the blankness check fail into its own catch. + const fillRect = + vi.fn<(x: number, y: number, w: number, h: number) => void>(); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + fillStyle: "", + fillRect, + } as unknown as CanvasRenderingContext2D); + + const view = document.createElement("div"); + view.id = "canvas-pdf-export-view"; + view.dataset.instanceId = "inst"; + view.dataset.canvasName = "canvas"; + header = document.createElement("div"); + header.id = "canvas-pdf-export-header"; + const rows = document.createElement("div"); + rows.className = "row-container"; + view.append(header, rows); + document.body.appendChild(view); + + vi.mocked(toJpeg).mockClear(); + vi.mocked(getFontEmbedCSS).mockClear(); + await captureCanvasBlocks({ + instanceId: "inst", + canvasName: "canvas", + includeFilters: true, + }); + + // Read here, not in the tests: the mocks are cleared between them. + probeFills = fillRect.mock.calls.map((args) => [...args] as number[]); + fontNode = vi.mocked(getFontEmbedCSS).mock.calls[0][0]; + [probeNode, probeOptions] = vi.mocked(toJpeg).mock.calls[0] as [ + HTMLElement, + Record, + ]; + }); + + // The export header is a sibling of the row container, and getFontEmbedCSS + // keeps only the @font-face rules used inside the node it is handed, so + // collecting from the rows alone drops any face only the header uses. + it("collects the font CSS from a node that covers the header too", () => { + expect(fontNode.contains(header)).toBe(true); + }); + + // A square small enough to decode before WebKit paints would report a browser + // that needs no warm-up, and the export would go quietly blank. + it("probes with a canvas the size of a chart card", () => { + const canvas = probeNode.querySelector("canvas")!; + expect(canvas.width).toBeGreaterThanOrEqual(300); + expect(canvas.height).toBeGreaterThanOrEqual(200); + expect(probeOptions.pixelRatio).toBe(2); + }); + + // WebKit's decode cache outlives the page, so a probe that serializes the same + // canvas twice would have its answer handed back from the cache. + it("signs the probe so it is never the same image twice", () => { + expect(probeFills.some(([, , w, h]) => w === 1 && h === 1)).toBe(true); + }); + + // The probe carries no text, so resolving the app's web fonts for it is pure + // latency on the first export in every browser, affected or not. + it("probes without resolving web fonts", () => { + expect(probeOptions.skipFonts).toBe(true); + }); +}); diff --git a/web-common/src/features/exports/pdf/capture.ts b/web-common/src/features/exports/pdf/capture.ts index 0adabb5082b2..2f7189a68437 100644 --- a/web-common/src/features/exports/pdf/capture.ts +++ b/web-common/src/features/exports/pdf/capture.ts @@ -1,4 +1,4 @@ -import { toJpeg } from "html-to-image"; +import { getFontEmbedCSS, toJpeg } from "html-to-image"; import { FILTER_BAR_ID, FILTER_BAR_ROW_INDEX, @@ -47,19 +47,129 @@ 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. +// Sized and captured like a real block: clear of the smallest chart block a +// canvas can lay out, at the real pixel ratio. A probe easier to decode than +// the blocks it stands in for wins the race below and reports a browser that +// needs no warm-up, which ships blank charts with no error. +const PROBE_WIDTH_PX = 400; +const PROBE_HEIGHT_PX = 360; + +// 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 canvas once and see whether it survives. Memoized at module scope: +// the answer is a property of the browser, so it holds for the page's lifetime. +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_WIDTH_PX; + canvas.height = PROBE_HEIGHT_PX; + canvas.style.display = "block"; + const ctx = canvas.getContext("2d"); + if (!ctx) return true; + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, PROBE_WIDTH_PX, PROBE_HEIGHT_PX); + // A repeated payload comes back from WebKit's decode cache, which outlives + // the page. + ctx.fillStyle = "#000"; + ctx.fillRect(Date.now() % PROBE_WIDTH_PX, 0, 1, 1); + + 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: PIXEL_RATIO, + backgroundColor: "#000", + // The probe asks a question about the browser, so there is no reason to + // walk the document's stylesheets and inline the app's faces to answer it. + skipFonts: true, + }), + ); + } 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; + // Web fonts, already resolved to data URIs, shared by every capture. Letting + // html-to-image re-resolve them per capture pushes the two passes below far + // enough apart that WebKit drops the decoded canvas between them, and the + // warm-up stops working. + fontEmbedCSS: 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, fontEmbedCSS, warmUpCanvas }: RasterizeOptions, ): Promise { const restoreSvgStyles = inlineSvgStyles(node); + const options = { + cacheBust: true, + pixelRatio: PIXEL_RATIO, + quality: JPEG_QUALITY, + backgroundColor, + fontEmbedCSS, + }; try { - return await toJpeg(node, { - cacheBust: true, - pixelRatio: PIXEL_RATIO, - quality: JPEG_QUALITY, - backgroundColor, - }); + if (warmUpCanvas && node.querySelector("canvas")) { + try { + await toJpeg(node, options); + } catch (e) { + // The warm-up's own result is thrown away, so a failure here is no + // reason to lose the block: fall through and capture for real. + console.warn("Canvas warm-up pass failed", e); + } + } + return await toJpeg(node, options); } finally { restoreSvgStyles(); } @@ -111,6 +221,14 @@ export async function captureCanvasBlocks( const targets = captureTargetsIn(rowContainer); + // Probed once per page load, not per block or per export: the answer is a + // property of the browser, and the probe itself rasterizes. + const warmUpCanvas = await needsCanvasWarmup(); + // Collected from the whole export view rather than the rows: the header is a + // sibling of the row container, and getFontEmbedCSS keeps only the @font-face + // rules whose family is used inside the node it is handed. + const fontEmbedCSS = await getFontEmbedCSS(exportView); + const blocks: CapturedBlock[] = []; const total = targets.length + (opts.includeFilters ? 1 : 0); let done = 0; @@ -129,7 +247,11 @@ 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, + fontEmbedCSS, + warmUpCanvas, + }); blocks.push({ id: FILTER_BAR_ID, dataUrl, @@ -151,7 +273,11 @@ 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, + fontEmbedCSS, + 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 ebac7287b396..12c21ec553ba 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( [ @@ -241,6 +285,33 @@ describe("paginate", () => { expect(labels[0].yPt).toBeLessThan(slices[0].yPt); }); + // The title and the footer are drawn from this, so it has to be the gutter + // the blocks actually start after. + it("exposes the gutter it centred the content with", () => { + const result = paginate([block({ id: "a", widthPx: 390, heightPx: 300 })], { + ...A4, + contentWidthPx: 390, + }); + + expect(result.contentOffsetPt).toBeGreaterThan(0); + expect(result.marginPt + result.contentOffsetPt).toBeCloseTo( + result.placements[0].xPt, + 1, + ); + }); + + // Centring against a width of zero used to shift every block half a content + // width to the right, off the page. + it("does not offset the content when its width is unknown", () => { + const result = paginate([block({ id: "a", widthPx: 390, heightPx: 300 })], { + ...A4, + contentWidthPx: 0, + }); + + expect(result.contentOffsetPt).toBe(0); + expect(result.placements[0].xPt).toBeCloseTo(result.marginPt, 1); + }); + it("places the filter bar (rowIndex -1) before content rows", () => { const result = paginate( [ diff --git a/web-common/src/features/exports/pdf/layout.ts b/web-common/src/features/exports/pdf/layout.ts index 6a74eea80057..79d40986c112 100644 --- a/web-common/src/features/exports/pdf/layout.ts +++ b/web-common/src/features/exports/pdf/layout.ts @@ -47,6 +47,10 @@ export interface PaginationResult { pageWidthPt: number; pageHeightPt: number; marginPt: number; + // Horizontal inset applied to every placement: a capture narrower than the + // page is centred in the content box. Exposed so the page chrome can line up + // with the blocks rather than with the margin. + contentOffsetPt: number; pageCount: number; orientation: ResolvedOrientation; placements: Placement[]; @@ -63,7 +67,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 +83,21 @@ 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; + // A capture that never laid out has no width to centre, and offsetting against + // it would push every block half a content width towards the right edge. + const contentOffsetPt = + opts.contentWidthPx > 0 + ? (contentWidthPt - opts.contentWidthPx * scale) / 2 + : 0; const rows = groupIntoRows(blocks); @@ -141,7 +159,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 +186,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, @@ -182,6 +200,7 @@ export function paginate( pageWidthPt, pageHeightPt, marginPt, + contentOffsetPt, pageCount: placements.length ? Math.max(...placements.map((p) => p.page)) + 1 : 0,