Branded PDF documents
A document that goes to a bank, an investor or a paying client is judged on its appearance before a word of it is read. This skill turns a markdown document into a PDF with a cover page, a contents list, running headers, page numbers and tables that do not break across pages, using a script that needs no installation beyond a browser you already have.
The rule that matters: write the content as markdown, control the appearance with the brand file, never hand build the layout. Every hour spent nudging a text box is an hour not spent on the content, and the content is the only part the reader remembers.
The script
node scripts/render_pdf.mjs document.md
node scripts/render_pdf.mjs document.md --out=proposal.pdf --brand=brand.json
node scripts/render_pdf.mjs document.md --html
Needs Node 18 or newer and Chrome or Edge installed. There is no npm install. The markdown parser and the browser client are both inside the script.
--html stops after writing the intermediate HTML instead of rendering. Use it when the pagination is wrong: open that file in a browser, press print, and you see exactly what the renderer sees, which is much faster than rendering a PDF to find out where a page broke.
Why a browser and not a PDF library. A real document needs a cover, running headers, page numbers, repeated table headers and controlled page breaks. Chrome’s print engine does all of that from CSS you can read and edit. A PDF library means drawing every box by hand, and the output looks like it.
The document
Frontmatter at the very top of the file drives the cover page. Leave it out and there is no cover, which is right for an internal memo and wrong for anything sent outside the business.
---
title: Business plan
subtitle: FY26 to FY28
client: Northside Electrical
author: Dana Reyes
date: March 2026
confidential: true
---
Two markers work in the body:
| Marker | Effect |
|---|---|
<!-- toc --> | A contents list built from the ## and ### headings |
<!-- pagebreak --> | Forces a new page. \newpage on its own line is the same |
The supported markdown is a deliberate subset. What is in it, and what silently is not, is in references/markdown-supported.md. Anything outside the subset passes through as a paragraph rather than failing, so a document never quietly loses text.
The brand file
One JSON file sets the colours, fonts, logo, page size and margins. Copy assets/brand.example.json, change the values, and every document rendered with it matches.
node scripts/render_pdf.mjs plan.md --brand=acme-brand.json
The logo path inside the brand file is resolved relative to the brand file, not to wherever you happen to be standing. That way a brand folder moves as one piece.
Do not edit assets/document.css to change a colour. The colours and fonts are injected from the brand file as CSS variables, and editing the stylesheet is how one customer’s document ends up with another customer’s blue. Edit the stylesheet only to change structural things, and see references/page-setup.md before you do.
Rules for documents that get sent out
- Set the margins once and leave them. The script sets the page margins. The stylesheet deliberately does not set
@pagemargins, because Chrome applies both to the same box and a document with both set has mysteriously doubled margins - Right align every column of numbers. Use
|---:|in the table delimiter row. A column of currency that is left aligned reads as amateur from across a room - Never let a heading sit alone at the foot of a page. The stylesheet already handles this. If you add headings of your own, carry the
break-after: avoid-pagewith them - Check the last page. A single orphaned line on a final page is the most common flaw in a generated document, and the fix is usually cutting two sentences, not changing the CSS
- Name the file the way the recipient will file it.
northside-electrical-proposal-2026-03.pdf, notdocument.pdforfinal_v3_FINAL.pdf - Render once, read the PDF, then send. The PDF is the artefact. Reading the markdown and assuming is how a broken table reaches a client
What it will not do
Said plainly, because finding out late is expensive:
- No page numbers in the contents list. Knowing what page a heading landed on needs a second render pass. A contents list that is confidently wrong is worse than one that is honestly a list of sections
- No charts. Generate the chart as an image and reference it with
. The renderer will embed it - No footnotes, no cross references, no index
- No editable output. If the recipient needs to edit it, send them the markdown or a Word file. A PDF is a final artefact
- No Word or PowerPoint output
Troubleshooting for the things that actually go wrong, blank images and mysterious extra pages included, is in references/troubleshooting.md.
What this is good for
Anything that has to leave the business as a document rather than as an email:
| Document | The part this earns its keep on |
|---|---|
| A business plan or funding pack | Financial tables that hold together, and a cover a bank expects |
| A proposal or statement of work | Looking like a business, before a word is read |
| A quote | A branded document beats the same number in an email body, reliably |
| A cash flow forecast | Right aligned columns, and a table header that repeats across pages |
| A report, audit or review | A contents list, and page numbers somebody can refer to on a call |
The script is self contained on purpose. Copy scripts/render_pdf.mjs, assets/document.css and a brand file into any project that needs them and they work as they stand, with no installation and nothing else to fetch.
Reference files
Everything the skill tells your AI to read, exactly as it ships in the zip.
references/markdown-supported.md 3.0 KB
# What the markdown parser supports
The parser inside `render_pdf.mjs` is a deliberate subset, written to be readable in one sitting rather than to be CommonMark. Anything it does not recognise falls through as a paragraph, so text is never lost, it is only rendered plainly.
## Supported
| Syntax | Notes |
|---|---|
| `#` through `######` | `##` and `###` are the ones that appear in the contents list |
| Paragraphs | Consecutive lines join into one paragraph |
| `**bold**`, `*italic*`, `~~strike~~` | |
| Backtick code spans | Parsed before emphasis, so a code span is never re-parsed |
| Fenced code blocks | No syntax highlighting. Long lines wrap rather than running off the page |
| `-`, `*`, `+` lists | One level of nesting, indented two spaces or more |
| `1.` lists | A start number other than 1 is honoured |
| `- [ ]` and `- [x]` | Rendered as real checkboxes, ticked ones in the accent colour |
| `>` blockquote | Parsed recursively, so a list inside a quote works |
| Pipe tables | GFM style. The delimiter row is required |
| `---` on its own line | Horizontal rule. In the first position it is frontmatter instead |
| `[text](url)` | |
| `` | A paragraph containing only an image becomes a figure with the alt text as its caption |
## Table alignment
The delimiter row sets it, and this is the single most useful thing in the whole parser:
```
| Line | FY26 | FY27 |
|---------------|----------:|----------:|
| Revenue | 3,400,000 | 4,100,000 |
| Gross profit | 1,054,000 | 1,435,000 |
```
| Delimiter | Alignment |
|---|---|
| Three dashes | Left |
| Three dashes then a colon | Right. Use it for every number |
| Colon, dashes, colon | Centre |
Right aligned cells also get tabular figures, so the digits line up in a column even in a proportional font.
## Not supported
Listed so you find out here rather than in a rendered PDF:
- **Reference style links**, the `[text][ref]` form
- **Inline HTML.** It is escaped and shown as text, which is deliberate: a document assembled partly from a customer's own words should not be able to inject markup into your template
- **Footnotes**, definition lists, abbreviations
- **Nesting past one level.** A sub-sub-list flattens into the sub-list
- **Setext headings**, the underlined style
- **Hard line breaks** from two trailing spaces. Start a new paragraph instead
- **Autolinks.** Write the link out in full bracket and parenthesis form, not as a bare URL
- **Syntax highlighting** in code fences. The language tag is accepted and ignored
## Frontmatter
Only `key: value` on single lines. No nesting, no lists, no multi line values. Quotes around a value are stripped, so a quoted and an unquoted title are the same thing.
Keys the cover page uses: `title`, `subtitle`, `client`, `author`, `company`, `date`, `version`, `confidential`. Any other key is parsed and ignored, which makes it a safe place to keep notes to yourself.
Without a `title` there is no cover page at all. That is the switch: internal documents leave it out, anything sent outside the business puts it in.
references/page-setup.md 3.5 KB
# Page setup, breaks and the stylesheet
## Where the page size actually comes from
The script sets the paper size and margins through the browser's print call. The stylesheet deliberately sets **no** `@page` rule.
This matters because Chrome applies a print call's margins and an `@page` margin to the same box. Setting both is the standard cause of a document whose margins are suddenly twice as wide as intended, and because each is individually correct, it takes a long time to spot. If you need different margins, change them in the brand file:
```
"margin": { "top": "22mm", "bottom": "20mm", "left": "18mm", "right": "18mm" }
```
Values may be `mm`, `cm`, `in` or `pt`. A bare number is read as millimetres.
Page sizes the script knows: `A4`, `Letter`, `Legal`, `A5`. A4 is the default. Use `Letter` for a US audience, because a document that prints with cropped margins on the recipient's own printer undoes everything else you did.
## Headers and footers
The running header and footer are separate mini documents rendered by the browser's print engine. They **cannot see the page stylesheet**. That is a Chrome rule, not a choice in this script, and it has consequences:
- Their styling has to be inline, which is why the script builds them as a string
- Their font sizes must be absolute. Relative units resolve against nothing
- An image in them must be a data URI, because relative paths do not resolve there
The header carries the brand name on the left and the document title on the right. The footer carries `footerNote` from the brand file on the left and the page number on the right. Chrome substitutes these class names into the templates: `pageNumber`, `totalPages`, `title`, `url`, `date`.
To show "Page 3 of 12", put both spans in the footer template, the first with class `pageNumber` and the second with class `totalPages`.
## Controlling breaks
| Want | Do |
|---|---|
| A new page here | A pagebreak comment on its own line |
| A table that does not split | Already the default for rows. A whole long table may still split, which is correct |
| The table header repeated on each page | Already the default |
| No heading stranded at the foot of a page | Already the default |
| This section always starts a page | Add a CSS rule for that heading in an extra stylesheet |
Orphans and widows are set to 3, so a paragraph will not leave a single line behind at a page break.
## Editing the stylesheet
Do it for structure. Do not do it for brand colours or fonts, which come from the brand file as CSS variables injected above the stylesheet at render time.
The variables available to any rule you add:
```
var(--accent) var(--ink) var(--muted) var(--rule)
var(--font-heading) var(--font-body) var(--font-mono)
```
If one document must differ, do not fork the stylesheet. Pass an extra file:
```
node scripts/render_pdf.mjs plan.md --brand=brand.json --css=this-one-document.css
```
It is appended after the main stylesheet, so ordinary cascade rules apply and you only write the difference.
## Fonts
The brand file takes CSS font stacks, and a local render has no access to a web font service. A named font has to be installed on the machine doing the rendering, which means the same document rendered on two machines can paginate differently.
If the document must look identical everywhere, render it once and distribute the PDF, which is the normal case anyway. If you are rendering on a server, install the fonts there or stay on the defaults, which resolve to something sensible on every platform.
references/troubleshooting.md 3.8 KB
# When the PDF comes out wrong
Work in this order. `--html` is the first move for almost everything: it writes the intermediate HTML and stops, and opening that in a browser and pressing print shows you exactly what the renderer sees.
```
node scripts/render_pdf.mjs document.md --html
```
## "No Chrome or Edge found"
The script looks in the standard install locations for Chrome, Chromium and Edge on Windows, macOS and Linux. The error lists every path it tried. On a server, install one:
```
apt-get install -y chromium
```
Then add its path to the `BROWSERS` array at the top of the script.
## Images are blank boxes
Almost always a path problem, and it is worth knowing exactly how paths resolve:
- **Image paths in the markdown** resolve relative to the markdown file. This is why the intermediate HTML is written next to the source and not in a temp directory
- **The logo path in the brand file** resolves relative to the brand file
- **An image in a header or footer template** must be a data URI. A file path there will not load
If the path is right and the box is still blank, the image is probably still decoding. The script waits for the document to be complete and for every image to report itself finished, but a very large image on a slow disk can exceed the 15 second ceiling, at which point it warns and prints anyway. Shrink the image. A 4000 pixel photo in a document printed 180mm wide is wasted bytes in every sense.
## Everything lands on one enormous page
Something in the document has a fixed height larger than the page, usually a pasted element with an inline style, or an image taller than the printable area. The stylesheet caps image width at 100% but cannot cap a height it does not know about.
## Mysterious blank pages
Three usual causes:
1. **A pagebreak immediately before a heading that already starts a page.** The break fires, then the heading's own rule fires
2. **A trailing pagebreak at the end of the document.** It produces a final empty page
3. **The contents list.** It already breaks after itself. A manual break after the toc marker gives you two
## The margins doubled
An `@page` rule was added to the stylesheet. Chrome applies it on top of the print call's margins. Remove the `@page` rule and set margins in the brand file instead. See [page-setup.md](page-setup.md).
## A table row split across a page
Rows are set to avoid breaking. A single row taller than a page cannot honour that, and there is nothing to be done except shorten the cell. A long table splitting across pages is correct, and the header repeats on each page automatically.
## The numbers do not line up
The column is not right aligned. Put a colon at the end of that column's delimiter in the table's delimiter row. Right aligned cells also get tabular figures, which is what makes a column of currency look typeset rather than typed.
## Text appears where markup should be
The parser is a subset. Reference style links, inline HTML, footnotes and bare URLs all render as literal text by design. The full list is in [markdown-supported.md](markdown-supported.md).
Inline HTML being escaped is deliberate, not a gap: a document assembled partly from a customer's own words must not be able to inject markup into your branded template.
## The render hangs
The script has a 15 second ceiling on page readiness and a 20 second ceiling on the browser starting. If it sits longer than that, the browser process itself is stuck. Kill it, and check the temporary profile directory is writable, because a browser that cannot write its profile will start and then do nothing.
## Exit codes
| Code | Meaning |
|---|---|
| 0 | Rendered |
| 1 | Usage error, or a file that does not exist |
| 2 | The browser failed to start, or the render itself failed |
On code 2 the intermediate HTML is deliberately left on disk and its path is printed. Open it.
scripts/render_pdf.mjs 20.4 KB
/**
* Render a markdown document to a branded, print-quality PDF.
*
* node render_pdf.mjs plan.md
* node render_pdf.mjs plan.md --out=business-plan.pdf --brand=brand.json
* node render_pdf.mjs plan.md --html keep the intermediate HTML and stop
*
* Needs Node 18+ and Chrome or Edge installed. No npm install: the markdown parser
* and the CDP client below are the whole dependency list.
*
* Why Chrome and not a PDF library: a real business document needs a cover page,
* running headers and footers, page numbers, controlled page breaks and tables that
* do not split a row across a page. Chrome's print engine does all of that from CSS
* you can read, and the output is the same shape a designer would expect. A PDF
* library would mean drawing every box by hand.
*
* The intermediate HTML is written NEXT TO the source markdown, not in a temp
* directory, so relative image paths in the markdown resolve the way the author
* wrote them. It is deleted afterwards unless --html is passed.
*
* Frontmatter (optional, must be the very first thing in the file) drives the cover:
*
* ---
* title: Business plan
* subtitle: FY26 to FY28
* client: Northside Electrical
* author: Dana Reyes
* date: March 2026
* confidential: true
* ---
*
* Body markers:
* <!-- toc --> replaced by a contents list built from the h2 and h3 headings
* <!-- pagebreak --> forces a new page. \newpage on its own line does the same
*
* Exit codes: 0 rendered, 1 usage or input error, 2 browser or render failure.
*/
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, extname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const srcArg = argv.find((a) => !a.startsWith("--"));
const flag = (name) => argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
const htmlOnly = argv.includes("--html");
if (!srcArg) {
console.error("Usage: node render_pdf.mjs <document.md> [--out=file.pdf] [--brand=brand.json] [--css=extra.css] [--html]");
process.exit(1);
}
const srcPath = resolve(srcArg);
if (!existsSync(srcPath)) {
console.error(`No such file: ${srcPath}`);
process.exit(1);
}
const srcDir = dirname(srcPath);
const outPath = resolve(flag("out") ?? join(srcDir, `${basename(srcPath, extname(srcPath))}.pdf`));
const BROWSERS = [
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
];
const DEFAULT_BRAND = {
name: "",
accent: "#0B6EE8",
ink: "#12161C",
muted: "#5A6472",
rule: "#DCE1E8",
headingFont: "Georgia, 'Times New Roman', serif",
bodyFont: "system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif",
monoFont: "'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
logo: null,
footerNote: "",
pageSize: "A4",
margin: { top: "22mm", bottom: "20mm", left: "18mm", right: "18mm" },
};
/* ------------------------------------------------------------------ markdown */
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
/** Inline spans. Code first, so a backtick span is never re-parsed for emphasis. */
function inline(text) {
const code = [];
let s = text.replace(/`([^`]+)`/g, (_, c) => `\u0000${code.push(`<code>${esc(c)}</code>`) - 1}\u0000`);
// Author notes are not content. Code spans were lifted out above, so a comment
// deliberately being SHOWN inside backticks survives this.
s = s.replace(/<!--[\s\S]*?-->/g, "");
s = esc(s);
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g,
(_, alt, src, title) => `<img src="${src}" alt="${alt}"${title ? ` title="${title}"` : ""}>`);
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2">$1</a>');
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/(^|[^*])\*([^*]+)\*/g, "$1<em>$2</em>");
s = s.replace(/~~([^~]+)~~/g, "<del>$1</del>");
s = s.replace(/\u0000(\d+)\u0000/g, (_, i) => code[Number(i)]);
return s;
}
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
/** Pull `key: value` frontmatter if the file opens with a --- fence. */
function frontmatter(raw) {
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!m) return { meta: {}, body: raw };
const meta = {};
for (const line of m[1].split(/\r?\n/)) {
const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
if (kv) meta[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, "");
}
return { meta, body: raw.slice(m[0].length) };
}
/**
* Block parser. A deliberate subset: headings, paragraphs, fenced code, pipe tables,
* ordered and unordered lists with one level of nesting, blockquotes, rules, images.
* Anything outside the subset passes through as a paragraph rather than failing, so a
* document never silently loses text.
*/
function blocks(md) {
const lines = md.replace(/\r\n/g, "\n").split("\n");
const out = [];
const headings = [];
let i = 0;
/** A sublist belongs INSIDE the <li> it hangs off, not after it. */
const nest = (item, sub) => item.replace(/<\/li>$/, `<ul>${sub.join("")}</ul></li>`);
const listItem = (text) => {
const task = text.match(/^\[([ xX])\]\s+(.*)$/);
if (!task) return `<li>${inline(text)}</li>`;
const done = task[1].toLowerCase() === "x";
return `<li class="task"><span class="box${done ? " on" : ""}"></span>${inline(task[2])}</li>`;
};
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) { i++; continue; }
if (/^(<!--\s*pagebreak\s*-->|\\newpage)\s*$/i.test(line.trim())) {
out.push('<div class="pagebreak"></div>'); i++; continue;
}
if (/^<!--\s*toc\s*-->$/i.test(line.trim())) { out.push("\u0001TOC\u0001"); i++; continue; }
// An author note, possibly spanning several lines. Templates are full of these
// and none of them belong in the rendered document.
if (line.trimStart().startsWith("<!--")) {
while (i < lines.length && !lines[i].includes("-->")) i++;
i++;
continue;
}
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) {
const level = h[1].length;
const text = h[2].trim();
const id = slug(text);
if (level === 2 || level === 3) headings.push({ level, text, id });
out.push(`<h${level} id="${id}">${inline(text)}</h${level}>`);
i++; continue;
}
const fence = line.match(/^```\s*([\w-]*)\s*$/);
if (fence) {
const buf = [];
i++;
while (i < lines.length && !/^```\s*$/.test(lines[i])) buf.push(lines[i++]);
i++;
out.push(`<pre class="code"><code>${esc(buf.join("\n"))}</code></pre>`);
continue;
}
if (/^(\*\*\*|---|___)\s*$/.test(line.trim())) { out.push("<hr>"); i++; continue; }
// Pipe table. Needs the delimiter row, otherwise it is just text with pipes in it.
if (line.includes("|") && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(lines[i + 1] ?? "")) {
const cells = (r) => r.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
const head = cells(line);
const align = cells(lines[i + 1]).map((d) =>
d.startsWith(":") && d.endsWith(":") ? "center" : d.endsWith(":") ? "right" : "left");
i += 2;
const body = [];
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) body.push(cells(lines[i++]));
const th = head.map((c, n) => `<th style="text-align:${align[n] ?? "left"}">${inline(c)}</th>`).join("");
const tr = body.map((r) =>
`<tr>${r.map((c, n) => `<td style="text-align:${align[n] ?? "left"}">${inline(c)}</td>`).join("")}</tr>`).join("");
out.push(`<table><thead><tr>${th}</tr></thead><tbody>${tr}</tbody></table>`);
continue;
}
if (/^\s*>/.test(line)) {
const buf = [];
while (i < lines.length && /^\s*>/.test(lines[i])) buf.push(lines[i++].replace(/^\s*>\s?/, ""));
out.push(`<blockquote>${blocks(buf.join("\n")).html}</blockquote>`);
continue;
}
const bullet = line.match(/^(\s*)([-*+])\s+(.*)$/);
const number = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/);
if (bullet || number) {
const ordered = Boolean(number);
const tag = ordered ? "ol" : "ul";
const start = ordered ? Number(number[2]) : 1;
const items = [];
let nested = null;
while (i < lines.length) {
const b = lines[i].match(/^(\s*)([-*+])\s+(.*)$/);
const n = lines[i].match(/^(\s*)(\d+)[.)]\s+(.*)$/);
const m = b || n;
if (!m) {
// A plain indented line continues the item it follows.
if (items.length && /^\s{2,}\S/.test(lines[i])) { items[items.length - 1] += ` ${inline(lines[i].trim())}`; i++; continue; }
break;
}
if (Boolean(n) !== ordered && m[1].length === 0) break;
if (m[1].length >= 2) {
nested ??= [];
nested.push(listItem(m[3]));
i++; continue;
}
if (nested) { items[items.length - 1] = nest(items[items.length - 1], nested); nested = null; }
items.push(listItem(m[3]));
i++;
}
if (nested && items.length) items[items.length - 1] = nest(items[items.length - 1], nested);
out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ""}>${items.join("")}</${tag}>`);
continue;
}
const para = [];
while (i < lines.length && lines[i].trim() && !/^(#{1,6}\s|```|\s*>|\s*[-*+]\s|\s*\d+[.)]\s)/.test(lines[i])) {
para.push(lines[i++]);
}
const joined = para.join(" ").trim();
if (!joined) { i++; continue; }
// A paragraph that is nothing but an image gets to be a figure, not a text line.
const lone = joined.match(/^!\[([^\]]*)\]\(([^)\s]+)\)$/);
out.push(lone
? `<figure><img src="${lone[2]}" alt="${lone[1]}">${lone[1] ? `<figcaption>${inline(lone[1])}</figcaption>` : ""}</figure>`
: `<p>${inline(joined)}</p>`);
}
return { html: out.join("\n"), headings };
}
/* ---------------------------------------------------------------- assembling */
function coverPage(meta, brand) {
if (!meta.title) return "";
const logo = brand.logo ? `<img class="cover-logo" src="${brand.logo}" alt="">` : "";
const rows = [
meta.client && ["Prepared for", meta.client],
meta.author && ["Prepared by", meta.author],
(meta.company || brand.name) && ["Company", meta.company || brand.name],
meta.date && ["Date", meta.date],
meta.version && ["Version", meta.version],
].filter(Boolean);
return `<section class="cover">
${logo}
<div class="cover-body">
<h1 class="cover-title">${inline(meta.title)}</h1>
${meta.subtitle ? `<p class="cover-subtitle">${inline(meta.subtitle)}</p>` : ""}
${rows.length ? `<dl class="cover-meta">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${inline(v)}</dd>`).join("")}</dl>` : ""}
${String(meta.confidential).toLowerCase() === "true" ? '<p class="cover-confidential">Commercial in confidence</p>' : ""}
</div>
</section>
<div class="pagebreak"></div>`;
}
/**
* A contents list, without page numbers. Page numbers would need a second render to
* find out what page each heading landed on, and a contents list that is confidently
* wrong is worse than one that is honestly a list of sections.
*/
function toc(headings) {
if (!headings.length) return "";
const items = headings.map((h) =>
`<li class="toc-h${h.level}"><a href="#${h.id}">${inline(h.text)}</a></li>`).join("");
return `<nav class="toc"><h2 class="toc-title">Contents</h2><ol>${items}</ol></nav>`;
}
async function loadBrand() {
const path = flag("brand");
if (!path) return DEFAULT_BRAND;
let parsed;
try {
parsed = JSON.parse(await readFile(resolve(path), "utf8"));
} catch (e) {
console.error(`Could not read brand file ${path}: ${e.message}`);
process.exit(1);
}
const brand = { ...DEFAULT_BRAND, ...parsed, margin: { ...DEFAULT_BRAND.margin, ...(parsed.margin ?? {}) } };
// A logo path in the brand file is relative to the brand file, not the cwd.
if (brand.logo && !/^(https?:|data:)/.test(brand.logo)) {
brand.logo = pathToFileURL(resolve(dirname(resolve(path)), brand.logo)).href;
}
return brand;
}
async function readCss(brand) {
const bundled = join(HERE, "..", "assets", "document.css");
let css = existsSync(bundled) ? await readFile(bundled, "utf8") : "";
if (!css) console.warn("assets/document.css not found next to the script. Rendering with browser defaults.");
const extra = flag("css");
if (extra) css += `\n${await readFile(resolve(extra), "utf8")}`;
const vars = `:root{
--accent:${brand.accent};--ink:${brand.ink};--muted:${brand.muted};--rule:${brand.rule};
--font-heading:${brand.headingFont};--font-body:${brand.bodyFont};--font-mono:${brand.monoFont};
}`;
return `${vars}\n${css}`;
}
/* ------------------------------------------------------------------- browser */
class Cdp {
#ws; #id = 0; #pending = new Map();
static async connect(url) {
const c = new Cdp();
c.#ws = new WebSocket(url);
await new Promise((res, rej) => {
c.#ws.onopen = res;
c.#ws.onerror = () => rej(new Error("CDP websocket failed to open"));
});
c.#ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
const p = c.#pending.get(msg.id);
if (!p) return; // an event, not a reply
c.#pending.delete(msg.id);
msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result);
};
return c;
}
send(method, params = {}, sessionId) {
const id = ++this.#id;
return new Promise((resolve, reject) => {
this.#pending.set(id, { resolve, reject });
this.#ws.send(JSON.stringify({ id, method, params, sessionId }));
});
}
close() { try { this.#ws.close(); } catch { /* already gone */ } }
}
async function launch(port) {
const exe = BROWSERS.find((p) => existsSync(p));
if (!exe) throw new Error(`No Chrome or Edge found. Looked in:\n ${BROWSERS.join("\n ")}`);
const profile = await mkdtemp(join(tmpdir(), "render-pdf-"));
const proc = spawn(exe, [
"--headless=new", `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`,
"--no-first-run", "--no-default-browser-check", "--disable-extensions",
"--force-color-profile=srgb", "--allow-file-access-from-files", "about:blank",
]);
proc.on("error", (e) => { console.error("Failed to launch browser:", e.message); process.exit(2); });
const deadline = Date.now() + 20000;
let wsUrl;
while (Date.now() < deadline && !wsUrl) {
try {
const res = await fetch(`http://127.0.0.1:${port}/json/version`);
if (res.ok) wsUrl = (await res.json()).webSocketDebuggerUrl;
} catch { /* not up yet */ }
if (!wsUrl) await new Promise((r) => setTimeout(r, 120));
}
if (!wsUrl) throw new Error(`DevTools never came up on :${port}`);
const cdp = await Cdp.connect(wsUrl);
return {
cdp,
async dispose() { cdp.close(); proc.kill(); await rm(profile, { recursive: true, force: true }).catch(() => {}); },
};
}
const PAGE_SIZES = { // inches, because printToPDF takes inches
A4: [8.27, 11.69],
Letter: [8.5, 11],
Legal: [8.5, 14],
A5: [5.83, 8.27],
};
const mmToIn = (v) => {
const n = parseFloat(v);
if (/mm$/i.test(v)) return n / 25.4;
if (/cm$/i.test(v)) return n / 2.54;
if (/in$/i.test(v)) return n;
if (/pt$/i.test(v)) return n / 72;
return n / 25.4; // bare numbers are millimetres
};
/* ---------------------------------------------------------------------- main */
const raw = await readFile(srcPath, "utf8");
const { meta, body } = frontmatter(raw);
const brand = await loadBrand();
const parsed = blocks(body);
const html = parsed.html.replace("\u0001TOC\u0001", toc(parsed.headings));
const css = await readCss(brand);
const docTitle = meta.title || basename(srcPath, extname(srcPath));
const page = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${esc(docTitle)}</title>
<style>${css}</style></head>
<body class="doc">
${coverPage(meta, brand)}
<main>${html}</main>
</body></html>`;
const htmlPath = join(srcDir, `.${basename(srcPath, extname(srcPath))}.render.html`);
await writeFile(htmlPath, page, "utf8");
if (htmlOnly) {
console.log(`HTML written to ${htmlPath}`);
console.log("Open it in a browser and use Print to preview pagination before rendering the PDF.");
process.exit(0);
}
const [pw, ph] = PAGE_SIZES[brand.pageSize] ?? PAGE_SIZES.A4;
// The header and footer are separate mini-documents with no access to the page CSS,
// so their styling is inline and their font sizes are absolute. This is a Chrome rule,
// not a choice.
const chrome = (content) =>
`<div style="width:100%;font-size:8px;font-family:${brand.bodyFont.replace(/"/g, "'")};color:${brand.muted};
padding:0 ${brand.margin.left} 0 ${brand.margin.right};display:flex;justify-content:space-between;">${content}</div>`;
let browser;
try {
browser = await launch(9339);
const { cdp } = browser;
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
await cdp.send("Page.enable", {}, sessionId);
await cdp.send("Runtime.enable", {}, sessionId);
await cdp.send("Page.navigate", { url: pathToFileURL(htmlPath).href }, sessionId);
// The Cdp client above drops events on the floor, so readiness is polled rather than
// awaited. Poll for the real condition: the document is complete AND every image has
// finished, because a half decoded image prints as a blank box with no error anywhere.
// `complete` goes true when an image finishes loading OR fails. Waiting for
// naturalWidth as well would hang the full 15s on every broken path, which is the
// opposite of useful: a missing logo should be reported instantly, not waited on.
const ready = `document.readyState === "complete" &&
Array.from(document.images).every((i) => i.complete)`;
const deadline = Date.now() + 15000;
for (;;) {
const { result } = await cdp.send("Runtime.evaluate", { expression: ready, returnByValue: true }, sessionId);
if (result.value === true) break;
if (Date.now() > deadline) { console.warn("Page never settled after 15s. Printing it as it stands."); break; }
await new Promise((r) => setTimeout(r, 100));
}
// Name anything that failed to load. A blank box in a PDF with no warning anywhere
// is the single most common way a branded document goes out looking broken.
const { result: broken } = await cdp.send("Runtime.evaluate", {
expression: `JSON.stringify(Array.from(document.images)
.filter((i) => i.naturalWidth === 0 && i.getAttribute("src"))
.map((i) => i.getAttribute("src")))`,
returnByValue: true,
}, sessionId);
for (const src of JSON.parse(broken.value || "[]")) {
console.warn(`Image did not load, it will be blank in the PDF: ${decodeURI(src)}`);
}
// Fonts lay out after load and shift the pagination if printed too early.
await cdp.send("Runtime.evaluate", { expression: "document.fonts.ready", awaitPromise: true }, sessionId).catch(() => {});
const { data } = await cdp.send("Page.printToPDF", {
printBackground: true,
preferCSSPageSize: false,
paperWidth: pw,
paperHeight: ph,
marginTop: mmToIn(brand.margin.top),
marginBottom: mmToIn(brand.margin.bottom),
marginLeft: mmToIn(brand.margin.left),
marginRight: mmToIn(brand.margin.right),
displayHeaderFooter: true,
headerTemplate: chrome(`<span>${esc(brand.name || "")}</span><span>${esc(docTitle)}</span>`),
footerTemplate: chrome(
`<span>${esc(brand.footerNote || "")}</span><span class="pageNumber"></span>`),
}, sessionId);
await writeFile(outPath, Buffer.from(data, "base64"));
console.log(`Wrote ${outPath}`);
if (parsed.headings.length) console.log(`${parsed.headings.length} headings, cover ${meta.title ? "on" : "off"}`);
} catch (e) {
console.error(`Render failed: ${e.message}`);
console.error(`The intermediate HTML is at ${htmlPath}. Open it in a browser to see what the page actually looks like.`);
process.exitCode = 2;
} finally {
await browser?.dispose();
if (!process.exitCode) await rm(htmlPath, { force: true }).catch(() => {});
}
assets/brand.example.json 621 B
{
"name": "Northside Electrical",
"accent": "#0B6EE8",
"ink": "#12161C",
"muted": "#5A6472",
"rule": "#DCE1E8",
"headingFont": "Georgia, 'Times New Roman', serif",
"bodyFont": "system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif",
"monoFont": "'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
"_logo": "a path relative to THIS file, e.g. logo.png. Rename to \"logo\" to use it",
"logo": null,
"footerNote": "Northside Electrical Pty Ltd | Commercial in confidence",
"pageSize": "A4",
"margin": { "top": "22mm", "bottom": "20mm", "left": "18mm", "right": "18mm" }
}
assets/document.css 5.7 KB
/*
* Print stylesheet for render_pdf.mjs.
*
* The script injects a :root block above this file with --accent, --ink, --muted,
* --rule and the three font stacks, taken from the brand file. Everything here is
* expressed in those variables, so rebranding a document means editing brand.json
* and not this file.
*
* Page size and margins are set by the script through printToPDF, not by @page.
* Chrome applies printToPDF margins and @page margins to the same box, and having
* both set is the usual cause of a document whose margins mysteriously double.
*/
* { box-sizing: border-box; }
body.doc {
margin: 0;
font-family: var(--font-body);
font-size: 10.5pt;
line-height: 1.55;
color: var(--ink);
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* ------------------------------------------------------------------- cover */
.cover {
display: flex;
flex-direction: column;
justify-content: center;
/* Fills the printable box. 297mm less the 22mm and 20mm the script sets. */
min-height: 250mm;
}
.cover-logo { max-height: 18mm; max-width: 70mm; margin-bottom: 16mm; }
.cover-title {
font-family: var(--font-heading);
font-size: 34pt;
line-height: 1.12;
font-weight: 700;
margin: 0 0 4mm;
color: var(--ink);
}
.cover-subtitle {
font-size: 13pt;
color: var(--muted);
margin: 0 0 14mm;
font-weight: 400;
}
.cover-body::before {
content: "";
display: block;
width: 28mm;
height: 3pt;
background: var(--accent);
margin-bottom: 10mm;
}
.cover-meta {
display: grid;
grid-template-columns: 34mm 1fr;
gap: 2mm 6mm;
margin: 0;
font-size: 10pt;
}
.cover-meta dt { color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; font-size: 8pt; padding-top: 1pt; }
.cover-meta dd { margin: 0; font-weight: 600; }
.cover-confidential {
margin-top: 16mm;
font-size: 8pt;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
/* --------------------------------------------------------------- headings */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
color: var(--ink);
line-height: 1.25;
/* Never leave a heading alone at the foot of a page. */
break-after: avoid-page;
page-break-after: avoid;
break-inside: avoid-page;
}
main h1 { font-size: 22pt; margin: 0 0 6mm; }
h2 {
font-size: 15pt;
margin: 10mm 0 3mm;
padding-bottom: 2mm;
border-bottom: 0.7pt solid var(--rule);
}
h3 { font-size: 12pt; margin: 7mm 0 2mm; }
h4 { font-size: 10.5pt; margin: 5mm 0 1.5mm; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
main > h2:first-child, main > h1:first-child { margin-top: 0; }
/* ------------------------------------------------------------------- text */
p { margin: 0 0 3.2mm; orphans: 3; widows: 3; }
a { color: var(--accent); text-decoration: none; }
strong { font-weight: 650; }
ul, ol { margin: 0 0 3.2mm; padding-left: 6mm; }
li { margin-bottom: 1.4mm; break-inside: avoid; }
li > ul, li > ol { margin-top: 1.4mm; }
li.task { list-style: none; margin-left: -5mm; display: flex; gap: 2.5mm; align-items: baseline; }
li.task .box {
flex: 0 0 auto;
width: 3mm; height: 3mm;
border: 0.7pt solid var(--muted);
border-radius: 0.6mm;
}
li.task .box.on { background: var(--accent); border-color: var(--accent); }
blockquote {
margin: 0 0 3.2mm;
padding: 1mm 0 1mm 5mm;
border-left: 2pt solid var(--accent);
color: var(--muted);
break-inside: avoid;
}
blockquote p:last-child { margin-bottom: 0; }
hr { border: 0; border-top: 0.7pt solid var(--rule); margin: 7mm 0; }
code {
font-family: var(--font-mono);
font-size: 0.88em;
background: #F3F5F8;
padding: 0.3mm 1mm;
border-radius: 0.8mm;
}
pre.code {
font-family: var(--font-mono);
font-size: 8.5pt;
line-height: 1.45;
background: #F3F5F8;
border: 0.7pt solid var(--rule);
border-radius: 1.5mm;
padding: 3mm 4mm;
margin: 0 0 3.2mm;
white-space: pre-wrap; /* a long line wraps rather than being cut off the page */
word-break: break-word;
break-inside: avoid;
}
pre.code code { background: none; padding: 0; font-size: inherit; }
/* ----------------------------------------------------------------- tables */
table {
width: 100%;
border-collapse: collapse;
margin: 0 0 4mm;
font-size: 9.5pt;
break-inside: auto;
}
thead { display: table-header-group; } /* repeat the header on every page */
tr { break-inside: avoid; page-break-inside: avoid; }
th {
text-align: left;
font-weight: 650;
font-size: 8pt;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
border-bottom: 1pt solid var(--ink);
padding: 2mm 2.5mm;
}
td { padding: 2mm 2.5mm; border-bottom: 0.5pt solid var(--rule); vertical-align: top; }
tbody tr:nth-child(even) { background: #FAFBFC; }
/* Right align a column of numbers by putting :--- in the markdown delimiter row. */
td[style*="right"], th[style*="right"] { font-variant-numeric: tabular-nums; }
/* ---------------------------------------------------------------- figures */
figure { margin: 0 0 4mm; break-inside: avoid; text-align: center; }
img { max-width: 100%; }
figcaption { font-size: 8.5pt; color: var(--muted); margin-top: 1.5mm; }
/* -------------------------------------------------------------- contents */
.toc { break-after: page; page-break-after: always; }
.toc-title { border: 0; margin-top: 0; }
.toc ol { list-style: none; padding: 0; }
.toc li { margin-bottom: 2mm; }
.toc a { color: var(--ink); }
.toc-h3 { padding-left: 6mm; font-size: 9.5pt; color: var(--muted); }
.toc-h3 a { color: var(--muted); }
/* ----------------------------------------------------------------- breaks */
.pagebreak { break-after: page; page-break-after: always; height: 0; }
/* Put class="keep" on a heading to start its section on a fresh page. */
.keep { break-before: page; page-break-before: always; }