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(".docs-page-gap-band").forEach((el) => { el.style.display = "none" }) stack.querySelectorAll(".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("[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(".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(".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(".ProseMirror").forEach((el) => { el.removeAttribute("contenteditable") }) } async function cloneStylesIntoDocument(targetDoc: Document): Promise { const head = targetDoc.head const loads: Promise[] = [] 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((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((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((resolve) => requestAnimationFrame(() => resolve())) await new Promise((resolve) => requestAnimationFrame(() => resolve())) return { root: clone, dispose: () => { iframe.remove() }, } } async function rasterizePageSlice( element: HTMLElement, pageWidth: number, pageHeight: number, yOffset: number, scale: number ): Promise { 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(fn: () => Promise): Promise { 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 { 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() } }) }