DEV Community

LachlanHolm6518
LachlanHolm6518

Posted on

Commerce PDF Generation Explained: 6 Font and Print CSS Diagnostics

Short answer: treat a broken monthly commerce PDF as a rendering-input problem, not a mysterious file-format problem. Freeze the exact HTML, print stylesheet, fonts, viewport, and renderer version for the failed job. Then compare those inputs with a known-good artifact in that order. The team that owns the template should also own those fixtures and visual checks. That single boundary makes layout failures much easier to reproduce.

A useful before/after mental model is small. Before: an application sends changing HTML to a renderer and keeps only the PDF. After: it creates a versioned render bundle, records what produced the PDF, validates the result, and archives both the PDF and its evidence. The PDF is the output. The bundle is the debugging unit.

How should I debug a broken PDF generation layout?

Screen layout and paged layout answer different questions. A browser viewport can grow and scroll; a page has a fixed box, margins, and break points. Print styles may also override the rules that looked correct during an on-screen preview. A report can therefore be valid HTML and still place a totals row on the next page, clip a wide invoice table, or leave a heading stranded at the bottom.

Paper is finite.

Fonts add a quieter failure mode. If the intended face is unavailable when layout runs, a fallback face can change glyph widths and line heights. One wrapped product name may add a row line; enough changed rows can move the summary onto another page. Loading a font after capture has begun creates the same class of surprise. Wait for the document font set to report readiness, and use font files whose deployment and embedding rights are understood.

Start with six checks: the physical page size, print margins, active @media print rules, loaded font faces, break behavior around tables and summaries, and the renderer build. Keep them concrete. A screenshot of the web page is useful context, but it does not prove which print rules or font bytes participated in pagination. This is also where template ownership matters. If a marketing repository owns the markup, a platform service owns rendering, and finance owns the archived result, nobody naturally owns the render fixture. Assign one team responsibility for template versions, font assets, representative data, and approval snapshots. Other teams can still contribute. The debugging contract stays clear.

Capture one reproducible render bundle

For an e-commerce monthly report, retain sanitized input that exercises the hard shapes: a long item name, a multi-line address, a zero-tax line, enough invoice rows to cross a page, and a totals block near that boundary. Do not use live customer data as a fixture. Stable synthetic records are easier to review and safer to retain.

The manifest below is intentionally generic. It creates a content-derived identifier for the inputs that should affect pagination. In a real pipeline, store the matching HTML, CSS, and permitted font files beside the manifest, then associate their location with the archived PDF.

import { createHash } from "node:crypto";

type RenderManifest = {
  templateVersion: string;
  rendererVersion: string;
  page: { format: "A4" | "Letter"; marginMm: number };
  locale: string;
  fontFiles: string[];
  fixtureId: string;
};

const manifest: RenderManifest = {
  templateVersion: "commerce-monthly-v6",
  rendererVersion: "pinned-by-deployment",
  page: { format: "A4", marginMm: 12 },
  locale: "en-US",
  fontFiles: ["ReportSans-Regular.woff2", "ReportSans-Bold.woff2"],
  fixtureId: "invoice-summary-page-boundary",
};

const renderId = createHash("sha256")
  .update(JSON.stringify(manifest))
  .digest("hex")
  .slice(0, 16);

console.log({ renderId, manifest });
Enter fullscreen mode Exit fullscreen mode

The identifier is not proof that two PDFs are visually equal. It answers a narrower and valuable question: did the declared rendering inputs change? Include the renderer version because layout behavior belongs to the executable environment as well as the template. Pinning that version makes a failed artifact reproducible while an upgrade is evaluated against fixtures.

Sixteen characters are enough for this fixture label, not for a security boundary.

Now make the stylesheet explicit about paper. Avoid relying on whatever defaults happen to exist in a developer laptop or worker image. The following string can live with the template and be tested as part of it.

export const printCss = `
  @page {
    size: A4;
    margin: 12mm;
  }

  @media print {
    html { print-color-adjust: exact; }
    table { width: 100%; border-collapse: collapse; }
    thead { display: table-header-group; }
    tr, .invoice-summary { break-inside: avoid; }
  }
`;
Enter fullscreen mode Exit fullscreen mode

break-inside: avoid is a request to the fragmentation algorithm, not permission to create an object taller than the page. If an invoice summary can exceed the printable area, redesign that component to split deliberately. No CSS declaration can fit unbounded content into a bounded box.

Debug in the order layout actually happens

First reproduce with the saved bundle. Confirm page format and margins before touching component CSS; an A4-versus-Letter mismatch contaminates every later observation. Next inspect which print declarations win in the computed style. A broad screen rule with higher specificity can survive into print, and a late print rule can undo a carefully tested default. Then verify fonts before tuning widths. Check that every declared face finished loading, that bold text maps to a real bold asset rather than synthetic styling, and that the same files are present in test and production. Only after those checks should you adjust table geometry, wrapping, and fragmentation. Otherwise a CSS tweak may compensate for fallback metrics and fail again when the intended font loads. Picture the boundary fixture: the last invoice row fits on page one with the intended face, the totals block follows, and the archive validator reports two pages. With a fallback face, one product name wraps, the row grows, and the totals move. Changing the table width might hide this specimen, but it leaves the font race untouched. That is why font readiness precedes geometry tuning.

Fonts come first.

Last, inspect the generated artifact structurally and visually. Parse it with an independent PDF reader, confirm the expected page count range for the fixture, and render pages to images for comparison. Exact binary equality is usually too strict because metadata and serialization can differ without changing appearance. A visual threshold, paired with targeted assertions such as "the invoice total appears exactly once," produces a more useful signal.

Keep the alert actionable. Emit one event per render with the render ID, template version, renderer version, duration, output byte count, page count, and validation result. Alert on sustained validation failures or an unexpected page-count shift for a stable fixture. Do not put customer names, addresses, or invoice contents in labels or logs. High-cardinality identifiers also belong in trace or event fields, not metric labels.

What should a deployment gate reject?

A template change should fail its gate when a representative fixture cannot load its required fonts, the document cannot be parsed, required text disappears, or the visual difference exceeds the reviewed tolerance. Snapshot approval must be deliberate: the owner examines the changed pages and updates the baseline in the same review as the template.

There is a trade-off. Pixel comparison catches subtle movement, but antialiasing can vary across environments. Run the golden test in a pinned container, compare rendered page images there, and keep semantic assertions alongside it. Semantic checks catch missing totals; images catch a border, wrap, or page break that moved. Neither replaces the other.

Do not approve blindly.

Use a small fixture matrix rather than dozens of near-duplicates: one ordinary month, one page-boundary invoice set, one long-text case, and one locale with different date and number widths. Four purposeful cases teach more than a large pile of happy-path snapshots. Production monitoring then covers the distribution that fixtures cannot enumerate.

Do we need to rewrite the template engine?

Usually, no. A broken layout often exposes uncontrolled inputs or a missing ownership boundary, not a fundamental flaw in template syntax. Reproduce the bundle, stabilize font loading and paper settings, and add the failing shape to the fixture matrix first. A rewrite changes many variables at once and discards the comparison point you need.

Consider a larger change only when the current owner cannot version templates and assets together, required print capabilities cannot be expressed, or the execution environment cannot be pinned and observed. Write those constraints down. The decision should follow from the report's pagination and archival requirements, not from frustration with one malformed run.

The durable design is pleasantly boring: versioned template in, deterministic render bundle through, validated PDF out, evidence archived beside it. Keep ownership close to the template and make every failed page reproducible. That is how monthly reports stop being a visual guessing game.

Further reading

Top comments (0)