Files
portfolio-generator-app/server/lib/pdf-pipeline.ts
Valentin Le Moign 59dfbe4155 Générateur de portfolios PDF Figures Libres (app Nuxt autonome)
App Nuxt 3 + Nuxt UI qui lit les projets du CMS Grav et compose des portfolios
A3 paysage exportés en PDF (gabarit book_v3) : composeur WYSIWYG, templates
cover/toc/projet/grille/page libre, pipeline Playwright + Ghostscript, gel du
contenu à la génération, partage public opt-in.

Dépôt autonome : Dockerfile (base Playwright + Ghostscript + ffmpeg + rsync) inclus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:37:09 +02:00

199 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Pipeline de génération PDF (spec §10) :
// 1. gel du contenu (§7.3) : textes + médias utilisés copiés dans data/frozen/<id>/
// 2. rendu de la route /print/<id> 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<FrozenSnapshot> {
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<string>()
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<string, Project> = {}
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
}
}
}
}
return { generatedAt: new Date().toISOString(), fileName, projects }
}
export interface GenerateResult {
fileName: string
downloadUrl: string
sizeBytes: number
pageCount: number
}
export async function generatePdf(portfolio: Portfolio, rawFileName?: string): Promise<GenerateResult> {
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,
}
}