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,219 @@
|
||||
// Grilles ADAPTATIVES des pages d'images : chaque « famille » de grille se
|
||||
// construit selon le nombre d'images posées (2 images = 2 cases, jamais de
|
||||
// case vide) et l'orientation du lot. Le damier vise le format écran 16:9
|
||||
// (majorité des paysages) ou affiche (~1/√2) pour les lots portrait.
|
||||
// Les compositions du gabarit (p.2/4/6/8) restent les familles « grande à
|
||||
// gauche + bandeaux » et « file + grande à droite ». La même définition sert
|
||||
// au rendu (CSS grid) et aux icônes SVG du composeur.
|
||||
|
||||
export interface GridCell {
|
||||
c: number // colonne de départ (0-based)
|
||||
r: number // ligne de départ
|
||||
cs?: number // colspan
|
||||
rs?: number // rowspan
|
||||
}
|
||||
|
||||
export interface GridDef {
|
||||
id: string
|
||||
label: string
|
||||
cols: number[] // proportions (fr)
|
||||
rows: number[]
|
||||
cells: GridCell[]
|
||||
}
|
||||
|
||||
export interface GridFamily {
|
||||
id: string
|
||||
label: string
|
||||
max: number // nombre maximal d'images
|
||||
}
|
||||
|
||||
export const GRID_FAMILIES: GridFamily[] = [
|
||||
{ id: 'mosaic', label: 'Damier (s\'adapte au format)', max: 8 },
|
||||
{ id: 'rows', label: 'Pleine largeur, empilées', max: 4 },
|
||||
{ id: 'cols', label: 'Pleine hauteur, côte à côte', max: 4 },
|
||||
{ id: 'hero-left', label: 'Grande à gauche + bandeaux', max: 6 },
|
||||
{ id: 'hero-right', label: 'File + grande à droite', max: 5 },
|
||||
]
|
||||
|
||||
// Anciens ids de grilles fixes → famille équivalente (portfolios existants)
|
||||
const LEGACY_IDS: Record<string, string> = {
|
||||
'full': 'mosaic',
|
||||
'duo': 'cols',
|
||||
'cols-3': 'cols',
|
||||
'rows-2': 'rows',
|
||||
'rows-3': 'rows',
|
||||
'large-left-2': 'hero-left',
|
||||
'large-left-3': 'hero-left',
|
||||
'large-left-4': 'hero-left',
|
||||
'stack3-large': 'hero-right',
|
||||
'quad': 'mosaic',
|
||||
'six': 'mosaic',
|
||||
'land-2x3': 'mosaic',
|
||||
'port-4x2': 'mosaic',
|
||||
'tall-5': 'hero-left',
|
||||
'tall-banners': 'hero-left',
|
||||
}
|
||||
|
||||
export function gridFamily(id: string): GridFamily {
|
||||
const fid = LEGACY_IDS[id] ?? id
|
||||
return GRID_FAMILIES.find(f => f.id === fid) ?? GRID_FAMILIES[0]
|
||||
}
|
||||
|
||||
export type ImageOrientation = 'landscape' | 'portrait' | 'mixed'
|
||||
|
||||
/**
|
||||
* Orientation dominante d'un lot d'images (≥ 70 % dans le même sens),
|
||||
* d'après leurs ratios largeur/hauteur.
|
||||
*/
|
||||
export function dominantOrientation(ratios: number[]): ImageOrientation {
|
||||
const known = ratios.filter(r => r > 0)
|
||||
if (!known.length) return 'mixed'
|
||||
const landscape = known.filter(r => r >= 1.15).length
|
||||
const portrait = known.filter(r => r <= 0.87).length
|
||||
if (landscape / known.length >= 0.7) return 'landscape'
|
||||
if (portrait / known.length >= 0.7) return 'portrait'
|
||||
return 'mixed'
|
||||
}
|
||||
|
||||
// Zone utile A3 paysage hors marges, en mm (sert aux calculs de ratios)
|
||||
const INNER_W_MM = 404
|
||||
const INNER_H_MM = 281
|
||||
|
||||
function pgcd(a: number, b: number): number {
|
||||
return b ? pgcd(b, a % b) : a
|
||||
}
|
||||
|
||||
/**
|
||||
* Damier : choisit le nombre de colonnes qui maximise la surface d'une image
|
||||
* au ratio cible (16:9 en paysage/mixte — la majorité des paysages sont des
|
||||
* écrans —, ~1/√2 en portrait). Les images de la dernière rangée incomplète
|
||||
* se partagent toute la largeur (pas de case vide).
|
||||
*/
|
||||
function mosaicDef(n: number, orientation: ImageOrientation): GridDef {
|
||||
const target = orientation === 'portrait' ? 0.707 : 16 / 9
|
||||
let bestC = 1
|
||||
let bestArea = 0
|
||||
for (let c = 1; c <= Math.min(n, 4); c++) {
|
||||
const r = Math.ceil(n / c)
|
||||
const w = INNER_W_MM / c
|
||||
const h = INNER_H_MM / r
|
||||
const iw = Math.min(w, h * target)
|
||||
const area = iw * (iw / target)
|
||||
if (area > bestArea) {
|
||||
bestArea = area
|
||||
bestC = c
|
||||
}
|
||||
}
|
||||
const C = bestC
|
||||
const R = Math.ceil(n / C)
|
||||
const rem = n % C
|
||||
const cells: GridCell[] = []
|
||||
if (!rem || R === 1) {
|
||||
for (let i = 0; i < n; i++) cells.push({ c: i % C, r: Math.floor(i / C) })
|
||||
return { id: 'mosaic', label: 'Damier', cols: Array(C).fill(1), rows: Array(R).fill(1), cells }
|
||||
}
|
||||
// Dernière rangée incomplète : rem images se partagent la largeur.
|
||||
// Grille exprimée en L = ppcm(C, rem) colonnes pour des spans entiers.
|
||||
const L = (C * rem) / pgcd(C, rem)
|
||||
const fullSpan = L / C
|
||||
const remSpan = L / rem
|
||||
const full = n - rem
|
||||
for (let i = 0; i < full; i++) {
|
||||
cells.push({ c: (i % C) * fullSpan, r: Math.floor(i / C), cs: fullSpan })
|
||||
}
|
||||
for (let i = 0; i < rem; i++) {
|
||||
cells.push({ c: i * remSpan, r: R - 1, cs: remSpan })
|
||||
}
|
||||
return { id: 'mosaic', label: 'Damier', cols: Array(L).fill(1), rows: Array(R).fill(1), cells }
|
||||
}
|
||||
|
||||
/**
|
||||
* Définition effective d'une grille : famille × nombre d'images × orientation.
|
||||
*/
|
||||
export function gridDef(id: string, count = 1, orientation: ImageOrientation = 'mixed'): GridDef {
|
||||
const fam = gridFamily(id)
|
||||
const n = Math.max(1, Math.min(count, fam.max))
|
||||
if (n === 1) {
|
||||
return { id: fam.id, label: fam.label, cols: [1], rows: [1], cells: [{ c: 0, r: 0 }] }
|
||||
}
|
||||
switch (fam.id) {
|
||||
case 'rows':
|
||||
return {
|
||||
id: fam.id,
|
||||
label: fam.label,
|
||||
cols: [1],
|
||||
rows: Array(n).fill(1),
|
||||
cells: Array.from({ length: n }, (_, i) => ({ c: 0, r: i })),
|
||||
}
|
||||
case 'cols':
|
||||
return {
|
||||
id: fam.id,
|
||||
label: fam.label,
|
||||
cols: Array(n).fill(1),
|
||||
rows: [1],
|
||||
cells: Array.from({ length: n }, (_, i) => ({ c: i, r: 0 })),
|
||||
}
|
||||
case 'hero-left': {
|
||||
// Grande image pleine hauteur à gauche + bandeaux empilés à droite.
|
||||
// Image de gauche à largeur maximale (gabarit p.2/p.8 : 60/40 puis 70/30)
|
||||
// et marges toujours égales entre les bandeaux (rangées 1fr).
|
||||
const cols = n <= 3 ? [60, 40] : [70, 30]
|
||||
return {
|
||||
id: fam.id,
|
||||
label: fam.label,
|
||||
cols,
|
||||
rows: Array(n - 1).fill(1),
|
||||
cells: [
|
||||
{ c: 0, r: 0, rs: n - 1 },
|
||||
...Array.from({ length: n - 1 }, (_, i) => ({ c: 1, r: i })),
|
||||
],
|
||||
}
|
||||
}
|
||||
case 'hero-right':
|
||||
// Gabarit p.4 : file à gauche (30 %), grande à droite (70 %)
|
||||
return {
|
||||
id: fam.id,
|
||||
label: fam.label,
|
||||
cols: [30, 70],
|
||||
rows: Array(n - 1).fill(1),
|
||||
cells: [
|
||||
...Array.from({ length: n - 1 }, (_, i) => ({ c: 0, r: i })),
|
||||
{ c: 1, r: 0, rs: n - 1 },
|
||||
],
|
||||
}
|
||||
default:
|
||||
return mosaicDef(n, orientation)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Famille par défaut : compositions du gabarit en lot mixte, damier
|
||||
* auto-optimisé (16:9 ou affiche) sinon.
|
||||
*/
|
||||
export function defaultGridFor(count: number, orientation: ImageOrientation = 'mixed'): string {
|
||||
if (count <= 1) return 'mosaic'
|
||||
if (orientation === 'mixed') return count <= 6 ? 'hero-left' : 'mosaic'
|
||||
return 'mosaic'
|
||||
}
|
||||
|
||||
/**
|
||||
* Rectangles des cellules en fractions [0..1] de la zone utile (pour les
|
||||
* icônes SVG et le calcul des largeurs d'affichage). Les gouttières sont
|
||||
* exprimées en fraction de la largeur (gapX) et de la hauteur (gapY) utiles.
|
||||
*/
|
||||
export function gridCellRects(def: GridDef, gapX = 0.0095, gapY = gapX): { x: number; y: number; w: number; h: number }[] {
|
||||
const colTotal = def.cols.reduce((a, b) => a + b, 0)
|
||||
const rowTotal = def.rows.reduce((a, b) => a + b, 0)
|
||||
const availW = 1 - gapX * (def.cols.length - 1)
|
||||
const availH = 1 - gapY * (def.rows.length - 1)
|
||||
const colW = def.cols.map(c => (c / colTotal) * availW)
|
||||
const rowH = def.rows.map(r => (r / rowTotal) * availH)
|
||||
const colX = colW.map((_, i) => colW.slice(0, i).reduce((a, b) => a + b, 0) + gapX * i)
|
||||
const rowY = rowH.map((_, i) => rowH.slice(0, i).reduce((a, b) => a + b, 0) + gapY * i)
|
||||
return def.cells.map(cell => ({
|
||||
x: colX[cell.c],
|
||||
y: rowY[cell.r],
|
||||
w: colW.slice(cell.c, cell.c + (cell.cs ?? 1)).reduce((a, b) => a + b, 0) + gapX * ((cell.cs ?? 1) - 1),
|
||||
h: rowH.slice(cell.r, cell.r + (cell.rs ?? 1)).reduce((a, b) => a + b, 0) + gapY * ((cell.rs ?? 1) - 1),
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user