// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Rapport PDF « Méthode du coût » — vectoriel (pdfkit), même * branding que report.ts : bandeau encre, logo Vrai-Prix, cartes, filets, * format Lettre. Chaque nombre du rapport vient du moteur (CostEstimate) ; * la nature de chaque prix est marquée : ● observé/officiel · ◐ calculé · * ○ indexé · ◇ référence interne (hypothèse). */ import PDFDocument from "pdfkit"; import path from "path"; import type { CostEstimate, PriceKind } from "./cost/types"; import { INDIRECT_LABELS, QUALITIES, BUILDING_TYPES, tradeLabel } from "./cost/taxonomy"; import { UNIT_LABELS } from "./cost/units"; const INK = "#141814"; const INK2 = "#4d5551"; const INK3 = "#8b928c"; const PAPER = "#f5f3ee"; const SURFACE2 = "#faf9f5"; const GREEN = "#9e2a25"; // bordeaux Vrai-Prix (--green) const GREEN_DEEP = "#771f1b"; const LIME = "#ff5148"; // rouge vif Vrai-Prix (--accent-bright) const LIME_SOFT = "#ffe3e0"; const AMBER_SOFT = "#fdf3e2"; const DANGER = "#b3423a"; const DANGER_SOFT = "#fbe9e7"; const LINE = "#dedcd4"; const W = 612; const H = 792; const M = 44; const BOTTOM = H - 62; // limite basse du contenu (au-dessus du pied de page) const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f); const fmt = (v: number | null | undefined) => v == null || !Number.isFinite(v) ? "—" : new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v); const fmt2 = (v: number | null | undefined) => v == null || !Number.isFinite(v) ? "—" : new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(v); const num = (v: number | null | undefined, d = 0) => v == null || !Number.isFinite(v) ? "—" : v.toLocaleString("fr-CA", { maximumFractionDigits: d, minimumFractionDigits: 0 }); const pct = (v: number | null | undefined, d = 1) => (v == null || !Number.isFinite(v) ? "—" : `${v.toLocaleString("fr-CA", { maximumFractionDigits: d })} %`); const neg = (v: number) => (v > 0 ? `−${fmt(v)}` : fmt(0)); const KIND_ICON: Record = { observed: "●", official: "●", derived: "◐", indexed: "○", reference: "◇", assumption: "◇" }; const KIND_TXT: Record = { observed: "observé", official: "officiel", derived: "calculé", indexed: "indexé", reference: "référence (hypothèse)", assumption: "hypothèse" }; const SRC_TXT: Record = { MAMH: "rôle MAMH", user: "saisi", AI: "inféré par IA", derived: "dérivé", assumed: "supposé", listing: "annonce", cadastral: "cadastre" }; const CONF_BG: Record = { A: LIME, B: LIME_SOFT, C: AMBER_SOFT, D: DANGER_SOFT }; const CONF_TXT: Record = { A: "Très fiable", B: "Fiable", C: "Indicative", D: "Peu fiable" }; type Doc = InstanceType; /* ------------------------------------------------------------- primitives */ /** Mot-symbole Vrai-Prix (identique à report.ts) : « Vrai » papier + « Prix » sur pastille rouge inclinée. */ function logo(doc: Doc, x: number, y: number, scale = 1) { doc.save(); doc.font("SG-Bold").fontSize(24 * scale); doc.fillColor(PAPER).text("Vrai", x, y, { lineBreak: false }); const w = doc.widthOfString("Vrai"); const bx = x + w + 4 * scale; doc.save(); doc.rotate(-3, { origin: [bx, y + 12 * scale] }); const bw = doc.widthOfString("Prix") + 12 * scale; doc.roundedRect(bx, y - 3 * scale, bw, 30 * scale, 5 * scale).fill(LIME); doc.fillColor(INK).text("Prix", bx + 6 * scale, y, { lineBreak: false }); doc.restore(); doc.restore(); } function kicker(doc: Doc, txt: string, x: number, y: number, color = GREEN) { doc.rect(x, y + 3.5, 20, 2).fill(color); doc.font("JB-Bold").fontSize(8).fillColor(color).text(txt.toUpperCase(), x + 26, y, { characterSpacing: 1.4, lineBreak: false, width: W - 2 * M - 26, height: 12, ellipsis: true }); } function pageBg(doc: Doc) { doc.rect(0, 0, W, H).fill(PAPER); } function pageFooter(doc: Doc, page: number, total: number, generated: string) { doc.rect(M, H - 46, W - 2 * M, 1.2).fill(INK); doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text( "VRAI-PRIX.COM · MÉTHODE DU COÛT — ESTIMATION STATISTIQUE À TITRE INDICATIF, NI SOUMISSION NI ÉVALUATION CERTIFIÉE (OEAQ)", M, H - 39, { width: W - 2 * M - 60, characterSpacing: 0.3, lineBreak: false, height: 9, ellipsis: true } ); doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text("SIMON-PIERRE BOUCHER · CONTACT@SPBOUCHER.AI", M, H - 30, { width: W - 2 * M - 60, characterSpacing: 0.4, lineBreak: false }); doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(`${page} / ${total}`, W - M - 40, H - 39, { width: 40, align: "right" }); doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(generated, W - M - 160, H - 30, { width: 160, align: "right" }); } function card(doc: Doc, x: number, y: number, w: number, h: number, opts?: { shadow?: boolean; fill?: string }) { if (opts?.shadow !== false) doc.roundedRect(x + 3.5, y + 3.5, w, h, 8).fill("#e3e1d9"); doc.roundedRect(x, y, w, h, 8).lineWidth(1.3).fillAndStroke(opts?.fill ?? "#ffffff", INK); } function kvCell(doc: Doc, x: number, y: number, w: number, h: number, k: string, v: string) { doc.lineWidth(0.8); doc.roundedRect(x, y, w, h, 4).fillAndStroke(SURFACE2, LINE); doc.font("JB-Bold").fontSize(5.8).fillColor(INK3).text(k.toUpperCase(), x + 7, y + 6, { width: w - 14, characterSpacing: 0.7, height: 8, ellipsis: true, lineBreak: false }); doc.font("SG-Bold").fontSize(7.4).fillColor(INK).text(v, x + 7, y + 15.5, { width: w - 14, height: h - 18, ellipsis: true, lineGap: -0.5 }); doc.lineWidth(1.3); } /* -------------------------------------------------------- mise en page */ class Layout { y = 34; constructor(readonly doc: Doc) {} newPage() { this.doc.addPage({ size: "LETTER", margin: 0 }); pageBg(this.doc); this.doc.rect(0, 0, W, 8).fill(INK); this.doc.rect(0, 8, W, 2.5).fill(LIME); this.y = 34; } /** garantit `h` points disponibles, sinon nouvelle page */ ensure(h: number) { if (this.y + h > BOTTOM) this.newPage(); } section(title: string) { this.ensure(60); kicker(this.doc, title, M, this.y); this.y += 18; } para(txt: string, opts: { size?: number; color?: string; font?: string; gap?: number } = {}) { const d = this.doc; d.font(opts.font ?? "Inter").fontSize(opts.size ?? 8); const h = d.heightOfString(txt, { width: W - 2 * M, lineGap: 1.4 }); this.ensure(h + 4); d.fillColor(opts.color ?? INK2).text(txt, M, this.y, { width: W - 2 * M, lineGap: 1.4 }); this.y += h + (opts.gap ?? 6); } bullet(txt: string) { const d = this.doc; d.font("Inter").fontSize(7.8); const h = d.heightOfString(txt, { width: W - 2 * M - 14, lineGap: 1.2 }); this.ensure(h + 3); d.rect(M + 2, this.y + 3.5, 4, 4).fill(GREEN); d.fillColor(INK2).text(txt, M + 14, this.y, { width: W - 2 * M - 14, lineGap: 1.2 }); this.y += h + 3; } /** tableau avec en-tête répété à chaque saut de page */ table(cols: { h: string; w: number; align?: "left" | "right"; mono?: boolean }[], rows: string[][], opts: { rowH?: number; total?: string[]; highlight?: (i: number) => boolean } = {}) { const d = this.doc; const rowH = opts.rowH ?? 14; const x0 = M; const totalW = cols.reduce((s, c) => s + c.w, 0); const scale = (W - 2 * M) / totalW; const ws = cols.map((c) => c.w * scale); const header = () => { this.ensure(rowH + 4); d.rect(x0, this.y, W - 2 * M, rowH).fill(INK); let x = x0; cols.forEach((c, i) => { d.font("JB-Bold").fontSize(6.2).fillColor(PAPER).text(c.h.toUpperCase(), x + 5, this.y + 4.2, { width: ws[i] - 10, align: c.align ?? "left", lineBreak: false, height: rowH, ellipsis: true, characterSpacing: 0.4 }); x += ws[i]; }); this.y += rowH; }; header(); rows.forEach((row, ri) => { if (this.y + rowH > BOTTOM) { this.newPage(); header(); } const hl = opts.highlight?.(ri); if (hl) d.rect(x0, this.y, W - 2 * M, rowH).fill(LIME_SOFT); else if (ri % 2 === 1) d.rect(x0, this.y, W - 2 * M, rowH).fill(SURFACE2); let x = x0; cols.forEach((c, i) => { d.font(hl ? "SG-Bold" : c.mono ? "JB-Reg" : "Inter").fontSize(c.mono ? 6.6 : 7.2).fillColor(INK) .text(row[i] ?? "", x + 5, this.y + 3.6, { width: ws[i] - 10, align: c.align ?? "left", lineBreak: false, height: rowH, ellipsis: true }); x += ws[i]; }); d.moveTo(x0, this.y + rowH).lineTo(W - M, this.y + rowH).lineWidth(0.4).stroke(LINE); this.y += rowH; }); if (opts.total) { if (this.y + rowH + 2 > BOTTOM) { this.newPage(); header(); } d.rect(x0, this.y, W - 2 * M, rowH + 2).fill(INK); let x = x0; cols.forEach((c, i) => { d.font("SG-Bold").fontSize(7.4).fillColor(PAPER).text(opts.total![i] ?? "", x + 5, this.y + 4.2, { width: ws[i] - 10, align: c.align ?? "left", lineBreak: false, height: rowH, ellipsis: true }); x += ws[i]; }); this.y += rowH + 2; } this.y += 8; } gap(h = 8) { this.y += h; } } /* -------------------------------------------------------------- libellés */ const BASEMENT: Record = { none: "aucun (dalle)", crawl: "vide sanitaire", unfinished: "non fini", partial: "partiellement fini", finished: "fini", walkout: "fini, sortie de plain-pied" }; const FOUND: Record = { poured_concrete: "béton coulé", concrete_block: "blocs de béton", slab_on_grade: "dalle sur sol", piers: "pieux", stone: "pierre" }; const ROOF: Record = { asphalt_shingle: "bardeau d'asphalte", metal: "tôle", membrane: "membrane", cedar: "cèdre", slate_tile: "ardoise/tuile" }; const GEOM: Record = { gable: "à deux versants", hip: "à quatre versants", flat: "plat", mansard: "mansardé", complex: "complexe" }; const WIN: Record = { pvc: "PVC", hybrid: "hybride", aluminum: "aluminium", wood: "bois" }; const HEAT: Record = { electric_baseboard: "plinthes électriques", heat_pump: "thermopompe centrale", furnace_electric: "fournaise électrique", furnace_gas: "fournaise au gaz", furnace_oil: "fournaise à l'huile", hydronic: "eau chaude", geothermal: "géothermie", wood: "bois" }; const GARAGE: Record = { none: "aucun", attached: "attenant", detached: "détaché", integrated: "intégré", carport: "abri d'auto" }; const SIDING: Record = { vinyl: "vinyle", brick: "brique", fiber_cement: "fibrociment", wood: "bois", stone: "pierre", stucco: "stuc", aluminum: "aluminium", steel: "acier" }; const MODE: Record = { property: "propriété du rôle (MAMH)", construction: "construction hypothétique", listing: "annonce à vendre (analyse IA)" }; const qLabel = (q: string) => QUALITIES.find((x) => x.key === q)?.fr ?? q; const unitLabel = (u: string) => UNIT_LABELS[u as keyof typeof UNIT_LABELS]?.fr ?? u; const shortUrl = (u: string | null | undefined) => { if (!u) return ""; try { const x = new URL(u); return (x.host.replace(/^www\./, "") + x.pathname).slice(0, 60); } catch { return u.slice(0, 60); } }; /* ============================================================ rapport */ export async function buildCostReport(est: CostEstimate): Promise { const b = est.input.building; const generated = new Date().toISOString().slice(0, 16).replace("T", " "); const doc = new PDFDocument({ size: "LETTER", margin: 0, bufferPages: true, info: { Title: `Vrai-Prix — Méthode du coût — ${est.input.address ?? "bâtiment"}`, Author: "Vrai-Prix — Simon-Pierre Boucher (contact@spboucher.ai)" } }); doc.registerFont("SG-Bold", F("SpaceGrotesk-Bold.ttf")); doc.registerFont("SG-Med", F("SpaceGrotesk-Medium.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", (c: Buffer) => chunks.push(c)); const done = new Promise((res) => doc.on("end", () => res(Buffer.concat(chunks)))); const L = new Layout(doc); const src = est.input.attributeSources; const prov = (k: string) => SRC_TXT[src[k] ?? ""] ?? (src[k] ?? "supposé"); /* ================================ PAGE 1 — couverture ================================ */ pageBg(doc); doc.rect(0, 0, W, 96).fill(INK); logo(doc, M, 30); doc.font("JB-Reg").fontSize(7).fillColor(LIME).text("ESTIMATION IMMOBILIÈRE TRANSPARENTE · RAPPORT — MÉTHODE DU COÛT", M, 66, { characterSpacing: 1.2, lineBreak: false }); doc.font("JB-Reg").fontSize(6).fillColor("#9aa39c").text("COÛT DE REMPLACEMENT À NEUF · DÉPRÉCIATION · TERRAIN — TROISIÈME LECTURE DE LA VALEUR", M, 78, { characterSpacing: 1, lineBreak: false }); doc.font("JB-Reg").fontSize(7).fillColor("#9aa39c") .text(`ESTIMATION ${est.id.slice(0, 8).toUpperCase()}`, W - M - 220, 34, { width: 220, align: "right" }) .text(`GÉNÉRÉ LE ${generated}`, W - M - 220, 46, { width: 220, align: "right" }) .text(`PRIX AU ${est.priceDate} · MÉTHODE v${est.methodVersion}`, W - M - 220, 58, { width: 220, align: "right" }); doc.rect(0, 96, W, 3).fill(LIME); let y = 122; kicker(doc, "Indication de valeur par la méthode du coût", M, y); y += 16; const titre = [est.input.address, est.input.municipality].filter(Boolean).join(" · ") || `${BUILDING_TYPES.find((t) => t.key === b.type)?.fr ?? b.type} — ${num(b.grossFloorAreaSqft)} pi²`; doc.font("SG-Bold").fontSize(15).fillColor(INK).text(titre.toUpperCase(), M, y, { width: W - 2 * M }); y = doc.y + 6; doc.font("SG-Bold").fontSize(46).fillColor(INK).text(fmt(est.costApproachValue), M, y); y = doc.y + 10; const cols = [ ["COÛT DE REMPLACEMENT À NEUF", fmt(est.replacementCostNew)], ["DÉPRÉCIATION TOTALE", neg(est.depreciation.total)], ["VALEUR DU TERRAIN", fmt(est.landValue)], ["FOURCHETTE RCN P10-P90", `${fmt(est.range.p10)} → ${fmt(est.range.p90)}`], ] as const; let cx0 = M; for (const [k, v] of cols) { doc.font("JB-Bold").fontSize(6.3).fillColor(INK3).text(k, cx0, y, { characterSpacing: 0.6, lineBreak: false }); const lw = doc.widthOfString(k) + 6; doc.font("SG-Bold").fontSize(12).fillColor(INK).text(v, cx0, y + 11, { lineBreak: false }); cx0 += Math.max(doc.widthOfString(v), lw) + 26; } const c = est.confidence; const confTxt = `CONFIANCE ${c.letter} · ${c.total} / 100 — ${CONF_TXT[c.letter].toUpperCase()}`; doc.font("JB-Bold").fontSize(7.5); const cw = doc.widthOfString(confTxt) + 20; doc.roundedRect(M, y + 34, cw, 18, 4).lineWidth(1).fillAndStroke(CONF_BG[c.letter], INK); doc.fillColor(c.letter === "D" ? DANGER : INK).text(confTxt, M + 10, y + 39.5, { lineBreak: false }); y += 72; // équation card(doc, M, y, W - 2 * M, 58, { fill: INK, shadow: false }); doc.font("JB-Bold").fontSize(7).fillColor(LIME).text("LA FORMULE — JAMAIS MASQUÉE", M + 16, y + 11, { characterSpacing: 1.4 }); doc.font("JB-Reg").fontSize(8.2).fillColor("#e8e6df").text( `V_coût = V_terrain + RCN − D → ${fmt(est.costApproachValue)} = ${fmt(est.landValue)} + ${fmt(est.replacementCostNew)} − ${fmt(est.depreciation.total)}`, M + 16, y + 26, { width: W - 2 * M - 32, lineBreak: false, height: 12, ellipsis: true }); doc.font("JB-Reg").fontSize(7).fillColor("#9fb4bd").text( `RCN = Direct ${fmt(est.directCost)} + Indirects ${fmt(est.indirectCost)} + Frais gén. ${fmt(est.contractorOverhead)} + Profit ${fmt(est.contractorProfit)} + Contingence ${fmt(est.contingency)}`, M + 16, y + 41, { width: W - 2 * M - 32, lineBreak: false, height: 10, ellipsis: true }); y += 78; // fiche du bâtiment (3 cartes) kicker(doc, "1 · Propriété et bâtiment — chaque caractéristique avec sa provenance", M, y); y += 16; const sidingTxt = Object.entries(b.siding).filter(([, v]) => (v ?? 0) > 0).map(([k, v]) => `${SIDING[k] ?? k} ${Math.round((v ?? 0) * 100)} %`).join(", ") || "—"; const groups: [string, [string, string][]][] = [ ["IDENTIFICATION", [ ["Mode", MODE[est.input.mode] ?? est.input.mode], ["Id / annonce", est.input.propertyId ?? est.input.listingUid ?? "—"], ["Municipalité", est.input.municipality ?? "—"], ["Type", `${BUILDING_TYPES.find((t) => t.key === b.type)?.fr ?? b.type} (${prov("type")})`], ["Qualité", `${qLabel(b.quality)} (${prov("quality")})`], ["Logements", String(b.units)], ["Année constr.", `${b.yearBuilt ?? "—"} (${prov("yearBuilt")})`], ["Localisation", est.location.nameFr], ]], ["GÉOMÉTRIE", [ ["Aire d'étages", `${num(b.grossFloorAreaSqft)} pi² (${prov("grossFloorAreaSqft")})`], ["Étages", `${b.stories} (${prov("stories")})`], ["Sous-sol", `${BASEMENT[b.basement] ?? b.basement} (${prov("basement")})`], ["Fondation", `${FOUND[b.foundation] ?? b.foundation} (${prov("foundation")})`], ["Garage", `${GARAGE[b.garage.type]}${b.garage.spaces ? ` ${b.garage.spaces} pl.` : ""} (${prov("garage")})`], ["Toit", `${ROOF[b.roof] ?? b.roof}, ${GEOM[b.roofGeometry] ?? b.roofGeometry} ${b.roofPitch}/12 (${prov("roof")})`], ["Fenêtres", `${WIN[b.windows] ?? b.windows}${b.windowCount != null ? ` × ${b.windowCount}` : ""} (${prov("windows")})`], ["Revêtement", `${sidingTxt} (${prov("siding")})`], ]], ["INTÉRIEUR ET MÉCANIQUE", [ ["Chauffage", `${HEAT[b.heating] ?? b.heating} (${prov("heating")})`], ["Clim. / VRC", `${b.hasAirConditioning ? "clim." : "—"} / ${b.hasAirExchanger ? "VRC" : "—"}`], ["Cuisines", `${b.kitchens} · ${qLabel(b.kitchenQuality)} (${prov("kitchenQuality")})`], ["Salles de bain", `${b.bathrooms} + ${b.powderRooms} s. d'eau · ${qLabel(b.bathroomQuality)} (${prov("bathrooms")})`], ["Terrasse", b.deckSqft ? `${num(b.deckSqft)} pi²` : "—"], ["Entrée", b.drivewaySqft ? `${num(b.drivewaySqft)} pi² (${b.driveway})` : "—"], ["Piscine", b.pool === "none" ? "—" : b.pool === "inground" ? "creusée" : "hors terre"], ["Aménagement", b.landscapingSqft ? `${num(b.landscapingSqft)} pi²` : "—"], ]], ]; const gW = (W - 2 * M - 2 * 14) / 3; const cellH = 38; const gH = 4 * (cellH + 6) + 34; groups.forEach(([title, rows], gi) => { const gx = M + gi * (gW + 14); card(doc, gx, y, gW, gH); doc.font("JB-Bold").fontSize(7).fillColor(GREEN_DEEP).text(title, gx + 12, y + 11, { characterSpacing: 1.2, lineBreak: false }); doc.moveTo(gx + 12, y + 24).lineTo(gx + gW - 12, y + 24).dash(2, { space: 3 }).lineWidth(0.8).stroke(LINE).undash(); const cw2 = (gW - 24 - 6) / 2; rows.forEach(([k, v], i) => { const col = i % 2, row = Math.floor(i / 2); kvCell(doc, gx + 12 + col * (cw2 + 6), y + 32 + row * (cellH + 6), cw2, cellH, k, v); }); }); y += gH + 14; doc.font("JB-Reg").fontSize(6.3).fillColor(INK3).text( "PROVENANCE : rôle MAMH = registre officiel · saisi = utilisateur · inféré par IA = analyse multimodale · dérivé = formule · supposé = hypothèse par défaut. Légende des prix : ● observé/officiel · ◐ calculé · ○ indexé · ◇ référence interne (hypothèse).", M, y, { width: W - 2 * M, characterSpacing: 0.2, lineGap: 1 }); /* ================================ PAGE 2 — hypothèses & localisation ================================ */ L.newPage(); L.section("2 · Hypothèses — chaque pourcentage est visible, modifiable et expliqué"); const p = est.input.params; L.table( [{ h: "Poste", w: 40 }, { h: "Taux", w: 10, align: "right", mono: true }, { h: "Base de calcul", w: 30 }, { h: "Explication (hypothèse documentée)", w: 60 }], [ ...est.indirect.map((i) => [i.labelFr, pct(i.pct, 1), "coût direct", INDIRECT_LABELS[i.key].noteFr]), ["Frais généraux entrepreneur", pct(p.overheadPct, 1), "direct + indirects", "Frais fixes de l'entreprise (bureau, gestion) — distincts du profit."], ["Profit entrepreneur", pct(p.profitPct, 1), "direct + indirects + FG", "Rémunération du risque de l'entrepreneur général."], ["Contingence", pct(p.contingencyPct, 1), "direct + indirects", "Réserve pour imprévus de chantier — ni profit ni frais indirects."], ], { rowH: 15 } ); L.para(`Coût de REMPLACEMENT (et non de reproduction) : le bâtiment est reconstruit avec les composantes modernes équivalentes (murs 2 × 6 R24, comble R50, poutrelles ajourées), quel que soit son année de construction. Constantes de prise de quantités : hauteur d'étage 9 pi, rapport de forme 1,5, 0,011 fenêtre de 10 pi² par pi² de plancher, 0,09 pi lin de cloison par pi², murs de sous-sol 8 pi, garage 10 pi. Dépréciation : méthode « ${est.depreciation.method === "components" ? "composante par composante (vie économique et condition de chaque groupe)" : "âge-vie simple (âge effectif ÷ vie économique)"} », vie économique ${est.depreciation.economicLife} ans, dépréciation physique plafonnée à 90 %.`); L.para(`Base de coûts version ${est.costDatabaseVersion} · assemblages v${est.assemblyVersion} · géométrie/quantités dérivées automatiquement sauf surcharges (${Object.keys(est.input.quantityOverrides).length}). Une estimation créée aujourd'hui peut être reconstruite plus tard avec les prix de sa date (instantané conservé).`, { size: 7.2, color: INK3 }); L.section("8 · Localisation — facteurs distincts matériaux / main-d'œuvre / équipement"); const loc = est.location; L.table( [{ h: "Région", w: 40 }, { h: "Matériaux", w: 15, align: "right", mono: true }, { h: "Main-d'œuvre", w: 15, align: "right", mono: true }, { h: "Équipement", w: 15, align: "right", mono: true }, { h: "Global", w: 15, align: "right", mono: true }, { h: "Confiance", w: 15, align: "right", mono: true }], [[`${loc.nameFr} (${loc.code})`, loc.materialFactor.toFixed(3), loc.labourFactor.toFixed(3), loc.equipmentFactor.toFixed(3), loc.overallFactor.toFixed(3), `${loc.confidence}/100`]] ); L.para(`Méthode : ${loc.sourceMethod}. Ajustement de localisation appliqué au coût direct : ${fmt(est.lines.reduce((s, l) => s + l.locationAdjustment, 0))} (matériaux × ${loc.materialFactor}, main-d'œuvre × ${loc.labourFactor}, équipement × ${loc.equipmentFactor}). Les conventions collectives de la construction (Loi R-20) fixent des taux uniformes pour tout le Québec : le facteur main-d'œuvre est 1,00 sauf régions éloignées.`); /* ================================ 4 · direct par catégorie ================================ */ L.section("4 · Coût direct par catégorie — matériaux, main-d'œuvre, équipement"); const maxAdj = Math.max(1, ...est.categories.map((k) => k.adjusted)); L.table( [{ h: "Catégorie", w: 34 }, { h: "Matériaux", w: 16, align: "right", mono: true }, { h: "Main-d'œuvre", w: 16, align: "right", mono: true }, { h: "Équip.", w: 12, align: "right", mono: true }, { h: "Direct localisé", w: 16, align: "right", mono: true }, { h: "Part", w: 10, align: "right", mono: true }], est.categories.map((k) => [k.labelFr, fmt(k.material), fmt(k.labour), fmt(k.equipment), fmt(k.adjusted), pct(k.sharePct, 1)]), { total: ["COÛTS DIRECTS", fmt(est.directMaterial), fmt(est.directLabour), fmt(est.directEquipment), fmt(est.directCost), "100 %"] } ); // barres L.ensure(est.categories.length * 11 + 14); est.categories.forEach((k) => { const trackX = M + 150, trackW = W - 2 * M - 150 - 70; doc.font("JB-Reg").fontSize(6.4).fillColor(INK).text(k.labelFr, M, L.y + 1, { width: 145, lineBreak: false, height: 9, ellipsis: true }); doc.rect(trackX, L.y, trackW, 8).fill(SURFACE2); const wM = (k.material / maxAdj) * trackW, wL = (k.labour / maxAdj) * trackW, wE = (k.equipment / maxAdj) * trackW; doc.rect(trackX, L.y, wM, 8).fill(INK); doc.rect(trackX + wM, L.y, wL, 8).fill(GREEN); doc.rect(trackX + wM + wL, L.y, wE, 8).fill(LIME); doc.font("JB-Bold").fontSize(6.4).fillColor(INK).text(fmt(k.adjusted), W - M - 66, L.y + 1, { width: 66, align: "right", lineBreak: false }); L.y += 11; }); doc.font("JB-Reg").fontSize(6.2).fillColor(INK3).text("■ MATÉRIAUX (encre) · ■ MAIN-D'ŒUVRE (bordeaux) · ■ ÉQUIPEMENT (rouge)", M, L.y + 2, { characterSpacing: 0.3 }); L.y += 16; /* ================================ 5 · indirects + 9 · RCN ================================ */ L.section("5 · Indirects, frais généraux, profit, contingence → 9 · RCN"); L.table( [{ h: "Poste", w: 55 }, { h: "Taux", w: 15, align: "right", mono: true }, { h: "Montant", w: 30, align: "right", mono: true }], [ ["Coûts directs localisés", "", fmt(est.directCost)], ...est.indirect.map((i) => [`Indirect — ${i.labelFr}`, pct(i.pct, 1), fmt(i.amount)]), ["Sous-total coûts indirects", pct(est.indirect.reduce((s, i) => s + i.pct, 0), 1), fmt(est.indirectCost)], ["Frais généraux entrepreneur", pct(p.overheadPct, 1), fmt(est.contractorOverhead)], ["Profit entrepreneur", pct(p.profitPct, 1), fmt(est.contractorProfit)], ["Contingence", pct(p.contingencyPct, 1), fmt(est.contingency)], ], { total: ["COÛT DE REMPLACEMENT À NEUF (RCN)", "", fmt(est.replacementCostNew)], highlight: (i) => i === 0 || i === est.indirect.length + 1 } ); L.ensure(70); const tW3 = (W - 2 * M - 28) / 3; [["RCN TOTAL", fmt(est.replacementCostNew)], ["RCN PAR PI² D'AIRE D'ÉTAGES", `${fmt2(est.perSqft)} / pi²`], ["RCN PAR M²", `${fmt2(est.perM2)} / m²`]].forEach(([k, v], i) => { const x = M + i * (tW3 + 14); card(doc, x, L.y, tW3, 52); doc.font("JB-Bold").fontSize(6.3).fillColor(INK3).text(k, x + 12, L.y + 10, { characterSpacing: 0.6, lineBreak: false, width: tW3 - 24, height: 9, ellipsis: true }); doc.font("SG-Bold").fontSize(17).fillColor(INK).text(v, x + 12, L.y + 24, { lineBreak: false, width: tW3 - 24, height: 22, ellipsis: true }); }); L.y += 74; /* ================================ 13 · fourchette ================================ */ L.section("13 · Fourchette de coût — P10 · central · P90"); L.ensure(60); { const r = est.range; const lo = r.p10 * 0.96, hi = r.p90 * 1.04; const tx = M, tw = W - 2 * M; const X = (v: number) => tx + ((v - lo) / (hi - lo)) * tw; doc.rect(tx, L.y + 14, tw, 10).fill(SURFACE2); doc.rect(X(r.p10), L.y + 14, X(r.p90) - X(r.p10), 10).fill(LIME_SOFT); doc.rect(X(r.p10), L.y + 10, 1.5, 18).fill(GREEN); doc.rect(X(r.p90), L.y + 10, 1.5, 18).fill(GREEN); doc.rect(X(est.replacementCostNew) - 1.5, L.y + 6, 3, 26).fill(INK); doc.font("JB-Bold").fontSize(6.5).fillColor(GREEN_DEEP).text(`P10 ${fmt(r.p10)}`, X(r.p10) - 40, L.y + 32, { width: 80, align: "center", lineBreak: false }); doc.text(`P90 ${fmt(r.p90)}`, X(r.p90) - 40, L.y + 32, { width: 80, align: "center", lineBreak: false }); doc.fillColor(INK).text(`RCN ${fmt(est.replacementCostNew)}`, X(est.replacementCostNew) - 50, L.y - 2, { width: 100, align: "center", lineBreak: false }); L.y += 46; } L.para(`Écart-type combiné ${fmt(est.range.sigma)} : dispersion des prix de chaque article (observés multi-sources, ou ±8 % observé / ±15 % référence / ±20 % hypothèse), incertitude des heures (±15 %), de l'équipement (±20 %) et des quantités (dérivées ±10 %, saisies ±3 %, IA ±15 %), combinées en racine des carrés, plus une composante systématique (localisation, modèle d'assemblages 5 %). P10/P90 = RCN ∓ 1,28 σ.`); /* ================================ 10 · dépréciation ================================ */ L.newPage(); L.section("10 · Dépréciation — physique, fonctionnelle, externe"); const d = est.depreciation; L.table( [{ h: "Élément", w: 60 }, { h: "Valeur", w: 40, align: "right", mono: true }], [ ["Âge chronologique", d.chronologicalAge != null ? `${d.chronologicalAge} ans` : "—"], ["Âge effectif retenu", d.effectiveAge != null ? `${num(d.effectiveAge, 1)} ans (${SRC_TXT[est.input.depreciation.effectiveAgeSource] ?? est.input.depreciation.effectiveAgeSource})` : "—"], ["Vie économique", `${d.economicLife} ans`], ["Méthode", d.method === "components" ? "composante par composante" : "âge-vie simple"], ["Détérioration physique", `${pct(d.physicalPct, 1)} → ${fmt(d.physical)}`], ] ); if (d.method === "components" && d.components.length) { L.table( [{ h: "Composante", w: 28 }, { h: "RCN", w: 13, align: "right", mono: true }, { h: "Vie", w: 8, align: "right", mono: true }, { h: "Condition", w: 11 }, { h: "Âge eff.", w: 11, align: "right", mono: true }, { h: "Dépr. %", w: 10, align: "right", mono: true }, { h: "Dépréciation", w: 14, align: "right", mono: true }, { h: "Restant", w: 14, align: "right", mono: true }], d.components.map((k) => [k.labelFr, fmt(k.rcn), `${k.economicLife} a`, k.condition ?? "— (âge)", num(k.effectiveAge, 1), pct(k.depreciationPct, 0), fmt(k.depreciation), fmt(k.remaining)]), { total: ["TOTAL PHYSIQUE", fmt(d.components.reduce((s, k) => s + k.rcn, 0)), "", "", "", pct(d.physicalPct, 1), fmt(d.physical), fmt(d.components.reduce((s, k) => s + k.remaining, 0))] } ); L.para("Règle : âge effectif de la composante = ratio de condition × vie économique (règles documentées modifiables : neuf 0 · excellent 0,10 · rénové 0,12 · très bon 0,20 · bon 0,35 · moyen 0,55 · sous la moyenne 0,70 · mauvais 0,85) ; sans condition saisie, l'âge chronologique plafonné à la vie est utilisé.", { size: 7.2, color: INK3 }); } const fo = est.input.depreciation.functional; L.table( [{ h: "Désuétude", w: 34 }, { h: "Type", w: 16 }, { h: "Détail", w: 30 }, { h: "Montant", w: 20, align: "right", mono: true }], [ ...fo.map((f) => [`Fonctionnelle — ${f.type || "—"}`, f.curable ? "curable" : "incurable", f.curable ? `coût de correction ${fmt(f.costToCure)}` : `perte de valeur ${fmt(f.valueLoss)}`, neg(f.curable ? f.costToCure : f.valueLoss)]), ["Désuétude fonctionnelle (total)", "", `${fo.length} déficience(s)`, neg(d.functional)], ["Désuétude externe", "", est.input.depreciation.externalNote || "aucune preuve saisie → 0 $", neg(d.external)], ], { total: ["DÉPRÉCIATION TOTALE", "", `${pct(est.replacementCostNew ? (d.total / est.replacementCostNew) * 100 : 0, 1)} du RCN`, neg(d.total)] } ); /* ================================ 11 · terrain + 12 · valeur ================================ */ L.section("11 · Valeur du terrain"); const land = est.input.land; const LANDSRC: Record = { role: `Rôle d'évaluation foncière ${land.rollYear ?? 2026} (MAMH)`, user: "Valeur saisie par l'utilisateur", market: "Valeur marchande (ventes de terrains)", residual: "Technique du résiduel", none: "Aucune valeur — 0 $ (à compléter)" }; L.table([{ h: "Valeur du terrain utilisée", w: 30, align: "right", mono: true }, { h: "Source", w: 35 }, { h: "Méthode", w: 35 }], [[fmt(est.landValue), LANDSRC[land.source] ?? land.source, land.method || "—"]]); L.para("⚠ La valeur du terrain au rôle n'est pas nécessairement la valeur marchande actuelle du terrain : le rôle est établi 18 à 24 mois avant son entrée en vigueur. Vous pouvez la conserver, la remplacer ou la documenter par une autre méthode.", { color: DANGER, size: 7.4 }); L.section("12 · Indication de valeur par la méthode du coût"); L.table( [{ h: "Poste", w: 60 }, { h: "Montant", w: 40, align: "right", mono: true }], [ ...est.categories.map((k) => [k.labelFr, fmt(k.adjusted)]), ["Coûts directs", fmt(est.directCost)], ["Coûts indirects", fmt(est.indirectCost)], ["Frais généraux", fmt(est.contractorOverhead)], ["Profit", fmt(est.contractorProfit)], ["Contingence", fmt(est.contingency)], ["COÛT DE REMPLACEMENT À NEUF", fmt(est.replacementCostNew)], ["Détérioration physique", neg(d.physical)], ["Désuétude fonctionnelle", neg(d.functional)], ["Désuétude externe", neg(d.external)], ["Valeur dépréciée du bâtiment", fmt(d.depreciatedImprovementValue)], ["Valeur du terrain", fmt(est.landValue)], ], { total: ["INDICATION PAR LE COÛT", fmt(est.costApproachValue)], highlight: (i) => [est.categories.length, est.categories.length + 5, est.categories.length + 9].includes(i) } ); /* ================================ 14 · confiance + benchmarks ================================ */ L.section("14 · Indice de confiance — décomposition"); L.table( [{ h: "Critère", w: 50 }, { h: "Score", w: 20, align: "right", mono: true }, { h: "Maximum", w: 30, align: "right", mono: true }], [["Fraîcheur des prix", String(c.freshness), "20"], ["Couverture (prix observés vs référence)", String(c.coverage), "20"], ["Localisation", String(c.location), "15"], ["Main-d'œuvre (grilles officielles)", String(c.labour), "15"], ["Benchmarks externes", String(c.benchmarks), "10"], ["Complétude du bâtiment", String(c.building), "20"]], { total: [`TOTAL — ${c.letter} · ${CONF_TXT[c.letter]}`, String(c.total), "100"] } ); c.notesFr.forEach((n) => L.bullet(n)); L.bullet(`Couverture des prix : ${pct(est.coverage.materialObservedShare * 100, 0)} du coût des matériaux repose sur des prix observés/officiels ; ${est.coverage.assembliesPriced}/${est.coverage.assembliesTotal} assemblages chiffrés.`); L.gap(4); L.section("Validation externe — benchmarks"); if (est.benchmarks.length) { L.table( [{ h: "Source", w: 26 }, { h: "Type / marché", w: 26 }, { h: "Plage", w: 20, align: "right", mono: true }, { h: "Vrai-Prix", w: 14, align: "right", mono: true }, { h: "État", w: 14 }], est.benchmarks.map((k) => [`${k.source} ${k.year}`, `${k.buildingType} · ${k.market}`, `${fmt(k.low)}–${fmt(k.high)} ${k.unit}`, fmt2(k.estimatePerUnit), k.status === "within" ? "dans la plage" : k.status === "below" ? `⚠ sous la plage (${pct(k.deviationPct, 0)})` : k.status === "above" ? `⚠ au-dessus (${pct(k.deviationPct, 0)})` : "non disponible"]) ); L.para("Le benchmark ne modifie jamais l'estimation : un écart important est signalé, pas corrigé.", { size: 7.2, color: INK3 }); } else { L.para("Donnée non disponible — aucun benchmark externe importé pour ce type de bâtiment et ce marché (le guide Altus requiert un import manuel après téléchargement légal).", { color: INK3 }); } if (est.otherReadings) { const o = est.otherReadings; L.section("Trois lectures de la valeur — à comparer, jamais à moyenner"); L.table( [{ h: "Lecture", w: 50 }, { h: "Valeur", w: 25, align: "right", mono: true }, { h: "vs coût", w: 25, align: "right", mono: true }], [ ["Modèle hédonique (LightGBM)", fmt(o.hedonic), o.hedonic ? pct(((o.hedonic - est.costApproachValue) / est.costApproachValue) * 100, 1) : "—"], ["Comparables ajustés", fmt(o.comparables), o.comparables ? pct(((o.comparables - est.costApproachValue) / est.costApproachValue) * 100, 1) : "—"], ["Mesure Vrai-Prix (hybride 65/35)", fmt(o.hybrid), o.hybrid ? pct(((o.hybrid - est.costApproachValue) / est.costApproachValue) * 100, 1) : "—"], ["Méthode du coût (ce rapport)", fmt(est.costApproachValue), "—"], ...(o.askingPrice ? [["Prix demandé", fmt(o.askingPrice), pct(((o.askingPrice - est.costApproachValue) / est.costApproachValue) * 100, 1)]] : []), ["Rôle 2026 — valeur totale", fmt(o.rollValue), o.rollValue ? pct(((o.rollValue - est.costApproachValue) / est.costApproachValue) * 100, 1) : "—"], ["Rôle 2026 — bâtiment / terrain", `${fmt(o.rollBuilding)} / ${fmt(o.rollLand)}`, ""], ] ); L.para(`RCN vs valeur du bâtiment au rôle : ${fmt(est.replacementCostNew)} vs ${fmt(o.rollBuilding)} ; valeur dépréciée vs rôle bâtiment : ${fmt(d.depreciatedImprovementValue)} vs ${fmt(o.rollBuilding)}. Le but est de comparer les approches, pas de faire une moyenne naïve.`, { size: 7.2, color: INK3 }); } /* ================================ 3 + 6 · assemblages ================================ */ L.newPage(); L.section("3 · Assemblages retenus (6 · quantités, coûts unitaires, totaux)"); L.para(`${est.lines.length} assemblages quantifiés par le moteur de géométrie (formule affichée) ou surchargés (${Object.keys(est.input.quantityOverrides).length}). Coût unitaire = matériaux (qté × pertes × prix) + main-d'œuvre (heures × taux employeur) + équipement, avant facteur régional. Prov. = nature dominante des prix des composants.`, { size: 7.2, color: INK3 }); const kindOf = (l: CostEstimate["lines"][number]): string => { const kinds = l.unitDetail.components.filter((k) => k.itemCode).map((k) => k.provenance.kind); if (!kinds.length) return "◐"; if (kinds.every((k) => k === "observed" || k === "official")) return "●"; if (kinds.some((k) => k === "observed" || k === "official")) return "◐"; if (kinds.some((k) => k === "indexed")) return "○"; return "◇"; }; L.table( [{ h: "Assemblage", w: 30 }, { h: "Quantité", w: 11, align: "right", mono: true }, { h: "Formule", w: 27 }, { h: "Unitaire", w: 10, align: "right", mono: true }, { h: "M / MO / É", w: 14, align: "right", mono: true }, { h: "Total loc.", w: 11, align: "right", mono: true }, { h: "P", w: 3 }], est.lines.map((l) => [l.nameFr, `${num(l.quantity, l.quantity < 10 ? 1 : 0)} ${unitLabel(l.unit)}`, l.quantityFormula.replace(/\s+/g, " "), fmt2(l.unitCost), `${num(l.material / 1000, 1)}k/${num(l.labour / 1000, 1)}k/${num(l.equipment / 1000, 1)}k`, fmt(l.adjusted), kindOf(l)]), { rowH: 13, total: ["COÛTS DIRECTS LOCALISÉS", "", "", "", `${num(est.directMaterial / 1000, 0)}k/${num(est.directLabour / 1000, 0)}k/${num(est.directEquipment / 1000, 0)}k`, fmt(est.directCost), ""] } ); // descriptions des assemblages L.section("Description des assemblages"); const seen = new Set(); for (const l of est.lines) { if (seen.has(l.assemblyCode)) continue; seen.add(l.assemblyCode); const a = est.lines.find((x) => x.assemblyCode === l.assemblyCode)!; const desc = a.unitDetail.components.filter((k) => k.itemCode).map((k) => `${k.nameFr} ${num(k.quantity, 3)} ${unitLabel(k.unit)}`).join(" · "); L.para(`${l.nameFr} [${l.assemblyCode}, par ${unitLabel(l.unit)}] — ${desc || "main-d'œuvre/équipement seulement"}.`, { size: 6.8, color: INK2, gap: 3 }); } /* ================================ 7 · main-d'œuvre ================================ */ L.newPage(); L.section("7 · Main-d'œuvre — heures et coût par métier (taux employeur complet)"); const trades = new Map(); for (const l of est.lines) for (const k of l.unitDetail.components) { if (!k.labourHours) continue; const t = k.trade ?? "—"; const cur = trades.get(t) ?? { h: 0, cost: 0, rate: k.hourlyRate, src: k.rateProvenance?.source ?? "—", date: k.rateProvenance?.effectiveDate ?? "—", kind: k.rateProvenance?.kind === "official" ? "● officiel" : "◇ repli (hypothèse)" }; cur.h += k.labourHours * l.quantity; cur.cost += k.labourCost * l.quantity * loc.labourFactor; trades.set(t, cur); } const trows = [...trades.entries()].sort((a, b) => b[1].cost - a[1].cost); L.table( [{ h: "Métier", w: 22 }, { h: "Heures", w: 9, align: "right", mono: true }, { h: "Taux employeur", w: 13, align: "right", mono: true }, { h: "Coût localisé", w: 13, align: "right", mono: true }, { h: "Source", w: 33 }, { h: "En vigueur", w: 10, mono: true }], trows.map(([t, v]) => [tradeLabel(t, true), num(v.h, 0), v.rate != null ? `${fmt2(v.rate)}/h` : "—", fmt(v.cost), `${v.kind} — ${v.src}`, v.date]), { total: ["TOTAL MAIN-D'ŒUVRE", num(trows.reduce((s, [, v]) => s + v.h, 0), 0), "", fmt(est.directLabour), "", ""] } ); L.para("Le taux employeur complet inclut le salaire horaire conventionné (CCQ), l'indemnité de congés (13 %), les avantages sociaux, les cotisations (AE, RQAP, RRQ, FSS, CNESST, prélèvement CCQ, AECQ, fonds de formation) — jamais le salaire horaire nu. Les heures par unité d'assemblage sont des hypothèses de productivité résidentielle documentées.", { size: 7.2, color: INK3 }); /* ================================ 15 · sources ================================ */ L.section("15 · Sources — chaque prix important est retraçable"); const srcMap = new Map(); for (const l of est.lines) for (const k of l.unitDetail.components) { if (!k.itemCode) continue; for (const nm of k.provenance.source.split(", ")) { const key = nm.trim(); const cur = srcMap.get(key) ?? { kind: `${KIND_ICON[k.provenance.kind]} ${KIND_TXT[k.provenance.kind]}`, date: k.provenance.observedAt ?? k.provenance.effectiveDate ?? "—", url: shortUrl(k.provenance.sourceUrl), n: 0 }; cur.n++; srcMap.set(key, cur); } if (k.rateProvenance) { const key = k.rateProvenance.source; const cur = srcMap.get(key) ?? { kind: `${KIND_ICON[k.rateProvenance.kind]} ${KIND_TXT[k.rateProvenance.kind]}`, date: k.rateProvenance.effectiveDate ?? "—", url: shortUrl(k.rateProvenance.sourceUrl), n: 0 }; cur.n++; srcMap.set(key, cur); } } L.table( [{ h: "Source", w: 34 }, { h: "Nature", w: 20 }, { h: "Date", w: 12, mono: true }, { h: "Composants", w: 10, align: "right", mono: true }, { h: "URL", w: 24, mono: true }], [...srcMap.entries()].sort((a, b) => b[1].n - a[1].n).map(([k, v]) => [k, v.kind, v.date, String(v.n), v.url]), { rowH: 13 } ); L.para(`Localisation : ${loc.nameFr} — ${loc.sourceMethod}. Indices : Statistique Canada 18-10-0289-01 (actualisation des observations anciennes). Prix de référence internes (◇) : hypothèses documentées pour les articles sans observation détaillant — ils réduisent la couverture et l'indice de confiance.`, { size: 7.2, color: INK3 }); /* ================================ 16 · limites ================================ */ L.section("16 · Limites"); [ "Estimation indicative : les coûts présentés sont des estimations statistiques et ne constituent ni une soumission d'entrepreneur ni une évaluation professionnelle certifiée (OEAQ).", "Les quantités sont dérivées de formules géométriques génériques (rapport de forme, hauteurs, ratios d'ouvertures) et non d'un relevé des plans ; les surcharges saisies priment.", "Les caractéristiques « supposées » (revêtement, toiture, chauffage, qualité…) sont des valeurs par défaut à confirmer ; leur part abaisse le score « complétude du bâtiment ».", "Les prix de référence internes (◇) ne sont pas observés ; les prix détaillants observés (●) reflètent le prix affiché au détail, non les escomptes d'entrepreneur.", "La dépréciation dépend fortement de l'âge effectif et des conditions saisies ; la désuétude externe n'est jamais calculée sans preuve.", "Le facteur de localisation matériaux/équipement des régions éloignées est une hypothèse ; la main-d'œuvre suit les conventions collectives provinciales (CCQ).", ...(est.warnings.filter((w) => !w.startsWith("localisation")).map((w) => `Avertissement du moteur : ${w}.`)), ].forEach((t) => L.bullet(t)); /* ---- pieds de page avec total ---- */ const range = doc.bufferedPageRange(); for (let i = range.start; i < range.start + range.count; i++) { doc.switchToPage(i); pageFooter(doc, i + 1, range.count, generated); } doc.end(); return done; }