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:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user