ultisuite-client/lib/drive/docs-page-capture.ts
R3D347HR4Y bd9605c853
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(drive): update document printing and PDF export functionality
- 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.
2026-06-15 17:53:27 +02:00

256 lines
7.8 KiB
TypeScript

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()
}
})
}