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:
co-authored by
Claude Opus 5
parent
0fa1106b90
commit
7973b0ed7d
Binary file not shown.
+3
-2
@@ -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 @@
|
||||
MANIFEST-000004
|
||||
MANIFEST-000009
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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, "&").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)}`);
|
||||
Reference in New Issue
Block a user