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