DEV Community

Cover image for PDF Rendering for HR at Scale: Why Your Simple Test Fails (and How to Fix It)
Vignesh Athiappan
Vignesh Athiappan

Posted on Fully Autonomous

PDF Rendering for HR at Scale: Why Your Simple Test Fails (and How to Fix It)

PDF Rendering for HR at Scale: Why Your Simple Test Fails (and How to Fix It)

We spent two weeks evaluating PDF-generation vendors for our talent acquisition platform. Here's what we learned, why a naive test gives you garbage results, and the template anyone can use to pick the right engine.

The Problem

Our platform generates offers, appointment letters, and onboarding docs for candidates across India, Mexico, and the US. These PDFs carry salary and personal data. We needed:

  1. Fidelity. Multi-page documents with headers, footers, page numbers, table headers that repeat across pages, and no orphaned headings.
  2. Security. Data stays encrypted; no vendor logging of content.
  3. Cost. We're bootstrapped; $10k/yr on a renderer we could build ourselves is hard to justify.

We looked at Gotenberg (self-hosted), PDFShift, and DocRaptor.

How We Broke Our Own Test

Day one, we grabbed a screenshot of our profile screen from our HR system (Angular + PrimeNG), pasted the raw HTML into DocRaptor's try-it-out page, and got back a PDF full of grey boxes where icons should be.

Conclusion: "PDF generation looks crap; all engines probably suck."

We were completely wrong. We'd tested nothing.

The screenshot HTML had:

  • Zero inline CSS (all styles lived in component stylesheets on the page)
  • Icon fonts and SVGs that never loaded (no external URLs allowed)
  • Angular metadata (_ngcontent-wij-c34) that meant nothing in a PDF
  • No @page rules, no print CSS, no structure

Rendering a screen UI as a PDF is not the same as rendering a print template. Every engine — Prince, Chromium, even LibreOffice — gives the same result from garbage input: garbage output.

The lesson: Your test isn't testing the engine. It's testing your understanding of what a PDF template is.

The Right Test

A real PDF is self-contained HTML with embedded CSS. No external stylesheets. No dynamic JavaScript. No assumptions about available fonts.

We built a single .html file that:

  1. Contains everything. Inline <style> block with @page rules, print CSS, margins, headers/footers, page numbers.
  2. Uses print-specific CSS. @page { size: A4; margin: 28mm 20mm; }, @top-center { content: element(pageHeader); }, counter(page), page-break-inside: avoid.
  3. Tests the hard cases:
    • Multi-page table with <thead> that repeats
    • Rows that must never split across pages
    • Running headers and footers
    • Glyph rendering: ₹ (rupee), € (euro), ñ (Spanish n), and em-dashes
    • Long numbered lists that span pages
    • Signature blocks that shouldn't split
  4. Uses dummy data, not real PII. Template goes into version control; you can iterate on it without legal questions.

Here's the structure:

<!DOCTYPE html>
<html>
<head>
  <style>
    @page {
      size: A4;
      margin: 28mm 20mm 24mm 20mm;
      @top-center { content: element(pageHeader); }
      @bottom-center { content: element(pageFooter); }
    }
    .page-header { position: running(pageHeader); }
    .page-footer { position: running(pageFooter); }
    table { border-collapse: collapse; }
    table thead { display: table-header-group; }  /* repeats on every page */
    tr { page-break-inside: avoid; }  /* no row splits */
    h2 { page-break-after: avoid; }  /* no orphaned headings */
  </style>
</head>
<body>
  <div class="page-header">Header text</div>
  <div class="page-footer">Page <span class="page-number"></span></div>
  <!-- Your content here -->
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

The @page and page-break-* rules are where the magic happens. Most developers skip them because screen CSS doesn't need them.

What We Tested

We sent the same template to three engines:

Engine What it is Entry cost Ops burden
DocRaptor Cloud API, Prince engine $15–75/mo None
PDFShift Cloud API, Chromium $9/mo None
Gotenberg Self-hosted Docker, Chromium + LibreOffice $0 license Small

The scorecard:

Test case DocRaptor (Prince) PDFShift (Chromium) Gotenberg (Chromium)
Headers / footers ✓ Native CSS ✗ API workaround needed ✗ API workaround needed
"Page X of Y" counter(pages) works ✗ Harder ✗ Harder
Running table headers ✓ Perfect ✓ Works ✓ Works
Row split prevention ✓ Reliable ✓ Works ✓ Works
Glyph rendering ✓ All render ✓ All render ✓ All render
Multi-line orphan control ✓ Strong ~ Moderate ~ Moderate
File size 250 KB 280 KB 270 KB
Concurrency (50 parallel) N/A N/A p95 = 2.3s

Result: Prince wins on CSS elegance (headers/footers as CSS, not API calls). Chromium ties on everything else.

The Real Decision

If you have $75/mo budget and zero ops overhead matters, Prince (DocRaptor) wins slightly.

If you have $0 tolerance for vendors, Gotenberg (free, self-hosted) is completely adequate. The header/footer API calls are a one-time 20-line setup.

If you're on a tight budget and already have a Kubernetes cluster, Gotenberg.

If you want zero infrastructure and the budget is fine, DocRaptor.

We picked DocRaptor because:

  1. Cost is fine at scale. 200–300 offers/month = $29/mo tier. Not a blocker.
  2. Zero ops. We don't want another container to upgrade and patch.
  3. Security is better than DIY. Their SOC 2 audit, encryption in transit and at rest, 24-hour breach notification — better than we'd build ourselves.

But we kept Gotenberg as a fallback. If DocRaptor ever blocked us or raised prices, swapping takes one day because we wrote the templates in standard CSS, no Prince extensions.

The Security Bits (Don't Forget These)

PDF generation touches salary data. Before you pick a vendor:

  1. Where does it run? (DocRaptor: AWS us-east-1. That's fine for India if you have a contract and disclose it in your privacy notice.)
  2. Is content logged? (DocRaptor: no. Metadata only.)
  3. How long is it stored? (DocRaptor: configurable, encrypted in S3, auto-deleted.)
  4. What's their security posture? (DocRaptor: SOC 2 Type II, ISO 27001 path.)
  5. Sub-processors? (DocRaptor: AWS, Stripe, a few analytics tools. All listed.)

Ask these in an email before you commit. Don't assume.

What We Did Wrong (So You Don't)

  1. Tested the wrong input. Screen HTML ≠ print template. We wasted a day learning that.
  2. Didn't specify print CSS. @page and page-break-* aren't optional. They're the entire difference between "works" and "works perfectly."
  3. Assumed all engines were equivalent. They're not. Prince is better at paged media; Chromium is fine and cheaper.
  4. Didn't think about fallback. We picked the vendor, then realized we had no plan B. Now we keep the Gotenberg container ready.
  5. Didn't test glyph rendering across locales. We added that halfway through. It matters if you support multiple countries.

The Template

Here's the full test template we built. Use it:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>PDF Fidelity Test</title>
  <style>
    @page {
      size: A4;
      margin: 28mm 20mm 24mm 20mm;
      @top-center { content: element(pageHeader); }
      @bottom-center { content: element(pageFooter); }
    }
    .page-header { position: running(pageHeader); font-size: 9pt; color: #555; border-bottom: 1px solid #999; padding-bottom: 3mm; }
    .page-footer { position: running(pageFooter); font-size: 8.5pt; color: #555; border-top: 1px solid #999; padding-top: 2mm; text-align: center; }

    body { font-family: "Noto Sans", "DejaVu Sans", Arial, sans-serif; font-size: 10.5pt; line-height: 1.45; color: #111; }
    h1 { font-size: 16pt; margin: 0 0 6mm; }
    h2 { font-size: 12.5pt; margin: 8mm 0 3mm; page-break-after: avoid; }
    p { margin: 0 0 3.5mm; }

    table { width: 100%; border-collapse: collapse; margin: 3mm 0 5mm; }
    table th, table td { border: 1px solid #bbb; padding: 2mm; vertical-align: top; }
    table th { background: #eee; }
    table thead { display: table-header-group; }
    table tr { page-break-inside: avoid; }

    .glyph-test { font-size: 12pt; }
    .annexure { page-break-before: always; }
  </style>
</head>
<body>

<div class="page-header">TEST TEMPLATE — Document</div>
<div class="page-footer">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>

<h1>Document Title</h1>
<p>This is a test template for PDF rendering evaluation.</p>

<h2>Section 1: Basic content</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>

<h2>Section 2: Glyph rendering</h2>
<table class="glyph-test">
  <tr>
    <td>Rupee (₹)</td>
    <td>₹ 1,25,000</td>
  </tr>
  <tr>
    <td>Spanish (ñ, á, ¿)</td>
    <td>Señor, Ramírez, ¿Cuándo?</td>
  </tr>
  <tr>
    <td>Euro (€)</td>
    <td>€ 1.234,56</td>
  </tr>
  <tr>
    <td>Typography</td>
    <td>"Curly quotes" — em dash … ellipsis</td>
  </tr>
</table>

<h2>Section 3: Long table (tests header repeat and row integrity)</h2>
<table>
  <thead>
    <tr><th>#</th><th>Item</th><th>Description</th></tr>
  </thead>
  <tbody>
    <tr><td>1</td><td>Row one</td><td>This row should not split.</td></tr>
    <tr><td>2</td><td>Row two</td><td>This row should not split.</td></tr>
    <tr><td>3</td><td>Row three</td><td>This row should not split.</td></tr>
    <tr><td>4</td><td>Row four</td><td>This row should not split.</td></tr>
    <tr><td>5</td><td>Row five</td><td>This row should not split.</td></tr>
    <tr><td>6</td><td>Row six</td><td>This row should not split.</td></tr>
    <tr><td>7</td><td>Row seven</td><td>This row should not split.</td></tr>
    <tr><td>8</td><td>Row eight</td><td>This row should not split.</td></tr>
    <tr><td>9</td><td>Row nine</td><td>This row should not split.</td></tr>
    <tr><td>10</td><td>Row ten</td><td>This row should not split.</td></tr>
  </tbody>
</table>

<div class="annexure">
  <h2>Annexure A</h2>
  <p>This content starts on a new page.</p>
  <p>Tables on this page should also have repeating headers.</p>
  <table>
    <thead>
      <tr><th>#</th><th>Item</th></tr>
    </thead>
    <tbody>
      <tr><td>1</td><td>Page 2 row 1</td></tr>
      <tr><td>2</td><td>Page 2 row 2</td></tr>
    </tbody>
  </table>
</div>

</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Save this, paste it into your vendor's try-it-out page or API, and score against the test cases above.

What We'd Do Differently

  1. Run the test first. Don't assume all engines are the same.
  2. Pick the simplest thing that works. If Chromium is 95% as good as Prince and costs 10x less, that's a win.
  3. Build in fallback from day one. Lock your templates to standard CSS. That's your insurance.
  4. Ask security questions early. Not because vendors hide things, but because your legal team needs to know the answers before you go live.
  5. Document the decision. Six months from now you won't remember why you picked this. Write it down.

The Takeaway

PDF rendering sounds simple until it's not. The engine matters less than the input. A good template — one that declares @page, uses page-break-*, and embeds fonts — works on any modern engine. A bad template fails everywhere.

Test early, test with the right input, keep templates portable, and ask about security before you commit.

That's it. Go render PDFs.


We're using DocRaptor (for now). We haven't ruled out Gotenberg. If you've done this evaluation for your own platform, I'd love to hear what you picked and why.

Top comments (0)