DEV Community

Alan
Alan

Posted on AI-assisted

Printing 72 vector PDFs from HTML and CSS with headless Chrome

A blank weekly planner has to be one page on US Letter (8.5×11 in) and on A4 (210×297 mm). A4 is narrower and taller, so a layout tuned for one sheet can spill onto a second page or leave a gap on the other.

MakeInfographics.ai offers its templates as free printable PDFs. There are 12 layouts, each in 3 color themes and 2 paper sizes, which makes 72 files. They come from a small Node script that writes HTML and asks Chrome to print it. The project has no PDF library: none in dependencies or devDependencies, and the script imports only Node built-ins and its own modules.

This post covers how the script is organized, how one layout fits both papers, and what its one-page check does and does not catch. I ran everything below on 2026-10-26 with Chrome 155 on macOS (Apple M4 Pro).

Four pieces

scripts/printables/
  registry.mjs   which layouts exist, plus the theme and paper IDs
  base.mjs       page skeleton, CSS and small HTML helpers
  layouts/*.mjs  one file per layout (12 files)
  build.mjs      renders every combination with Chrome
Enter fullscreen mode Exit fullscreen mode

build.mjs is 158 lines, base.mjs is 114, and the 12 layouts add up to 325. The registry is a list plus two constants, so 12 × 3 × 2 = 72:

export const PRINTABLES = [
  { archetype: "weekly-meal-planner", templateId: "handdrawn-weekly-meal-planner", title: "Weekly Meal Planner", pinTheme: "sage" },
  { archetype: "study-schedule-planner", templateId: "playful-study-schedule-planner", title: "Weekly Study Schedule", pinTheme: "blush" },
  // ...10 more
];

export const THEME_IDS = ["mono", "sage", "blush"];
export const PAPER_IDS = ["letter", "a4"];
Enter fullscreen mode Exit fullscreen mode

A layout only says what is on the page. Here is the monthly budget planner, with some parts shortened:

import { box, field, table } from "../base.mjs";

const money = (labels) =>
  table({
    cols: "1.6fr 1fr 1fr",
    header: ["Category", "Planned", "Actual"],
    rows: labels.map((l) => [l, "", ""]),
  });

export default {
  title: "Monthly Budget Planner",
  subtitle: "Know where every dollar goes — planned versus actual.",
  meta: `${field("Month", "1.3in")}${field("Take-home income", "1.1in")}`,
  body: `
<div class="row" style="flex:1">
  <div class="col" style="flex:1">
    ${box("Income", money(["Salary", "Side income", "Other"]), "flex:0 0 auto;")}
    ${box("Fixed expenses", money([/* 8 labels */]), "flex:1")}
  </div>
  <div class="col" style="flex:1">
    ${box("Variable expenses", money([/* 8 labels */]), "flex:1")}
    <!-- savings goal box -->
  </div>
</div>
<!-- three summary boxes -->`,
};
Enter fullscreen mode Exit fullscreen mode

The layout contains no paper size, no colors and no page margins.

One sheet, sized to the paper

base.mjs owns the page. The two papers are two entries in a map:

export const PAPERS = {
  letter: { css: "8.5in 11in", width: "8.5in", height: "11in" },
  a4: { css: "210mm 297mm", width: "210mm", height: "297mm" },
};
Enter fullscreen mode Exit fullscreen mode

The stylesheet turns the chosen paper into an @page size and a fixed-size sheet:

@page { size: ${p.css}; margin: 0; }
:root { --ac:${t.ac}; --soft:${t.soft}; --line:${t.line}; --ink:${t.ink}; --mute:${t.mute}; --pw:${p.width}; --ph:${p.height}; }
.sheet { width: var(--pw); height: var(--ph); padding: 0.5in 0.55in 0.35in; display: flex; flex-direction: column; gap: 9px; overflow: hidden; background: #fff; }
.body { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 9px; }
.lines > i { flex: 1; min-height: 14px; border-bottom: 0.6pt solid var(--line); }
Enter fullscreen mode Exit fullscreen mode

The trick that lets one layout serve both papers is that rows do not have heights. Writing lines are flex: 1, boxes take a flex share, and the table helper gives its rows auto repeat(n, 1fr): a header row plus equal rows that split what is left. On Letter the body is about 11 in tall minus padding, on A4 about 297 mm minus padding, and the rows stretch to fit. All 72 files come out as one page.

A light palette

Each theme is a handful of colors:

mono: { ac: "#374151", soft: "#f3f4f6", line: "#b7bcc5", ink: "#111827", mute: "#6b7280", pinBg: "#eef0f3" },
Enter fullscreen mode Exit fullscreen mode

The accent color goes on the title, the rule under it, the small box headings and the table headers. Boxes, tables and writing lines use pale fills and hairlines between 0.6 and 0.75 pt, so a page has no large areas of solid color.

The stylesheet also sets -webkit-print-color-adjust: exact; print-color-adjust: exact;. MDN describes economy, the default, as letting a browser leave backgrounds out when printing, and exact as asking it not to. I tried removing the line: headless Chrome 155 printed the pale fills either way, and the two renders had identical pixel counts for the fill color. I kept it for other print paths, but I could not make it matter here.

Driving Chrome

build.mjs writes one HTML file per PDF into a temp folder and prints it through a file:// URL:

const chromeBase = [
  "--headless=new",
  "--disable-gpu",
  "--no-sandbox",
  "--hide-scrollbars",
  "--force-device-scale-factor=1",
  "--virtual-time-budget=15000",
  "--run-all-compositor-stages-before-draw",
];

function renderPdf(url, out) {
  fs.mkdirSync(path.dirname(out), { recursive: true });
  execFileSync(CHROME, [...chromeBase, "--no-pdf-header-footer", `--print-to-pdf=${out}`, url], { stdio: "pipe" });
}
Enter fullscreen mode Exit fullscreen mode

It is one Chrome process per file, run one after another. Generating all 72 took 74.9 seconds on my machine, about a second each.

The pages load Inter and DM Serif Display from Google Fonts when they are rendered, so the script needs a network connection and a local Chrome (CHROME_PATH overrides the default macOS path). I also tried removing two flags. Without --virtual-time-budget, the PDF embedded the same fonts. Without --no-pdf-header-footer, pdftotext found no header or footer text. I did not find a case where either mattered, but I only tested one Chrome version, so I am leaving them in.

What is inside the PDFs

I looked at the 72 committed files:

  • Page size. The 36 Letter files are 612 × 792 pt. The 36 A4 files are 594.96 × 841.92 pt, where exact A4 is 595.28 × 841.89 pt. The difference is about 0.1 mm. I did not look into why.
  • File size. 96 to 146 KB, 118 KB on average.
  • No bitmaps. I searched the raw bytes of all 72 files for /Subtype /Image and found none. pdftotext returns the layout's text, so it is real text and not outlines.
  • Fonts. pdffonts lists 852 font entries across the 72 files, all embedded and subset. DM Serif Display is a CID TrueType font; Inter comes out as Type 3. I did not look into why.

The one-page check, and what it cannot see

After each render the script counts pages by looking for page objects in the raw PDF bytes:

function pageCount(pdfFile) {
  const text = fs.readFileSync(pdfFile).toString("latin1");
  return (text.match(/\/Type\s*\/Page[^s]/g) || []).length;
}
Enter fullscreen mode Exit fullscreen mode

The [^s] matters. A PDF also has a page-tree node, /Type /Pages. In a one-page PDF I rendered the same way, /Type /Pages appears once, the pattern with [^s] matches once, and without [^s] it matches twice. If any file has a page count other than 1, the script reports it and exits with an error.

What does that catch? I tested two ways of making a layout too big. First, a block 14 in tall inside a Letter sheet, with the word TOPMARKER at its top edge and BOTTOMMARKER at its bottom edge:

  • The file still has 1 page.
  • pdftotext finds TOPMARKER and the footer, but not BOTTOMMARKER.
  • The same block at 8 in tall shows both markers.

Because .sheet has a fixed height and overflow: hidden, a layout that is too tall is cut off at the page edge. It does not spill onto page two. Second, when I replaced the .sheet rule with one that has no fixed height and no overflow: hidden, a 14 in block produced 2 pages, and the check fails.

So the check protects against the sheet becoming taller than the paper. It does not detect a layout that is too tall for its sheet: that content is clipped and the file still reports one page. The script's error message suggests looking for overflow, but this check cannot see it, and there is no check for clipping in the script today.

There is a second check in the Next.js build. A prebuild step runs a content linter that, for every template with a printable, confirms each PDF exists, starts with %PDF- and is at most 400 KB. It does not look at page count or content either.

Same HTML, other outputs

The preview images and the Pinterest pins use the same layouts. sheetHtml can return the CSS and the sheet separately, and a second function places the sheet on a 1000 × 1500 canvas:

const { css, sheet } = sheetHtml(layout, { theme, paper: "a4", templateId: item.templateId }, { wrap: false });
Enter fullscreen mode Exit fullscreen mode

Chrome takes a screenshot with --screenshot and --window-size=1000,1500, and cwebp -quiet -q 84 converts the preview to WebP. The pin variant adds a title and a brand bar, tilts the sheet by 3 degrees and uses the theme's background color.

What it does not do

  • It does not detect clipped content, as shown above.
  • It needs a local Chrome, a network connection for fonts and, for previews, cwebp.
  • It runs by hand with npm run printables; it is not part of the Next.js build.
  • I have not tested other Chrome versions or other operating systems.

If you have a cheap way to detect overflow in a print-to-PDF pipeline like this, I would like to hear it in the comments.

The files this script produces are the free downloads on pages such as the Monthly Budget Planner.

This article was drafted with help from an AI assistant and edited by me. The code excerpts come from the project's repository, and the measurements are from my own runs on 2026-10-26.

Top comments (0)