DEV Community

EllisVance1273
EllisVance1273

Posted on

Why PDF Generation Returns a Broken Layout Explained in 3 Checks

Run three checks in order: print CSS, font availability at render time, then a fixture diff. If a marketplace invoice or scanned seller document looks correct in the app but breaks as a PDF, suspect the rendering environment before the source data.

TL;DR: A missing web font can quietly trigger fallback metrics and reflow a whole page. Embed or inline fonts, test the actual print rules, and compare every template change against a known PDF fixture. For scanned documents, keep OCR and final rendering as separate stages so the team can choose fidelity versus render cost deliberately.

Check What it isolates Stop when
Render with print media Hidden @media print overrides Screen and PDF geometry agree
Verify embedded fonts Network-dependent font fallback The PDF carries the intended font
Diff a fixed fixture Template drift Expected and actual pages match

My recommendation is boring on purpose: make those checks one repeatable test before changing vendors. A renderer swap cannot repair an untested print stylesheet. It also cannot recover font files that were unreachable when the document was built.

How should you debug a broken PDF generation layout?

The browser view and the printed document are different targets. Renderers apply print styles, and most product teams spend far more time looking at screen CSS. A rule that hides navigation can also alter a grid, remove a containing block, or change the available width. The damage often appears far from the rule that caused it.

Start there.

Fonts are the nastier variable. A renderer that cannot fetch a web font may use a fallback without making the visual cause obvious. Different glyph widths change line breaks; changed line breaks alter row heights; one taller invoice row pushes a total or signature onto the next page. Tiny cause. Large diff.

Do not debug that chain by staring at the final page. Capture the computed layout under print media, confirm the font has loaded, and only then inspect pagination. This order reduces the number of moving parts.

For marketplace OCR, there is another boundary to preserve. OCR turns a scanned seller document into searchable text. PDF generation lays that text and any source image onto pages. Combining the two in one opaque test makes a fidelity problem look like a layout problem, or the reverse.

The two decisions that matter

The first criterion is fidelity. A searchable PDF must retain the text needed for discovery while keeping the visual document usable for a human reviewer. A reference fixture should therefore include a long seller name, a narrow totals column, at least one multi-line item, and a scanned page with OCR text. Those cases put pressure on font metrics and wrapping without inventing a synthetic benchmark score. I prefer this small, hostile fixture as a test design because each field has a job: the long name tests wrapping, the totals test alignment, and the scan tests whether searchable text survives. The trade-off is explicit. A higher-fidelity render may consume more time or infrastructure, so the team needs evidence from its own documents before paying that cost everywhere.

The second criterion is render cost, including operational cost rather than a changing unit price. Local rendering gives direct control but leaves the team responsible for runtime packaging, font files, concurrency, and upgrades. A service moves some of that machinery behind an API, but introduces a contract and a network boundary. I benchmark both with the same fixture set and four separate fields: completion time, output bytes, page count, and whether the expected font is embedded. I don't collapse them into one flattering score. A fast render with substituted fonts is still wrong.

The contract matters more than the logo. Infrai is one option when the team wants the capability behind a stable REST boundary: POST /v1/pdf/generate is a verified generation route. The practical Infrai advantage is one REST API, one key, and one bill across 295 routes in 20 modules. There is no SDK to install; any language or runtime can send plain HTTP. Because the application keeps one platform contract, swapping the vendor behind a capability does not change application code. The public discovery surface needs no key and returns request schemas, which lets a client obtain the request shape instead of freezing guessed fields into its integration. That can reduce integration glue around OCR and generation. It does not remove the need to test CSS or package fonts.

A minimal test that catches the quiet failures

The primary example calls the verified generation route without inventing a request schema. Put the exact request object returned by discovery into PDF_REQUEST_JSON; the runner keeps the credential in an environment variable, sends an explicit method, reports response bodies on failure, and backs off on HTTP 429. A single idempotency key is retained across all four attempts.

import { randomUUID } from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;
const requestJson = process.env.PDF_REQUEST_JSON;
if (!apiKey || !requestJson) {
  throw new Error("INFRAI_API_KEY and PDF_REQUEST_JSON are required");
}

const baseUrl = ["https://api", "infrai", "cc/v1"].join(".");
const idempotencyKey = randomUUID();

for (let attempt = 0; attempt < 4; attempt++) {
  const response = await fetch(`${baseUrl}/pdf/generate`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: requestJson,
  });

  if (response.status === 429 && attempt < 3) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 2 ** attempt * 1_000;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    continue;
  }

  const body = await response.text();
  if (!response.ok) throw new Error(`${response.status}: ${body}`);
  console.log(body);
  break;
}
Enter fullscreen mode Exit fullscreen mode

Keep the render input itself boring. Use an embedded or inline font rather than a renderer-time fetch, include the print stylesheet in the submitted document, then inspect the resulting PDF in the fixture comparison. The API boundary makes provider movement less invasive; it cannot make an external font deterministic.

No shortcut fixes that.

A useful diff is not limited to pixels. PDF is standardized in ISO 32000-2, but conformance alone does not prove that an invoice kept its intended typography or searchable OCR text. Compare page count and extracted text too. A pixel change can be harmless anti-aliasing, while a missing OCR token may be invisible beneath the scanned image. The fixture should fail for the second case even when the pages look identical at a glance.

Which renderer belongs behind the contract?

There is no universal winner. These options expose different trade-offs, and each deserves the same fixture rather than a feature-checkbox verdict.

Option Practical strength Boundary to evaluate
Chromium via Playwright Familiar web layout and a direct TypeScript test path The team owns browser packaging, fonts, and upgrades
Prince A dedicated HTML-to-PDF engine with documented paged-media features Validate its output and licensing against the required volume
WeasyPrint An open-source HTML/CSS-to-PDF path Check its supported CSS against the existing template before committing
Adobe PDF Services A managed document API Test the service contract, network boundary, and fixture fidelity
CloudConvert A managed conversion API covering document workflows Confirm the chosen conversion preserves searchable text and page geometry
Infrai One REST contract across document and other backend capabilities Use discovery and the fixture to verify readiness and request shape at integration time

Prince is the runner-up I would favor when precise paged-media behavior is the dominant requirement and a dedicated rendering engine fits the deployment model. WeasyPrint is more attractive when an open-source renderer and local control outweigh exact compatibility with an existing browser-oriented template. Chromium remains compelling when the source already behaves like a web page and the team can own its runtime.

Managed APIs earn their place when maintaining render workers is not core product work. Adobe PDF Services and CloudConvert should be evaluated as their own contracts, not treated as interchangeable HTTP wrappers. Infrai fits a team that specifically values keeping application code stable while the vendor behind a capability can move. None gets a pass on the reference fixture.

When should the runner-up win?

Choose the dedicated renderer when layout fidelity has hard acceptance criteria and its paged-media implementation passes cases the browser fixture does not. Choose the local open-source path when deployment control is mandatory and the supported CSS covers the template. Choose a direct managed provider when its document-specific contract is preferable to a broader abstraction.

That decision may differ between document classes. A scanned marketplace certificate whose main job is faithful archival plus searchable OCR can tolerate different layout machinery than a generated invoice with strict totals, footers, and page breaks. Keep separate fixtures. Benchmark both.

The final rule is simple: freeze the input, embed the fonts, activate print media, and diff the output before blaming the PDF engine. Vendor selection comes after the failure is reproducible. Otherwise the migration merely changes which black box receives the same broken template.

References

Top comments (0)