Every static site eventually needs a PDF.
Mine needed three: a two-page reference sheet, a two-page table, and a fourteen-page practice paper with an answer key. The site itself is hand-written HTML with no build step, and its content lives in plain JS data files - the kind that are literally const ITEMS = [...] and get loaded by the browser directly.
So the constraint was: generate the PDFs from the exact same source of truth, in Node, without adopting a generator, a CMS, or a rendering API. wkhtmltopdf plus about 150 lines of Node was enough. These are the five things that cost me real time.
1. new URL(import.meta.url).pathname breaks on paths containing #
This is the code everyone writes first:
const ROOT = path.resolve(new URL(".", import.meta.url).pathname, "..");
It works on your machine, and it is wrong. URL.pathname is percent-encoded, not decoded. Any # in a directory name comes back as %23, and any non-ASCII character comes back as percent-encoded bytes. On macOS you then get a path that looks plausible in a log line and fails on readFileSync.
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
This was not hypothetical for me. The project lives under a directory called #Web_刷题网站 - a hash and CJK characters - which is close to a worst case for this bug.
2. Your "JS data file" is not JSON, and it never will be
The data files are valid JavaScript:
const ITEMS = [
{ "id": "a", "weight": 13 },
{ "id": "b", "weight": 14 },
];
JSON.parse chokes on the trailing comma. You have three options: import the module (and deal with ESM/CJS and side effects), regex the file before parsing, or generate proper JSON in the first place.
I went with a four-line loader, because these files are hand-maintained:
function loadArray(rel) {
const text = fs.readFileSync(path.join(ROOT, rel), "utf8");
const a = text.indexOf("[");
const b = text.lastIndexOf("]");
if (a < 0 || b < 0) throw new Error("cannot locate array in " + rel);
return JSON.parse(text.slice(a, b + 1).replace(/,\s*\]/g, "]"));
}
Two notes, both of which matter:
-
Slice between the outermost brackets, not from the start of the file. The files begin with
//comments; you want the data, not the header. -
Stripping trailing commas with a regex is a heuristic.
/,\s*\]/gwill corrupt a file that contains the literal characters]preceded by a comma inside a string value. I checked the data for that pattern and accepted the trade-off - but if you cannot make that guarantee, parse it properly instead of getting clever.
3. Page breaks are a CSS problem, not a tooling problem
wkhtmltopdf renders with Qt WebKit. The flags do most of the work:
wkhtmltopdf \
--print-media-type \
--page-size Letter \
--margin-top 15mm --margin-bottom 16mm \
--margin-left 14mm --margin-right 14mm \
--footer-left "example.com" --footer-right "[page]/[topage]" \
--footer-font-size 7 \
input.html output.pdf
--print-media-type makes @media print rules apply. The rest is obvious until you combine @page { margin: 0 } in your stylesheet with CLI margins, and get a layout that disagrees with itself.
The line that actually mattered was this one:
.q { page-break-inside: avoid; }
Without it, a 90-question paper cheerfully splits question stems from their answer options across a page boundary, and the document becomes much harder to read than it needs to be. The same line fixes repeated cards, table rows and figure blocks. Use page-break-before: always on a wrapper div to force an answer key onto a fresh page.
The rule of thumb: think in atomic blocks, then declare which blocks must not be split. The renderer is not going to guess.
4. Counting pages without pulling in a PDF library
I wanted a sanity check - did this produce 14 pages, or 1? - without adding a dependency. This works well enough:
const pageCount = (buf) =>
(buf.toString("latin1").match(/\/Type\s*\/Page[^s]/g) || []).length;
The [^s] is the whole trick. /Type /Pages is the page-tree node, and a naive /\/Type\s*\/Page/g counts it as a page. One character fixes it.
Be honest about what this is: a heuristic that works because wkhtmltopdf emits uncompressed object headers. It is a smoke test, not a parser. If you need real PDF introspection, use a real library.
5. Serving it is half the job
A PDF your generator can write but your host mislabels is not shipped. Three things:
curl -sI https://example.com/downloads/sheet.pdf | grep -i content-type
# content-type: application/pdf
- Make sure the host serves
application/pdfand notapplication/octet-stream, or browsers download instead of previewing. - Put the PDFs in your sitemap. They are indexable documents with their own queries.
- Use the
downloadattribute on the anchor if you want the file rather than a preview tab.
And one lesson that is about intent rather than plumbing, because it cost me weeks: if people are searching for a PDF and your page explains that no PDF exists, you have not answered the query. The page ranks, collects impressions, and gets no clicks. Generating the artifact is the fix.
The shape of the thing
The whole generator is one file: a function per document that returns an HTML string, a shared page(title, body) wrapper that inlines the CSS, and a loop that writes the HTML to pdf-src/ and shells out to wkhtmltopdf. It reads the same data files the site reads, so the PDF cannot drift from the site.
If a worked example is useful, the three artefacts it produced are the exam blueprint, the temperature reference sheet and the 90-question paper. All three are free and need no email.
The general point: generating documents from your existing data is a small job, and it fails in a handful of specific, learnable ways. None of them are the ones you expect. Two of mine were a percent-encoded directory name and a missing [^s].
Top comments (0)