diff --git a/docs/print/STANDING_ORDER.pdf b/docs/print/STANDING_ORDER.pdf new file mode 100644 index 0000000..9903e82 Binary files /dev/null and b/docs/print/STANDING_ORDER.pdf differ diff --git a/package.json b/package.json index cb6c724..de21597 100644 --- a/package.json +++ b/package.json @@ -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)", diff --git a/packs/weapons/000006.log b/packs/weapons/000010.log similarity index 100% rename from packs/weapons/000006.log rename to packs/weapons/000010.log diff --git a/packs/weapons/CURRENT b/packs/weapons/CURRENT index cacca75..6ba31a3 100644 --- a/packs/weapons/CURRENT +++ b/packs/weapons/CURRENT @@ -1 +1 @@ -MANIFEST-000004 +MANIFEST-000009 diff --git a/packs/weapons/MANIFEST-000004 b/packs/weapons/MANIFEST-000004 deleted file mode 100644 index e0cdc92..0000000 Binary files a/packs/weapons/MANIFEST-000004 and /dev/null differ diff --git a/packs/weapons/MANIFEST-000009 b/packs/weapons/MANIFEST-000009 new file mode 100644 index 0000000..035a569 Binary files /dev/null and b/packs/weapons/MANIFEST-000009 differ diff --git a/tools/make-pdf.mjs b/tools/make-pdf.mjs new file mode 100644 index 0000000..1aaf17c --- /dev/null +++ b/tools/make-pdf.mjs @@ -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 ] [--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 [--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, ">"); + +/** 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)}`);