make-pdf: print a scenario as an A4 GM document

Chrome headless is the only print engine on these machines, and its page
rules are real: one section per page, tables and read-aloud blocks never
split, cite markers dropped, and the scenario's own 01 plate as a cover.
npm run pdf docs/scenarios/<FILE>.md writes docs/print/<FILE>.pdf.

Named make-pdf rather than scenario-pdf on purpose: check-scenarios treats
every tools/scenario-* file as scenario text, so the first name swept a
stylesheet's point sizes into the figures corpus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-23 19:09:52 +01:00
co-authored by Claude Opus 5
parent 0fa1106b90
commit 7973b0ed7d
7 changed files with 211 additions and 3 deletions
Binary file not shown.
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "ringbrp",
"version": "0.9.0",
"description": "The Custodians — a Basic Roleplaying game of crossings, for Foundry VTT",
"description": "The Custodians \u2014 a Basic Roleplaying game of crossings, for Foundry VTT",
"main": "ringbrp.mjs",
"scripts": {
"build": "node tools/build-packs.mjs && node tools/update-readme.mjs",
@@ -14,7 +14,8 @@
"check": "bun tools/check-seam.mjs && bun tools/check-generator.mjs && bun tools/check-forge.mjs && bun tools/check-armed.mjs && bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-outcomes.mjs && bun tools/check-creatures.mjs && bun tools/check-powers.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-firstblood.mjs && bun tools/check-attackers.mjs && bun tools/check-fight-tail.mjs && bun tools/check-packs.mjs && bun tools/detention-tables.mjs --check && bun tools/bellhouse-plans.mjs --check && bun tools/check-cited.mjs && bun tools/check-figures.mjs && bun tools/check-handouts.mjs && bun tools/check-bestiary.mjs",
"test": "bun run check",
"readme": "bun tools/update-readme.mjs",
"tokens": "python3 tools/make-tokens.py"
"tokens": "python3 tools/make-tokens.py",
"pdf": "node tools/make-pdf.mjs"
},
"keywords": [],
"author": "Tim Evans (slaguru666)",
+1 -1
View File
@@ -1 +1 @@
MANIFEST-000004
MANIFEST-000009
Binary file not shown.
Binary file not shown.
+207
View File
@@ -0,0 +1,207 @@
/**
* 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 <path.pdf>] [--keep-html]
*/
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 <scenario.md> [--out file.pdf]"); process.exit(1); }
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
/** Inline: **bold**, *italic*, `code`, and the em dashes already in the source. */
function inline(s) {
return esc(s)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*\*([^*]+)\*\*\*/g, "<strong><em>$1</em></strong>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>");
}
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(/<!--[\s\S]*?-->/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('<hr class="rule"/>'); i++; continue; }
const h = /^(#{1,4})\s+(.*)$/.exec(line);
if (h) {
const n = h[1].length;
out.push(`<h${n}>${inline(h[2])}</h${n}>`);
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(`<table><thead><tr>${head.map(c => `<th>${c}</th>`).join("")}</tr></thead><tbody>`
+ body.map(r => `<tr>${r.map(c => `<td>${c}</td>`).join("")}</tr>`).join("")
+ "</tbody></table>");
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, "<br/>$1") : flat;
};
out.push(`<blockquote class="${warn ? "warn" : "aloud"}">${
text.split(/\n{2,}/).map(p => `<p>${para(p)}</p>`).join("")}</blockquote>`);
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 => `<li>${inline(t)}</li>`).join("")}</${ordered ? "ol" : "ul"}>`);
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(`<p>${inline(buf.join(" "))}</p>`);
}
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 = `<!DOCTYPE html>
<html lang="en-GB"><head><meta charset="utf-8"><title>${esc(title)}</title>
<style>
@page { size: A4; margin: 18mm 16mm 20mm; }
@page :first { margin: 0; }
html { font-family: Georgia, "Times New Roman", serif; font-size: 10.5pt; color: #1b1b1b; }
body { margin: 0; line-height: 1.45; }
h1, h2, h3, h4 { font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; line-height: 1.2; }
h1 { display: none; } /* the cover carries the title */
h2 { font-size: 15pt; letter-spacing: .04em; text-transform: uppercase;
border-bottom: 2pt solid #7a1f1f; padding-bottom: 2mm; margin: 0 0 4mm;
break-before: page; break-after: avoid; }
h3 { font-size: 12pt; margin: 6mm 0 2mm; break-after: avoid; }
h4 { font-size: 10.5pt; margin: 4mm 0 1mm; break-after: avoid; }
p { margin: 0 0 2.4mm; orphans: 3; widows: 3; }
ul, ol { margin: 0 0 3mm; padding-left: 6mm; }
li { margin-bottom: 1.2mm; }
code { font-family: "SF Mono", Menlo, monospace; font-size: 9pt; background: #f0ece2; padding: 0 1mm; }
hr.rule { border: 0; border-top: .5pt solid #bbb; margin: 5mm 0; }
table { border-collapse: collapse; width: 100%; font-size: 9.5pt; margin: 0 0 4mm; break-inside: avoid; }
th, td { border: .5pt solid #b9b2a4; padding: 1.4mm 2mm; text-align: left; vertical-align: top; }
th { background: #efe3c4; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 9pt; letter-spacing: .04em; }
blockquote { margin: 0 0 4mm; padding: 2.5mm 4mm; break-inside: avoid; }
blockquote.aloud { background: #f6f1e4; border-left: 3pt solid #7a1f1f; font-style: italic; }
blockquote.warn { background: #fdf2f2; border-left: 3pt solid #a33; }
blockquote p { margin: 0 0 1.5mm; }
blockquote p:last-child { margin-bottom: 0; }
/* Handouts read as paper, not as quotations. */
h3 + p + blockquote.aloud, h3 + blockquote.aloud {
font-style: normal; font-family: "Courier New", Courier, monospace; font-size: 9.5pt;
background: #fbfaf5; border: .8pt solid #9a917f; border-left-width: .8pt;
}
.cover { position: relative; height: 297mm; break-after: page; color: #1b1b1b; }
.cover img { width: 100%; height: 170mm; object-fit: cover; display: block; filter: saturate(.92); }
.cover .plate { padding: 18mm 20mm 0; }
.cover h1.show { display: block; font-size: 26pt; letter-spacing: .06em; text-transform: uppercase;
margin: 0 0 3mm; }
.cover .sub { font-size: 11pt; font-style: italic; color: #444; margin-bottom: 6mm; }
.cover .tag { border-top: 2.2pt solid #7a1f1f; padding-top: 4mm; font-size: 11pt; font-style: italic; }
.cover .foot { position: absolute; bottom: 14mm; left: 20mm; right: 20mm;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 8.5pt;
letter-spacing: .1em; text-transform: uppercase; color: #777;
display: flex; justify-content: space-between; }
</style></head><body>
<section class="cover">
${cover ? `<img src="file://${cover}" alt=""/>` : ""}
<div class="plate">
<h1 class="show">${esc(short)}</h1>
<div class="sub">${esc(title)}</div>
<div class="tag">${render(md.split("\n## ")[0].split("\n").filter(l => /^>/.test(l))
.map(l => l.replace(/^>\s?/, "")).join(" ")).replace(/<\/?p>/g, "")}</div>
</div>
<div class="foot"><span>The Custodians</span><span>GM document — private convention play</span>
<span>${new Date().toISOString().slice(0, 10)}</span></div>
</section>
${render(md.replace(/^#\s+.*$/m, "").replace(/^>[\s\S]*?(?=\n##\s)/m, ""))}
</body></html>`;
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)}`);