// UQO Éval — laboratoire pédagogique d'évaluation immobilière /** * Rapport statistique provincial — PDF vectoriel (gabarit UQO Éval) * v2 (src/ka/stats/SPEC.md §3), moteur pdfkit (équivalent TS de kapdf.py) : * couverture estampillée « UQO ÉVAL · RAPPORT STATISTIQUE », en-tête/pied * normalisés, sommaire paginé, graphiques 100 % VECTORIELS (courbes, aires, * barres, multi-courbes à motifs distincts, empilées, anneaux, jauges, * calendrier), statistiques de séries (min/max/moy/méd/σ), tableaux zébrés * paginés, records, page de fin avec mentions pédagogiques. * * 5 MODES (SPEC §3) — un mode inconnu retombe sur « complet » : * complet · synthese · tendances · repartitions · donnees * Toutes les données viennent de getDashboard() (agrégats réels du rôle). */ import PDFDocument from "pdfkit"; import path from "path"; import type { ProvStats } from "@/components/StatsView"; import { getDashboard, buildRecords, PERIOD_LABEL, type KaSerie, type KaRecord } from "@/lib/stats-dashboard"; import stats from "@/data/stats.json"; const INK = "#141814"; const INK2 = "#4d5551"; const INK3 = "#8b928c"; const PAPER = "#f5f3ee"; const SURFACE2 = "#faf9f5"; const GREEN = "#005F85"; // bleu UQO (PMS 7469) const GREEN_DEEP = "#00465F"; // bleu UQO profond const LIME = "#2BCCAE"; // turquoise UQO (accent web officiel) const WHITE = "#ffffff"; /* A4 portrait */ const W = 595.28; const H = 841.89; const M = 44; const CW = W - 2 * M; const TOP = 56; // début du contenu sous l'en-tête const BOT = H - 58; // fin du contenu au-dessus du pied const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f); const money = (v: number) => new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v); const compact = (v: number) => { if (v >= 1e12) return `${(v / 1e12).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} billions $`; if (v >= 1e9) return `${(v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} G$`; if (v >= 1e6) return `${(v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M$`; return money(v); }; const num = (v: number) => v.toLocaleString("fr-CA"); /** Nombre court pour axes/valeurs de graphiques. */ const short = (v: number) => { if (Math.abs(v) >= 1e12) return `${(v / 1e12).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} B`; if (Math.abs(v) >= 1e9) return `${(v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} G`; if (Math.abs(v) >= 1e6) return `${(v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 1 })} M`; if (Math.abs(v) >= 1e4) return `${(v / 1e3).toLocaleString("fr-CA", { maximumFractionDigits: 0 })} k`; return v.toLocaleString("fr-CA", { maximumFractionDigits: 1 }); }; const POP_QC = 9_111_000; const BUDGET_QC = 165_800_000_000; const PIB_QC = 640_000_000_000; export const REPORT_MODES = ["complet", "synthese", "tendances", "repartitions", "donnees"] as const; export type ReportMode = (typeof REPORT_MODES)[number]; const MODE_FR: Record = { complet: "Rapport complet", synthese: "Synthèse exécutive", tendances: "Tendances & évolution", repartitions: "Répartitions & géographie", donnees: "Données détaillées", }; type Doc = InstanceType; type Dash = ReturnType; /** Wordmark UQO Éval : « UQO » encre + boîte encre / accent. */ function wordmark(doc: Doc, x: number, y: number, size = 24) { doc.font("SG-Bold").fontSize(size); doc.fillColor(INK).text("UQO ", x, y, { lineBreak: false }); const w = doc.widthOfString("UQO "); const bx = x + w + 0.18 * size; const bw = doc.widthOfString("Éval") + 0.5 * size; doc.roundedRect(bx, y - 0.14 * size, bw, 1.28 * size, 0.18 * size).fill(INK); doc.fillColor(LIME).text("Éval", bx + 0.25 * size, y, { lineBreak: false }); } function kicker(doc: Doc, txt: string, x: number, y: number, size = 8) { doc.rect(x, y + size * 0.44, 2.5 * size, size / 4).fill(GREEN); doc.font("JB-Bold").fontSize(size).fillColor(GREEN) .text(txt.toUpperCase(), x + 2.5 * size + 6, y, { characterSpacing: 0.175 * size, lineBreak: false, width: CW - 2.5 * size - 6, height: size * 1.6, ellipsis: true }); } /** En-tête + pied normalisés (numéros de page ajoutés en post-passe). */ function chrome(doc: Doc) { const year = new Date().getFullYear(); doc.rect(0, 0, W, H).fill(PAPER); doc.font("SG-Bold").fontSize(9.5).fillColor(INK) .text("UQO Éval", M, 22, { lineBreak: false }); doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) .text("RAPPORT STATISTIQUE", W - M - 180, 24.5, { width: 180, align: "right", characterSpacing: 0.8 }); doc.rect(M, 37, CW, 1.1).fill(INK); doc.rect(M, H - 42, CW, 0.7).fill(INK3); doc.font("JB-Reg").fontSize(6.8).fillColor(INK3) .text(`© UQO Éval — ${year} — www.uqo-eval.app · ${PERIOD_LABEL}`, M, H - 34, { lineBreak: false }); } function card(doc: Doc, x: number, y: number, w: number, h: number, fill = "#ffffff") { doc.roundedRect(x + 3, y + 3, w, h, 8).fill("#e3e1d9"); doc.roundedRect(x, y, w, h, 8).lineWidth(1.3).fillAndStroke(fill, INK); } function hbarRow(doc: Doc, x: number, y: number, w: number, label: string, frac: number, txt: string, sub: string | null, dark = false) { doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(label, x, y + 3, { width: 82, height: 9, ellipsis: true, lineBreak: false }); const tx = x + 88; const tw = w - 88 - 118; doc.rect(tx, y, tw, 13).fill(SURFACE2); doc.rect(tx, y, Math.max(2, frac * tw), 13).fill(dark ? INK : GREEN); doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(txt, x + w - 112, y + (sub ? 0 : 3), { width: 112, align: "right" }); if (sub) doc.font("JB-Reg").fontSize(5.8).fillColor(INK3).text(sub, x + w - 112, y + 9, { width: 112, align: "right" }); } /* ============================ curseur de page ============================ */ class Cursor { y = TOP; sections: { label: string; page: number }[] = []; constructor(public doc: Doc) {} page() { // bufferedPageRange().count = pages déjà créées (1-based pour l'humain) return this.doc.bufferedPageRange().count; } ensure(h: number) { if (this.y + h > BOT) { this.doc.addPage({ size: "A4", margin: 0 }); chrome(this.doc); this.y = TOP; } } section(label: string, remember = true) { this.ensure(30); if (remember) this.sections.push({ label, page: this.page() }); kicker(this.doc, label, M, this.y); this.y += 20; } } /* ============================ primitives graphiques ============================ */ /** Cadre de graphique : carte + titre, renvoie la zone de tracé. */ function chartFrame(c: Cursor, title: string, h: number) { c.ensure(h + 34); const { doc } = c; card(doc, M, c.y, CW, h + 26); doc.font("SG-Bold").fontSize(9.5).fillColor(INK) .text(title, M + 14, c.y + 10, { width: CW - 28, height: 12, ellipsis: true, lineBreak: false }); const area = { x: M + 52, y: c.y + 30, w: CW - 52 - 22, h: h - 14 }; c.y += h + 26 + 12; return area; } function axes(doc: Doc, a: { x: number; y: number; w: number; h: number }, vmin: number, vmax: number, labels: string[]) { for (let g = 0; g <= 4; g++) { const y = a.y + (a.h * g) / 4; const v = vmax - ((vmax - vmin) * g) / 4; doc.rect(a.x, y, a.w, 0.5).fill("#e3e1d9"); doc.font("JB-Reg").fontSize(5.8).fillColor(INK3) .text(short(v), a.x - 40, y - 2.5, { width: 36, align: "right", lineBreak: false }); } const idx = [0, Math.floor(labels.length / 2), labels.length - 1].filter((v, i, arr) => arr.indexOf(v) === i); idx.forEach((i) => { const x = a.x + (labels.length > 1 ? (a.w * i) / (labels.length - 1) : 0); doc.font("JB-Reg").fontSize(5.8).fillColor(INK3) .text(labels[i] ?? "", x - 24, a.y + a.h + 4, { width: 48, align: "center", lineBreak: false }); }); } function linePdf(c: Cursor, s: KaSerie, h = 120) { const pts = s.points ?? []; if (pts.length < 2) return; const a = chartFrame(c, s.title, h); const { doc } = c; const vmax = Math.max(...pts.map((p) => p.v), 1); const vmin = Math.min(0, ...pts.map((p) => p.v)); axes(doc, a, vmin, vmax, pts.map((p) => p.t)); const X = (i: number) => a.x + (a.w * i) / (pts.length - 1); const Y = (v: number) => a.y + a.h * (1 - (v - vmin) / (vmax - vmin || 1)); if (s.kind === "bar") { const bw = Math.max(1.2, a.w / pts.length - 1.2); pts.forEach((p, i) => { const bh = a.h * ((p.v - Math.max(0, vmin)) / (vmax - vmin || 1)); doc.rect(a.x + (a.w * i) / pts.length, a.y + a.h - bh, bw, Math.max(bh, 0.6)).fill(GREEN); }); return; } if (s.kind === "area") { doc.moveTo(X(0), Y(pts[0].v)); pts.forEach((p, i) => doc.lineTo(X(i), Y(p.v))); doc.lineTo(X(pts.length - 1), a.y + a.h).lineTo(a.x, a.y + a.h).closePath(); doc.fillOpacity(0.16).fill(GREEN).fillOpacity(1); } doc.moveTo(X(0), Y(pts[0].v)); pts.forEach((p, i) => doc.lineTo(X(i), Y(p.v))); doc.lineWidth(1.6).strokeColor(GREEN).stroke(); // point final + valeur const lastX = X(pts.length - 1), lastY = Y(pts[pts.length - 1].v); doc.circle(lastX, lastY, 2).fill(INK); doc.font("JB-Bold").fontSize(6).fillColor(INK) .text(short(pts[pts.length - 1].v), lastX - 60, lastY - 10, { width: 58, align: "right", lineBreak: false }); } /** Min/max/moyenne/médiane/écart-type sous une courbe (SPEC §1.4). */ function statLine(c: Cursor, s: KaSerie) { const vs = (s.points ?? []).map((p) => p.v); if (vs.length < 2) return; const sorted = [...vs].sort((x, y) => x - y); const mean = vs.reduce((x, y) => x + y, 0) / vs.length; const sd = Math.sqrt(vs.reduce((x, v) => x + (v - mean) ** 2, 0) / vs.length); const items: [string, number][] = [ ["MIN", sorted[0]], ["MAX", sorted[sorted.length - 1]], ["MOYENNE", mean], ["MÉDIANE", sorted[Math.floor(sorted.length / 2)]], ["ÉCART-TYPE", sd], ]; c.ensure(16); c.y -= 6; const w = CW / items.length; items.forEach(([l, v], i) => { c.doc.font("JB-Reg").fontSize(5.5).fillColor(INK3).text(l, M + i * w, c.y, { width: w - 6, lineBreak: false }); c.doc.font("JB-Bold").fontSize(7).fillColor(INK).text(short(v), M + i * w, c.y + 7.5, { width: w - 6, lineBreak: false }); }); c.y += 24; } const MULTI_PDF = [ { color: GREEN, dash: null as number[] | null, width: 1.8 }, { color: INK, dash: null, width: 1.2 }, { color: GREEN_DEEP, dash: [5, 2.5], width: 1.5 }, { color: INK3, dash: [1.5, 2.5], width: 1.5 }, ]; function multiLinePdf(c: Cursor, ms: { title: string; unit?: string; series: { label: string; points: { t: string; v: number }[] }[] }, h = 130) { const series = (ms.series ?? []).filter((s) => (s.points ?? []).length > 1).slice(0, 4); if (!series.length) return; const a = chartFrame(c, ms.title + (ms.unit ? ` (${ms.unit})` : ""), h + 14); const { doc } = c; // légende (motif + libellé — jamais la couleur seule) let lx = a.x; series.forEach((s, i) => { const st = MULTI_PDF[i]; doc.lineWidth(2).strokeColor(st.color); if (st.dash) doc.dash(st.dash[0], { space: st.dash[1] }); else doc.undash(); doc.moveTo(lx, a.y + 2).lineTo(lx + 16, a.y + 2).stroke(); doc.undash(); doc.font("JB-Bold").fontSize(6).fillColor(INK).text(s.label.toUpperCase(), lx + 20, a.y - 1, { lineBreak: false }); lx += 26 + doc.widthOfString(s.label.toUpperCase()) + 14; }); const area = { ...a, y: a.y + 12, h: a.h - 12 }; const all = series.flatMap((s) => s.points.map((p) => p.v)); const vmax = Math.max(...all, 1); const vmin = Math.min(0, ...all); axes(doc, area, vmin, vmax, series[0].points.map((p) => p.t)); series.forEach((s, i) => { const st = MULTI_PDF[i]; const X = (j: number) => area.x + (area.w * j) / (s.points.length - 1); const Y = (v: number) => area.y + area.h * (1 - (v - vmin) / (vmax - vmin || 1)); doc.moveTo(X(0), Y(s.points[0].v)); s.points.forEach((p, j) => doc.lineTo(X(j), Y(p.v))); doc.lineWidth(st.width).strokeColor(st.color); if (st.dash) doc.dash(st.dash[0], { space: st.dash[1] }); else doc.undash(); doc.stroke(); doc.undash(); }); } const SHADES = [1, 0.72, 0.5, 0.34, 0.22, 0.13]; function stackedPdf(c: Cursor, st: { title: string; unit?: string; keys: string[]; points: { t: string; values: number[] }[] }, h = 130) { const keys = (st.keys ?? []).slice(0, 6); const pts = st.points ?? []; if (!keys.length || !pts.length) return; const a = chartFrame(c, st.title, h + 14); const { doc } = c; let lx = a.x; keys.forEach((k, i) => { doc.fillOpacity(SHADES[i]).rect(lx, a.y - 1, 8, 8).fill(GREEN).fillOpacity(1); doc.rect(lx, a.y - 1, 8, 8).lineWidth(0.5).stroke(INK); doc.font("JB-Bold").fontSize(6).fillColor(INK).text(k.toUpperCase(), lx + 12, a.y, { lineBreak: false }); lx += 18 + doc.widthOfString(k.toUpperCase()) + 12; }); const area = { ...a, y: a.y + 12, h: a.h - 12 }; const totals = pts.map((p) => p.values.slice(0, keys.length).reduce((s, v) => s + (v || 0), 0)); const vmax = Math.max(...totals, 1); axes(doc, area, 0, vmax, pts.map((p) => p.t)); const bw = Math.max(1, area.w / pts.length - 1); pts.forEach((p, i) => { const x = area.x + (area.w * i) / pts.length; let yAcc = area.y + area.h; keys.forEach((k, j) => { const v = p.values[j] || 0; const bh = area.h * (v / vmax); yAcc -= bh; if (bh > 0.3) { doc.fillOpacity(SHADES[j]).rect(x, yAcc, bw, bh).fill(GREEN).fillOpacity(1); } }); }); } /** Anneau vectoriel (arcs tracés en petits segments) + légende. */ function donutPdf(c: Cursor, title: string, items: { label: string; value: number }[]) { const rows = (items ?? []).filter((i) => i.value > 0).slice(0, 8); const total = rows.reduce((s, r) => s + r.value, 0); if (!total) return; const h = 150; c.ensure(h + 40); const { doc } = c; card(doc, M, c.y, CW, h + 26); doc.font("SG-Bold").fontSize(9.5).fillColor(INK).text(title, M + 14, c.y + 10, { lineBreak: false }); const cx = M + 90, cy = c.y + 30 + (h - 20) / 2, R = 52; let angle = -Math.PI / 2; rows.forEach((r, i) => { const sweep = (2 * Math.PI * r.value) / total; const steps = Math.max(2, Math.ceil(sweep / 0.06)); doc.lineWidth(24).strokeColor(GREEN).strokeOpacity(SHADES[i % SHADES.length] * 0.9 + 0.1); doc.moveTo(cx + R * Math.cos(angle), cy + R * Math.sin(angle)); for (let s = 1; s <= steps; s++) { const t = angle + (sweep * s) / steps; doc.lineTo(cx + R * Math.cos(t), cy + R * Math.sin(t)); } doc.stroke().strokeOpacity(1); angle += sweep; }); doc.circle(cx, cy, R + 12).lineWidth(0.7).strokeOpacity(0.5).stroke(INK).strokeOpacity(1); // légende const lx = M + 190; let ly = c.y + 34; rows.forEach((r, i) => { doc.fillOpacity(SHADES[i % SHADES.length]).rect(lx, ly, 8, 8).fill(GREEN).fillOpacity(1); doc.rect(lx, ly, 8, 8).lineWidth(0.5).stroke(INK); doc.font("SG-Bold").fontSize(7.5).fillColor(INK).text(r.label, lx + 14, ly, { width: 200, height: 9, ellipsis: true, lineBreak: false }); doc.font("JB-Bold").fontSize(7).fillColor(INK2) .text(`${((100 * r.value) / total).toFixed(1).replace(".", ",")} % · ${short(r.value)}$`, lx + 220, ly + 0.5, { width: CW - 220 - (lx - M) - 14, align: "right", lineBreak: false }); ly += Math.min(17, (h - 10) / rows.length); }); c.y += h + 26 + 12; } /** Barres horizontales (répartitions / géo). */ function hbarsPdf(c: Cursor, title: string, items: { label: string; value: number }[], fmt: (v: number) => string = compact) { const rows = (items ?? []).slice(0, 15); if (!rows.length) return; const boxH = rows.length * 17 + 24; c.ensure(boxH + 30); const { doc } = c; c.section(title, false); card(doc, M, c.y, CW, boxH); const max = Math.max(...rows.map((r) => r.value), 1); rows.forEach((r, i) => { hbarRow(doc, M + 14, c.y + 12 + i * 17, CW - 28, r.label, r.value / max, fmt(r.value), null, i === 0); }); c.y += boxH + 14; } /** Jauges demi-arc (accent), 4 par rangée. */ function gaugesPdf(c: Cursor, gauges: Dash["gauges"]) { if (!gauges?.length) return; const gw = (CW - 3 * 10) / 4; const rowsN = Math.ceil(gauges.length / 4); c.ensure(rowsN * 86 + 10); const { doc } = c; gauges.forEach((g, i) => { const x = M + (i % 4) * (gw + 10); const y = c.y + Math.floor(i / 4) * 86; card(doc, x, y, gw, 78); const cx = x + gw / 2, cy = y + 44, R = 26; const arc = (from: number, to: number, color: string, lw: number) => { const steps = Math.max(2, Math.ceil(((to - from) / Math.PI) * 24)); doc.lineWidth(lw).strokeColor(color); doc.moveTo(cx + R * Math.cos(from), cy + R * Math.sin(from)); for (let s = 1; s <= steps; s++) { const t = from + ((to - from) * s) / steps; doc.lineTo(cx + R * Math.cos(t), cy + R * Math.sin(t)); } doc.stroke(); }; arc(Math.PI, 2 * Math.PI, "#e3e1d9", 8); const frac = Math.max(0, Math.min(1, g.max ? g.value / g.max : 0)); if (frac > 0.01) arc(Math.PI, Math.PI + Math.PI * frac, GREEN, 8); doc.font("SG-Bold").fontSize(13).fillColor(INK) .text(`${g.value.toLocaleString("fr-CA", { maximumFractionDigits: 1 })}${g.unit ?? ""}`, x, cy - 12, { width: gw, align: "center", lineBreak: false }); doc.font("JB-Bold").fontSize(5.2).fillColor(INK3) .text(g.label.toUpperCase(), x + 8, y + 58, { width: gw - 16, align: "center", characterSpacing: 0.3, height: 16 }); }); c.y += rowsN * 86 + 8; } /** Grille de cartes KPI (valeur, libellé, delta coloré à 1 décimale). */ function kpiGridPdf(c: Cursor, kpis: Dash["kpis"]) { const kw = (CW - 3 * 10) / 4; const rowsN = Math.ceil(kpis.length / 4); c.ensure(rowsN * 64 + 6); const { doc } = c; kpis.forEach((k, i) => { const x = M + (i % 4) * (kw + 10); const y = c.y + Math.floor(i / 4) * 64; card(doc, x, y, kw, 56); const val = typeof k.value === "number" ? num(k.value) : k.value; doc.font("SG-Bold").fontSize(12).fillColor(INK) .text(val + (k.unit && typeof k.value === "number" ? ` ${k.unit}` : ""), x + 9, y + 9, { width: kw - 18, height: 15, ellipsis: true, lineBreak: false }); if (k.delta_pct !== undefined && k.delta_pct !== null) { const up = (k.direction ?? (k.delta_pct >= 0 ? "up" : "down")) === "up"; doc.font("JB-Bold").fontSize(6.5).fillColor(up ? "#1c5c41" : "#b3423a") .text(`${up ? "▲" : "▼"} ${k.delta_pct >= 0 ? "+" : ""}${k.delta_pct.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} %`, x + 9, y + 26, { lineBreak: false }); } doc.font("JB-Bold").fontSize(5).fillColor(INK3) .text(k.label.toUpperCase(), x + 9, y + 37, { width: kw - 18, characterSpacing: 0.3, height: 15 }); }); c.y += rowsN * 64 + 10; } /** Calendrier d'activité (grille par semaine, opacité ∝ valeur). */ function heatmapPdf(c: Cursor, hm: { title: string; cells: { date: string; value: number }[] }) { if (!hm?.cells?.length) return; const byDate = new Map(hm.cells.map((x) => [x.date, x.value])); const dates = hm.cells.map((x) => x.date).sort(); const end = new Date(dates[dates.length - 1] + "T12:00:00"); const max = Math.max(...hm.cells.map((x) => x.value), 1); const weeks = 26; const cell = (CW - 28) / weeks; const gridH = 7 * cell; c.ensure(gridH + 60); const { doc } = c; c.section(hm.title, false); card(doc, M, c.y, CW, gridH + 24); const cur = new Date(end); cur.setDate(cur.getDate() - (weeks * 7 - 1)); for (let w = 0; w < weeks; w++) { for (let d = 0; d < 7; d++) { const iso = cur.toISOString().slice(0, 10); const v = byDate.get(iso) ?? 0; const x = M + 14 + w * cell; const y = c.y + 12 + d * cell; if (v > 0) { doc.fillOpacity(0.2 + 0.8 * (v / max)).rect(x, y, cell - 1.6, cell - 1.6).fill(GREEN).fillOpacity(1); } else { doc.rect(x, y, cell - 1.6, cell - 1.6).fill("#eceae2"); } cur.setDate(cur.getDate() + 1); } } c.y += gridH + 24 + 14; } /** Tableau zébré paginé proprement (jamais de ligne coupée). */ function tablePdf(c: Cursor, t: { title: string; columns: string[]; rows: (string | number | null)[][] }, maxRows: number, remember = true) { const rows = t.rows.slice(0, maxRows); if (!rows.length) return; const { doc } = c; c.ensure(90); if (remember) c.sections.push({ label: t.title, page: c.page() }); kicker(doc, t.title, M, c.y); c.y += 18; // largeur des colonnes : numériques 88 pt à droite, texte se partage le reste const isNum = t.columns.map((_, ci) => rows.every((r) => r[ci] === null || typeof r[ci] === "number")); const numW = 88; const rankW = 30; const widths: number[] = t.columns.map((col, ci) => { if (ci === 0 && (col === "Rang" || col === "#")) return rankW; return isNum[ci] ? numW : 0; }); const fixed = widths.reduce((a, b) => a + b, 0); const flexN = widths.filter((w) => w === 0).length || 1; const flexW = (CW - fixed) / flexN; const xs: number[] = []; let xAcc = M; widths.forEach((w) => { xs.push(xAcc); xAcc += w || flexW; }); const header = () => { doc.rect(M, c.y, CW, 15).fill(INK); doc.font("JB-Bold").fontSize(5.6).fillColor(WHITE); t.columns.forEach((col, ci) => { const w = (widths[ci] || flexW) - 10; doc.text(col.toUpperCase(), xs[ci] + 5, c.y + 5, { width: w, align: isNum[ci] && ci > 0 ? "right" : "left", lineBreak: false }); }); c.y += 15; }; header(); const fmtCell = (v: string | number | null, ci: number) => { if (v === null || v === undefined) return "—"; if (typeof v !== "number") return String(v); if (/\$/.test(t.columns[ci]) && Math.abs(v) >= 1e6) return compact(v); return num(v); }; rows.forEach((r, ri) => { if (c.y + 13 > BOT) { doc.rect(M, c.y, CW, 0.9).fill(INK); doc.addPage({ size: "A4", margin: 0 }); chrome(doc); c.y = TOP; kicker(doc, `${t.title} (suite)`, M, c.y); c.y += 18; header(); } if (ri % 2 === 0) doc.rect(M, c.y, CW, 13).fill(SURFACE2); t.columns.forEach((col, ci) => { const w = (widths[ci] || flexW) - 10; const right = isNum[ci] && ci > 0; doc.font(ci === 1 && !isNum[1] ? "SG-Bold" : "JB-Reg").fontSize(6.6).fillColor(INK) .text(fmtCell(r[ci], ci), xs[ci] + 5, c.y + 3.6, { width: w, align: right ? "right" : "left", height: 9, ellipsis: true, lineBreak: false }); }); c.y += 13; }); doc.rect(M, c.y, CW, 0.9).fill(INK); c.y += 14; } /** Records & faits marquants — cartes compactes 2 par rangée. */ function recordsPdf(c: Cursor, records: KaRecord[], remember = true) { if (!records?.length) return; c.section("Records & faits marquants", remember); const { doc } = c; const rw = (CW - 10) / 2; records.forEach((r, i) => { if (i % 2 === 0) c.ensure(52); const x = M + (i % 2) * (rw + 10); const y = c.y; card(doc, x, y, rw, 44, SURFACE2); doc.font("SG-Bold").fontSize(9).fillColor(INK) .text(r.value, x + 10, y + 8, { width: rw - 20, height: 11, ellipsis: true, lineBreak: false }); doc.font("JB-Bold").fontSize(5.2).fillColor(INK3) .text(r.label.toUpperCase() + (r.date ? ` · ${r.date}` : ""), x + 10, y + 24, { width: rw - 20, characterSpacing: 0.3, height: 14 }); if (i % 2 === 1 || i === records.length - 1) c.y += 52; }); c.y += 6; } /* ------------------------------- COUVERTURE ------------------------------- */ function cover(doc: Doc, s: ProvStats, generated: string, modeLabel: string) { doc.rect(0, 0, W, H).fill(PAPER); doc.lineWidth(2).rect(28, 28, W - 56, H - 56).stroke(INK); kicker(doc, "UQO Éval · Rapport statistique", 64, 100, 10.5); wordmark(doc, 64, 172, 46); doc.font("Inter").fontSize(13).fillColor(INK2).text( "Laboratoire pédagogique d'évaluation immobilière — rapport statistique provincial du parc immobilier québécois.", 64, 256, { width: W - 168, lineGap: 3 }); const rows: [string, string][] = [ ["Type de rapport", modeLabel], ["Période couverte", "Instantané du rôle d'évaluation 2026 (millésime)"], ["Généré le", `${generated} (heure de l'Est)`], ["Plateforme", "www.uqo-eval.app"], ["Données", `${num(s.unites)} propriétés · ${num(s.municipalites)} municipalités`], ]; doc.rect(64, 318, 34, 3).fill(LIME); let y = 336; rows.forEach(([k, v]) => { doc.font("JB-Reg").fontSize(8).fillColor(INK3) .text(k.toUpperCase(), 64, y + 2.5, { width: 150, characterSpacing: 0.6, lineBreak: false }); doc.font("SG-Bold").fontSize(11.5).fillColor(INK) .text(v, 214, y, { width: W - 214 - 64, height: 14, ellipsis: true, lineBreak: false }); y += 24; }); doc.rect(28, H - 28 - 64, W - 56, 64).fill(INK); const by = H - 28 - 64 + 24; doc.font("SG-Bold").fontSize(15).fillColor(WHITE).text("UQO ", 64, by, { lineBreak: false }); doc.fillColor(LIME).text("Éval", 64 + doc.widthOfString("UQO "), by, { lineBreak: false }); doc.font("JB-Bold").fontSize(9.5).fillColor(LIME) .text("www.uqo-eval.app", W - 64 - 180, by + 4, { width: 180, align: "right", characterSpacing: 0.8 }); } /* --------------------- VUE D'ENSEMBLE (le chiffre + comparaisons) --------------------- */ function overview(c: Cursor, s: ProvStats) { const { doc } = c; c.section("Valeur totale du parc immobilier québécois"); card(doc, M, c.y, CW, 96, INK); doc.font("SG-Bold").fontSize(34).fillColor(LIME).text(money(s.valeur_totale_2026), M + 20, c.y + 18); doc.font("JB-Reg").fontSize(7.5).fillColor("rgba(255,81,72,0.75)") .text(`≈ ${compact(s.valeur_totale_2026).toUpperCase()} · ${num(s.unites)} PROPRIÉTÉS · ${num(s.municipalites)} MUNICIPALITÉS`, M + 20, c.y + 62, { characterSpacing: 0.8 }); c.y += 112; const comps: [string, string][] = [ ["Par Québécois·e", money(s.valeur_totale_2026 / POP_QC)], ["Budgets annuels du Québec", `× ${(s.valeur_totale_2026 / BUDGET_QC).toLocaleString("fr-CA", { maximumFractionDigits: 1 })}`], ["Fois le PIB du Québec", `× ${(s.valeur_totale_2026 / PIB_QC).toLocaleString("fr-CA", { maximumFractionDigits: 1 })}`], ["Croissance 2021→2026*", `+${s.croissance_2021_2026_pct.toLocaleString("fr-CA")} %`], ]; const tw2 = (CW - 3 * 10) / 4; comps.forEach(([k, v], i) => { const tx = M + (i % 4) * (tw2 + 10); card(doc, tx, c.y, tw2, 48); doc.font("SG-Bold").fontSize(12.5).fillColor(INK).text(v, tx + 10, c.y + 10, { width: tw2 - 20, height: 16, ellipsis: true, lineBreak: false }); doc.font("JB-Bold").fontSize(5.5).fillColor(INK3).text(k.toUpperCase(), tx + 10, c.y + 32, { width: tw2 - 20, characterSpacing: 0.5, height: 12 }); }); c.y += 56 + 6; doc.font("JB-Reg").fontSize(6).fillColor(INK3) .text("* À PÉRIMÈTRE CONSTANT (UNITÉS PRÉSENTES AUX DEUX MILLÉSIMES)", M, c.y, { characterSpacing: 0.5 }); c.y += 18; } /* ------------------------------ PAGE DE FIN ------------------------------ */ function finalPage(doc: Doc) { let y = 60; kicker(doc, "UQO Éval · À propos", M, y); y += 20; doc.font("SG-Bold").fontSize(21).fillColor(INK).text("UQO Éval — laboratoire pédagogique", M, y); y += 40; doc.font("JB-Bold").fontSize(9.5).fillColor(GREEN) .text("www.uqo-eval.app — laboratoire pédagogique d'évaluation immobilière", M, y, { lineBreak: false }); y += 26; doc.rect(M, y, 44, 3).fill(LIME); y += 14; doc.font("Inter").fontSize(10).fillColor(INK2) .text( "Plateforme à vocation pédagogique, universitaire et expérimentale. Données lues à la source, rien d'inventé, tout est traçable.", M, y, { width: 420, lineGap: 3 }); y = doc.y + 16; doc.font("Inter").fontSize(8).fillColor(INK3).text( "Mentions : rapport généré automatiquement à partir des données réelles de la plateforme au moment " + "indiqué en couverture. Les valeurs sont des estimations statistiques (modèle hédonique, rôle " + "d'évaluation 2026) produites à des fins pédagogiques et expérimentales ; elles ne constituent pas " + "une évaluation immobilière professionnelle certifiée (OEAQ). Conditions d'utilisation et politique " + "de confidentialité (Loi 25) : www.uqo-eval.app/conditions · /confidentialite.", M, y, { width: 420, lineGap: 2.5 }); wordmark(doc, M, H - 92, 15); } /* --------------------------------- RAPPORT -------------------------------- */ export async function buildStatsReport(mode: ReportMode = "complet"): Promise { const s = stats as ProvStats; const dash = getDashboard(); const generated = new Date().toLocaleString("fr-CA", { timeZone: "America/Toronto", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }).replace(",", " ·"); const doc = new PDFDocument({ size: "A4", margin: 0, bufferPages: true, info: { Title: `UQO Éval — Rapport statistique (${MODE_FR[mode]})`, Author: "UQO Éval — www.uqo-eval.app" }, }); doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf")); doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf")); doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf")); doc.registerFont("Inter", F("Inter-Regular.ttf")); const chunks: Buffer[] = []; doc.on("data", (b: Buffer) => chunks.push(b)); const done = new Promise((res) => doc.on("end", () => res(Buffer.concat(chunks)))); /* ------ PAGE 1 : COUVERTURE ------ */ cover(doc, s, generated, MODE_FR[mode]); const withToc = mode === "complet" || mode === "donnees"; if (withToc) { doc.addPage({ size: "A4", margin: 0 }); // page 2 réservée au sommaire (remplie en post-passe) } doc.addPage({ size: "A4", margin: 0 }); chrome(doc); const c = new Cursor(doc); if (mode === "complet") { c.sections.push({ label: "Vue d'ensemble provinciale", page: c.page() }); overview(c, s); c.section("Indicateurs clés"); kpiGridPdf(c, dash.kpis); c.section("Couverture & complétude du rôle"); gaugesPdf(c, dash.gauges); c.section("Évolution — millésimes & corpus de ventes"); dash.series.forEach((se) => { linePdf(c, se as KaSerie); statLine(c, se as KaSerie); }); c.section("Marché — indices & prix au m²"); dash.multiseries.forEach((ms) => multiLinePdf(c, ms)); dash.stacked.forEach((st) => stackedPdf(c, st)); c.section("Répartitions"); dash.breakdowns.forEach((b) => { if (b.kind === "donut") donutPdf(c, b.title, b.items); else hbarsPdf(c, b.title, b.items, b.id === "types_n" ? num : compact); }); dash.distributions.forEach((d) => linePdf(c, { id: d.id, title: d.title, unit: d.unit, kind: "bar", points: d.bins.map((b) => ({ t: b.label, v: b.value })) })); hbarsPdf(c, dash.geo.title, dash.geo.items); heatmapPdf(c, dash.heatmap); dash.tables.forEach((t) => tablePdf(c, t, 200)); recordsPdf(c, dash.records); } else if (mode === "synthese") { overview(c, s); c.section("Indicateurs clés"); kpiGridPdf(c, dash.kpis); c.section("Couverture & complétude du rôle"); gaugesPdf(c, dash.gauges); recordsPdf(c, dash.records); } else if (mode === "tendances") { c.section("Indicateurs clés"); kpiGridPdf(c, dash.kpis); c.section("Évolution — millésimes & corpus de ventes"); dash.series.forEach((se) => { linePdf(c, se as KaSerie); statLine(c, se as KaSerie); }); c.section("Marché — indices & prix au m²"); dash.multiseries.forEach((ms) => multiLinePdf(c, ms)); dash.stacked.forEach((st) => stackedPdf(c, st)); recordsPdf(c, dash.records); } else if (mode === "repartitions") { c.section("Répartitions"); dash.breakdowns.forEach((b) => { if (b.kind === "donut") donutPdf(c, b.title, b.items); else hbarsPdf(c, b.title, b.items, b.id === "types_n" ? num : compact); }); c.section("Distributions"); dash.distributions.forEach((d) => linePdf(c, { id: d.id, title: d.title, unit: d.unit, kind: "bar", points: d.bins.map((b) => ({ t: b.label, v: b.value })) })); c.section("Répartition géographique"); hbarsPdf(c, dash.geo.title, dash.geo.items); heatmapPdf(c, dash.heatmap); } else { // donnees : tous les tableaux en version longue (≤ 400 lignes) dash.tables.forEach((t) => tablePdf(c, t, 400)); } /* ------ DERNIÈRE PAGE : COORDONNÉES & MENTIONS ------ */ doc.addPage({ size: "A4", margin: 0 }); chrome(doc); finalPage(doc); /* ------ POST-PASSE : sommaire (page 2) + numéros de page ------ */ const range = doc.bufferedPageRange(); const total = range.count; if (withToc) { doc.switchToPage(1); chrome(doc); let y = TOP + 4; kicker(doc, MODE_FR[mode], M, y); y += 20; doc.font("SG-Bold").fontSize(21).fillColor(INK).text("Sommaire", M, y); y += 40; const entries: [string, string][] = [ ...c.sections.map((sec) => [sec.label, String(sec.page)] as [string, string]), ["À propos d'UQO Éval & mentions", String(total)], ]; entries.slice(0, 16).forEach(([label, pg]) => { doc.rect(M, y + 22, CW, 0.7).fill("#e3e1d9"); doc.font("SG-Bold").fontSize(11).fillColor(INK).text(label, M, y, { width: CW - 70, height: 26, ellipsis: true }); doc.font("JB-Bold").fontSize(10.5).fillColor(GREEN).text(pg, W - M - 60, y + 1, { width: 60, align: "right" }); y += 34; }); y += 8; doc.font("JB-Reg").fontSize(7).fillColor(INK3).text( `PÉRIODE : ${PERIOD_LABEL.toUpperCase()} · STATISTIQUES GÉNÉRÉES LE ${s.generated} · SOURCE : RÔLE D'ÉVALUATION FONCIÈRE + CORPUS DE VENTES + MODÈLE HÉDONIQUE UQO ÉVAL`, M, y, { characterSpacing: 0.4, width: CW }); } for (let i = 1; i < total; i++) { doc.switchToPage(i); doc.font("JB-Bold").fontSize(7.5).fillColor(INK) .text(`p. ${i + 1}/${total}`, W - M - 60, H - 34.5, { width: 60, align: "right", lineBreak: false }); } doc.end(); return done; } /** Nom de fichier normalisé — pas de suffixe pour `complet`. */ export function reportFilename(mode: ReportMode | "personnalise" = "complet"): string { const today = new Date().toLocaleDateString("fr-CA", { timeZone: "America/Toronto" }); const suffix = mode === "complet" ? "" : `_${mode}`; return `uqo-eval_stats_instantane${suffix}_${today}.pdf`; } /* ===================== v3 : rapports personnalisés (SPEC §3bis) ===================== Catalogue de blocs dérivé du dashboard + génération d'un PDF composé bloc par bloc (rendu au choix), même gabarit. */ export type CatalogBlock = { key: string; section: string; title: string; renders: string[]; default_render: string; count?: number; }; export type CustomBlock = { key: string; render?: string }; export type CustomSpec = { title?: string; blocks?: CustomBlock[] }; export function catalogFromDashboard(dash: Dash = getDashboard()): CatalogBlock[] { const out: CatalogBlock[] = []; const add = (key: string, title: string, renders: string[], def?: string, count?: number) => out.push({ key, section: key.split(":")[0], title, renders, default_render: def ?? renders[0], ...(count !== undefined ? { count } : {}) }); if (dash.kpis?.length) add("kpis", "Indicateurs clés (KPI)", ["cards", "table"], undefined, dash.kpis.length); if (dash.gauges?.length) add("gauges", "Taux & couvertures (jauges)", ["gauges", "table"], undefined, dash.gauges.length); for (const s of dash.series ?? []) { if ((s.points?.length ?? 0) < 2) continue; const kind = (s as KaSerie).kind ?? "line"; add(`series:${s.id}`, s.title, ["line", "area", "bar", "table"], ["line", "area", "bar"].includes(kind) ? kind : "line", s.points.length); } for (const ms of dash.multiseries ?? []) { if (!ms.series?.length) continue; add(`multiseries:${ms.id}`, ms.title, ["lines", "table"], undefined, ms.series.length); } for (const st of dash.stacked ?? []) { if (!st.points?.length) continue; add(`stacked:${st.id}`, st.title, ["stacked", "table"], undefined, st.keys?.length); } for (const b of dash.breakdowns ?? []) { if (!b.items?.length) continue; add(`breakdowns:${b.id}`, b.title, ["donut", "bars", "table"], b.kind === "donut" ? "donut" : "bars", b.items.length); } for (const d of dash.distributions ?? []) { if (!d.bins?.length) continue; add(`distributions:${d.id}`, d.title, ["histogram", "table"], undefined, d.bins.length); } if (dash.geo?.items?.length) add("geo", dash.geo.title ?? "Répartition géographique", ["bars", "table"], undefined, dash.geo.items.length); if (dash.heatmap?.cells?.length) add("heatmap", dash.heatmap.title ?? "Calendrier d'activité", ["heatmap", "table"]); for (const t of dash.tables ?? []) { if (!t.rows?.length) continue; add(`tables:${t.id}`, t.title, ["table"], undefined, t.rows.length); } if (dash.records?.length) add("records", "Records & faits marquants", ["cards", "table"], undefined, dash.records.length); return out; } type AnyRow = (string | number | null)[]; const seriesAsTable = (s: KaSerie) => ({ title: s.title, columns: ["Date", (s.unit ?? "Valeur")] as string[], rows: (s.points ?? []).map((p) => [p.t, p.v] as AnyRow), }); function renderCustomBlock(c: Cursor, dash: Dash, key: string, render: string, title: string) { const [section, id] = [key.split(":")[0], key.split(":").slice(1).join(":")]; const mark = () => { c.ensure(160); c.sections.push({ label: title, page: c.page() }); }; if (section === "kpis") { if (render === "table") { tablePdf(c, { title: "Indicateurs clés", columns: ["Indicateur", "Valeur", "Δ %"], rows: dash.kpis.map((k) => [k.label, typeof k.value === "number" ? num(k.value) + (k.unit ? ` ${k.unit}` : "") : String(k.value), k.delta_pct == null ? "" : `${k.delta_pct >= 0 ? "+" : ""}${k.delta_pct} %`] as AnyRow) }, 400); } else { c.section("Indicateurs clés"); kpiGridPdf(c, dash.kpis); } } else if (section === "gauges") { if (render === "table") { tablePdf(c, { title: "Taux & couvertures", columns: ["Mesure", "Valeur", "Max", "Part"], rows: dash.gauges.map((g) => [g.label, `${num(g.value)}${g.unit ? ` ${g.unit}` : ""}`, num(g.max), `${Math.round((100 * g.value) / (g.max || 1))} %`] as AnyRow) }, 400); } else { c.section("Taux & couvertures"); gaugesPdf(c, dash.gauges); } } else if (section === "records") { if (render === "table") { tablePdf(c, { title: "Records & faits marquants", columns: ["Fait marquant", "Valeur", "Date"], rows: dash.records.map((r) => [r.label, r.value, r.date ?? ""] as AnyRow) }, 400); } else recordsPdf(c, dash.records); } else if (section === "series") { const s = (dash.series ?? []).find((x) => String(x.id) === id) as KaSerie | undefined; if (!s) return; if (render === "table") tablePdf(c, seriesAsTable(s), 400); else { mark(); linePdf(c, { ...s, kind: (["line", "area", "bar"].includes(render) ? render : s.kind) as KaSerie["kind"] }); statLine(c, s); } } else if (section === "multiseries") { const ms = (dash.multiseries ?? []).find((x) => String(x.id) === id); if (!ms) return; if (render === "table") { const labels = ms.series.slice(0, 4).map((x) => x.label); const byT = new Map>(); for (const se of ms.series.slice(0, 4)) for (const p of se.points) { const m = byT.get(p.t) ?? {}; m[se.label] = p.v; byT.set(p.t, m); } tablePdf(c, { title: ms.title, columns: ["Date", ...labels], rows: [...byT.keys()].sort().map((t) => [t, ...labels.map((l) => byT.get(t)?.[l] ?? "")] as AnyRow) }, 400); } else { mark(); multiLinePdf(c, ms); } } else if (section === "stacked") { const st = (dash.stacked ?? []).find((x) => String(x.id) === id); if (!st) return; if (render === "table") { const keys = (st.keys ?? []).slice(0, 6); tablePdf(c, { title: st.title, columns: ["Date", ...keys, "Total"], rows: st.points.map((p) => { const vs = keys.map((_, j) => p.values[j] ?? 0); return [p.t, ...vs, vs.reduce((a, b) => a + b, 0)] as AnyRow; }) }, 400); } else { mark(); stackedPdf(c, st); } } else if (section === "breakdowns") { const b = (dash.breakdowns ?? []).find((x) => String(x.id) === id); if (!b) return; if (render === "table") { tablePdf(c, { title: b.title, columns: ["Libellé", "Valeur"], rows: b.items.map((it) => [it.label, it.value] as AnyRow) }, 400); } else { mark(); if (render === "donut") donutPdf(c, b.title, b.items); else hbarsPdf(c, b.title, b.items, b.id === "types_n" ? num : compact); } } else if (section === "distributions") { const d = (dash.distributions ?? []).find((x) => String(x.id) === id); if (!d) return; if (render === "table") { tablePdf(c, { title: d.title, columns: ["Tranche", "Valeur"], rows: d.bins.map((b) => [b.label, b.value] as AnyRow) }, 400); } else { mark(); linePdf(c, { id: d.id, title: d.title, unit: d.unit, kind: "bar", points: d.bins.map((b) => ({ t: b.label, v: b.value })) }); } } else if (section === "geo") { if (!dash.geo?.items?.length) return; if (render === "table") { tablePdf(c, { title: dash.geo.title, columns: ["Zone", "Valeur"], rows: dash.geo.items.map((it) => [it.label, it.value] as AnyRow) }, 400); } else { mark(); hbarsPdf(c, dash.geo.title, dash.geo.items); } } else if (section === "heatmap") { if (!dash.heatmap?.cells?.length) return; if (render === "table") { const cells = [...dash.heatmap.cells].sort((a, b) => b.value - a.value).slice(0, 40); tablePdf(c, { title: `${dash.heatmap.title} — jours les plus chargés`, columns: ["Date", "Valeur"], rows: cells.map((x) => [x.date, x.value] as AnyRow) }, 400); } else { mark(); heatmapPdf(c, dash.heatmap); } } else if (section === "tables") { const t = (dash.tables ?? []).find((x) => String(x.id) === id); if (t) tablePdf(c, t, 400); } } /** PDF personnalisé — lève Error("aucun-bloc") si la composition est vide. */ export async function buildCustomReport(spec: CustomSpec): Promise { const s = stats as ProvStats; const dash = getDashboard(); const cat = new Map(catalogFromDashboard(dash).map((b) => [b.key, b])); const blocks = (spec.blocks ?? []) .filter((b): b is CustomBlock => !!b && typeof b === "object" && cat.has(String(b.key))) .slice(0, 40); if (!blocks.length) throw new Error("aucun-bloc"); const title = String(spec.title ?? "").slice(0, 80).trim(); const label = title ? `Rapport personnalisé — ${title}` : "Rapport personnalisé"; const generated = new Date().toLocaleString("fr-CA", { timeZone: "America/Toronto", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }).replace(",", " ·"); const doc = new PDFDocument({ size: "A4", margin: 0, bufferPages: true, info: { Title: `UQO Éval — ${label}`, Author: "UQO Éval — www.uqo-eval.app" }, }); doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf")); doc.registerFont("JB-Reg", F("JetBrainsMono-Regular.ttf")); doc.registerFont("JB-Bold", F("JetBrainsMono-Bold.ttf")); doc.registerFont("Inter", F("Inter-Regular.ttf")); const chunks: Buffer[] = []; doc.on("data", (b: Buffer) => chunks.push(b)); const done = new Promise((res) => doc.on("end", () => res(Buffer.concat(chunks)))); cover(doc, s, generated, label); doc.addPage({ size: "A4", margin: 0 }); // page 2 : sommaire (post-passe) doc.addPage({ size: "A4", margin: 0 }); chrome(doc); const c = new Cursor(doc); for (const blk of blocks) { const b = cat.get(String(blk.key))!; const render = b.renders.includes(String(blk.render ?? "")) ? String(blk.render) : b.default_render; renderCustomBlock(c, dash, b.key, render, b.title); } doc.addPage({ size: "A4", margin: 0 }); chrome(doc); finalPage(doc); const range = doc.bufferedPageRange(); const total = range.count; doc.switchToPage(1); chrome(doc); let y = TOP + 4; kicker(doc, label, M, y); y += 20; doc.font("SG-Bold").fontSize(21).fillColor(INK).text("Sommaire", M, y); y += 40; const entries: [string, string][] = [ ...c.sections.map((sec) => [sec.label, String(sec.page)] as [string, string]), ["À propos d'UQO Éval & mentions", String(total)], ]; entries.slice(0, 16).forEach(([lab, pg]) => { doc.rect(M, y + 22, CW, 0.7).fill("#e3e1d9"); doc.font("SG-Bold").fontSize(11).fillColor(INK).text(lab, M, y, { width: CW - 70, height: 26, ellipsis: true }); doc.font("JB-Bold").fontSize(10.5).fillColor(GREEN).text(pg, W - M - 60, y + 1, { width: 60, align: "right" }); y += 34; }); for (let i = 1; i < total; i++) { doc.switchToPage(i); doc.font("JB-Bold").fontSize(7.5).fillColor(INK) .text(`p. ${i + 1}/${total}`, W - M - 60, H - 34.5, { width: 60, align: "right", lineBreak: false }); } doc.end(); return done; } /** Compat : buildRecords ré-exporté pour les consommateurs historiques. */ export { buildRecords };