1// UQO Éval — Métré : rapport PDF bilingue (pdfkit, vectoriel, Lettre) régénéré depuis une RÉVISION immuable. SERVEUR seulement.2import PDFDocument from "pdfkit";3import path from "path";4import type { EstimateVersion, Measurement, Project, ValuationResult } from "./types";5import { unitLabel } from "./units";6import type { ValuationCase } from "./estimates";7import { disableLigatures } from "../export-generic";89const INK = "#16181b", INK2 = "#4b5258", INK3 = "#8b928c", PAPER = "#f4f5f3", SURFACE2 = "#f9fafa", BLUE = "#005F85", TURQ = "#2BCCAE", TURQ_SOFT = "#d9f4ee", AMBER_SOFT = "#fdf3e2", LINE = "#dedcd4";10const W = 612, H = 792, M = 44, BOTTOM = H - 62;11const F = (f: string) => path.join(process.cwd(), "assets", "fonts", f);12const LOGO = path.join(process.cwd(), "assets", "uqo", "UQO_blanc.png");13type Doc = InstanceType<typeof PDFDocument>;1415const money = (v: number | null | undefined, lang: string) => (v == null || !Number.isFinite(v) ? "—" : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(v));16const money2 = (v: number | null | undefined, lang: string) => (v == null || !Number.isFinite(v) ? "—" : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { style: "currency", currency: "CAD", minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(v));17const num = (v: number | null | undefined, lang: string, d = 2) => (v == null || !Number.isFinite(v) ? "—" : v.toLocaleString(lang === "fr" ? "fr-CA" : "en-CA", { maximumFractionDigits: d }));1819class L {20 y = 34;21 constructor(readonly doc: Doc) {}22 newPage() { this.doc.addPage({ size: "LETTER", margin: 0 }); this.doc.rect(0, 0, W, H).fill(PAPER); this.doc.rect(0, 0, W, 8).fill(INK); this.doc.rect(0, 8, W, 2.5).fill(TURQ); this.y = 34; }23 ensure(h: number) { if (this.y + h > BOTTOM) this.newPage(); }24 section(t: string) { this.ensure(60); this.doc.rect(M, this.y + 3.5, 20, 2).fill(BLUE); this.doc.font("JB-Bold").fontSize(8).fillColor(BLUE).text(t.toUpperCase(), M + 26, this.y, { characterSpacing: 1.4, lineBreak: false, width: W - 2 * M - 26 }); this.y += 18; }25 para(t: string, o: { size?: number; color?: string; font?: string; gap?: number } = {}) { const d = this.doc; d.font(o.font ?? "Inter").fontSize(o.size ?? 8); const h = d.heightOfString(t, { width: W - 2 * M, lineGap: 1.4 }); this.ensure(h + 4); d.fillColor(o.color ?? INK2).text(t, M, this.y, { width: W - 2 * M, lineGap: 1.4 }); this.y += h + (o.gap ?? 6); }26 bullet(t: string) { const d = this.doc; d.font("Inter").fontSize(7.8); const h = d.heightOfString(t, { width: W - 2 * M - 14, lineGap: 1.2 }); this.ensure(h + 3); d.rect(M + 2, this.y + 3.5, 4, 4).fill(BLUE); d.fillColor(INK2).text(t, M + 14, this.y, { width: W - 2 * M - 14, lineGap: 1.2 }); this.y += h + 3; }27 kv(cells: [string, string][], cols = 4) { const w = (W - 2 * M - (cols - 1) * 6) / cols; const h = 30; let i = 0; while (i < cells.length) { this.ensure(h + 6); for (let c = 0; c < cols && i < cells.length; c++, i++) { const x = M + c * (w + 6); const [k, v] = cells[i]; this.doc.lineWidth(0.8).roundedRect(x, this.y, w, h, 4).fillAndStroke(SURFACE2, LINE); this.doc.font("JB-Bold").fontSize(5.8).fillColor(INK3).text(k.toUpperCase(), x + 7, this.y + 6, { width: w - 14, characterSpacing: 0.7, lineBreak: false, height: 8, ellipsis: true }); this.doc.font("SG-Bold").fontSize(7.4).fillColor(INK).text(v, x + 7, this.y + 15.5, { width: w - 14, height: 12, ellipsis: true, lineBreak: false }); } this.y += h + 6; } }28 table(cols: { h: string; w: number; align?: "left" | "right"; mono?: boolean }[], rows: string[][], o: { rowH?: number; total?: string[]; highlight?: (i: number) => boolean } = {}) {29 const d = this.doc; const rowH = o.rowH ?? 14; const tw = cols.reduce((s, c) => s + c.w, 0); const ws = cols.map((c) => (c.w * (W - 2 * M)) / tw);30 const header = () => { this.ensure(rowH + 4); d.rect(M, this.y, W - 2 * M, rowH).fill(INK); let x = M; 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; };31 header();32 rows.forEach((row, ri) => { if (this.y + rowH > BOTTOM) { this.newPage(); header(); } const hl = o.highlight?.(ri); if (hl) d.rect(M, this.y, W - 2 * M, rowH).fill(TURQ_SOFT); else if (ri % 2) d.rect(M, this.y, W - 2 * M, rowH).fill(SURFACE2); let x = M; 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(M, this.y + rowH).lineTo(W - M, this.y + rowH).lineWidth(0.4).stroke(LINE); this.y += rowH; });33 if (o.total) { if (this.y + rowH + 2 > BOTTOM) { this.newPage(); header(); } d.rect(M, this.y, W - 2 * M, rowH + 2).fill(INK); let x = M; cols.forEach((c, i) => { d.font("SG-Bold").fontSize(7.4).fillColor(PAPER).text(o.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; }34 this.y += 8;35 }36}3738export interface ReportInput { project: Project; version: EstimateVersion; measurements: Measurement[]; valuation: ValuationCase | null; lang: "fr" | "en"; ownerName: string; generatedBy: string }3940export async function buildMetreReport(input: ReportInput): Promise<Buffer> {41 const { project: p, version: v, lang } = input;42 const fr = lang === "fr";43 const r = v.result;44 const t = (a: string, b: string) => (fr ? a : b);45 const doc = new PDFDocument({ size: "LETTER", margin: 0, autoFirstPage: false, bufferPages: true, info: { Title: `${t("Quantity Survey", "Quantity Survey")} — ${p.name}`, Author: "UQO Éval", Subject: "IMM1033" } });46 doc.registerFont("Inter", F("Inter-Regular.ttf")); 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"));47 disableLigatures(doc);48 const chunks: Buffer[] = [];49 doc.on("data", (c: Buffer) => chunks.push(c));50 const done = new Promise<Buffer>((res) => doc.on("end", () => res(Buffer.concat(chunks))));51 const generated = new Date().toLocaleString(fr ? "fr-CA" : "en-CA", { dateStyle: "long", timeStyle: "short" });52 const academic = input.generatedBy !== "professional";5354 /* ---------------------------------------------------------- page 1 */55 doc.addPage({ size: "LETTER", margin: 0 });56 doc.rect(0, 0, W, H).fill(PAPER);57 doc.rect(0, 0, W, 230).fill(INK);58 doc.rect(0, 230, W, 3).fill(TURQ);59 doc.image(LOGO, M, 30, { height: 26 });60 doc.font("SG-Bold").fontSize(22).fillColor(TURQ).text("Éval", M + 26 * (2123 / 853) + 8, 34, { lineBreak: false });61 doc.font("JB-Bold").fontSize(7.5).fillColor(TURQ).text(t("QUANTITY SURVEY · INTELLIGENCE DE LA CONSTRUCTION", "QUANTITY SURVEY · CONSTRUCTION INTELLIGENCE"), M, 78, { characterSpacing: 1.6 });62 doc.font("SG-Bold").fontSize(26).fillColor("#ffffff").text(p.name, M, 94, { width: W - 2 * M, lineGap: -2 });63 doc.font("Inter").fontSize(9.5).fillColor("#c9d1d4").text([p.address, p.municipality].filter(Boolean).join(", ") || t("Sans adresse", "No address"), M, doc.y + 4, { width: W - 2 * M });64 doc.font("JB-Reg").fontSize(7.2).fillColor("#9aa5aa").text(`${t("Révision", "Revision")} ${v.number} — ${v.label}${v.scenario ? ` · ${t("scénario", "scenario")} ${v.scenario}` : ""} · ${t("figée le", "frozen on")} ${v.createdAt.slice(0, 10)} · SHA-256 ${v.contentHash.slice(0, 16)}…`, M, 200, { width: W - 2 * M });65 if (academic) { doc.roundedRect(W - M - 150, 30, 150, 22, 4).fill(AMBER_SOFT); doc.font("JB-Bold").fontSize(6.5).fillColor("#8a5a12").text(t("EXEMPLE ACADÉMIQUE — NON VÉRIFIÉ", "ACADEMIC EXAMPLE — UNVERIFIED"), W - M - 150, 38, { width: 150, align: "center", characterSpacing: 0.6 }); }6667 const lay = new L(doc); lay.y = 252;68 lay.section(t("Résultat", "Result"));69 doc.roundedRect(M + 3.5, lay.y + 3.5, W - 2 * M, 92, 8).fill("#e3e1d9"); doc.roundedRect(M, lay.y, W - 2 * M, 92, 8).lineWidth(1.3).fillAndStroke("#ffffff", INK);70 doc.font("JB-Bold").fontSize(6.5).fillColor(INK3).text(t("TOTAL DE L'ESTIMATION", "ESTIMATE TOTAL").toUpperCase(), M + 16, lay.y + 12, { characterSpacing: 1 });71 doc.font("SG-Bold").fontSize(30).fillColor(INK).text(money(r.totals.total, lang), M + 16, lay.y + 24, { lineBreak: false });72 doc.font("Inter").fontSize(8).fillColor(INK2).text(`${t("Direct localisé", "Located direct")} ${money(r.totals.located, lang)} · ${t("avant taxes", "before tax")} ${money(r.totals.subtotalBeforeTax, lang)} · ${r.params.applyGst || r.params.applyQst ? t("taxes incluses", "taxes included") : t("hors taxes", "excluding taxes")}`, M + 16, lay.y + 62, { width: W - 2 * M - 32 });73 doc.font("JB-Reg").fontSize(6.8).fillColor(INK3).text(`${t("Prix au", "Prices as of")} ${r.params.priceDate} · ${t("région", "region")} ${fr ? r.location.nameFr : r.location.nameEn} · ${t("couverture observée", "observed coverage")} ${Math.round(r.coverage.observedShare * 100)} % · ${t("part d'hypothèses", "assumption share")} ${Math.round(r.coverage.assumptionShare * 100)} %`, M + 16, lay.y + 76, { width: W - 2 * M - 32 });74 lay.y += 104;75 lay.kv([[t("Date d'évaluation", "Valuation date"), p.valuationDate], [t("Type de bâtiment", "Building type"), p.buildingKind], [t("Étages", "Storeys"), String(p.stories)], [t("Unités", "Units"), p.unitSystem === "imperial" ? t("impériales", "imperial") : t("métriques", "metric")], [t("Catalogue", "Catalogue"), v.result.catalogueVersion.slice(0, 28)], [t("Formules", "Formulas"), v.result.formulaVersion], [t("Lignes chiffrées", "Priced lines"), `${r.coverage.linesPriced} / ${r.coverage.linesTotal}`], [t("Responsable", "Owner"), input.ownerName]]);76 lay.para(t("Ce rapport est régénéré à partir d'un instantané immuable (lignes, paramètres, prix utilisés, versions du catalogue et des formules). Chaque montant provient du moteur déterministe du module Quantity Survey d'UQO Éval ; la nature de chaque prix est indiquée (observé, officiel, indexé, calculé, référence ou hypothèse).", "This report is regenerated from an immutable snapshot (lines, parameters, prices used, catalogue and formula versions). Every amount comes from the deterministic engine of the UQO Éval Quantity Survey module; the nature of each price is shown (observed, official, indexed, computed, reference or assumption)."));7778 /* ------------------------------------------------------- récapitulatif */79 lay.section(t("Récapitulatif des coûts et ordre d'application", "Cost summary and application order"));80 const T = r.totals;81 lay.table([{ h: t("Étape", "Step"), w: 60 }, { h: t("Montant", "Amount"), w: 22, align: "right", mono: true }], [82 [t("Coût direct (matériaux + main-d'œuvre + équipement + autres)", "Direct cost (materials + labour + equipment + other)"), money2(T.direct, lang)],83 [t("Ajustement régional", "Regional adjustment") + ` (${r.location.code})`, money2(T.locationAdjustment, lang)],84 [t("= Direct localisé", "= Located direct"), money2(T.located, lang)],85 [`${t("Conditions générales", "General conditions")} ${r.params.generalConditionsPct} %`, money2(T.generalConditions, lang)],86 [`${t("Frais généraux", "Overhead")} ${r.params.overheadPct} %`, money2(T.overhead, lang)],87 [`${t("Profit", "Profit")} ${r.params.profitPct} %`, money2(T.profit, lang)],88 [`${t("Contingence", "Contingency")} ${r.params.contingencyPct} %`, money2(T.contingency, lang)],89 [`${t("Actualisation", "Escalation")} ${r.params.escalationFactor ? `× ${r.params.escalationFactor.toFixed(4)} (${r.params.escalationSource ?? ""})` : t("aucune", "none")}`, money2(T.escalation, lang)],90 [t("= Sous-total avant taxes", "= Subtotal before tax"), money2(T.subtotalBeforeTax, lang)],91 [`TPS/GST ${r.params.applyGst ? "5 %" : "—"}`, money2(T.gst, lang)], [`TVQ/QST ${r.params.applyQst ? "9,975 %" : "—"}`, money2(T.qst, lang)],92 ], { total: [t("TOTAL", "TOTAL"), money2(T.total, lang)], highlight: (i) => i === 2 || i === 8 });93 lay.para(t("Répartition : matériaux", "Split: materials") + ` ${money(T.material, lang)} · ${t("main-d'œuvre", "labour")} ${money(T.labour, lang)} · ${t("équipement", "equipment")} ${money(T.equipment, lang)} · ${t("autres (sous-traitance, transport, prix installés)", "other (subcontract, transport, installed prices)")} ${money(T.other, lang)}.`);94 for (const s of r.applicationOrder) lay.bullet(s);9596 /* ------------------------------------------------------ lignes (WBS) */97 lay.section(t("Lignes d'estimation par division", "Estimate lines by division"));98 lay.table([{ h: t("Division", "Division"), w: 40 }, { h: t("Direct", "Direct"), w: 16, align: "right", mono: true }, { h: t("Localisé", "Located"), w: 16, align: "right", mono: true }, { h: "%", w: 8, align: "right", mono: true }], r.byWbs.map((w) => [w.wbs, money(w.direct, lang), money(w.located, lang), `${w.sharePct}`]));99 const KIND: Record<string, string> = { observed: "●", official: "●", indexed: "○", derived: "◐", reference: "◇", assumption: "◇", modeled: "≈" };100 lay.table([{ h: t("Description", "Description"), w: 34 }, { h: t("Assemblage", "Assembly"), w: 16, mono: true }, { h: t("Qté", "Qty"), w: 12, align: "right", mono: true }, { h: t("Unité", "Unit"), w: 7 }, { h: t("Unitaire", "Unit cost"), w: 11, align: "right", mono: true }, { h: t("Localisé", "Located"), w: 12, align: "right", mono: true }, { h: t("Prov.", "Prov."), w: 6, mono: true }],101 r.lines.filter((l) => !l.excluded).map((l) => [`${l.wbs} · ${l.description}`, l.basis === "rsmeans" ? (l.provenance.note?.match(/ligne (\d{12})/)?.[1] ?? "RSMeans") : l.assemblyCode ?? (l.basis === "assumption" ? t("hypothèse", "assumption") : "—"), num(l.quantity, lang, 2), unitLabel(l.unit, fr), money2(l.unitDirect, lang), money(l.located, lang), `${KIND[l.provenance.kind] ?? "·"} ${Math.round(l.provenance.observedShare * 100)}%`]),102 { rowH: 13 });103 lay.para(t("Légende : ● observé/officiel · ○ indexé (StatCan) · ◐ calculé depuis composants · ◇ référence interne / hypothèse · ≈ estimation de modèle ; le pourcentage est la part du coût matériau appuyée sur des prix observés.", "Legend: ● observed/official · ○ indexed (StatCan) · ◐ computed from components · ◇ internal reference / assumption · ≈ model estimate; the percentage is the share of material cost backed by observed prices."), { size: 7 });104 const excluded = r.lines.filter((l) => l.excluded);105 if (excluded.length) { lay.para(t("Exclusions de portée :", "Scope exclusions:"), { font: "SG-Bold", color: INK }); for (const l of excluded) lay.bullet(`${l.wbs} · ${l.description}`); }106107 /* --------------------------------------------------------- quantités */108 lay.section(t("Registre de métré (quantités mesurées)", "Takeoff ledger (measured quantities)"));109 const used = new Set(r.lines.map((l) => l.measurementId).filter(Boolean));110 const ms = input.measurements.filter((m) => used.has(m.id));111 if (!ms.length) lay.para(t("Aucune ligne n'est liée au registre de mesures : toutes les quantités sont saisies manuellement.", "No line is linked to the measurement ledger: all quantities were entered manually."));112 else lay.table([{ h: t("Mesure", "Measurement"), w: 26 }, { h: t("Formule", "Formula"), w: 30, mono: true }, { h: t("Valeur", "Value"), w: 12, align: "right", mono: true }, { h: t("Unité", "Unit"), w: 7 }, { h: t("Cal. rév.", "Cal. rev."), w: 8, align: "right", mono: true }, { h: t("Vérifiée", "Verified"), w: 10 }],113 ms.map((m) => [m.name, m.formulaText, num(m.value, lang, 3), unitLabel(m.unit, fr), m.calibrationRevision == null ? "—" : String(m.calibrationRevision), m.verifiedAt ? `${t("oui", "yes")} ${m.verifiedAt.slice(0, 10)}` : t("non", "no")]), { rowH: 13 });114115 /* --------------------------------------------------------- évaluation */116 if (input.valuation?.result) {117 const vr: ValuationResult = input.valuation.result;118 lay.section(t("Méthode du coût — valeur indiquée", "Cost approach — indicated value"));119 lay.table([{ h: t("Étape", "Step"), w: 60 }, { h: t("Montant", "Amount"), w: 22, align: "right", mono: true }], vr.steps.map((s) => [s.label, money(s.amount, lang)]), { highlight: (i) => i === vr.steps.length - 1 });120 if (vr.functionalSkipped.length) lay.para(t("Déficiences non déduites (déjà éliminées par la conception de remplacement) : ", "Deficiencies not deducted (already eliminated by the replacement design): ") + vr.functionalSkipped.map((f) => `${f.label} (${money(f.amount, lang)})`).join(" ; "), { size: 7.5 });121 lay.para(t("Le coût de construction n'est pas la valeur marchande : la valeur indiquée par la méthode du coût doit être réconciliée avec les lectures du marché (comparables, modèle hédonique) avant toute conclusion.", "Construction cost is not market value: the cost-approach indication must be reconciled with market readings (comparables, hedonic model) before any conclusion."), { size: 7.5 });122 }123124 /* -------------------------------------------------------- hypothèses */125 lay.section(t("Hypothèses, avertissements et incertitude", "Assumptions, warnings and uncertainty"));126 const assumptions = r.lines.filter((l) => !l.excluded && l.basis === "assumption");127 if (assumptions.length) for (const l of assumptions) lay.bullet(`${l.description} : ${money2(l.unitDirect, lang)}/${unitLabel(l.unit, fr)} — ${l.provenance.note ?? t("hypothèse sans justification notée", "assumption without recorded justification")}`);128 for (const w of r.warnings) lay.bullet(w);129 for (const l of r.lines) for (const w of l.warnings) lay.bullet(`${l.description} : ${w}`);130 if (!assumptions.length && !r.warnings.length) lay.para(t("Aucune hypothèse explicite ni avertissement.", "No explicit assumption or warning."));131132 /* ------------------------------------------------------------- sources */133 lay.section(t("Sources et bibliographie des prix", "Price sources and bibliography"));134 const srcs = new Map<string, { n: number; kind: string; at: string | null }>();135 for (const l of r.lines) { if (l.excluded) continue; const k = l.provenance.source; const e = srcs.get(k) ?? { n: 0, kind: l.provenance.kind, at: l.provenance.observedAt }; e.n++; srcs.set(k, e); }136 lay.table([{ h: t("Source", "Source"), w: 50 }, { h: t("Nature", "Kind"), w: 14 }, { h: t("Date", "Date"), w: 12, mono: true }, { h: t("Lignes", "Lines"), w: 8, align: "right", mono: true }], [...srcs.entries()].map(([k, e]) => [k, e.kind, e.at?.slice(0, 10) ?? "—", String(e.n)]), { rowH: 13 });137 lay.bullet(t("Main-d'œuvre : grilles APCHQ « Coût horaire de la main-d'œuvre » (conventions CCQ), coût employeur complet.", "Labour: APCHQ hourly cost grids (CCQ collective agreements), full employer cost."));138 lay.bullet(t("Indices : Statistique Canada, tableau 18-10-0289-01 (licence ouverte).", "Indices: Statistics Canada table 18-10-0289-01 (open licence)."));139 lay.bullet(t("Catalogue UQO Éval : prix détaillants observés (Canac, BMR, Patrick Morin) et prix de référence internes étiquetés « hypothèse ».", "UQO Éval catalogue: observed retailer prices (Canac, BMR, Patrick Morin) and internal reference prices labelled “assumption”."));140 if (r.lines.some((l) => !l.excluded && l.basis === "rsmeans")) lay.bullet(t("RSMeans (Gordian) 2026 T4 : coûts unitaires « bare » (matériau, main-d'œuvre, équipement) localisés pour la ville indiquée (prix extraits, ou moyenne nationale × facteur de localisation par section validé) ; les majorations ci-dessus s'ajoutent à ces coûts bare — le Total O&P RSMeans n'est pas utilisé. Données sous licence, usage pédagogique interne.", "RSMeans (Gordian) 2026 Q4: bare unit costs (material, labour, equipment) localized to the stated city (extracted prices, or national average × validated section location factor); the markups above are applied to these bare costs — the RSMeans Total O&P is not used. Licensed data, internal teaching use."));141142 /* ----------------------------------------------------------- pieds */143 const range = doc.bufferedPageRange();144 for (let i = range.start; i < range.start + range.count; i++) {145 doc.switchToPage(i);146 doc.rect(M, H - 46, W - 2 * M, 1.2).fill(INK);147 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(t("UQO-EVAL.APP · QUANTITY SURVEY — ESTIMATION PÉDAGOGIQUE, NI SOUMISSION NI ÉVALUATION CERTIFIÉE (OEAQ)", "UQO-EVAL.APP · TAKEOFF ESTIMATE — TEACHING ESTIMATE, NEITHER A BID NOR A CERTIFIED APPRAISAL (OEAQ)"), M, H - 39, { width: W - 2 * M - 60, characterSpacing: 0.3, lineBreak: false });148 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(t("COURS IMM1003 · IMM1033 — SIMON-PIERRE BOUCHER, UQO", "COURSES IMM1003 · IMM1033 — SIMON-PIERRE BOUCHER, UQO"), M, H - 30, { width: W - 2 * M - 60, characterSpacing: 0.4, lineBreak: false });149 doc.font("JB-Bold").fontSize(7.5).fillColor(INK).text(`${i - range.start + 1} / ${range.count}`, W - M - 40, H - 39, { width: 40, align: "right" });150 doc.font("JB-Reg").fontSize(6.5).fillColor(INK3).text(generated, W - M - 160, H - 30, { width: 160, align: "right" });151 }152 doc.end();153 return done;154}155