Skip to content
Open
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
11 changes: 6 additions & 5 deletions web-common/src/features/canvas/CanvasDashboardWrapper.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,12 @@
class="pointer-events-none absolute"
style="left: -99999px; top: 0;"
>
<CanvasPdfExportView
{canvasName}
{instanceId}
width={clientWidth || maxWidth}
/>
<!-- Rendered at the canvas's own design width rather than the width it
happens to occupy on screen, so the same dashboard always exports
the same document. Following the viewport meant a phone produced a
narrow capture that the page then had to magnify, which inflated
every row past the page height and sliced charts across pages. -->
<CanvasPdfExportView {canvasName} {instanceId} width={maxWidth} />
</div>
{/if}

Expand Down
60 changes: 58 additions & 2 deletions web-common/src/features/exports/pdf/capture.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <canvas>, 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("<canvas></canvas>"), {
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("<canvas></canvas>"), {
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("<svg></svg>"), {
backgroundColor: "#fff",
warmUpCanvas: true,
});
expect(vi.mocked(toJpeg)).toHaveBeenCalledTimes(1);
});

it("captures both passes with identical options", async () => {
await rasterizeNode(cardWith("<canvas></canvas>"), {
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", () => {
Expand Down
114 changes: 104 additions & 10 deletions web-common/src/features/exports/pdf/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <canvas> into an <img> nested inside the <foreignObject>
// 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<boolean> | undefined;

function needsCanvasWarmup(): Promise<boolean> {
canvasWarmupProbe ??= probeCanvasWarmup();
return canvasWarmupProbe;
}

async function probeCanvasWarmup(): Promise<boolean> {
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<boolean> {
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 <canvas> 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<string> {
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();
}
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions web-common/src/features/exports/pdf/layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand Down
17 changes: 13 additions & 4 deletions web-common/src/features/exports/pdf/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading