fix: ensure images load correctly when printing to PDF (#14618)

Fixes #14304

## Summary
This PR resolves an issue where images sometimes fail to appear when
exporting or printing AFFiNE pages to PDF. The issue occurs because
images may not finish loading inside the hidden print iframe before
`window.print()` is triggered.

## Changes
- Avoid using `display: none` for the print iframe and instead keep it
hidden while remaining in the rendering tree to ensure resources load
correctly.
- Remove `loading="lazy"` from all images before printing to prevent
viewport-based lazy loading from blocking image fetches.
- Force image reload by reassigning the `src` attribute after removing
lazy loading.
- Add a `waitForImages` helper to ensure all images (including those
inside Shadow DOM) finish loading before calling `window.print()`.
- Improve reliability by checking both `img.complete` and
`img.naturalWidth` to confirm successful image loading.
- Wait for fonts using `document.fonts.ready` before triggering the
print dialog.

## Verification
1. Run AFFiNE in development mode: npm run dev
2. Open a page containing multiple images.
3. Click **Print** and select **Save as PDF** (or any PDF printer).
4. Verify that all images appear correctly in the generated PDF.

## Notes
This change focuses only on improving the reliability of the existing
print-to-PDF workflow without altering any feature flags or export
behavior.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved PDF export reliability by waiting for all images (including
inside shadow content) and fonts to load before printing.
* Removed lazy-loading interference so images reliably appear in
exports.
* Ensured styles and light-theme attributes are consistently applied to
the print document.

* **Improvements**
* More robust print preparation using a hidden-but-rendering iframe
document, deep-cloning content (flattening shadow DOM), and preserved
canvas mapping for accurate renders.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
sahilkhan09k
2026-03-13 07:27:07 +05:30
committed by GitHub
parent 13ad1beb10
commit 8f571ddc30

View File

@@ -17,7 +17,14 @@ export async function printToPdf(
return new Promise<void>((resolve, reject) => {
const iframe = document.createElement('iframe');
document.body.append(iframe);
iframe.style.display = 'none';
// Use a hidden but rendering-enabled state instead of display: none
Object.assign(iframe.style, {
visibility: 'hidden',
position: 'absolute',
width: '0',
height: '0',
border: 'none',
});
iframe.srcdoc = '<!DOCTYPE html>';
iframe.onload = async () => {
if (!iframe.contentWindow) {
@@ -28,6 +35,44 @@ export async function printToPdf(
reject(new Error('Root element not defined, unable to print pdf'));
return;
}
const doc = iframe.contentWindow.document;
doc.write(`<!DOCTYPE html><html><head><style>@media print {
html, body {
height: initial !important;
overflow: initial !important;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
color: #000 !important;
background: #fff !important;
color-scheme: light !important;
}
::-webkit-scrollbar {
display: none;
}
:root, body {
--affine-text-primary: #000 !important;
--affine-text-secondary: #111 !important;
--affine-text-tertiary: #333 !important;
--affine-background-primary: #fff !important;
--affine-background-secondary: #fff !important;
--affine-background-tertiary: #fff !important;
}
body, [data-theme='dark'] {
color: #000 !important;
background: #fff !important;
}
body * {
color: #000 !important;
-webkit-text-fill-color: #000 !important;
}
:root {
--affine-note-shadow-box: none !important;
--affine-note-shadow-sticker: none !important;
}
}</style></head><body></body></html>`);
doc.close();
iframe.contentWindow.document
.write(`<!DOCTYPE html><html><head><style>@media print {
html, body {
@@ -71,7 +116,7 @@ export async function printToPdf(
for (const element of document.styleSheets) {
try {
for (const cssRule of element.cssRules) {
const target = iframe.contentWindow.document.styleSheets[0];
const target = doc.styleSheets[0];
target.insertRule(cssRule.cssText, target.cssRules.length);
}
} catch (e) {
@@ -86,12 +131,33 @@ export async function printToPdf(
}
}
// Recursive function to find all canvases, including those in shadow roots
const findAllCanvases = (root: Node): HTMLCanvasElement[] => {
const canvases: HTMLCanvasElement[] = [];
const traverse = (node: Node) => {
if (node instanceof HTMLCanvasElement) {
canvases.push(node);
}
if (node instanceof HTMLElement || node instanceof ShadowRoot) {
node.childNodes.forEach(traverse);
}
if (node instanceof HTMLElement && node.shadowRoot) {
traverse(node.shadowRoot);
}
};
traverse(root);
return canvases;
};
// convert all canvas to image
const canvasImgObjectUrlMap = new Map<string, string>();
const allCanvas = rootElement.getElementsByTagName('canvas');
const allCanvas = findAllCanvases(rootElement);
let canvasKey = 1;
const canvasToKeyMap = new Map<HTMLCanvasElement, string>();
for (const canvas of allCanvas) {
canvas.dataset['printToPdfCanvasKey'] = canvasKey.toString();
const key = canvasKey.toString();
canvasToKeyMap.set(canvas, key);
canvasKey++;
const canvasImgObjectUrl = await new Promise<Blob | null>(resolve => {
try {
@@ -106,20 +172,42 @@ export async function printToPdf(
);
continue;
}
canvasImgObjectUrlMap.set(
canvas.dataset['printToPdfCanvasKey'],
URL.createObjectURL(canvasImgObjectUrl)
);
canvasImgObjectUrlMap.set(key, URL.createObjectURL(canvasImgObjectUrl));
}
const importedRoot = iframe.contentWindow.document.importNode(
rootElement,
true
) as HTMLDivElement;
// Recursive deep clone that flattens Shadow DOM into Light DOM
const deepCloneWithShadows = (node: Node): Node => {
const clone = doc.importNode(node, false);
if (
clone instanceof HTMLCanvasElement &&
node instanceof HTMLCanvasElement
) {
const key = canvasToKeyMap.get(node);
if (key) {
clone.dataset['printToPdfCanvasKey'] = key;
}
}
const appendChildren = (source: Node) => {
source.childNodes.forEach(child => {
(clone as Element).append(deepCloneWithShadows(child));
});
};
if (node instanceof HTMLElement && node.shadowRoot) {
appendChildren(node.shadowRoot);
}
appendChildren(node);
return clone;
};
const importedRoot = deepCloneWithShadows(rootElement) as HTMLDivElement;
// force light theme in print iframe
iframe.contentWindow.document.documentElement.dataset.theme = 'light';
iframe.contentWindow.document.body.dataset.theme = 'light';
doc.documentElement.dataset.theme = 'light';
doc.body.dataset.theme = 'light';
importedRoot.dataset.theme = 'light';
// draw saved canvas image to canvas
@@ -138,17 +226,67 @@ export async function printToPdf(
}
}
// append to iframe and print
iframe.contentWindow.document.body.append(importedRoot);
// Remove lazy loading from all images and force reload
const allImages = importedRoot.querySelectorAll('img');
allImages.forEach(img => {
img.removeAttribute('loading');
const src = img.getAttribute('src');
if (src) img.setAttribute('src', src);
});
// append to iframe
doc.body.append(importedRoot);
await options.beforeprint?.(iframe);
// browser may take some time to load font
await new Promise<void>(resolve => {
setTimeout(() => {
resolve();
}, 1000);
});
// Robust image waiting logic
const waitForImages = async (container: HTMLElement) => {
const images: HTMLImageElement[] = [];
const view = container.ownerDocument.defaultView;
if (!view) return;
const findImages = (root: Node) => {
if (root instanceof view.HTMLImageElement) {
images.push(root);
}
if (
root instanceof view.HTMLElement ||
root instanceof view.ShadowRoot
) {
root.childNodes.forEach(findImages);
}
if (root instanceof view.HTMLElement && root.shadowRoot) {
findImages(root.shadowRoot);
}
};
findImages(container);
await Promise.all(
images.map(img => {
if (img.complete) {
if (img.naturalWidth === 0) {
console.warn('Image failed to load:', img.src);
}
return Promise.resolve();
}
return new Promise(resolve => {
img.onload = resolve;
img.onerror = resolve;
});
})
);
};
await waitForImages(importedRoot);
// browser may take some time to load font or other resources
await (doc.fonts?.ready ??
new Promise<void>(resolve => {
setTimeout(() => {
resolve();
}, 1000);
}));
iframe.contentWindow.onafterprint = async () => {
iframe.remove();