/** * make-pdf — print a scenario document as a GM-readable A4 PDF. * * The scenarios live as Markdown because that is what a GM reads at the table and what the * pack builder parses. A convention folder needs paper, and "export from a Markdown viewer" * gives you a web page with a page break through the middle of the detention table. * * So the same document is rendered here with print rules that serve the house style: * every top-level section starts a page, tables and read-aloud blocks never split across * one, and the cited-figure comments are dropped rather than printed as noise. * * There is no LaTeX or wkhtmltopdf on these machines. Chrome is on all of them, and its * headless print is a real renderer with real page rules, so that is the engine. * * node tools/make-pdf.mjs docs/scenarios/STANDING_ORDER.md [--out ] [--keep-html] * * --compact drops the page break before each section. A scenario wants one section per page, * because a GM turns to a section and reads it; a two-page update note does not, and giving * it one printed eight pages with a paragraph on each. */ import { readFileSync, readdirSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { execFileSync } from "node:child_process"; import path from "node:path"; const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), ".."); const argv = process.argv.slice(2); const SRC = argv.find(a => !a.startsWith("--")); if (!SRC) { console.error("usage: node tools/make-pdf.mjs [--out file.pdf]"); process.exit(1); } const COMPACT = argv.includes("--compact"); const outArg = argv.indexOf("--out"); const OUT = path.resolve(ROOT, outArg > -1 ? argv[outArg + 1] : path.join("docs", "print", path.basename(SRC).replace(/\.md$/, ".pdf"))); const CHROME = [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser" ].find(existsSync); if (!CHROME) { console.error("make-pdf: no Chrome or Chromium found — it is the print engine."); process.exit(1); } /* ── Markdown, the subset the scenarios actually use ──────────────────────── */ const esc = s => s.replace(/&/g, "&").replace(//g, ">"); /** Inline: **bold**, *italic*, `code`, and the em dashes already in the source. */ function inline(s) { return esc(s) .replace(/`([^`]+)`/g, "$1") .replace(/\*\*\*([^*]+)\*\*\*/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/\*([^*]+)\*/g, "$1"); } const cells = line => line.trim().replace(/^\||\|$/g, "").split("|").map(c => inline(c.trim())); function render(md) { // The cite markers are a build-time contract, not something a GM reads on paper. const lines = md.replace(//g, "").split("\n"); const out = []; let i = 0; while (i < lines.length) { const line = lines[i]; if (/^\s*$/.test(line)) { i++; continue; } if (/^---+\s*$/.test(line)) { out.push('
'); i++; continue; } const h = /^(#{1,4})\s+(.*)$/.exec(line); if (h) { const n = h[1].length; out.push(`${inline(h[2])}`); i++; continue; } // Table: a header row, a divider, then rows. Kept whole on one page. if (/^\|/.test(line) && /^\|[\s:|-]+\|?\s*$/.test(lines[i + 1] ?? "")) { const head = cells(line); i += 2; const body = []; while (i < lines.length && /^\|/.test(lines[i])) { body.push(cells(lines[i])); i++; } out.push(`${head.map(c => ``).join("")}` + body.map(r => `${r.map(c => ``).join("")}`).join("") + "
${c}
${c}
"); continue; } // Read-aloud and warning blocks. if (/^>/.test(line)) { const buf = []; while (i < lines.length && /^>/.test(lines[i])) { buf.push(lines[i].replace(/^>\s?/, "")); i++; } const text = buf.join("\n").trim(); const warn = /^⚠/.test(text); // A handout's numbered clauses are consecutive lines, and joining them turns ORDERS // into one grey paragraph. Two or more "N." markers in a run means a list, so break // before each; ordinary read-aloud prose is wrapped mid-sentence and must not be. const para = t => { const flat = inline(t.replace(/\n/g, " ")); return (flat.match(/(?:^|\s)\d\.\s/g) ?? []).length >= 2 ? flat.replace(/(?!^)\s(\d\.\s)/g, "
$1") : flat; }; out.push(`
${ text.split(/\n{2,}/).map(p => `

${para(p)}

`).join("")}
`); continue; } // Lists, ordered and not. A continuation line is indented. const li = /^(\s*)([-*]|\d+\.)\s+(.*)$/.exec(line); if (li) { const ordered = /\d/.test(li[2]); const items = []; while (i < lines.length) { const m = /^(\s*)([-*]|\d+\.)\s+(.*)$/.exec(lines[i]); if (m) { items.push(m[3]); i++; continue; } if (/^\s+\S/.test(lines[i]) && items.length) { items[items.length - 1] += " " + lines[i].trim(); i++; continue; } break; } out.push(`<${ordered ? "ol" : "ul"}>${items.map(t => `
  • ${inline(t)}
  • `).join("")}`); continue; } // Paragraph: run on until a blank line or a block starts. const buf = []; while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#{1,4}\s|>|\||---+\s*$)/.test(lines[i]) && !/^(\s*)([-*]|\d+\.)\s+/.test(lines[i])) { buf.push(lines[i].trim()); i++; } if (buf.length) out.push(`

    ${inline(buf.join(" "))}

    `); } return out.join("\n"); } /* ── The page ─────────────────────────────────────────────────────────────── */ const md = readFileSync(path.resolve(ROOT, SRC), "utf8"); const title = (/^#\s+(.*)$/m.exec(md)?.[1] ?? path.basename(SRC)).replace(/\*/g, ""); const short = title.split(":").pop().trim(); /* A cover plate if the scenario has one. The art stems are the scenario's initials — STANDING_ORDER is so_, CLEAN GROUND cg_, THROUGH TRAIN tt_ — so build the prefix that way rather than from the first word, which gave "st" and silently found nothing. */ const stem = path.basename(SRC, ".md").toLowerCase().split(/[_\s-]+/).map(w => w[0]).join(""); const covers = readdirSync(path.join(ROOT, "art", "scenes")) .filter(f => f.startsWith(`${stem}_01`) && f.endsWith(".webp")).sort(); const cover = covers.length ? path.join(ROOT, "art", "scenes", covers[0]) : null; if (!cover) console.warn(`make-pdf: no cover plate matching art/scenes/${stem}_01*.webp`); const html = ` ${esc(title)}
    ${cover ? `` : ""}

    ${esc(short)}

    ${esc(title)}
    ${render(md.split("\n## ")[0].split("\n").filter(l => /^>/.test(l)) .map(l => l.replace(/^>\s?/, "")).join(" ")).replace(/<\/?p>/g, "")}
    The CustodiansGM document — private convention play ${new Date().toISOString().slice(0, 10)}
    ${render(md.replace(/^#\s+.*$/m, "").replace(/^>[\s\S]*?(?=\n##\s)/m, ""))} `; mkdirSync(path.dirname(OUT), { recursive: true }); const htmlPath = OUT.replace(/\.pdf$/, ".html"); writeFileSync(htmlPath, html); execFileSync(CHROME, ["--headless", "--disable-gpu", "--no-pdf-header-footer", `--print-to-pdf=${OUT}`, `file://${htmlPath}`], { stdio: "pipe" }); if (!argv.includes("--keep-html")) execFileSync("/bin/rm", ["-f", htmlPath]); console.log(`make-pdf: wrote ${path.relative(ROOT, OUT)}`);