fix(canvas): PDF export comes out blank in Safari - #9790
Conversation
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.
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.
|
Hey @nishantmonu51 / @AdityaHegde Also, dark mode dashboards print as a wall of ink, Should the export always be light, on a white background page? 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 Just let me know how to proceed. |
AdityaHegde
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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" }), |
There was a problem hiding this comment.
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")) { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
|
Great catches, thanks. All four are addressed, let me know any other thought. |
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-imageclones a<canvas>into an<img>nested in the<foreignObject>it serializes, and WebKit paints that SVG before the nested image has decoded.captureCanvasBlocksrasterizes 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
paginatemagnifying captures narrower than the page; render the export view at the canvas'smax_widthso the document does not depend on the exporter's window.Same capture path as #9770.
Checklist: