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>
208 lines
10 KiB
JavaScript
208 lines
10 KiB
JavaScript
/**
|
|
* 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
|
|
/** 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)}`);
|