mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-03-23 07:40:46 +08:00
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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user