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
commit 59dfbe4155
79 changed files with 18587 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
// Couverture du portfolio — reprend l'accueil du site figureslibres.cc
// (docs/portfolios-examples/screenshot-site-figures-libres.png) : la phrase
// d'intro seule, bloc centré à 65 % de large, chaque keyword dans sa fonte
// (sans soulignement, contrairement au site), flèche vers le bas. Rien d'autre.
import type { Background } from '~~/types'
const props = withDefaults(
defineProps<{
title: string // utilisé pour les métadonnées PDF, pas affiché
subtitle?: string
recipient?: string
background: Background
bandColor?: string // filet couleur en bas (définit aussi celui du sommaire)
renderMode?: 'preview' | 'print'
}>(),
{ renderMode: 'preview' },
)
// Le site affiche l'intro sur fond gris clair : « Blanc » rend le gris charte.
const pageStyle = computed(() => {
const s = backgroundStyle(props.background)
if (props.background.mode === 'white') s.backgroundColor = FL_BASE
return s
})
const strip = computed(() => props.bandColor || FL_INK)
</script>
<template>
<div class="fl-page" :style="pageStyle">
<div class="absolute inset-0 flex flex-col items-center justify-center">
<!-- Phrase d'intro du site (pages/01.home) — Lato, keywords dans les
fontes d'« utilité » du thème Grav, non soulignés -->
<p class="w-[65%]" :style="{ fontFamily: 'Lato, sans-serif', fontSize: '54px', lineHeight: '1.35' }">
<span class="font-bold" style="font-family: 'Syne', sans-serif">Figures&nbsp;Libres</span>
est un&nbsp;collectif de&nbsp;design graphique et interactif porteur de messages d'utilité
<span class="fl-font-publique">publique</span>,
<span class="fl-font-sociale">sociale</span> et
<span class="fl-font-culturelle">culturelle</span>
qui œuvre sur logiciels&nbsp;libres.
</p>
<!-- Flèche vers le bas (theme://images/pictos/arrow-down.svg) -->
<svg viewBox="0 0 24 30" :style="{ width: '60px', height: '54px', marginTop: '90px' }" aria-hidden="true">
<g transform="rotate(90,11,12.999996)">
<path
d="m 16.681932,5.2684395 0.08629,0.091376 5.042819,6.0548205 v 0 l 0.06362,0.100098 v 0 l 0.06561,0.144066 v 0 l 0.0389,0.137168 v 0 l 0.01717,0.117449 v 0 L 22,12 v 0 l -0.0025,0.07061 v 0 l -0.01594,0.12126 v 0 l -0.02359,0.09573 -0.0317,0.08965 v 0 l -0.06326,0.128377 v 0 l -0.09484,0.134549 -5,6 c -0.353564,0.424277 -0.984128,0.481601 -1.408405,0.128037 C 14.968124,18.441846 14.889154,17.879455 15.15741,17.46116 L 15.231779,17.359816 18.864,12.999975 3,13 C 2.4477153,13 2,12.552285 2,12 2,11.487164 2.3860402,11.064493 2.8833789,11.006728 L 3,11 18.864,10.999975 15.231779,6.6401844 c -0.353564,-0.4242769 -0.29624,-1.0548416 0.128037,-1.4084057 0.39164,-0.3263668 0.959052,-0.3026278 1.322116,0.036661 z"
fill="currentColor"
fill-rule="nonzero"
/>
</g>
</svg>
</div>
<!-- Filet couleur pleine largeur en bas -->
<div class="absolute inset-x-0 bottom-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
</div>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
// Page libre (équipe, méthodologie…) — langage du gabarit : filet couleur
// 5 mm, marges 8 mm, titre Syne 45 pt, texte Syne 12/16 pt, médias entiers
// calés à droite. Pas de multi-colonnes CSS (Chromium les imprime en blanc).
import type { Background, ResolvedMedia } from '~~/types'
const props = withDefaults(
defineProps<{
title?: string
text?: string // HTML
media?: ResolvedMedia[]
background: Background
bandColor?: string
page: { index: number; total: number }
renderMode?: 'preview' | 'print'
}>(),
{ renderMode: 'preview' },
)
const strip = computed(() => props.bandColor || FL_INK)
</script>
<template>
<div class="fl-page" :style="backgroundStyle(background)">
<div class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
<div class="absolute flex flex-col" :style="{ inset: 'var(--fl-margin)', top: '60px' }">
<h2 v-if="title" class="max-w-[1000px]" :style="{ fontSize: '60px', lineHeight: '1.1' }">{{ title }}</h2>
<div class="mt-12 flex min-h-0 flex-1" :style="{ gap: '48px' }">
<div v-if="text" class="fl-prose max-w-[740px] flex-1 overflow-hidden" v-html="text" />
<div v-if="media?.length" class="flex w-[560px] shrink-0 flex-col" :style="{ gap: 'var(--fl-gutter)' }">
<div v-for="m in media.slice(0, 3)" :key="m.filename" class="min-h-0 flex-1">
<img :src="m.url" :alt="m.filename" class="size-full object-contain" style="object-position: right top">
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
// Page « couverture » de projet — gabarit_book_v3 p.1/3/5/7.
// Bandeau pleine largeur 55 mm : titre (fonte de la catégorie, 45 pt),
// pastille catégorie (10 pt), présentation (Syne 12/16 pt, colonne à 50,5 %).
// Sous le bandeau : image de couverture. Taille du titre et hauteur du
// bandeau (= calage haut de l'image) ajustables pour éviter les débords.
import type { InkMode, ResolvedMedia } from '~~/types'
const props = withDefaults(
defineProps<{
title: string
category: string // "publique" | "sociale" | "culturelle"
badge?: string // texte de la pastille (ex. « Utilité publique »)
intro?: string // ligne(s) d'intro en gras
text?: string // présentation, HTML
image?: ResolvedMedia
imageFit?: 'width' | 'height' // calage : pleine largeur ou pleine hauteur
imageAlign?: 'left' | 'center' | 'right' // fit hauteur : marge gauche / centrée / marge droite
titleScale?: number // % de la taille gabarit (100 = 45 pt)
bandHeight?: number // mm (gabarit : 55)
bandColor?: string
ink?: InkMode
}>(),
{ ink: 'auto', imageFit: 'width', imageAlign: 'center', titleScale: 100, bandHeight: 55 },
)
const PX_PER_MM = 3.7796
const band = computed(() => bandColorOf(props.category, props.bandColor))
const inkColor = computed(() => inkOn(band.value, props.ink))
const titleClass = computed(() => CATEGORY_TITLE_CLASS[props.category] ?? '')
// L'Avara est plus grasse : le gabarit la compose en 42 pt au lieu de 45 pt.
const titleSize = computed(() => {
const base = props.category === 'culturelle' ? 56 : 60
return `${(base * props.titleScale) / 100}px`
})
const bandPx = computed(() => `${props.bandHeight * PX_PER_MM}px`)
// Fit hauteur : calage horizontal sur la marge gauche, centré, ou marge droite
const alignStyle = computed(() => {
if (props.imageFit !== 'height') return { justifyContent: 'center' }
switch (props.imageAlign) {
case 'left': return { justifyContent: 'flex-start', paddingLeft: 'var(--fl-margin)' }
case 'right': return { justifyContent: 'flex-end', paddingRight: 'var(--fl-margin)' }
default: return { justifyContent: 'center' }
}
})
</script>
<template>
<div class="fl-page">
<!-- Bandeau -->
<div class="absolute inset-x-0 top-0" :style="{ height: bandPx, backgroundColor: band, color: inkColor }">
<h1
class="absolute"
:class="titleClass"
:style="{ left: 'var(--fl-margin)', top: '14px', fontSize: titleSize, lineHeight: '1.15', maxWidth: '620px' }"
>
{{ title }}
</h1>
<span
v-if="badge"
class="absolute whitespace-nowrap rounded-full border px-[12px] py-[3px]"
:style="{ left: '42.33%', top: '54px', transform: 'translateY(-50%)', fontSize: '13.3px', lineHeight: '1.2', borderColor: 'currentColor' }"
>
{{ badge }}
</span>
<div
class="absolute overflow-hidden"
:style="{ left: '50.47%', right: 'var(--fl-margin)', top: '33px', bottom: '12px' }"
>
<p v-if="intro" class="font-bold">{{ intro }}</p>
<div v-if="text" class="fl-prose" v-html="text" />
</div>
</div>
<!-- Image de couverture : calée sur la largeur (pleine largeur, fixée en
bas de page, débord haut coupé) ou sur la hauteur (pleine hauteur,
centrée). Le fond visible sous/à côté de l'image reprend la couleur
du bandeau. -->
<div
class="absolute inset-x-0 bottom-0 flex overflow-hidden"
:style="{ top: bandPx, backgroundColor: image ? band : '#ffffff', ...alignStyle }"
>
<img
v-if="image"
:src="image.url"
:alt="title"
:style="imageFit === 'height'
? { height: '100%', width: 'auto', maxWidth: 'none' }
: { width: '100%', height: 'auto', alignSelf: 'flex-end' }"
>
</div>
</div>
</template>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
// Page « grille » de projet — gabarit_book_v3 p.2/4/6/8.
// Filet couleur 5 mm en haut, images ENTIÈRES (object-contain) calées sur
// une grille ADAPTATIVE : la famille choisie se construit selon le nombre
// d'images et leur orientation. Marges 8 mm, gouttières 3,8 mm.
import type { ResolvedMedia } from '~~/types'
import type { ImageOrientation } from '~~/composables/useGrids'
const props = withDefaults(
defineProps<{
media: ResolvedMedia[]
grid: string // id de famille (useGrids)
orientation?: ImageOrientation
category?: string
bandColor?: string
greyBg?: boolean
}>(),
{ orientation: 'mixed' },
)
const def = computed(() => gridDef(props.grid, props.media.length, props.orientation))
const strip = computed(() => stripColorOf(props.category, props.bandColor))
// Métriques du canvas A3 paysage (theme.css) — zone utile hors marges 8 mm.
const MARGIN = 30.2
const GUTTER = 14.4
const AVAIL_W = 1587.4 - 2 * MARGIN // 1527 px
const AVAIL_H = 1122.5 - 2 * MARGIN // 1062,1 px
// Familles « grande image + bandeaux/file » : la colonne étroite empile ses
// images à leur HAUTEUR RÉELLE (à la largeur de colonne), si bien que les
// gouttières entre bandeaux valent exactement --fl-gutter — comme la colonne
// de la grille « pleine largeur empilées ». Si la pile dépasse la hauteur de
// page, on réduit toutes les images proportionnellement (aucun letterbox,
// images toujours entières).
const hero = computed(() => {
const id = def.value.id
if (id !== 'hero-left' && id !== 'hero-right') return null
const left = id === 'hero-left'
const b = def.value.cells.length - 1 // nombre de bandeaux/vignettes
if (b < 1) return null
const cols = def.value.cols
const bigFrac = left ? cols[0] : cols[1]
const smallFrac = left ? cols[1] : cols[0]
const total = bigFrac + smallFrac
// largeur de la colonne étroite (les deux colonnes se partagent la largeur
// utile moins la gouttière qui les sépare)
const smallW = (smallFrac / total) * (AVAIL_W - GUTTER)
const big = left ? props.media[0] : props.media[b]
const bandeaux = left ? props.media.slice(1, b + 1) : props.media.slice(0, b)
const natH = bandeaux.map(m => smallW / (m.ratio && m.ratio > 0 ? m.ratio : 1.5))
const gaps = (b - 1) * GUTTER
const sum = natH.reduce((a, h) => a + h, 0)
const maxH = AVAIL_H - gaps
const k = sum > maxH ? maxH / sum : 1
const heights = natH.map(h => h * k)
return { left, big, bandeaux, heights, bigFrac, smallFrac }
})
const gridStyle = computed(() => ({
gridTemplateColumns: def.value.cols.map(c => `${c}fr`).join(' '),
gridTemplateRows: def.value.rows.map(r => `${r}fr`).join(' '),
gap: 'var(--fl-gutter)',
}))
function cellStyle(i: number) {
const cell = def.value.cells[i]
if (!cell) return {}
return {
gridColumn: `${cell.c + 1} / span ${cell.cs ?? 1}`,
gridRow: `${cell.r + 1} / span ${cell.rs ?? 1}`,
}
}
/** Cale l'image sur les lignes extérieures de la grille (jamais tronquée). */
function objectPosition(i: number) {
const cell = def.value.cells[i]
if (!cell) return 'left top'
const lastCol = cell.c + (cell.cs ?? 1) === def.value.cols.length
const firstCol = cell.c === 0
const x = firstCol && lastCol ? 'center' : lastCol ? 'right' : 'left'
return `${x} top`
}
const items = computed(() =>
def.value.cells.map((_, i) => props.media[i]).filter((m): m is ResolvedMedia => !!m),
)
</script>
<template>
<div class="fl-page" :style="greyBg ? { backgroundColor: FL_BASE } : undefined">
<!-- Filet couleur pleine largeur -->
<div class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
<!-- Grande image + bandeaux/file : bandeaux à leur hauteur réelle -->
<div v-if="hero" class="absolute flex" :style="{ inset: 'var(--fl-margin)', gap: 'var(--fl-gutter)' }">
<!-- grande image (à gauche pour hero-left, à droite pour hero-right) -->
<div v-if="!hero.left" class="min-h-0 min-w-0 flex flex-col" :style="{ flex: `${hero.smallFrac} 0 0`, gap: 'var(--fl-gutter)' }">
<div v-for="(m, i) in hero.bandeaux" :key="m.filename + i" class="min-h-0 w-full" :style="{ height: `${hero.heights[i]}px` }">
<img :src="m.url" :alt="m.filename" class="size-full object-contain">
</div>
</div>
<div class="min-h-0 min-w-0" :style="{ flex: `${hero.bigFrac} 0 0` }">
<img
:src="hero.big.url"
:alt="hero.big.filename"
class="size-full object-contain"
:style="{ objectPosition: hero.left ? 'left top' : 'right top' }"
>
</div>
<div v-if="hero.left" class="min-h-0 min-w-0 flex flex-col" :style="{ flex: `${hero.smallFrac} 0 0`, gap: 'var(--fl-gutter)' }">
<div v-for="(m, i) in hero.bandeaux" :key="m.filename + i" class="min-h-0 w-full" :style="{ height: `${hero.heights[i]}px` }">
<img :src="m.url" :alt="m.filename" class="size-full object-contain">
</div>
</div>
</div>
<!-- Grille d'images standard dans les marges de 8 mm -->
<div v-else class="absolute grid" :style="{ inset: 'var(--fl-margin)', ...gridStyle }">
<div v-for="(m, i) in items" :key="m.filename + i" class="min-h-0 min-w-0" :style="cellStyle(i)">
<img
:src="m.url"
:alt="m.filename"
class="size-full object-contain"
:style="{ objectPosition: objectPosition(i) }"
>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
// Sommaire — langage du gabarit : filet couleur 5 mm en haut, marges 8 mm,
// titre Syne 45 pt. La taille des entrées et leur disposition (colonne unique
// ou grille) s'adaptent au nombre de projets listés.
import type { Background } from '~~/types'
const props = withDefaults(
defineProps<{
title?: string
entries: { title: string; subtitle?: string; category?: string; pageNumber: number }[]
background: Background
bandColor?: string
page: { index: number; total: number }
renderMode?: 'preview' | 'print'
}>(),
{ title: 'Sommaire', renderMode: 'preview' },
)
const emit = defineEmits<{ navigate: [pageNumber: number] }>()
const strip = computed(() => props.bandColor || FL_INK)
const isPrint = computed(() => props.renderMode === 'print')
// En print, chaque entrée est un lien interne #page-N (converti en lien PDF par
// Chromium). En aperçu, le même clic défile jusqu'à la page dans la preview.
function hrefFor(n: number) {
return isPrint.value ? `#page-${n}` : `#preview-page-${n}`
}
function onEntryClick(n: number, e: MouseEvent) {
if (isPrint.value) return // laisse le lien natif au PDF
e.preventDefault()
e.stopPropagation() // n'active pas la sélection de la page-sommaire elle-même
emit('navigate', n)
}
// Peu de projets = gros titres sur une colonne ; beaucoup = grille dense.
const layout = computed(() => {
const n = props.entries.length
if (n <= 4) return { cols: 1, size: 48, sub: 18.7, gapX: 0, gapY: 44 }
if (n <= 8) return { cols: 1, size: 32, sub: 14.7, gapX: 0, gapY: 30 }
if (n <= 16) return { cols: 2, size: 24, sub: 13.3, gapX: 96, gapY: 24 }
if (n <= 26) return { cols: 2, size: 18.7, sub: 12, gapX: 96, gapY: 18 }
return { cols: 3, size: 14.7, sub: 10.7, gapX: 60, gapY: 14 }
})
const gridStyle = computed(() => ({
gridTemplateColumns: `repeat(${layout.value.cols}, minmax(0, 1fr))`,
columnGap: `${layout.value.gapX}px`,
rowGap: `${layout.value.gapY}px`,
gridAutoRows: 'min-content',
}))
</script>
<template>
<div class="fl-page" :style="backgroundStyle(background)">
<div class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
<div class="absolute flex flex-col" :style="{ inset: 'var(--fl-margin)', top: '60px' }">
<h2 :style="{ fontSize: '60px', lineHeight: '1.1' }">{{ title }}</h2>
<div class="mt-14 grid min-h-0 flex-1 content-start" :style="gridStyle">
<a
v-for="entry in entries"
:key="`${entry.pageNumber}-${entry.title}`"
:href="hrefFor(entry.pageNumber)"
class="flex items-baseline gap-3 text-current no-underline"
:class="{ 'cursor-pointer': !isPrint }"
@click="onEntryClick(entry.pageNumber, $event)"
>
<span :class="CATEGORY_TITLE_CLASS[entry.category ?? '']" :style="{ fontSize: `${layout.size}px`, lineHeight: '1.2' }">{{ entry.title }}</span>
<span v-if="entry.subtitle" class="whitespace-nowrap opacity-70" :style="{ fontSize: `${layout.sub}px` }">{{ entry.subtitle }}</span>
<span class="flex-1 border-b border-dotted border-current opacity-40" />
<span class="tabular-nums opacity-80" :style="{ fontSize: `${layout.sub + 2}px` }">{{ entry.pageNumber }}</span>
</a>
</div>
</div>
</div>
</template>