Files
portfolio-generator-app/server/lib/pdf-pipeline.ts
Valentin Le Moign ef1f14335d Textes projet éditables suivant Grav + écriture atomique du store
- Champ Présentation prérempli avec le texte Grav, suivi modifié/synchro
  (textOverride undefined = suit Grav, sinon figé ; bouton Réinitialiser)
- Fix génération PDF : savePortfolio écrit en atomique (temp+rename) pour
  éviter la corruption de lecture par l'autosave concurrent du rendu print
- Toast d'échec de génération explicite (message serveur)
- Refonte architecture visuelle, fond unifié white/tint/full, dispositions
  couverture bandeau/colonne, instantané public complet, index 3 lignes
2026-07-06 13:26:06 +02:00

208 lines
7.9 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
}
}
}
}
// 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<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,
}
}