// Pipeline de génération PDF (spec §10) : // 1. gel du contenu (§7.3) : textes + médias utilisés copiés dans data/frozen// // 2. rendu de la route /print/ par Playwright/Chromium (mêmes composants Vue) // 3. page.pdf() au format 16:9 (960×540 pt ≡ 1280×720 px) // 4. post-traitement Ghostscript : recompression + métadonnées import { copyFile, mkdir, rename, rm, writeFile } from 'node:fs/promises' import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { dirname, join } from 'node:path' import { chromium } from 'playwright' import type { FrozenSnapshot, Portfolio, Project } from '~~/types' import { useContentSource } from './content-source' import { savePortfolio } from './portfolio-store' const execFileAsync = promisify(execFile) export function sanitizeFileName(name: string): string { const base = name .normalize('NFD') .replace(/[̀-ͯ]/g, '') .replace(/[^\w\s-]/g, '') .trim() .replace(/\s+/g, '-') .toLowerCase() return (base || 'portfolio') + '.pdf' } /** Fige le contenu utilisé : projets référencés + copie des fichiers médias. */ export async function freezeContent(portfolio: Portfolio, fileName: string): Promise { const config = useRuntimeConfig() const source = useContentSource() const frozenDir = join(config.dataDir, 'frozen', portfolio.id) // Re-création à neuf du snapshot await rm(frozenDir, { recursive: true, force: true }) await mkdir(frozenDir, { recursive: true }) const projectIds = new Set() for (const page of portfolio.pages) { if (page.kind === 'project' || page.kind === 'project-grid') projectIds.add(page.projectId) if (page.kind === 'free') { for (const ref of page.media ?? []) projectIds.add(ref.slice(0, ref.lastIndexOf('/'))) } } if (portfolio.cover.backgroundImage) { const ref = portfolio.cover.backgroundImage projectIds.add(ref.slice(0, ref.lastIndexOf('/'))) } const projects: Record = {} for (const id of projectIds) { const project = await source.getProject(id) if (!project) continue projects[id] = project // Copie de toutes les variantes des médias du projet utilisés for (const asset of project.media) { for (const variant of asset.variants) { const abs = source.resolveMediaPath(id, variant.path) if (!abs) continue const dest = join(frozenDir, id, variant.path) await mkdir(dirname(dest), { recursive: true }) try { await copyFile(abs, dest) } catch { // média manquant : le rendu retombera sur le contenu live } } } } // Instantané de la composition (mise en forme incluse) : la vue publique // rendra CETTE version figée, indépendante des éditions ultérieures. return { generatedAt: new Date().toISOString(), fileName, projects, name: portfolio.name, cover: structuredClone(portfolio.cover), pages: structuredClone(portfolio.pages), } } export interface GenerateResult { fileName: string downloadUrl: string sizeBytes: number pageCount: number } export async function generatePdf(portfolio: Portfolio, rawFileName?: string): Promise { const config = useRuntimeConfig() const fileName = sanitizeFileName(rawFileName || portfolio.frozenContent?.fileName?.replace(/\.pdf$/, '') || portfolio.name) // Gel du contenu : réutilisé tel quel s'il existe (régénération à l'identique), // sinon créé depuis le contenu live. « Réactualiser depuis Grav » le supprime. if (!portfolio.frozenContent) { portfolio.frozenContent = await freezeContent(portfolio, fileName) } else { portfolio.frozenContent.fileName = fileName } await savePortfolio(portfolio) const exportsDir = join(config.dataDir, 'exports') const workDir = join(config.dataDir, 'cache', 'pdf-work') await mkdir(exportsDir, { recursive: true }) await mkdir(workDir, { recursive: true }) const rawPdf = join(workDir, `${portfolio.id}-raw.pdf`) const finalPdf = join(workDir, `${portfolio.id}-final.pdf`) // ---- Rendu Chromium ---- const browser = await chromium.launch() try { const page = await browser.newPage({ viewportSize: { width: 1588, height: 1123 }, // A3 paysage en px CSS // Le jeton doit accompagner chaque sous-requête (médias, API), pas // seulement la navigation initiale. extraHTTPHeaders: { 'x-internal-token': config.internalToken }, }) const url = `http://localhost:3000/print/${portfolio.id}?internal-token=${encodeURIComponent(config.internalToken)}` const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 120_000 }) if (!response?.ok()) { throw new Error(`Rendu print en échec (HTTP ${response?.status()})`) } // Attendre fonts + images (les médias pleine résolution peuvent être longs) await page.evaluate(async () => { // Chargement forcé de TOUTES les faces déclarées (fonts.ready ne couvre // que celles déjà en cours de chargement) await Promise.all(Array.from((document as any).fonts).map((f: any) => f.load().catch(() => {}))) await (document as any).fonts.ready const images = Array.from(document.querySelectorAll('img')) await Promise.all(images.map(img => img.complete ? Promise.resolve() : new Promise((resolve) => { img.onload = img.onerror = resolve }))) }) await page.emulateMedia({ media: 'print' }) // Bug Chromium : le texte peint pendant la période « invisible » de // font-display: block reste absent du PDF même une fois les fontes // chargées. Invalider tous les styles + reflow force un repaint complet // avec les glyphes réels. await page.evaluate(async () => { for (const sheet of document.styleSheets) sheet.disabled = true void document.body.offsetHeight for (const sheet of document.styleSheets) sheet.disabled = false void document.body.offsetHeight await (document as any).fonts.ready await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))) }) await page.pdf({ path: rawPdf, printBackground: true, preferCSSPageSize: true, timeout: 120_000, }) } finally { await browser.close() } // ---- Post-traitement Ghostscript : recompression + métadonnées (§10.4) ---- const pdfmarks = join(workDir, `${portfolio.id}-marks.ps`) const psEscape = (s: string) => s.replace(/[\\()]/g, c => `\\${c}`) await writeFile( pdfmarks, `[ /Title (${psEscape(portfolio.cover.title || portfolio.name)})\n` + ` /Author (Figures Libres)\n` + ` /Creator (Générateur de portfolios Figures Libres)\n` + ` /DOCINFO pdfmark\n`, 'latin1', ) await execFileAsync('gs', [ '-dBATCH', '-dNOPAUSE', '-dQUIET', '-dSAFER', '-sDEVICE=pdfwrite', '-dCompatibilityLevel=1.6', '-dAutoRotatePages=/None', '-sColorConversionStrategy=RGB', '-dDownsampleColorImages=true', '-dColorImageDownsampleType=/Bicubic', '-dColorImageResolution=200', '-dDownsampleGrayImages=true', '-dGrayImageResolution=200', '-dMonoImageResolution=300', `-sOutputFile=${finalPdf}`, rawPdf, pdfmarks, ]) const exportPath = join(exportsDir, fileName) await rename(finalPdf, exportPath) await rm(rawPdf, { force: true }) await rm(pdfmarks, { force: true }) const { size } = await import('node:fs/promises').then(fs => fs.stat(exportPath)) // On aligne updatedAt sur lastExport.at (touch=false) : le PDF est ainsi // marqué « à jour » tant que le portfolio n'est pas réédité (toute sauvegarde // ultérieure retouche updatedAt et le rend périmé). const exportedAt = new Date().toISOString() portfolio.lastExport = { fileName, at: exportedAt } portfolio.updatedAt = exportedAt await savePortfolio(portfolio, false) return { fileName, downloadUrl: `/api/exports/${encodeURIComponent(fileName)}`, sizeBytes: size, pageCount: portfolio.pages.length, } }