SPB Git

spb/uqo-eval

Public
21.1 KB · 155 lines typescript
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
Files · main
Menu

Theme