Files
Valentin Le Moign 59dfbe4155 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>
2026-07-05 18:37:09 +02:00

220 lines
7.5 KiB
TypeScript
Raw Permalink 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.

// 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),
}))
}