// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * v3 — rapports personnalisés ValoPlex (ka-stats SPEC §3bis). * Catalogue de blocs dérivé des données provinciales (stats.json) + * génération d'un PDF composé bloc par bloc (rendu au choix : courbe/aire/ * barres/anneau/tableau…), au gabarit ValoPlex (LETTER, encre/papier/orange). * Le rapport v1 (`report-stats.ts`, /api/report/stats) reste inchangé. */ import PDFDocument from "pdfkit"; import path from "path"; import type { ProvStats } from "@/components/StatsView"; const INK = "#141814"; const INK2 = "#4d5551"; const INK3 = "#8b928c"; const PAPER = "#f5f3ee"; const SURFACE2 = "#faf9f5"; const ACCENT = "#ff9f45"; // orange ValoPlex const ACCENT_DEEP = "#b25f16"; const WHITE = "#ffffff"; const W = 612; // LETTER const H = 792; const M = 44; const CW = W - 2 * M; const TOP = 40; const BOT = H - 56; 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"); 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 TYPE_FR: Record = { unifamilial: "Unifamiliale", plex: "Plex (2-5 log.)", condo_ou_multi: "Condo / multi", chalet: "Chalet", maison_mobile: "Maison mobile", terrain: "Terrain", autre: "Autre", }; type Doc = InstanceType; type Row = (string | number)[]; type Point = { t: string; v: number }; type Item = { label: string; value: number }; /* ------------------------- dashboard dérivé de stats.json ------------------------- */ 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[] }; type VDash = { kpis: { label: string; value: string }[]; series: { id: string; title: string; unit: string; points: Point[]; fmt: (v: number) => string }[]; breakdowns: { id: string; title: string; items: Item[]; fmt: (v: number) => string }[]; geo: { title: string; items: Item[] }; tables: { id: string; title: string; columns: string[]; rows: Row[] }[]; records: { label: string; value: string }[]; }; export function buildVDash(s: ProvStats): VDash { const kpis = [ { label: "Valeur totale du parc (2026)", value: compact(s.valeur_totale_2026) }, { label: "Propriétés estimées", value: num(s.unites) }, { label: "Municipalités couvertes", value: num(s.municipalites) }, { label: "Valeur médiane provinciale", value: money(s.valeur_mediane_2026) }, { label: "Croissance 2021 → 2026 (périmètre constant)", value: `+${s.croissance_2021_2026_pct.toLocaleString("fr-CA")} %` }, { label: "Logements", value: num(s.logements) }, { label: "Plancher bâti", value: `${num(s.aire_etages_km2)} km²` }, { label: "Superficie de terrain", value: `${num(s.terrain_km2)} km²` }, { label: "Évaluation municipale totale (rôle)", value: compact(s.valeur_role_totale) }, ]; const series: VDash["series"] = [ { id: "millesimes", title: "Valeur provinciale par millésime", unit: "$", points: s.totaux_annee.map((x) => ({ t: String(x.year), v: x.total })), fmt: compact }, { id: "unites_millesime", title: "Unités estimées par millésime", unit: "unités", points: s.totaux_annee.map((x) => ({ t: String(x.year), v: x.n })), fmt: num }, ]; const breakdowns: VDash["breakdowns"] = [ { id: "types_valeur", title: "Valeur totale par type de propriété", items: s.par_type.map((t) => ({ label: TYPE_FR[t.type] ?? t.type, value: t.total })), fmt: compact }, { id: "types_n", title: "Propriétés par type", items: s.par_type.map((t) => ({ label: TYPE_FR[t.type] ?? t.type, value: t.n })), fmt: num }, ]; const geo = { title: "Top municipalités par valeur totale", items: s.par_ville.slice(0, 15).map((v) => ({ label: v.ville, value: v.total })), }; const tables: VDash["tables"] = [ { id: "villes", title: `Palmarès des municipalités (${s.par_ville.length})`, columns: ["#", "Municipalité", "Propriétés", "Valeur totale", "Valeur médiane"], rows: s.par_ville.map((v, i) => [i + 1, v.ville, num(v.n), compact(v.total), v.mediane ? money(v.mediane) : "—"]) }, { id: "types", title: "Détail par type de propriété", columns: ["Type", "Propriétés", "Valeur totale", "Valeur médiane"], rows: s.par_type.map((t) => [TYPE_FR[t.type] ?? t.type, num(t.n), compact(t.total), t.mediane ? money(t.mediane) : "—"]) }, ]; const top = s.par_ville[0]; const topType = [...s.par_type].sort((a, b) => b.total - a.total)[0]; const years = s.totaux_annee; let bestYoY: { y: number; pct: number } | null = null; for (let i = 1; i < years.length; i++) { const pct = (years[i].total / years[i - 1].total - 1) * 100; if (!bestYoY || pct > bestYoY.pct) bestYoY = { y: years[i].year, pct }; } const records = [ ...(top ? [{ label: "Municipalité la plus valorisée", value: `${top.ville} — ${compact(top.total)}` }] : []), ...(topType ? [{ label: "Type dominant (valeur)", value: `${TYPE_FR[topType.type] ?? topType.type} — ${compact(topType.total)}` }] : []), ...(bestYoY ? [{ label: "Plus forte croissance annuelle", value: `${bestYoY.y} — +${bestYoY.pct.toFixed(1)} %` }] : []), { label: "Valeur moyenne par propriété", value: money(s.valeur_totale_2026 / s.unites) }, { label: "Écart estimation vs rôle", value: `+${(((s.valeur_totale_2026 / s.valeur_role_totale) - 1) * 100).toFixed(1)} %` }, ]; return { kpis, series, breakdowns, geo, tables, records }; } export function catalogFromStats(s: ProvStats): CatalogBlock[] { const d = buildVDash(s); 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 } : {}) }); add("kpis", "Indicateurs clés (KPI)", ["cards", "table"], undefined, d.kpis.length); for (const se of d.series) add(`series:${se.id}`, se.title, ["line", "area", "bar", "table"], "bar", se.points.length); for (const b of d.breakdowns) add(`breakdowns:${b.id}`, b.title, ["donut", "bars", "table"], "bars", b.items.length); add("geo", d.geo.title, ["bars", "table"], undefined, d.geo.items.length); for (const t of d.tables) add(`tables:${t.id}`, t.title, ["table"], undefined, t.rows.length); add("records", "Records & faits marquants", ["cards", "table"], undefined, d.records.length); return out; } /* --------------------------------- primitives PDF --------------------------------- */ class C { y = TOP + 34; constructor(public doc: Doc, public generated: string) {} page() { return this.doc.bufferedPageRange().count; } chrome() { const { doc } = this; doc.rect(0, 0, W, H).fill(PAPER); doc.rect(0, 0, W, 8).fill(INK); doc.rect(0, 8, W, 2.5).fill(ACCENT); doc.rect(M, H - 46, CW, 1.2).fill(INK); doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text( "WWW.VALOPLEX.COM — RAPPORT PERSONNALISÉ · ESTIMATIONS STATISTIQUES (MODÈLE HÉDONIQUE) · SIMON-PIERRE BOUCHER · CONTACT@SPBOUCHER.AI", M, H - 38, { width: CW - 60, characterSpacing: 0.4, lineBreak: false }); doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) .text(this.generated, W - M - 160, H - 30, { width: 160, align: "right" }); } ensure(h: number) { if (this.y + h > BOT) { this.doc.addPage({ size: "LETTER", margin: 0 }); this.chrome(); this.y = TOP; } } kicker(txt: string) { this.ensure(26); const { doc } = this; doc.rect(M, this.y + 3.5, 20, 2).fill(ACCENT_DEEP); doc.font("JB-Bold").fontSize(8).fillColor(ACCENT_DEEP) .text(txt.toUpperCase(), M + 26, this.y, { characterSpacing: 1.4, width: CW - 26, height: 10, ellipsis: true, lineBreak: false }); this.y += 20; } card(h: number, fill = WHITE) { const { doc } = this; doc.roundedRect(M + 3, this.y + 3, CW, h, 8).fill("#e3e1d9"); doc.roundedRect(M, this.y, CW, h, 8).lineWidth(1.3).fillAndStroke(fill, INK); } } function axesGrid(c: C, a: { x: number; y: number; w: number; h: number }, vmin: number, vmax: number) { const { doc } = c; for (let g = 0; g <= 4; g++) { const gy = a.y + (a.h * g) / 4; doc.rect(a.x, gy, a.w, 0.5).fill("#e3e1d9"); doc.font("JB-Reg").fontSize(5.8).fillColor(INK3) .text(short(vmax - ((vmax - vmin) * g) / 4), a.x - 40, gy - 3, { width: 36, align: "right", lineBreak: false }); } } function chartBlock(c: C, title: string, h: number): { x: number; y: number; w: number; h: number } { c.ensure(h + 46); c.kicker(title); c.card(h + 18); const area = { x: M + 56, y: c.y + 10, w: CW - 56 - 22, h: h - 6 }; c.y += h + 18 + 14; return area; } function linePdf(c: C, title: string, pts: Point[], kind: "line" | "area" | "bar", fmt: (v: number) => string) { if (pts.length < 2 && kind !== "bar") kind = "bar"; const a = chartBlock(c, title, 120); const { doc } = c; const vmax = Math.max(...pts.map((p) => p.v), 1); const vmin = Math.min(0, ...pts.map((p) => p.v)); const rng = vmax - vmin || 1; axesGrid(c, a, vmin, vmax); const X = (i: number) => a.x + (a.w * i) / Math.max(pts.length - 1, 1); const Y = (v: number) => a.y + a.h * (1 - (v - vmin) / rng); if (kind === "bar") { const bw = Math.max(3, a.w / pts.length - 3); pts.forEach((p, i) => { const bh = (a.h * (p.v - vmin)) / rng; doc.rect(a.x + (a.w * i) / pts.length + 1.5, a.y + a.h - bh, bw, Math.max(bh, 1)) .lineWidth(0.6).fillAndStroke(ACCENT, INK); }); } else { if (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(X(0), a.y + a.h).closePath() .fillOpacity(0.18).fill(ACCENT).fillOpacity(1); } doc.moveTo(X(0), Y(pts[0].v)); pts.forEach((p, i) => doc.lineTo(X(i), Y(p.v))); doc.lineWidth(2).stroke(ACCENT_DEEP); } [0, Math.floor(pts.length / 2), pts.length - 1] .filter((v, i, arr) => arr.indexOf(v) === i) .forEach((i) => { doc.font("JB-Reg").fontSize(6).fillColor(INK3) .text(pts[i].t.slice(0, 10), X(i) - 22, a.y + a.h + 4, { width: 44, align: "center", lineBreak: false }); }); // min/max/moyenne sous le graphique const vs = pts.map((p) => p.v); const mean = vs.reduce((x, y) => x + y, 0) / vs.length; doc.font("JB-Reg").fontSize(6).fillColor(INK3).text( `MIN ${fmt(Math.min(...vs))} · MAX ${fmt(Math.max(...vs))} · MOYENNE ${fmt(mean)}`, M + 14, c.y - 10, { characterSpacing: 0.4, lineBreak: false }); } function hbarsPdf(c: C, title: string, items: Item[], fmt: (v: number) => string) { const rows = items.slice(0, 15); const h = rows.length * 19 + 16; c.ensure(h + 40); c.kicker(title); c.card(h); const { doc } = c; const max = Math.max(...rows.map((r) => r.value), 1); rows.forEach((r, i) => { const y = c.y + 10 + i * 19; doc.font("JB-Bold").fontSize(7.5).fillColor(INK) .text(r.label, M + 14, y + 3, { width: 120, height: 9, ellipsis: true, lineBreak: false }); const tx = M + 140, tw = CW - 140 - 118; doc.rect(tx, y, tw, 13).fill(SURFACE2); doc.rect(tx, y, Math.max(2, (r.value / max) * tw), 13).fill(ACCENT); doc.font("JB-Bold").fontSize(7.5).fillColor(INK) .text(fmt(r.value), M + CW - 112 - 14, y + 3, { width: 112, align: "right", lineBreak: false }); }); c.y += h + 14; } function donutPdf(c: C, title: string, items: Item[], fmt: (v: number) => string) { 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 = Math.max(120, rows.length * 15 + 20); c.ensure(h + 40); c.kicker(title); c.card(h); const { doc } = c; const cx = M + 78, cy = c.y + h / 2, R = Math.min(46, h / 2 - 12); const shades = [1, 0.78, 0.58, 0.42, 0.3, 0.22, 0.15, 0.1]; let start = -Math.PI / 2; rows.forEach((r, i) => { const frac = r.value / total; const steps = Math.max(2, Math.ceil(64 * frac)); doc.moveTo(cx, cy); for (let st = 0; st <= steps; st++) { const ang = start + 2 * Math.PI * frac * (st / steps); doc.lineTo(cx + R * Math.cos(ang), cy + R * Math.sin(ang)); } doc.closePath().fillOpacity(shades[i % shades.length]).fill(ACCENT).fillOpacity(1); start += 2 * Math.PI * frac; }); doc.circle(cx, cy, R * 0.55).lineWidth(1).fillAndStroke(WHITE, INK); doc.circle(cx, cy, R).lineWidth(1).stroke(INK); let ly = c.y + (h - rows.length * 15) / 2 + 2; rows.forEach((r, i) => { doc.rect(M + 150, ly + 2, 8, 8).lineWidth(0.7) .fillOpacity(shades[i % shades.length]).fillAndStroke(ACCENT, INK); doc.fillOpacity(1).font("JB-Reg").fontSize(7.2).fillColor(INK).text( `${r.label} — ${fmt(r.value)} (${((100 * r.value) / total).toFixed(1)} %)`, M + 164, ly + 2.5, { width: CW - 164 - 20, height: 10, ellipsis: true, lineBreak: false }); ly += 15; }); c.y += h + 14; } function tablePdf(c: C, title: string, columns: string[], rows: Row[], maxRows = 400) { c.kicker(title); const { doc } = c; const wcol = CW / columns.length; const head = () => { c.ensure(40); doc.rect(M, c.y, CW, 16).fill(INK); doc.font("JB-Bold").fontSize(6).fillColor(WHITE); columns.forEach((col, i) => doc.text(String(col).toUpperCase(), M + i * wcol + 8, c.y + 5, { width: wcol - 12, height: 8, ellipsis: true, lineBreak: false })); c.y += 16; }; head(); rows.slice(0, maxRows).forEach((row, ri) => { if (c.y + 15 > BOT) { c.ensure(40); head(); } if (ri % 2 === 0) doc.rect(M, c.y, CW, 15).fill(SURFACE2); row.forEach((cell, i) => { doc.font(i === 0 ? "SG-Bold" : "JB-Reg").fontSize(7).fillColor(INK) .text(String(cell), M + i * wcol + 8, c.y + 4, { width: wcol - 12, height: 9, ellipsis: true, lineBreak: false }); }); c.y += 15; }); doc.rect(M, c.y, CW, 0.9).fill(INK); c.y += 14; if (rows.length > maxRows) { doc.font("JB-Reg").fontSize(6.5).fillColor(INK3) .text(`… ${rows.length - maxRows} lignes supplémentaires non imprimées`, M, c.y - 8); } } function kpiCards(c: C, kpis: VDash["kpis"]) { c.kicker("Indicateurs clés"); const { doc } = c; const cols = 3, gap = 10, cw = (CW - (cols - 1) * gap) / cols, ch = 48; const rowsN = Math.ceil(kpis.length / cols); c.ensure(rowsN * (ch + 10)); kpis.forEach((k, i) => { const tx = M + (i % cols) * (cw + gap); if (i > 0 && i % cols === 0) c.y += ch + 10; if (c.y + ch > BOT) { c.ensure(ch + 12); } doc.roundedRect(tx + 3, c.y + 3, cw, ch, 8).fill("#e3e1d9"); doc.roundedRect(tx, c.y, cw, ch, 8).lineWidth(1.3).fillAndStroke(WHITE, INK); doc.font("SG-Bold").fontSize(12).fillColor(INK) .text(k.value, tx + 10, c.y + 10, { width: cw - 20, height: 15, ellipsis: true, lineBreak: false }); doc.font("JB-Bold").fontSize(5.4).fillColor(INK3) .text(k.label.toUpperCase(), tx + 10, c.y + 30, { width: cw - 20, characterSpacing: 0.4, height: 14 }); }); c.y += 48 + 18; } function recordCards(c: C, records: VDash["records"]) { c.kicker("Records & faits marquants"); const { doc } = c; records.forEach((r) => { c.ensure(26); doc.roundedRect(M, c.y, CW, 20, 6).lineWidth(1).fillAndStroke(SURFACE2, INK); doc.font("JB-Reg").fontSize(7.5).fillColor(INK2) .text(r.label, M + 12, c.y + 6.5, { width: CW * 0.5, height: 9, ellipsis: true, lineBreak: false }); doc.font("SG-Bold").fontSize(8.5).fillColor(INK) .text(r.value, M + CW * 0.5, c.y + 6, { width: CW * 0.5 - 14, align: "right", height: 10, ellipsis: true, lineBreak: false }); c.y += 24; }); c.y += 8; } /* ------------------------------- rapport personnalisé ------------------------------- */ export async function buildCustomReport(s: ProvStats, spec: CustomSpec): Promise { const dash = buildVDash(s); const cat = new Map(catalogFromStats(s).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().toISOString().slice(0, 16).replace("T", " "); const doc = new PDFDocument({ size: "LETTER", margin: 0, bufferPages: true, info: { Title: `ValoPlex — ${label}` }, }); 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)))); /* couverture (bandeau encre + logo ValoPlex, comme le rapport v1) */ doc.rect(0, 0, W, H).fill(PAPER); doc.rect(0, 0, W, 96).fill(INK); doc.font("SG-Bold").fontSize(24).fillColor(PAPER).text("Valo", M, 30, { lineBreak: false }); const lw = doc.widthOfString("Valo"); doc.save(); doc.rotate(-3, { origin: [M + lw + 4, 42] }); doc.roundedRect(M + lw + 4, 27, doc.widthOfString("Plex") + 12, 30, 5).fill(ACCENT); doc.fillColor(INK).text("Plex", M + lw + 10, 30, { lineBreak: false }); doc.restore(); doc.font("JB-Reg").fontSize(7).fillColor(ACCENT) .text("RAPPORT PERSONNALISÉ · QUÉBEC", M, 66, { characterSpacing: 1.6, lineBreak: false }); doc.font("JB-Reg").fontSize(7).fillColor("#9aa39c") .text(`MILLÉSIME 2026 · GÉNÉRÉ LE ${generated}`, W - M - 220, 40, { width: 220, align: "right" }); doc.rect(0, 96, W, 3).fill(ACCENT); let y = 140; doc.rect(M, y + 3.5, 20, 2).fill(ACCENT_DEEP); doc.font("JB-Bold").fontSize(8).fillColor(ACCENT_DEEP) .text("VALOPLEX · RAPPORT STATISTIQUE", M + 26, y, { characterSpacing: 1.4, lineBreak: false }); y += 26; doc.font("SG-Bold").fontSize(26).fillColor(INK).text(label, M, y, { width: CW }); y += doc.heightOfString(label, { width: CW }) + 46; const rows: [string, string][] = [ ["Type de rapport", label], ["Période couverte", "Instantané du rôle d'évaluation 2026 (millésime)"], ["Généré le", generated], ["Plateforme", "www.valoplex.com"], ["Composition", `${blocks.length} bloc${blocks.length > 1 ? "s" : ""}`], ["Données", `${num(s.unites)} propriétés · ${num(s.municipalites)} municipalités`], ]; doc.rect(M, y - 14, 34, 3).fill(ACCENT); rows.forEach(([k, v]) => { doc.font("JB-Reg").fontSize(8).fillColor(INK3) .text(k.toUpperCase(), M, y + 2.5, { width: 150, characterSpacing: 0.6, lineBreak: false }); doc.font("SG-Bold").fontSize(11).fillColor(INK) .text(v, M + 160, y, { width: CW - 160, height: 14, ellipsis: true, lineBreak: false }); y += 24; }); doc.rect(0, H - 64, W, 64).fill(INK); doc.font("SG-Bold").fontSize(13).fillColor(WHITE).text("ValoPlex — juste pour le show", M, H - 42, { lineBreak: false }); doc.font("JB-Bold").fontSize(9).fillColor(ACCENT) .text("www.valoplex.com", W - M - 180, H - 40, { width: 180, align: "right" }); /* blocs, dans l'ordre demandé */ doc.addPage({ size: "LETTER", margin: 0 }); const c = new C(doc, generated); c.chrome(); c.y = TOP; 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; const [section, id] = [b.section, b.key.split(":").slice(1).join(":")]; if (section === "kpis") { if (render === "table") tablePdf(c, "Indicateurs clés", ["Indicateur", "Valeur"], dash.kpis.map((k) => [k.label, k.value])); else kpiCards(c, dash.kpis); } else if (section === "series") { const se = dash.series.find((x) => x.id === id); if (!se) continue; if (render === "table") tablePdf(c, se.title, ["Millésime", se.unit === "$" ? "Valeur" : "Unités"], se.points.map((p) => [p.t, se.fmt(p.v)])); else linePdf(c, se.title, se.points, render as "line" | "area" | "bar", se.fmt); } else if (section === "breakdowns") { const b2 = dash.breakdowns.find((x) => x.id === id); if (!b2) continue; if (render === "table") tablePdf(c, b2.title, ["Type", "Valeur"], b2.items.map((it) => [it.label, b2.fmt(it.value)])); else if (render === "donut") donutPdf(c, b2.title, b2.items, b2.fmt); else hbarsPdf(c, b2.title, b2.items, b2.fmt); } else if (section === "geo") { if (render === "table") tablePdf(c, dash.geo.title, ["Municipalité", "Valeur totale"], dash.geo.items.map((it) => [it.label, compact(it.value)])); else hbarsPdf(c, dash.geo.title, dash.geo.items, compact); } else if (section === "tables") { const t = dash.tables.find((x) => x.id === id); if (t) tablePdf(c, t.title, t.columns, t.rows); } else if (section === "records") { if (render === "table") tablePdf(c, "Records & faits marquants", ["Fait marquant", "Valeur"], dash.records.map((r) => [r.label, r.value])); else recordCards(c, dash.records); } } /* numéros de page (post-passe) */ const total = doc.bufferedPageRange().count; for (let i = 1; i < total; i++) { doc.switchToPage(i); doc.font("JB-Bold").fontSize(7.5).fillColor(INK) .text(`${i + 1} / ${total}`, W - M - 40, H - 39, { width: 40, align: "right", lineBreak: false }); } doc.end(); return done; } export function customFilename(): string { const today = new Date().toLocaleDateString("fr-CA", { timeZone: "America/Toronto" }); return `valoplex_stats_personnalise_${today}.pdf`; }