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>
This commit is contained in:
2026-07-05 18:37:09 +02:00
co-authored by Claude Opus 4.8
commit 59dfbe4155
79 changed files with 18587 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
// 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,
}
}