// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Rapport PDF ValoPlex — 100 % vectoriel (pdfkit), identité « éditorial sharp » : * logo encre/lime, fiche complète du rôle, historique en barres, plan radar des * comparables dessiné dans le PDF, tableau des ajustements. Format Lettre. */ import PDFDocument from "pdfkit"; import path from "path"; import type { UnitEstimate } from "./estimator"; import { buildProforma } from "./proforma"; function doorsLabelPdf(n: number | null): string { if (!n) return "PLEX"; const map: Record = { 2: "DUPLEX", 3: "TRIPLEX", 4: "QUADRUPLEX", 5: "QUINTUPLEX", 6: "SIXPLEX" }; return map[n] ?? `MULTI ${n} PORTES`; } const INK = "#141814"; const INK3 = "#8b928c"; const PAPER = "#f5f3ee"; const SURFACE2 = "#faf9f5"; const GREEN = "#b25f16"; const GREEN_DEEP = "#8a4710"; const LIME = "#ff9f45"; const LIME_SOFT = "#ffedd9"; const AMBER_SOFT = "#fdf3e2"; const DANGER_SOFT = "#fbe9e7"; const LINE = "#dedcd4"; const W = 612; const CW3 = 612 - 2 * 44; const H = 792; const M = 44; // marge const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f); const fmt = (v: number | null | undefined) => v == null ? "—" : new Intl.NumberFormat("fr-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0, }).format(v); const signed = (v: number) => `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)))}`; const fmtDist = (m: number) => (m < 1000 ? `${Math.round(m)} m` : `${(m / 1000).toFixed(1)} km`); 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 — marché mince", }; type Doc = InstanceType; function logo(doc: Doc, x: number, y: number, scale = 1) { doc.save(); doc.font("SG-Bold").fontSize(24 * scale); doc.fillColor(PAPER).text("Valo", x, y, { lineBreak: false }); const w = doc.widthOfString("Valo"); const bx = x + w + 4 * scale; doc.save(); doc.rotate(-3, { origin: [bx, y + 12 * scale] }); const bw = doc.widthOfString("Plex") + 12 * scale; doc.roundedRect(bx, y - 3 * scale, bw, 30 * scale, 5 * scale).fill(LIME); doc.fillColor(INK).text("Plex", 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 }); } function pageFrame(doc: Doc, page: number, total: number, generated: string) { doc.rect(0, 0, W, H).fill(PAPER); // filet de bas de page doc.rect(M, H - 46, W - 2 * M, 1.2).fill(INK); doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text( "WWW.VALOPLEX.COM — ESTIMATION STATISTIQUE À TITRE INDICATIF · NE REMPLACE PAS UNE ÉVALUATION PROFESSIONNELLE · SIMON-PIERRE BOUCHER · CONTACT@SPBOUCHER.AI", M, H - 38, { 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).fillAndStroke(opts?.fill ?? "#ffffff", INK); doc.lineWidth(1.3); } 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 }); doc.font("SG-Bold").fontSize(9.2).fillColor(INK) .text(v, x + 7, y + 16.5, { width: w - 14, height: h - 20, ellipsis: true }); doc.lineWidth(1.3); } /** Plan radar vectoriel : sujet au centre, comparables par azimut/distance. */ function radar(doc: Doc, cx: number, cy: number, R: number, data: UnitEstimate) { const u = data.unit!; const comps = data.result.comps; const cos = Math.cos((u.lat * Math.PI) / 180); const pts = comps.map((c) => ({ ...c, x: (c.lng - u.lng) * 111320 * cos, y: (c.lat - u.lat) * 110574, })); const maxM = Math.max(120, ...pts.map((p) => Math.hypot(p.x, p.y))); const ringSets = [ [100, 250, 500], [250, 500, 1000], [500, 1000, 2000], [1000, 2500, 5000], [2500, 5000, 10000], [5000, 10000, 20000], [10000, 25000, 50000], ]; const rings = ringSets.find((s) => maxM <= s[2]) ?? ringSets[ringSets.length - 1]; const k = R / rings[2]; // cadre + grille doc.save(); doc.roundedRect(cx - R - 16, cy - R - 16, 2 * (R + 16), 2 * (R + 16), 8).clip(); doc.rect(cx - R - 16, cy - R - 16, 2 * (R + 16), 2 * (R + 16)).fill("#ffffff"); doc.lineWidth(0.5).strokeColor("#eceae3"); for (let g = -R - 16; g <= R + 16; g += 24) { doc.moveTo(cx + g, cy - R - 16).lineTo(cx + g, cy + R + 16).stroke(); doc.moveTo(cx - R - 16, cy + g).lineTo(cx + R + 16, cy + g).stroke(); } // anneaux rings.forEach((m, i) => { const rr = m * k; doc.lineWidth(i === 2 ? 1.3 : 0.8).strokeColor(i === 2 ? INK : "#b9b7ae"); if (i !== 2) doc.dash(3, { space: 3 }); doc.circle(cx, cy, rr).stroke(); doc.undash(); doc.font("JB-Reg").fontSize(5.5).fillColor(INK3) .text(fmtDist(m), cx + rr * 0.7071 + 2, cy - rr * 0.7071 - 7, { lineBreak: false }); }); // croix doc.lineWidth(0.6).strokeColor("#d5d3ca"); doc.moveTo(cx, cy - R).lineTo(cx, cy + R).stroke(); doc.moveTo(cx - R, cy).lineTo(cx + R, cy).stroke(); // comparables numérotés pts.forEach((p, i) => { const px = cx + p.x * k; const py = cy - p.y * k; const rr = 6.5 + p.weight * 3.5; doc.circle(px, py, rr).lineWidth(1.1).fillAndStroke(GREEN, INK); doc.font("JB-Bold").fontSize(rr > 8.5 ? 7 : 6).fillColor(PAPER) .text(String(i + 1), px - rr, py - 3, { width: 2 * rr, align: "center", lineBreak: false }); }); // sujet : losange lime doc.save().translate(cx, cy).rotate(45); doc.rect(-8, -8, 16, 16).lineWidth(1.6).fillAndStroke(LIME, INK); doc.rect(-2.5, -2.5, 5, 5).fill(INK); doc.restore(); // nord doc.save().translate(cx + R + 2, cy - R - 2); doc.path("M0,-9 L4,5 L0,2 L-4,5 Z").fill(INK); doc.font("JB-Bold").fontSize(6).fillColor(INK).text("N", -2.2, 8, { lineBreak: false }); doc.restore(); doc.restore(); // bordure du cadre doc.roundedRect(cx - R - 16, cy - R - 16, 2 * (R + 16), 2 * (R + 16), 8).lineWidth(1.3).stroke(INK); } export async function buildReport(data: UnitEstimate): Promise { const u = data.unit!; const s = u.specs; const r = data.result; const generated = new Date().toISOString().slice(0, 16).replace("T", " "); const doc = new PDFDocument({ size: "LETTER", margin: 0, info: { Title: `ValoPlex — ${u.adresse ?? ""}` } }); 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)))); /* =============================== PAGE 1 =============================== */ pageFrame(doc, 1, 3, generated); // bandeau encre doc.rect(0, 0, W, 96).fill(INK); logo(doc, M, 30); doc.font("JB-Reg").fontSize(7).fillColor(LIME) .text("RAPPORT D'ÉVALUATION · QUÉBEC", M, 66, { characterSpacing: 1.6, lineBreak: false }); doc.font("JB-Reg").fontSize(7).fillColor("#9aa39c") .text(`ID ${u.id}`, W - M - 200, 34, { width: 200, align: "right" }) .text(`GÉNÉRÉ LE ${generated}`, W - M - 200, 46, { width: 200, align: "right" }) .text(`MATRICULE ${s.matricule ?? "—"}`, W - M - 200, 58, { width: 200, align: "right" }); doc.rect(0, 96, W, 3).fill(LIME); // adresse + estimation let y = 122; kicker(doc, "Valeur estimée au marché", M, y); y += 16; doc.font("SG-Bold").fontSize(15).fillColor(INK) .text(`${(u.adresse ?? "").toUpperCase()}${s.apt ? " APP. " + s.apt : ""} · ${(u.municipalite ?? "").toUpperCase()}`, M, y, { width: W - 2 * M }); y = doc.y + 6; doc.font("SG-Bold").fontSize(46).fillColor(INK).text(fmt(r.estimate), M, y); y = doc.y + 10; // rangée fourchette / confiance / rôle / écart const rowY = y; const doorsN = u.nbLogements ?? 0; const cols = [ ["GABARIT", doorsLabelPdf(doorsN)], ["VALEUR / PORTE", doorsN >= 2 ? fmt(Math.round(r.estimate / doorsN)) : "—"], ["FOURCHETTE CALIBRÉE (80 %)", `${fmt(r.low)} → ${fmt(r.high)}`], ["ÉVALUATION MUNICIPALE 2026", fmt(u.valeurRole)], ["ÉCART VS RÔLE", u.valeurRole ? `${((r.estimate / u.valeurRole - 1) * 100).toFixed(0)} %` : "—"], ] as const; let cx0 = M; for (const [k, v] of cols) { doc.font("JB-Bold").fontSize(6.5).fillColor(INK3).text(k, cx0, rowY, { characterSpacing: 0.8, lineBreak: false }); const lw = doc.widthOfString(k) + cols.length * 0.8; doc.font("SG-Bold").fontSize(13).fillColor(INK).text(v, cx0, rowY + 11, { lineBreak: false }); cx0 += Math.max(doc.widthOfString(v), lw) + 34; } // pastille confiance const confTxt = `CONFIANCE ${r.confidenceLevel} · ${r.confidencePct} % — ${CONF_TXT[r.confidenceLevel].toUpperCase()}`; doc.font("JB-Bold").fontSize(7.5); const cw = doc.widthOfString(confTxt) + 20; doc.roundedRect(M, rowY + 34, cw, 18, 4).lineWidth(1).fillAndStroke(CONF_BG[r.confidenceLevel], INK); doc.fillColor(r.confidenceLevel === "D" ? "#b3423a" : INK).text(confTxt, M + 10, rowY + 39.5, { lineBreak: false }); // fiche de la propriété y = rowY + 72; kicker(doc, "Fiche de la propriété — registre officiel (rôle 2026)", M, y); y += 16; const groups: [string, [string, string][]][] = [ ["BÂTIMENT", [ ["Année constr.", u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? " (est.)" : ""}` : "—"], ["Aire étages", u.aireEtagesM2 ? `${u.aireEtagesM2} m²` : "—"], ["Étages", u.specs.nbEtages != null ? String(u.specs.nbEtages) : "—"], ["Genre", s.genreConstruction ?? "—"], ["Lien phys.", s.lienPhysique ?? "—"], ["Logements", u.nbLogements != null ? String(u.nbLogements) : "—"], ["Locaux n-rés.", s.nbLocauxNonResid != null ? String(s.nbLocauxNonResid) : "—"], ["Chambres loc.", s.nbChambresLocatives != null ? String(s.nbChambresLocatives) : "—"], ]], ["TERRAIN", [ ["Superficie", u.superficieTerrainM2 ? `${u.superficieTerrainM2} m²` : "—"], ["Mes. frontale", s.frontTerrainM ? `${s.frontTerrainM} m` : "—"], ["CUBF", s.cubf ? String(s.cubf) : "—"], ["Usage", s.cubfLibelle ?? "—"], ["Unité voisin.", s.uniteVoisinage || "—"], ["Arrond.", s.arrond || "—"], ]], ["RÔLE D'ÉVALUATION", [ ["Val. terrain", fmt(s.valeurTerrain)], ["Val. bâtiment", fmt(s.valeurBatiment)], ["Val. totale", fmt(u.valeurRole)], ["Rôle antér.", fmt(s.valeurAnterieure)], ["Cond. marché", s.datCondMarche ?? "—"], ["Adresses", s.nAdresses != null ? String(s.nAdresses) : "—"], ]], ]; const gW = (W - 2 * M - 2 * 14) / 3; const cellH = 30; 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 + 24; // adresses officielles des portes if (s.portesAdresses.length > 0) { doc.font("JB-Bold").fontSize(6.2).fillColor(GREEN_DEEP) .text("PORTES OFFICIELLES (RÔLE) : ", M, y, { characterSpacing: 0.6, lineBreak: false }); doc.font("JB-Reg").fontSize(6.2).fillColor(INK3) .text(s.portesAdresses.slice(0, 8).join(" · ") + (s.portesAdresses.length > 8 ? ` (+${s.portesAdresses.length - 8})` : ""), M + 118, y, { width: W - 2 * M - 118, height: 14, ellipsis: true }); y += 16; } // historique kicker(doc, "Valeur estimée par année — 2021 → 2026", M, y); y += 16; const hist = u.history; const hMax = Math.max(...hist.map((h) => h.value ?? 0), 1); const histH = hist.length * 21 + 24; card(doc, M, y, W - 2 * M, histH); hist.forEach((h, i) => { const ly = y + 14 + i * 21; doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(String(h.year), M + 14, ly + 2, { lineBreak: false }); const trackX = M + 52, trackW = W - 2 * M - 52 - 100; doc.rect(trackX, ly, trackW, 12).fill(SURFACE2); doc.rect(trackX, ly, Math.max(2, ((h.value ?? 0) / hMax) * trackW), 12).fill(h.year === 2026 ? INK : GREEN); doc.font("JB-Bold").fontSize(7.5).fillColor(INK) .text(fmt(h.value), W - M - 92, ly + 2, { width: 80, align: "right" }); }); /* =============================== PAGE 2 =============================== */ doc.addPage({ size: "LETTER", margin: 0 }); pageFrame(doc, 2, 3, generated); doc.rect(0, 0, W, 8).fill(INK); doc.rect(0, 8, W, 2.5).fill(LIME); y = 34; kicker(doc, "Pourquoi ce prix — le calcul, sans boîte noire", M, y); y += 16; const tileW = (W - 2 * M - 14) / 2; card(doc, M, y, tileW, 64); doc.font("JB-Bold").fontSize(6.5).fillColor(INK3).text("MODÈLE HÉDONIQUE PLEX EN RATIO (LIGHTGBM · 91 060 VENTES)", M + 14, y + 11, { characterSpacing: 0.6 }); doc.font("SG-Bold").fontSize(21).fillColor(INK).text(fmt(r.modelEstimate), M + 14, y + 24); doc.font("JB-Reg").fontSize(7).fillColor(GREEN_DEEP).text(`POIDS : ${(r.modelWeight * 100).toFixed(0)} %`, M + 14, y + 49); card(doc, M + tileW + 14, y, tileW, 64); doc.font("JB-Bold").fontSize(6.5).fillColor(INK3).text("COMPARABLES AJUSTÉS (MÉDIANE PONDÉRÉE)", M + tileW + 28, y + 11, { characterSpacing: 0.6 }); doc.font("SG-Bold").fontSize(21).fillColor(INK).text(fmt(r.compsEstimate), M + tileW + 28, y + 24); doc.font("JB-Reg").fontSize(7).fillColor(GREEN_DEEP).text( `POIDS : ${((1 - r.modelWeight) * 100).toFixed(0)} % · ${r.nCompsUsed} COMPS${r.compsDispersionPct != null ? ` · ±${r.compsDispersionPct} %` : ""}`, M + tileW + 28, y + 49); y += 88; // radar + tableau kicker(doc, `Plan des comparables — ${r.nCompsUsed} ventes réelles · azimut + distance`, M, y); y += 18; const R = 118; radar(doc, M + R + 16, y + R + 16, R, data); const tX = M + 2 * (R + 16) + 16; const tW = W - M - tX; doc.font("JB-Bold").fontSize(6.2).fillColor(INK3); let ty = y + 2; r.comps.slice(0, 12).forEach((c, i) => { const rowH = 22.5; if (i % 2 === 0) doc.rect(tX, ty - 2, tW, rowH).fill(SURFACE2); doc.circle(tX + 8, ty + 7, 6).lineWidth(0.9).fillAndStroke(GREEN, INK); doc.font("JB-Bold").fontSize(6).fillColor(PAPER).text(String(i + 1), tX + 2, ty + 4.5, { width: 12, align: "center", lineBreak: false }); doc.font("SG-Bold").fontSize(7.3).fillColor(INK) .text(`${c.street ?? ""}`, tX + 19, ty, { width: tW - 90, height: 9, ellipsis: true, lineBreak: false }); doc.font("JB-Reg").fontSize(5.8).fillColor(INK3) .text(`${c.date} · ${fmtDist(c.distanceM)} · VENDU ${fmt(c.amount)} · T ${signed(c.adjTime)} · S ${signed(c.adjArea)} · Â ${signed(c.adjAge)}`, tX + 19, ty + 10, { width: tW - 78, height: 8, ellipsis: true, lineBreak: false }); doc.font("SG-Bold").fontSize(8).fillColor(INK) .text(fmt(Math.round(c.adjustedPrice)), tX + tW - 68, ty + 3, { width: 68, align: "right", lineBreak: false }); ty += rowH; }); // légende radar const legY = y + 2 * (R + 16) + 8; doc.font("JB-Reg").fontSize(6.3).fillColor(INK3).text( "◆ = PROPRIÉTÉ ÉVALUÉE (CENTRE) · ● = VENTE COMPARABLE (TAILLE = POIDS) · T/S/Â = AJUSTEMENTS TEMPS / SUPERFICIE / ÂGE", M, legY, { width: W - 2 * M, characterSpacing: 0.4 }); /* =============================== PAGE 3 : PRO FORMA =============================== */ doc.addPage({ size: "LETTER", margin: 0 }); pageFrame(doc, 3, 3, generated); doc.rect(0, 0, W, 8).fill(INK); doc.rect(0, 8, W, 2.5).fill(LIME); y = 34; if (doorsN >= 2 && r.estimate > 0) { const pf = buildProforma(r.estimate, doorsN, u.valeurRole, u.municipalite); kicker(doc, `Pro forma de l'investisseur — taux ${pf.params.tauxHypoPct} % · mise ${pf.params.miseDeFondsPct} % · TGA ${pf.params.tgaPct} %`, M, y); y += 18; const colW3 = (CW3 - 14) / 2; // état des résultats card(doc, M, y, colW3, 250); doc.font("JB-Bold").fontSize(7).fillColor(GREEN_DEEP).text("ÉTAT DES RÉSULTATS NORMALISÉ (ANNUEL)", M + 12, y + 10, { characterSpacing: 0.8 }); const lines1: [string, string][] = [ [`Revenus bruts (${doorsN} portes × ${fmt(Math.round(pf.loyerMoyenMensuel))}/mois)`, fmt(Math.round(pf.revenusBruts))], [`Vacance (${pf.params.vacancePct} %)`, fmt(Math.round(pf.vacance))], ["Taxes municipales et scolaire", fmt(Math.round(pf.depenses[0].amount + pf.depenses[1].amount))], ["Assurances", fmt(Math.round(pf.depenses[2].amount))], ["Entretien + gestion", fmt(Math.round(pf.depenses[3].amount + pf.depenses[4].amount))], ["Déneigement, énergie, réserve", fmt(Math.round(pf.depenses[5].amount + pf.depenses[6].amount + pf.depenses[7].amount))], ["REVENU NET D'EXPLOITATION", fmt(Math.round(pf.rne))], ["TGA implicite / MRB", `${pf.tgaImplicitePct.toFixed(2)} % · × ${pf.mrb.toFixed(1)}`], ["RNE par porte", fmt(Math.round(pf.rne / doorsN))], ]; lines1.forEach(([k, v], i) => { const ly = y + 26 + i * 23; const strong = k === "REVENU NET D'EXPLOITATION"; if (i % 2 === 0) doc.rect(M + 8, ly - 3, colW3 - 16, 21).fill(SURFACE2); doc.font(strong ? "JB-Bold" : "Inter").fontSize(strong ? 7.4 : 7.6).fillColor(strong ? INK : "#4d5551") .text(k, M + 12, ly, { width: colW3 - 130, height: 18, lineBreak: false, ellipsis: true }); doc.font("SG-Bold").fontSize(8.4).fillColor(INK) .text(v, M + colW3 - 118, ly - 1, { width: 106, align: "right", lineBreak: false }); }); // financement + 5 ans const x2 = M + colW3 + 14; card(doc, x2, y, colW3, 250); doc.font("JB-Bold").fontSize(7).fillColor(GREEN_DEEP).text("FINANCEMENT ET PROJECTION 5 ANS", x2 + 12, y + 10, { characterSpacing: 0.8 }); const lines2: [string, string][] = [ [`Hypothèque (${100 - pf.params.miseDeFondsPct} %)`, fmt(Math.round(pf.hypotheque))], [`Paiement mensuel (${pf.params.amortAns} ans)`, fmt(Math.round(pf.paiementMensuelHypo))], ["Cashflow avant impôt / an", fmt(Math.round(pf.cashflowAnnuel))], ["Cashflow / porte / mois · DSCR", `${fmt(Math.round(pf.cashflowMensuelParPorte))} · ${pf.dscr.toFixed(2)}`], ["Capital remboursé an 1", fmt(Math.round(pf.capitalAn1))], ["Rendement global an 1", `${pf.rendementTotalAn1Pct.toFixed(1)} %`], [`Équité à 5 ans (appr. ${pf.params.appreciationPct} %/an)`, fmt(Math.round(pf.equite5Ans))], ["Gain total 5 ans", fmt(Math.round(pf.gainTotal5Ans))], ["Loyer de point mort / marge", `${fmt(Math.round(pf.loyerPointMort))} · ${pf.margeSecuriteLoyerPct.toFixed(0)} %`], ]; lines2.forEach(([k, v], i) => { const ly = y + 26 + i * 23; if (i % 2 === 0) doc.rect(x2 + 8, ly - 3, colW3 - 16, 21).fill(SURFACE2); doc.font("Inter").fontSize(7.6).fillColor("#4d5551") .text(k, x2 + 12, ly, { width: colW3 - 130, height: 18, lineBreak: false, ellipsis: true }); doc.font("SG-Bold").fontSize(8.4).fillColor(INK) .text(v, x2 + colW3 - 118, ly - 1, { width: 106, align: "right", lineBreak: false }); }); y += 264; // liquidités requises card(doc, M, y, CW3, 58, { fill: INK }); doc.font("JB-Bold").fontSize(7).fillColor(LIME).text("LIQUIDITÉS REQUISES — TOUT LE KIT", M + 14, y + 10, { characterSpacing: 1 }); doc.font("Inter").fontSize(8).fillColor("#e8e6df").text( `Mise de fonds ${fmt(Math.round(pf.miseDeFonds))} · Droits de mutation ${fmt(pf.droitsMutation)} · Notaire ${fmt(pf.fraisNotaire)} · Inspection ${fmt(pf.fraisInspection)}`, M + 14, y + 24, { lineBreak: false }); doc.font("SG-Bold").fontSize(14).fillColor(LIME) .text(`TOTAL ${fmt(Math.round(pf.liquiditesRequises))}`, M + 14, y + 37, { lineBreak: false }); y += 72; doc.font("JB-Reg").fontSize(6).fillColor(INK3).text( "PRO FORMA INDICATIF : LOYER IMPLICITE DÉRIVÉ DE LA VALEUR ESTIMÉE ET DU TGA — PAS DES BAUX RÉELS. TAXES APPROXIMÉES DEPUIS LE RÔLE. NE CONSTITUE PAS UN CONSEIL FINANCIER.", M, y, { width: CW3, characterSpacing: 0.3 }); y += 22; } // méthodo const my = Math.max(legY + 20, ty + 14); card(doc, M, my, W - 2 * M, 112, { fill: INK, shadow: false }); doc.font("JB-Bold").fontSize(7).fillColor(LIME).text("MÉTHODOLOGIE", M + 16, my + 12, { characterSpacing: 1.4 }); doc.font("Inter").fontSize(7.8).fillColor("#e8e6df").text( "L'estimation combine (1) un modèle hédonique spécialisé plex (gradient boosting en ratio prix/rôle, insensible à l'échelle) entraîné sur ~91 000 ventes de plex québécois " + "(2021-2026) appariées au rôle d'évaluation foncière géoréférencé du MAMH — erreur médiane de 14 % — et (2) une médiane " + "pondérée des ventes comparables voisines, chacune ajustée pour l'évolution du marché (indice mensuel), l'écart de " + "superficie (50 % du $/m²) et l'écart d'âge (0,5 %/an, plafonné à ±10 %) et l'écart de portes (50 % du prix par porte, plafonné à ±30 %). La fourchette correspond aux 10e et 90e " + "centiles du modèle, calibrés par méthode conforme (couverture 80 % vérifiée), recentrés sur l'estimation finale. L'indice de confiance (A-D) reflète le nombre de comparables, " + "leur dispersion et la largeur de la fourchette. Validation selon la norme IAAO sur études de ratios.", M + 16, my + 26, { width: W - 2 * M - 32, lineGap: 1.5 }); doc.end(); return done; }