DEV Community

asrocky010
asrocky010

Posted on

Generating printable PDFs from plain JS data files (five gotchas)

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, "..");
Enter fullscreen mode Exit fullscreen mode

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)), "..");
Enter fullscreen mode Exit fullscreen mode

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 },
];
Enter fullscreen mode Exit fullscreen mode

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, "]"));
}
Enter fullscreen mode Exit fullscreen mode

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*\]/g will 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
Enter fullscreen mode Exit fullscreen mode

--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; }
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • Make sure the host serves application/pdf and not application/octet-stream, or browsers download instead of previewing.
  • Put the PDFs in your sitemap. They are indexable documents with their own queries.
  • Use the download attribute 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)