feat(drive): update document printing and PDF export functionality
Some checks are pending
E2E / Playwright e2e (push) Waiting to run

- Replaced `html2canvas` with `html2canvas-pro` for improved rendering in document capture.
- Enhanced error handling in print functions to provide user feedback on print failures.
- Introduced new `docs-page-capture` module to streamline page capture logic for PDF exports.
- Refactored PDF export process to utilize captured canvases, improving performance and reliability.
- Updated print styles for better document layout during printing and PDF generation.
This commit is contained in:
R3D347HR4Y 2026-06-15 17:53:27 +02:00
parent 76eff3c351
commit bd9605c853
10 changed files with 460 additions and 362 deletions

View File

@ -671,12 +671,13 @@ export function RichTextDocumentEditor({
const handlePrintDocument = useCallback(async () => { const handlePrintDocument = useCallback(async () => {
const snapshot = getExportSnapshot() const snapshot = getExportSnapshot()
if (!snapshot) { if (!snapshot) {
window.print() toast.error("Impossible d'imprimer le document")
return return
} }
try { try {
await printDocsDocument(snapshot) await printDocsDocument(snapshot)
} catch { } catch (error) {
console.error("[docs] print failed", error)
toast.error("Impossible d'imprimer le document") toast.error("Impossible d'imprimer le document")
} }
}, [getExportSnapshot]) }, [getExportSnapshot])

View File

@ -24,6 +24,7 @@ import {
X, X,
} from "lucide-react" } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { toast } from "sonner"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -193,7 +194,13 @@ function DocsToolbarInner({
sepAfter: false, sepAfter: false,
node: ( node: (
<> <>
<ToolbarIconBtn label="Imprimer" onClick={() => onPrint?.() ?? window.print()}> <ToolbarIconBtn
label="Imprimer"
onClick={() => {
if (onPrint) onPrint()
else toast.error("Impossible d'imprimer le document")
}}
>
<Printer className="size-4" /> <Printer className="size-4" />
</ToolbarIconBtn> </ToolbarIconBtn>
<ToolbarIconBtn <ToolbarIconBtn

View File

@ -0,0 +1,255 @@
import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
import { DOCS_PAGE_GAP_PX } from "@/lib/drive/docs-page-layout-constants"
import type { DocsExportSnapshot } from "@/lib/drive/docs-export-snapshot"
export type DocsPageCaptureTarget = {
stack: HTMLElement
pageWidth: number
pageHeight: number
pageCount: number
}
export function resolveDocsPageCaptureTarget(
snapshot: DocsExportSnapshot
): DocsPageCaptureTarget | null {
const stack = snapshot.getPageStackElement()
if (!stack) return null
return {
stack,
pageWidth: Number(stack.dataset.docsPageWidth ?? snapshot.pageLayout.widthPx),
pageHeight: Number(stack.dataset.docsPageHeight ?? snapshot.pageLayout.heightPx),
pageCount: snapshot.pageCount,
}
}
function normalizeStackCloneForCapture(
stack: HTMLElement,
pageLayout: DocPageLayout,
pageCount: number
): void {
const pageWidth = pageLayout.widthPx
const pageHeight = pageLayout.heightPx
const pageStep = pageHeight + DOCS_PAGE_GAP_PX
const gapTotal = Math.max(0, pageCount - 1) * DOCS_PAGE_GAP_PX
stack.style.transform = "none"
stack.style.transformOrigin = "top left"
stack.style.left = "0"
stack.style.marginLeft = "0"
stack.style.position = "relative"
stack.style.width = `${pageWidth}px`
stack.classList.remove("left-1/2", "-translate-x-1/2", "top-0")
stack.querySelectorAll<HTMLElement>(".docs-page-gap-band").forEach((el) => {
el.style.display = "none"
})
stack.querySelectorAll<HTMLElement>(".docs-page-inter-margin-gutter").forEach((el) => {
el.style.display = "none"
})
const stackHeight = parseFloat(stack.style.height)
if (Number.isFinite(stackHeight) && gapTotal > 0) {
stack.style.height = `${stackHeight - gapTotal}px`
}
stack.querySelectorAll<HTMLElement>("[style]").forEach((el) => {
const top = parseFloat(el.style.top)
if (!Number.isFinite(top) || top <= 0) return
const pageIndex = Math.floor(top / pageStep)
el.style.top = `${top - pageIndex * DOCS_PAGE_GAP_PX}px`
})
stack.querySelectorAll<HTMLElement>(".docs-page-flow-spacer").forEach((spacer) => {
const height = parseFloat(spacer.style.height)
if (Number.isFinite(height) && height >= DOCS_PAGE_GAP_PX) {
spacer.style.height = `${height - DOCS_PAGE_GAP_PX}px`
}
})
const surface = stack.querySelector<HTMLElement>(".ultidrive-docs-editor-surface")
if (surface) {
const surfaceHeight = parseFloat(surface.style.height)
if (Number.isFinite(surfaceHeight) && gapTotal > 0) {
surface.style.height = `${surfaceHeight - gapTotal}px`
}
surface.removeAttribute("contenteditable")
}
stack.querySelectorAll<HTMLElement>(".ProseMirror").forEach((el) => {
el.removeAttribute("contenteditable")
})
}
async function cloneStylesIntoDocument(targetDoc: Document): Promise<void> {
const head = targetDoc.head
const loads: Promise<void>[] = []
const styleNodes = document.querySelectorAll('link[rel="stylesheet"], style')
for (const node of styleNodes) {
if (node instanceof HTMLLinkElement) {
const href = node.href
if (!href) continue
const link = targetDoc.createElement("link")
link.rel = "stylesheet"
link.href = href
if (node.media) link.media = node.media
loads.push(
new Promise<void>((resolve) => {
link.onload = () => resolve()
link.onerror = () => resolve()
})
)
head.appendChild(link)
continue
}
head.appendChild(node.cloneNode(true))
}
targetDoc.documentElement.lang = document.documentElement.lang
targetDoc.documentElement.className = document.documentElement.className
targetDoc.documentElement.classList.remove("dark")
targetDoc.documentElement.style.background = "#ffffff"
await Promise.all(loads)
if (targetDoc.fonts?.ready) {
await targetDoc.fonts.ready
}
}
async function createCaptureFrame(
stack: HTMLElement,
pageLayout: DocPageLayout,
pageCount: number
): Promise<{ root: HTMLElement; dispose: () => void }> {
const pageWidth = pageLayout.widthPx
const pageHeight = pageLayout.heightPx
const stageHeight = pageCount * pageHeight
const iframe = document.createElement("iframe")
iframe.setAttribute("aria-hidden", "true")
iframe.style.cssText =
"position:fixed;top:0;left:0;width:1px;height:1px;border:0;opacity:0;pointer-events:none;overflow:hidden"
const docReady = new Promise<Document>((resolve, reject) => {
iframe.onload = () => {
const doc = iframe.contentDocument
if (doc) resolve(doc)
else reject(new Error("Impossible de préparer la capture"))
}
iframe.onerror = () => reject(new Error("Impossible de préparer la capture"))
})
iframe.src = "about:blank"
document.body.appendChild(iframe)
const doc = await docReady
await cloneStylesIntoDocument(doc)
const clone = stack.cloneNode(true) as HTMLElement
normalizeStackCloneForCapture(clone, pageLayout, pageCount)
doc.body.replaceChildren()
doc.body.style.cssText = `margin:0;padding:0;background:#ffffff;width:${pageWidth}px;height:${stageHeight}px;overflow:hidden`
const mount = doc.createElement("div")
mount.style.cssText = `position:relative;width:${pageWidth}px;height:${stageHeight}px;overflow:hidden`
mount.appendChild(clone)
doc.body.appendChild(mount)
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
return {
root: clone,
dispose: () => {
iframe.remove()
},
}
}
async function rasterizePageSlice(
element: HTMLElement,
pageWidth: number,
pageHeight: number,
yOffset: number,
scale: number
): Promise<HTMLCanvasElement> {
const { default: html2canvas } = await import("html2canvas-pro")
const canvas = await html2canvas(element, {
backgroundColor: "#ffffff",
scale,
useCORS: true,
logging: false,
width: pageWidth,
height: pageHeight,
x: 0,
y: yOffset,
scrollX: 0,
scrollY: 0,
windowWidth: pageWidth,
windowHeight: element.scrollHeight,
})
if (canvas.width === 0 || canvas.height === 0) {
throw new Error("Capture page vide")
}
return canvas
}
async function withFrozenViewport<T>(fn: () => Promise<T>): Promise<T> {
const scrollX = window.scrollX
const scrollY = window.scrollY
const html = document.documentElement
const body = document.body
const prevHtmlOverflow = html.style.overflow
const prevBodyOverflow = body.style.overflow
html.style.overflow = "hidden"
body.style.overflow = "hidden"
try {
return await fn()
} finally {
html.style.overflow = prevHtmlOverflow
body.style.overflow = prevBodyOverflow
window.scrollTo(scrollX, scrollY)
}
}
/** Rasterize each page from an isolated iframe clone — no live UI mutation. */
export async function captureDocsPagesFromCanvas(
snapshot: DocsExportSnapshot,
options?: { scale?: number }
): Promise<HTMLCanvasElement[]> {
const target = resolveDocsPageCaptureTarget(snapshot)
if (!target) {
throw new Error("Page stack introuvable")
}
const { stack, pageWidth, pageHeight, pageCount } = target
const preferredScale = options?.scale ?? 2
return withFrozenViewport(async () => {
const { root, dispose } = await createCaptureFrame(stack, snapshot.pageLayout, pageCount)
try {
const canvases: HTMLCanvasElement[] = []
for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
const yOffset = pageIndex * pageHeight
let canvas: HTMLCanvasElement
try {
canvas = await rasterizePageSlice(root, pageWidth, pageHeight, yOffset, preferredScale)
} catch (firstError) {
if (preferredScale === 1) throw firstError
canvas = await rasterizePageSlice(root, pageWidth, pageHeight, yOffset, 1)
}
canvases.push(canvas)
}
return canvases
} finally {
dispose()
}
})
}

View File

@ -1,6 +1,5 @@
import { DOCS_PAGE_GAP_PX } from "@/lib/drive/docs-page-layout-constants"
import type { DocsExportSnapshot } from "@/lib/drive/docs-export-snapshot" import type { DocsExportSnapshot } from "@/lib/drive/docs-export-snapshot"
import { prepareDocsPrintEnvironment } from "@/lib/drive/docs-print" import { captureDocsPagesFromCanvas } from "@/lib/drive/docs-page-capture"
import { exportFileName } from "@/lib/drive/docs-export-download" import { exportFileName } from "@/lib/drive/docs-export-download"
function downloadBlob(blob: Blob, fileName: string) { function downloadBlob(blob: Blob, fileName: string) {
@ -13,23 +12,16 @@ function downloadBlob(blob: Blob, fileName: string) {
} }
export async function exportDocsToPdf(snapshot: DocsExportSnapshot): Promise<void> { export async function exportDocsToPdf(snapshot: DocsExportSnapshot): Promise<void> {
const cleanup = await prepareDocsPrintEnvironment(snapshot, "pdf-capture") const canvases = await captureDocsPagesFromCanvas(snapshot, { scale: 2 })
try { const [{ jsPDF }] = await Promise.all([import("jspdf")])
const stack = snapshot.getPageStackElement()
if (!stack) throw new Error("Page stack introuvable")
const canvasHost = stack.closest(".ultidrive-docs-canvas") as HTMLElement | null const pageWidth = Number(
if (!canvasHost) throw new Error("Canvas introuvable") snapshot.getPageStackElement()?.dataset.docsPageWidth ?? snapshot.pageLayout.widthPx
)
const pageHeight = Number(stack.dataset.docsPageHeight ?? snapshot.pageLayout.heightPx) const pageHeight = Number(
const pageWidth = Number(stack.dataset.docsPageWidth ?? snapshot.pageLayout.widthPx) snapshot.getPageStackElement()?.dataset.docsPageHeight ?? snapshot.pageLayout.heightPx
const pageCount = snapshot.pageCount )
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
import("html2canvas"),
import("jspdf"),
])
const pdf = new jsPDF({ const pdf = new jsPDF({
orientation: pageWidth > pageHeight ? "landscape" : "portrait", orientation: pageWidth > pageHeight ? "landscape" : "portrait",
@ -40,25 +32,8 @@ export async function exportDocsToPdf(snapshot: DocsExportSnapshot): Promise<voi
], ],
}) })
const hostRect = canvasHost.getBoundingClientRect() for (let pageIndex = 0; pageIndex < canvases.length; pageIndex += 1) {
const stackRect = stack.getBoundingClientRect() const imgData = canvases[pageIndex].toDataURL("image/jpeg", 0.92)
for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
const yOffset = pageIndex * (pageHeight + DOCS_PAGE_GAP_PX)
const canvas = await html2canvas(canvasHost, {
backgroundColor: "#ffffff",
scale: 2,
useCORS: true,
logging: false,
width: pageWidth,
height: pageHeight,
x: stackRect.left - hostRect.left,
y: stackRect.top - hostRect.top + yOffset,
windowWidth: canvasHost.scrollWidth,
windowHeight: canvasHost.scrollHeight,
})
const imgData = canvas.toDataURL("image/jpeg", 0.92)
if (pageIndex > 0) pdf.addPage() if (pageIndex > 0) pdf.addPage()
pdf.addImage( pdf.addImage(
imgData, imgData,
@ -72,7 +47,4 @@ export async function exportDocsToPdf(snapshot: DocsExportSnapshot): Promise<voi
const blob = pdf.output("blob") const blob = pdf.output("blob")
downloadBlob(blob, exportFileName(snapshot.sourceName, "pdf")) downloadBlob(blob, exportFileName(snapshot.sourceName, "pdf"))
} finally {
cleanup()
}
} }

View File

@ -1,10 +1,7 @@
import type { DocPageLayout } from "@/lib/drive/doc-page-setup" import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
import { buildParagraphStylesCss } from "@/lib/drive/docs-paragraph-styles-css"
import type { DocParagraphStylesCatalog } from "@/lib/drive/docs-paragraph-styles"
import type { DocsExportSnapshot } from "@/lib/drive/docs-export-snapshot" import type { DocsExportSnapshot } from "@/lib/drive/docs-export-snapshot"
import { DOCS_PAGE_GAP_PX } from "@/lib/drive/docs-page-layout-constants" import { captureDocsPagesFromCanvas } from "@/lib/drive/docs-page-capture"
const PRINT_STYLE_ID = "docs-print-dynamic-styles"
const PRINT_IFRAME_ID = "docs-print-iframe" const PRINT_IFRAME_ID = "docs-print-iframe"
function buildPageRule(pageLayout: DocPageLayout): string { function buildPageRule(pageLayout: DocPageLayout): string {
@ -15,257 +12,147 @@ function buildPageRule(pageLayout: DocPageLayout): string {
return `@page { size: ${size}; margin: 0; }` return `@page { size: ${size}; margin: 0; }`
} }
function buildPrintLayoutRules(pageLayout: DocPageLayout): string { function buildPrintStylesheet(pageLayout: DocPageLayout): string {
const pageWidth = pageLayout.widthPx const pageWidth = pageLayout.widthPx
const pageHeight = pageLayout.heightPx const pageHeight = pageLayout.heightPx
return ` return `
${buildPageRule(pageLayout)}
html, body { html, body {
margin: 0 !important; margin: 0;
padding: 0 !important; padding: 0;
background: white !important; background: white;
width: ${pageWidth}px !important;
min-height: 0 !important;
overflow: visible !important;
} }
.docs-print-page {
.docs-print-root { width: ${pageWidth}px;
margin: 0 !important; height: ${pageHeight}px;
padding: 0 !important; overflow: hidden;
width: ${pageWidth}px !important;
background: white !important;
}
[data-docs-page-stack] {
position: relative !important;
left: 0 !important;
top: 0 !important;
transform: none !important;
transform-origin: top left !important;
width: ${pageWidth}px !important;
margin: 0 !important;
}
.ultidrive-docs-page {
width: ${pageWidth}px !important;
height: ${pageHeight}px !important;
box-shadow: none !important;
border: none !important;
break-after: page; break-after: page;
page-break-after: always; page-break-after: always;
print-color-adjust: exact; print-color-adjust: exact;
-webkit-print-color-adjust: exact; -webkit-print-color-adjust: exact;
} }
.docs-print-page:last-child {
.ultidrive-docs-page:last-child {
break-after: auto; break-after: auto;
page-break-after: auto; page-break-after: auto;
} }
.docs-print-page img {
.docs-body-margin-mask { display: block;
border-color: transparent !important; width: 100%;
} height: 100%;
.docs-hf-chrome,
.docs-graphic-handle,
.docs-graphic-rotate-handle,
.docs-graphic-outline,
.docs-graphic-snap-guides {
display: none !important;
}
.docs-hf-band .docs-region-editor-root {
border: none !important;
background: transparent !important;
} }
` `
} }
type StyleRestore = () => void function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
function patchInlineStyle(el: HTMLElement, property: string, value: string): StyleRestore { canvas.toBlob(
const previous = el.style.getPropertyValue(property) (blob) => {
const priority = el.style.getPropertyPriority(property) if (blob) resolve(blob)
el.style.setProperty(property, value) else reject(new Error("Impossible de convertir la page en image"))
return () => { },
if (previous) { "image/jpeg",
el.style.setProperty(property, previous, priority) 0.92
} else {
el.style.removeProperty(property)
}
}
}
/** Collapse on-screen page gaps and zoom offsets so print matches page size. */
function adjustPageStackForPrint(
stack: HTMLElement,
pageLayout: DocPageLayout,
pageCount: number
): StyleRestore {
const restores: StyleRestore[] = []
const pageWidth = pageLayout.widthPx
const pageHeight = pageLayout.heightPx
const pageStep = pageHeight + DOCS_PAGE_GAP_PX
const gapTotal = Math.max(0, pageCount - 1) * DOCS_PAGE_GAP_PX
restores.push(patchInlineStyle(stack, "transform", "none"))
restores.push(patchInlineStyle(stack, "left", "0"))
restores.push(patchInlineStyle(stack, "width", `${pageWidth}px`))
let wrapper = stack.parentElement
while (wrapper && !wrapper.classList.contains("ultidrive-docs-canvas")) {
restores.push(patchInlineStyle(wrapper, "width", `${pageWidth}px`))
restores.push(patchInlineStyle(wrapper, "height", "auto"))
restores.push(patchInlineStyle(wrapper, "min-height", "0"))
restores.push(patchInlineStyle(wrapper, "padding-top", "0"))
restores.push(patchInlineStyle(wrapper, "padding-bottom", "0"))
restores.push(patchInlineStyle(wrapper, "margin-left", "0"))
restores.push(patchInlineStyle(wrapper, "margin-right", "0"))
wrapper = wrapper.parentElement
}
const stackHeight = parseFloat(stack.style.height)
if (Number.isFinite(stackHeight) && gapTotal > 0) {
restores.push(patchInlineStyle(stack, "height", `${stackHeight - gapTotal}px`))
}
stack.querySelectorAll<HTMLElement>("[style]").forEach((el) => {
const top = parseFloat(el.style.top)
if (!Number.isFinite(top) || top <= 0) return
const pageIndex = Math.floor(top / pageStep)
const nextTop = top - pageIndex * DOCS_PAGE_GAP_PX
if (nextTop !== top) {
restores.push(patchInlineStyle(el, "top", `${nextTop}px`))
}
})
stack.querySelectorAll<HTMLElement>(".docs-page-flow-spacer").forEach((spacer) => {
const height = parseFloat(spacer.style.height)
if (Number.isFinite(height) && height >= DOCS_PAGE_GAP_PX) {
restores.push(
patchInlineStyle(spacer, "height", `${height - DOCS_PAGE_GAP_PX}px`)
) )
}
}) })
const surface = stack.querySelector<HTMLElement>(".ultidrive-docs-editor-surface")
if (surface) {
const surfaceHeight = parseFloat(surface.style.height)
if (Number.isFinite(surfaceHeight) && gapTotal > 0) {
restores.push(
patchInlineStyle(surface, "height", `${surfaceHeight - gapTotal}px`)
)
}
}
return () => {
for (let i = restores.length - 1; i >= 0; i -= 1) {
restores[i]()
}
}
}
function buildDynamicPrintStyles(
pageLayout: DocPageLayout,
paragraphStyles: DocParagraphStylesCatalog
): string {
return [
buildPageRule(pageLayout),
buildPrintLayoutRules(pageLayout),
buildParagraphStylesCss(paragraphStyles, ".ultidrive-richtext-editor, .ultidrive-richtext-region-editor"),
].join("\n")
}
function injectPrintStyles(
pageLayout: DocPageLayout,
paragraphStyles: DocParagraphStylesCatalog
): () => void {
const existing = document.getElementById(PRINT_STYLE_ID)
existing?.remove()
const style = document.createElement("style")
style.id = PRINT_STYLE_ID
style.textContent = buildDynamicPrintStyles(pageLayout, paragraphStyles)
document.head.appendChild(style)
return () => {
style.remove()
}
}
function copyDocumentStyles(targetDoc: Document): void {
document.querySelectorAll('style, link[rel="stylesheet"]').forEach((node) => {
targetDoc.head.appendChild(node.cloneNode(true))
})
}
async function waitForDocumentReady(doc: Document): Promise<void> {
const links = Array.from(doc.querySelectorAll('link[rel="stylesheet"]'))
await Promise.all(
links.map(
(link) =>
new Promise<void>((resolve) => {
const sheet = link as HTMLLinkElement
if (sheet.sheet) {
resolve()
return
}
link.addEventListener("load", () => resolve(), { once: true })
link.addEventListener("error", () => resolve(), { once: true })
})
)
)
if (doc.fonts?.ready) {
await doc.fonts.ready
}
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
} }
function removePrintIframe(): void { function removePrintIframe(): void {
document.getElementById(PRINT_IFRAME_ID)?.remove() document.getElementById(PRINT_IFRAME_ID)?.remove()
} }
async function printStackInIframe( async function mountPrintDocument(
stack: HTMLElement, doc: Document,
snapshot: DocsExportSnapshot canvases: HTMLCanvasElement[],
pageLayout: DocPageLayout
): Promise<() => void> {
const pageWidth = pageLayout.widthPx
const pageHeight = pageLayout.heightPx
const objectUrls: string[] = []
doc.documentElement.lang = "fr"
doc.head.replaceChildren()
doc.body.replaceChildren()
const style = doc.createElement("style")
style.textContent = buildPrintStylesheet(pageLayout)
doc.head.appendChild(style)
for (const canvas of canvases) {
const blob = await canvasToBlob(canvas)
const url = URL.createObjectURL(blob)
objectUrls.push(url)
const page = doc.createElement("div")
page.className = "docs-print-page"
page.style.width = `${pageWidth}px`
page.style.height = `${pageHeight}px`
const img = doc.createElement("img")
img.alt = ""
img.width = pageWidth
img.height = pageHeight
img.src = url
page.appendChild(img)
doc.body.appendChild(page)
await img.decode().catch(() => undefined)
}
return () => {
for (const url of objectUrls) {
URL.revokeObjectURL(url)
}
}
}
async function printPageCanvasesInIframe(
canvases: HTMLCanvasElement[],
pageLayout: DocPageLayout
): Promise<void> { ): Promise<void> {
if (canvases.length === 0) {
throw new Error("Aucune page à imprimer")
}
removePrintIframe() removePrintIframe()
const iframe = document.createElement("iframe") const iframe = document.createElement("iframe")
iframe.id = PRINT_IFRAME_ID iframe.id = PRINT_IFRAME_ID
iframe.setAttribute("aria-hidden", "true") iframe.setAttribute("aria-hidden", "true")
iframe.style.cssText = iframe.style.cssText =
"position:fixed;right:0;bottom:0;width:0;height:0;border:0;opacity:0;pointer-events:none" "position:fixed;left:-100000px;top:0;width:1px;height:1px;border:0;opacity:0;pointer-events:none"
const docReady = new Promise<Document>((resolve, reject) => {
iframe.onload = () => {
const doc = iframe.contentDocument
if (doc) resolve(doc)
else reject(new Error("Impossible de préparer la fenêtre d'impression"))
}
iframe.onerror = () => reject(new Error("Impossible de préparer la fenêtre d'impression"))
})
iframe.src = "about:blank"
document.body.appendChild(iframe) document.body.appendChild(iframe)
const doc = iframe.contentDocument const doc = await docReady
const win = iframe.contentWindow const win = iframe.contentWindow
if (!doc || !win) { if (!win) {
removePrintIframe() removePrintIframe()
throw new Error("Impossible de préparer la fenêtre d'impression") throw new Error("Impossible de préparer la fenêtre d'impression")
} }
copyDocumentStyles(doc) let revokeObjectUrls = () => {}
try {
revokeObjectUrls = await mountPrintDocument(doc, canvases, pageLayout)
const printStyle = doc.createElement("style") await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
printStyle.textContent = buildDynamicPrintStyles( if (doc.fonts?.ready) {
snapshot.pageLayout, await doc.fonts.ready
snapshot.paragraphStyles }
)
doc.head.appendChild(printStyle)
doc.documentElement.classList.add("docs-printing")
const root = doc.createElement("div")
root.className = "docs-print-root"
root.appendChild(stack.cloneNode(true))
doc.body.replaceChildren(root)
await waitForDocumentReady(doc)
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const cleanup = () => { const cleanup = () => {
win.removeEventListener("afterprint", onAfterPrint) win.removeEventListener("afterprint", onAfterPrint)
revokeObjectUrls()
removePrintIframe() removePrintIframe()
} }
@ -292,57 +179,24 @@ async function printStackInIframe(
} }
}, 2000) }, 2000)
}) })
} catch (error) {
revokeObjectUrls()
removePrintIframe()
throw error
} }
async function waitForPrintLayout(): Promise<void> {
if (typeof document !== "undefined" && document.fonts?.ready) {
await document.fonts.ready
}
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
} }
export type DocsPrintMode = "print" | "pdf-capture" export type DocsPrintMode = "print" | "pdf-capture"
/** @deprecated Live DOM is no longer mutated; kept for PDF export compatibility. */
export async function prepareDocsPrintEnvironment( export async function prepareDocsPrintEnvironment(
snapshot: DocsExportSnapshot, _snapshot: DocsExportSnapshot,
mode: DocsPrintMode = "print" _mode: DocsPrintMode = "print"
): Promise<() => void> { ): Promise<() => void> {
const root = document.documentElement return () => {}
root.classList.add("docs-printing")
if (mode === "pdf-capture") {
root.classList.add("docs-pdf-capture")
}
const removeStyles = injectPrintStyles(snapshot.pageLayout, snapshot.paragraphStyles)
const stack = snapshot.getPageStackElement()
const restoreStackLayout =
stack != null
? adjustPageStackForPrint(stack, snapshot.pageLayout, snapshot.pageCount)
: () => {}
await waitForPrintLayout()
return () => {
restoreStackLayout()
root.classList.remove("docs-printing", "docs-pdf-capture")
removeStyles()
}
} }
export async function printDocsDocument(snapshot: DocsExportSnapshot): Promise<void> { export async function printDocsDocument(snapshot: DocsExportSnapshot): Promise<void> {
const cleanup = await prepareDocsPrintEnvironment(snapshot, "print") const canvases = await captureDocsPagesFromCanvas(snapshot, { scale: 2 })
await printPageCanvasesInIframe(canvases, snapshot.pageLayout)
try {
const stack = snapshot.getPageStackElement()
if (!stack) {
window.print()
return
}
await printStackInIframe(stack, snapshot)
} finally {
cleanup()
}
} }

View File

@ -176,12 +176,13 @@ export function useDocsFileMenu({
const handlePrint = useCallback(async () => { const handlePrint = useCallback(async () => {
const snapshot = getExportSnapshot() const snapshot = getExportSnapshot()
if (!snapshot) { if (!snapshot) {
window.print() toast.error("Impossible d'imprimer le document")
return return
} }
try { try {
await printDocsDocument(snapshot) await printDocsDocument(snapshot)
} catch { } catch (error) {
console.error("[docs] print failed", error)
toast.error("Impossible d'imprimer le document") toast.error("Impossible d'imprimer le document")
} }
}, [getExportSnapshot]) }, [getExportSnapshot])

View File

@ -99,7 +99,7 @@
"fflate": "^0.8.3", "fflate": "^0.8.3",
"fractional-indexing": "^3.2.0", "fractional-indexing": "^3.2.0",
"fuse.js": "^7.3.0", "fuse.js": "^7.3.0",
"html2canvas": "^1.4.1", "html2canvas-pro": "^2.0.4",
"idb": "^8.0.3", "idb": "^8.0.3",
"input-otp": "1.4.2", "input-otp": "1.4.2",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",

View File

@ -245,9 +245,9 @@ importers:
fuse.js: fuse.js:
specifier: ^7.3.0 specifier: ^7.3.0
version: 7.3.0 version: 7.3.0
html2canvas: html2canvas-pro:
specifier: ^1.4.1 specifier: ^2.0.4
version: 1.4.1 version: 2.0.4
idb: idb:
specifier: ^8.0.3 specifier: ^8.0.3
version: 8.0.3 version: 8.0.3
@ -2720,6 +2720,10 @@ packages:
hash.js@1.1.7: hash.js@1.1.7:
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
html2canvas-pro@2.0.4:
resolution: {integrity: sha512-tfL8XNvuITvYQJKgAx4bvANauuLKc88C+ZSZt7HZJveqQBWjBDtkqs/It06UzlqbM+sSq7Cv45rFbuUxOFgmow==}
engines: {node: '>=16.0.0'}
html2canvas@1.4.1: html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@ -5981,10 +5985,16 @@ snapshots:
inherits: 2.0.4 inherits: 2.0.4
minimalistic-assert: 1.0.1 minimalistic-assert: 1.0.1
html2canvas-pro@2.0.4:
dependencies:
css-line-break: 2.1.0
text-segmentation: 1.0.3
html2canvas@1.4.1: html2canvas@1.4.1:
dependencies: dependencies:
css-line-break: 2.1.0 css-line-break: 2.1.0
text-segmentation: 1.0.3 text-segmentation: 1.0.3
optional: true
iconv-lite@0.6.3: iconv-lite@0.6.3:
dependencies: dependencies:

View File

@ -51,7 +51,6 @@
.docs-printing.docs-pdf-capture .ultidrive-docs-page { .docs-printing.docs-pdf-capture .ultidrive-docs-page {
box-shadow: none !important; box-shadow: none !important;
border: none !important;
} }
.docs-printing.docs-pdf-capture .docs-body-margin-mask { .docs-printing.docs-pdf-capture .docs-body-margin-mask {
@ -59,8 +58,7 @@
} }
.docs-printing.docs-pdf-capture .docs-page-gap-band, .docs-printing.docs-pdf-capture .docs-page-gap-band,
.docs-printing.docs-pdf-capture .docs-page-inter-margin-gutter, .docs-printing.docs-pdf-capture .docs-page-inter-margin-gutter {
.docs-printing.docs-pdf-capture .docs-page-rim {
display: none !important; display: none !important;
} }

File diff suppressed because one or more lines are too long