Skip to content

fix(canvas): PDF export comes out blank in Safari - #9790

Open
dfliess wants to merge 6 commits into
rilldata:mainfrom
dfliess:dfliess/canvas-pdf-safari-blank
Open

fix(canvas): PDF export comes out blank in Safari#9790
dfliess wants to merge 6 commits into
rilldata:mainfrom
dfliess:dfliess/canvas-pdf-safari-blank

Conversation

@dfliess

@dfliess dfliess commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

In Safari every chart in a canvas PDF export comes out blank, headers only. Export twice without reloading and the second one is correct. Chrome and Firefox are unaffected.

html-to-image clones a <canvas> into an <img> nested in the <foreignObject> it serializes, and WebKit paints that SVG before the nested image has decoded. captureCanvasBlocks rasterizes each block once, so every chart is always a first capture.

Known upstream and unfixed. bubkoo/html-to-image#569 reports canvases blank on Safari across macOS, iOS and iPadOS; capturing twice is the standing workaround there (bubkoo/html-to-image#292), and the open fix (bubkoo/html-to-image#498) keys off a user agent regex. So it lives here for now, behind a probe instead.

Commits: probe for the affected browser and capture those blocks twice; stop paginate magnifying captures narrower than the page; render the export view at the canvas's max_width so the document does not depend on the exporter's window.

Same capture path as #9770.

Checklist:

  • Covered by tests
  • Ran it and it works as intended
  • Reviewed the diff before requesting a review
  • Checked for unhandled edge cases
  • Linked the issues it closes
  • Checked if the docs need to be updated. If so, create a separate Linear DOCS issue
  • Intend to cherry-pick into the release branch
  • I'm proud of this work!

dfliess added 3 commits August 5, 2026 13:37
html-to-image clones a <canvas> into an <img> nested inside the <foreignObject>
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.
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.
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.
@nishantmonu51 nishantmonu51 added Type:Bug Something isn't working Area:Dashboard Size:M Medium change: 100-499 lines labels Aug 7, 2026
html-to-image resolves and inlines the fonts on every capture, and cacheBust
means nothing is reused between them. Sharing one precomputed stylesheet takes
a pair of captures of a chart card from 3534ms to 414ms.

The gap it opens also breaks the canvas warm-up: WebKit hands back the decoded
nested image only on the capture immediately after the one that triggered it,
so the two passes have to stay close together.
@dfliess

dfliess commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Hey @nishantmonu51 / @AdityaHegde
I just added a commit for some font loading improvements that could block some rendering.

Also, dark mode dashboards print as a wall of ink, Should the export always be light, on a white background page?
We have an implementation we could add here as a commit or a stacked PR.

The shape of it: switch to light before the export view mounts and restore afterwards, leaving the persisted preference untouched. Both the dark class and the theme store have to flip, since components are split between reading the class (chart colour resolution) and subscribing to the store (CanvasChart). The catch is that the switch is global, so the live dashboard visibly flips for the length of the run. We cover the dashboard region with an opaque layer showing the export progress, which also stops anyone clicking into a dashboard that is mid-capture.

I have also been thinking about adding a small pdf: block to the canvas YAML (page size, orientation, and the width the export renders at) so a dashboard can control how it prints, but that is a YAML surface with proto changes, so I would rather ask before building anything.

Just let me know how to proceed.

@AdityaHegde AdityaHegde left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment worth acting on before merge is the missing zero-width guard on contentOffsetPt in layout.ts. The rest are smaller.


Developed in collaboration with Claude Code


const targets = captureTargetsIn(rowContainer);

// Probed once per capture rather than per block: the answer is a property of

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this is probed once per page load, not once per capture. canvasWarmupProbe is memoized at module scope, which is the right call given the answer is a property of the browser, but the comment reads as though the probe re-runs for each export.

// 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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The claim that the bug reproduces at any size is worth double-checking, because the probe fails in the unsafe direction. The underlying WebKit behaviour is a race against the decode of the image nested in the <foreignObject>, and an 8x8 white square is the payload most likely to win that race. If it decodes in time on some WebKit build or device, needsCanvasWarmup() returns false, the warm-up is skipped, and the export goes back to producing blank charts, with no error and no failed capture, which is exactly the failure mode the RasterizeOptions comment at line 126 calls out.

Biasing the probe toward the real workload would make it more trustworthy: a canvas closer to a chart block's dimensions, and pixelRatio: PIXEL_RATIO, given that the comment on rasterizeNode states the warm-up only works at the real pixel ratio.

try {
// White on black: any bright pixel means the canvas reached the raster.
return await isBlank(
await toJpeg(host, { pixelRatio: 1, backgroundColor: "#000" }),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without skipFonts, this call runs the full getWebFontCSS path to answer a boolean about an 8x8 square: it walks every stylesheet in the document, may fetch and re-parse cross-origin CSS, and inlines the app's web fonts as data URLs. That adds latency to the first export in every browser, including the ones unaffected by the bug, and works against the fontEmbedCSS hoisting in this same change. skipFonts: true here is free.

quality: JPEG_QUALITY,
backgroundColor,
});
if (warmUpCanvas && node.querySelector("canvas")) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the warm-up pass throws, the exception propagates and the caller's catch skips the block, even though the result is discarded and the real pass would likely have succeeded. A transient failure here costs a chart in the PDF for no benefit. Wrapping the warm-up in try {} catch {} and always falling through to line 152 is strictly better.

// 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 fontEmbedCSS = await getFontEmbedCSS(rowContainer);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hoisted CSS is computed from rowContainer, but it is also passed to the header capture at line 227, and #canvas-pdf-export-header is a sibling of .row-container rather than a descendant (CanvasPdfExportView.svelte:36). getWebFontCSS filters the collected @font-face rules through getUsedFonts(node), which only traverses the passed node's subtree, so any web font the header uses but no component in the row container does is dropped from its embedded CSS, and the header rasterizes in a fallback font. Before hoisting, each node received CSS scoped to itself.

Passing exportView here covers both subtrees.

opts.contentWidthPx > 0
? Math.min(contentWidthPt / opts.contentWidthPx, 1)
: 1;
const contentOffsetPt = (contentWidthPt - opts.contentWidthPx * scale) / 2;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

contentOffsetPt does not apply the opts.contentWidthPx > 0 guard that scale applies two lines above. In the degenerate case that guard exists for, scale falls back to 1 but contentOffsetPt evaluates to contentWidthPt / 2, so every placement shifts half a content width to the right and the blocks run off the page. Previously a zero width produced an unscaled but correctly positioned layout.

const contentOffsetPt =
  opts.contentWidthPx > 0
    ? (contentWidthPt - opts.contentWidthPx * scale) / 2
    : 0;

block,
page,
xPt: marginPt + block.xPx * scale,
xPt: marginPt + contentOffsetPt + block.xPx * scale,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Insetting the placements by contentOffsetPt leaves the page chrome behind. assemble.ts draws the title at result.marginPt (line 110) and the footer at the page margins (lines 126 and 130), so for a canvas narrower than the page content box, such as max_width: 400 on A4 portrait giving roughly a 74pt gutter, the title and footer sit flush to the margin while every block is indented. Either offset the title and footer by the same amount, or expose contentOffsetPt on PaginationResult so assemblePdf can.

A capture narrower than the page is centred in the content box, but the title
and the footer were still drawn at the page margins, so a canvas with a small
max_width printed its chrome flush left while every block sat an inch further
in. Expose the offset paginate centres with and draw both of them from it.

The offset was also applied when the capture reported a zero width, where scale
already falls back to 1: that pushed every placement half a content width
towards the right edge. Guard it the same way.
The warm-up pass exists to be discarded, but an exception in it propagated to
the caller, which skips the block: a chart lost for nothing. Warn and carry on
to the real capture.

Collect the embedded font CSS from the whole export view as well.
getFontEmbedCSS keeps only the @font-face rules whose family is used inside the
node it is handed, and the export header is a sibling of the row container, so
a face used only by the header was dropped from its capture.
@dfliess

dfliess commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Great catches, thanks. All four are addressed, let me know any other thought.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area:Dashboard Size:M Medium change: 100-499 lines Type:Bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants