When software engineers and designers build a resume in HTML and CSS, it looks razor-sharp in a browser viewport at 100% zoom—until they hit Cmd+P / Ctrl+P or run a headless PDF export. Exporting an HTML resume to PDF with its CSS print styles intact means bridging two fundamentally different rendering models: the continuous screen viewport (CSS Box Model + Flexbox/Grid) and fragmented paged media (W3C CSS Paged Media Module Level 3 and CSS Fragmentation Module Level 3).

When an HTML resume is exported to PDF without a print-hardened stylesheet, five concrete rendering failures occur inside the resulting PDF byte stream:

  1. Background Stripping (print-color-adjust: economy): Dark header banners (#0f172a) and skill pills (#e2e8f0) are stripped to pure white (1.0, 1.0, 1.0), while white heading text (#ffffff) is darkened only to washed-out light gray (#ababab, failing WCAG contrast at 2.30:1).
  2. Inline Link URL Duplication (a[href]::after): Legacy print resets append (mailto:...) and (https://...) directly into the visible text stream, wrapping single-line contact headers across three lines and polluting Applicant Tracking System (ATS) keyword extraction.
  3. @page Margin Collisions & file:// Path Leaks: Default browser print margins stack on top of body padding and stamp file:///tmp/resume.html and 1/2 into the top and bottom margin boxes, pushing the last two lines of a 1-page resume onto a second page.
  4. Mid-Role Block Slicing (break-inside): On 2-page senior or academic CVs, job entries split right between the role title at the bottom of Page 1 and the company name or bullet points at the top of Page 2.
  5. SPA Viewport Clipping (height: 100vh; overflow: hidden): Single-page application wrappers (h-screen overflow-hidden) lock the print container to a single page height and silently delete every element after 792 pt.
flowchart TD
    A["HTML + CSS Resume DOM"] --> B{"Conversion Engine Type?"}
    B -->|"Text-Only Converter<br/>(Regex HTML-to-Markdown + pdf-lib)"| C["Drops style & script Blocks and All CSS<br/>Keeps Headings, Lists & Text Only<br/>(Multi-Page A4 + Page X of Y Footer)"]
    B -->|"Chromium Blink Print Engine<br/>(Chrome CLI / Playwright page.pdf)"| D["Apply @page { size: letter; margin: 10mm 12mm }<br/>& --no-pdf-header-footer (CLI)<br/>or displayHeaderFooter: false (Playwright)"]
    D --> E["Enforce print-color-adjust: exact<br/>& Suppress a[href]::after"]
    E --> F["Reset height: auto; overflow: visible<br/>& Apply break-inside: avoid on .job-entry"]
    F --> G["Vector PDF Output (612 x 792 pt)<br/>Preserved #0f172a Fills + Native /URI Links<br/>+ Zero Mid-Role Splits"]

Reproducing the 4 Print CSS Failures on a Real HTML Resume

To measure exactly what happens inside the PDF object tree before and after fixing print CSS, save the following 1-page Staff Engineer resume as resume_broken.html. It uses a dark slate header (#0f172a), Flexbox job headers, .skill-pill badges (#e2e8f0), and the classic @media print { a[href]::after } rule found in standard CSS resets:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Alex Rivera - Staff Systems Engineer</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
      color: #1e293b;
      line-height: 1.42;
      font-size: 9.8pt;
      padding: 20px 24px;
    }
    @media print {
      a[href]::after {
        content: " (" attr(href) ")";
      }
    }
    .resume-header {
      background-color: #0f172a;
      color: #ffffff;
      padding: 18px 22px;
      border-radius: 6px;
      margin-bottom: 14px;
    }
    .resume-header h1 { font-size: 19pt; letter-spacing: -0.02em; }
    .resume-header p { color: #cbd5e1; font-size: 10pt; margin-top: 2px; }
    .contact-bar { margin-top: 6px; font-size: 8.8pt; }
    .contact-bar a { color: #38bdf8; text-decoration: none; margin-right: 12px; }
    .section-title {
      font-size: 10.5pt;
      text-transform: uppercase;
      letter-spacing: 0.06em;
      color: #0f172a;
      border-bottom: 1.5px solid #cbd5e1;
      padding-bottom: 3px;
      margin: 12px 0 7px;
    }
    .job-entry { margin-bottom: 10px; }
    .job-header { display: flex; justify-content: space-between; font-weight: 700; font-size: 10pt; }
    .job-sub { display: flex; justify-content: space-between; color: #475569; font-size: 9pt; margin-bottom: 3px; }
    ul { padding-left: 18px; }
    li { margin-bottom: 2.5px; }
    .skills-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 5px; }
    .skill-pill {
      background-color: #e2e8f0;
      color: #0f172a;
      padding: 2px 8px;
      border-radius: 4px;
      font-size: 8.5pt;
      font-weight: 600;
    }
  </style>
</head>
<body>
  <header class="resume-header">
    <h1>Alex Rivera</h1>
    <p>Staff Distributed Systems Engineer</p>
    <div class="contact-bar">
      <a href="mailto:alex.rivera@example.com">alex.rivera@example.com</a>
      <a href="https://github.com/arivera-sys">github.com/arivera-sys</a>
      <a href="https://linkedin.com/in/arivera-sys">linkedin.com/in/arivera-sys</a>
    </div>
  </header>

  <h2 class="section-title">Experience</h2>

  <article class="job-entry">
    <div class="job-header"><span>Principal Infrastructure Engineer</span><span>2023 - Present</span></div>
    <div class="job-sub"><span>Vanguard Cloud Platforms</span><span>San Francisco, CA</span></div>
    <ul>
      <li>Architected multi-region Raft consensus storage engine handling 1.4M write ops/sec at p99 latency under 8ms.</li>
      <li>Reduced cross-AZ egress spend by 38% (1.9M USD/yr) via zero-copy eBPF packet steering and Zstandard framing.</li>
      <li>Led migration of 420 microservices from legacy meshed proxies to kernel-level Cilium service routing.</li>
      <li>Authored automated chaos-testing harness verifying split-brain recovery across 18 production Kubernetes clusters.</li>
    </ul>
  </article>

  <article class="job-entry">
    <div class="job-header"><span>Senior Systems Software Engineer</span><span>2020 - 2023</span></div>
    <div class="job-sub"><span>Chronos Data Systems</span><span>Seattle, WA</span></div>
    <ul>
      <li>Designed lock-free ring buffer ingestion pipeline in Rust processing 65 GB/s of real-time telemetry events.</li>
      <li>Cut JVM garbage-collection tail pauses by 74% by moving hot index lookup tables into off-heap mmap arenas.</li>
      <li>Built deterministic simulation testing framework that uncovered 14 consensus race conditions before GA release.</li>
      <li>Mentored 9 engineers across storage, query execution, and observability teams; instituted RFC architecture reviews.</li>
    </ul>
  </article>

  <article class="job-entry">
    <div class="job-header"><span>Distributed Systems Engineer</span><span>2017 - 2020</span></div>
    <div class="job-sub"><span>Apex Financial Ledger</span><span>New York, NY</span></div>
    <ul>
      <li>Implemented idempotent settlement ledger in Go and PostgreSQL with serializable snapshot isolation guarantees.</li>
      <li>Automated blue-green database schema migrations across 64 sharded instances with zero customer downtime.</li>
      <li>Reduced container cold-start latency from 11.2s to 1.4s using lazy-loaded OCI image layers.</li>
      <li>Maintained 99.995% SLA across payment clearing gateways processing 14B USD in annual transaction volume.</li>
    </ul>
  </article>

  <article class="job-entry">
    <div class="job-header"><span>Software Engineer, Core Platform</span><span>2015 - 2017</span></div>
    <div class="job-sub"><span>Nimbus Telemetry</span><span>Austin, TX</span></div>
    <ul>
      <li>Built high-throughput gRPC ingestion proxy in Go supporting 250,000 concurrent persistent client streams.</li>
      <li>Implemented custom Prometheus metric exporter for Linux cgroup v2 memory pressure and NVMe I/O queue depths.</li>
      <li>Replaced nightly batch ETL jobs with incremental Kafka Streams aggregations, cutting reporting lag from 6h to 15s.</li>
    </ul>
  </article>

  <h2 class="section-title">Technical Skills</h2>
  <div class="skills-row">
    <span class="skill-pill">Rust</span>
    <span class="skill-pill">Go</span>
    <span class="skill-pill">C++20</span>
    <span class="skill-pill">Python</span>
    <span class="skill-pill">Linux eBPF</span>
    <span class="skill-pill">Kubernetes</span>
    <span class="skill-pill">PostgreSQL</span>
    <span class="skill-pill">Raft / Paxos</span>
  </div>

  <h2 class="section-title">Education</h2>
  <article class="job-entry">
    <div class="job-header"><span>B.S. in Computer Science</span><span>2011 - 2015</span></div>
    <div class="job-sub"><span>University of Texas at Austin</span><span>Austin, TX</span></div>
  </article>
</body>
</html>

Applying the Print-Hardened @page and @media print Fixes

Now create resume_fixed.html by appending the following @page and @media print rules inside the <style> block right before </style>:

    @page {
      size: letter;
      margin: 10mm 12mm;
    }
    @media print {
      html, body {
        -webkit-print-color-adjust: exact;
        print-color-adjust: exact;
        height: auto !important;
        min-height: 0 !important;
        overflow: visible !important;
        padding: 0;
      }
      a[href]::after {
        content: none !important;
      }
      .section-title {
        break-after: avoid;
        page-break-after: avoid;
      }
      .job-entry, .skills-row {
        break-inside: avoid;
        page-break-inside: avoid;
      }
    }

Each rule targets a specific stage of Chromium’s Blink print pipeline:

  • @page { size: letter; margin: 10mm 12mm; } + body { padding: 0; }: Locks the physical page box to US Letter (612.0 x 792.0 pt; use size: A4 for 595.28 x 841.89 pt) and eliminates double-stacked margins between @page and body.
  • -webkit-print-color-adjust: exact; print-color-adjust: exact;: Overrides CSS Color Adjustment Module Level 1 economy mode so dark headers (#0f172a) and .skill-pill backgrounds (#e2e8f0) are emitted as vector PDF fill operators (rg / re f) even when the user or headless driver leaves background printing disabled.
  • a[href]::after { content: none !important; }: Prevents CSS print resets from injecting literal (mailto:...) and (https://...) strings into the text layer while preserving Chromium’s native PDF /Subtype /Link /URI annotations.
  • height: auto !important; overflow: visible !important;: Prevents 100vh or overflow: hidden wrappers from clipping content after Page 1.
  • break-inside: avoid & break-after: avoid: Asks LayoutNG’s block fragmentation engine to avoid orphaning a .section-title at the bottom of a page or splitting a .job-entry across a page boundary. avoid is a strong preference, not a guarantee: a block taller than a full page will still be split.

Method 1: Converting HTML Resumes via Headless Chrome CLI (macOS & Linux)

You can render both resume_broken.html (using default flags) and resume_fixed.html (adding --no-pdf-header-footer to suppress browser running headers and footers) directly from your terminal.

Note the binary path difference between operating systems: macOS uses the app bundle binary /Applications/Google Chrome.app/Contents/MacOS/Google Chrome, whereas Linux distributions use google-chrome or chromium:

# macOS: Export broken resume (default flags) vs. fixed resume (--no-pdf-header-footer)
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --headless --disable-gpu \
  --print-to-pdf=resume_broken.pdf \
  resume_broken.html

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --headless --disable-gpu --no-pdf-header-footer \
  --print-to-pdf=resume_fixed.pdf \
  resume_fixed.html

# Linux (Debian/Ubuntu/Fedora): substitute google-chrome or chromium
# google-chrome --headless --disable-gpu --no-pdf-header-footer --print-to-pdf=resume_fixed.pdf resume_fixed.html

Output (Google Chrome 156 on macOS; exact byte sizes vary with the Chrome version and installed fonts):

221641 bytes written to file resume_broken.pdf
199492 bytes written to file resume_fixed.pdf

Method 2: Deterministic Export in Node.js with Playwright (page.pdf())

When automating resume builds in CI/CD or exporting templates programmatically with Playwright’s page.pdf(), two default behaviors catch developers off guard:

  1. printBackground defaults to false: If your CSS lacks print-color-adjust: exact and you call await page.pdf({ path: 'pw_default.pdf' }), Chromium strips every background rectangle to white (1.0, 1.0, 1.0) and converts #ffffff text to #ababab light gray.
  2. Web fonts load asynchronously: Calling page.pdf() immediately after goto() can snapshot fallback system fonts before @font-face files finish decoding, shifting line wraps. Waiting on await page.evaluate(() => document.fonts.ready) guarantees glyph metrics are locked before pagination.

Save and run export_resume.js to compare default page.pdf() against a print-hardened configuration (printBackground: true, preferCSSPageSize: true):

const path = require('node:path');
const fs = require('node:fs');
const { chromium } = require('playwright');

(async () => {
  const chromePath = process.platform === 'darwin'
    ? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
    : undefined;

  const browser = await chromium.launch(chromePath ? { executablePath: chromePath } : {});
  const page = await browser.newPage();

  // 1. Default page.pdf() on broken HTML (printBackground defaults to false)
  await page.goto('file://' + path.resolve('resume_broken.html'), { waitUntil: 'networkidle' });
  await page.pdf({ path: 'pw_default.pdf' });

  // 2. Print-hardened HTML with font barrier, printBackground: true, and preferCSSPageSize: true
  await page.goto('file://' + path.resolve('resume_fixed.html'), { waitUntil: 'networkidle' });
  await page.evaluate(() => document.fonts.ready);
  await page.pdf({
    path: 'pw_fixed.pdf',
    printBackground: true,
    preferCSSPageSize: true,
    displayHeaderFooter: false,
  });

  await browser.close();

  for (const file of ['pw_default.pdf', 'pw_fixed.pdf']) {
    console.log(file + ': ' + fs.statSync(file).size + ' bytes');
  }
})();

Output:

pw_default.pdf: 190355 bytes
pw_fixed.pdf: 187804 bytes

Auditing the Generated PDF Streams with Python (PyMuPDF)

To see why visual inspection alone is not enough, we can inspect the internal vector drawings (page.get_drawings()), text spans (page.get_text("dict")), clickable /URI link annotations (page.get_links()), and page boundaries across all four generated PDFs using PyMuPDF.

On both macOS (Homebrew Python) and Ubuntu/Debian Linux, PEP 668 blocks global pip installs by default, so create a local virtual environment first:

python3 -m venv .venv
.venv/bin/pip install pymupdf

Save and run audit_resume_pdf.py:

import os
import pymupdf

files = [
    "resume_broken.pdf",
    "pw_default.pdf",
    "resume_fixed.pdf",
    "pw_fixed.pdf",
]

for fname in files:
    doc = pymupdf.open(fname)
    p1 = doc[0]
    draws = p1.get_drawings()
    header_fill = tuple(round(c, 4) for c in draws[0]["fill"]) if (draws and draws[0].get("fill")) else None
    span0 = p1.get_text("dict")["blocks"][0]["lines"][0]["spans"][0]
    title_hex = f"0x{span0['color']:06x}"
    uris = [lnk.get("uri") for lnk in p1.get_links() if lnk.get("uri")]
    full_text = "\n".join(page.get_text() for page in doc)
    edu_pages = [
        i + 1 for i, page in enumerate(doc)
        if "B.S. in Computer Science" in page.get_text() or "University of Texas" in page.get_text()
    ]
    print(f"=== {fname} ({os.path.getsize(fname):,d} bytes) ===")
    print(f"  Pages & Box   : {len(doc)} page(s), {p1.rect.width:.1f} x {p1.rect.height:.1f} pt")
    print(f"  Header Vector : fill={header_fill}, title_color={title_hex}")
    print(f"  Clickable URIs: {len(uris)} -> {uris}")
    print(f"  Leaks & Splits: file_url={'file://' in full_text}, href_text_bloat={'(mailto:' in full_text}, edu_on_pages={edu_pages}")
    doc.close()

Output:

=== resume_broken.pdf (221,641 bytes) ===
  Pages & Box   : 2 page(s), 612.0 x 792.0 pt
  Header Vector : fill=(0.0588, 0.0902, 0.1647), title_color=0xffffff
  Clickable URIs: 4 -> ['mailto:alex.rivera@example.com', 'https://github.com/arivera-sys', 'https://github.com/arivera-sys', 'https://linkedin.com/in/arivera-sys']
  Leaks & Splits: file_url=True, href_text_bloat=True, edu_on_pages=[1, 2]
=== pw_default.pdf (190,355 bytes) ===
  Pages & Box   : 1 page(s), 612.0 x 792.0 pt
  Header Vector : fill=(1.0, 1.0, 1.0), title_color=0xababab
  Clickable URIs: 3 -> ['mailto:alex.rivera@example.com', 'https://github.com/arivera-sys', 'https://linkedin.com/in/arivera-sys']
  Leaks & Splits: file_url=False, href_text_bloat=True, edu_on_pages=[1]
=== resume_fixed.pdf (199,492 bytes) ===
  Pages & Box   : 1 page(s), 612.0 x 792.0 pt
  Header Vector : fill=(0.0588, 0.0902, 0.1647), title_color=0xffffff
  Clickable URIs: 3 -> ['mailto:alex.rivera@example.com', 'https://github.com/arivera-sys', 'https://linkedin.com/in/arivera-sys']
  Leaks & Splits: file_url=False, href_text_bloat=False, edu_on_pages=[1]
=== pw_fixed.pdf (187,804 bytes) ===
  Pages & Box   : 1 page(s), 612.0 x 792.0 pt
  Header Vector : fill=(0.0588, 0.0902, 0.1647), title_color=0xffffff
  Clickable URIs: 3 -> ['mailto:alex.rivera@example.com', 'https://github.com/arivera-sys', 'https://linkedin.com/in/arivera-sys']
  Leaks & Splits: file_url=False, href_text_bloat=False, edu_on_pages=[1]

Look closely at what the PDF object inspection reveals across the four files:

  1. Sliced Education Section (edu_on_pages=[1, 2] in resume_broken.pdf): In resume_broken.pdf, the degree line (B.S. in Computer Science | 2011 - 2015) prints at the bottom of Page 1 while the university line (University of Texas at Austin | Austin, TX) is pushed onto Page 2, accompanied by file:///.../resume_broken.html (file_url=True).
  2. Split Hyperlink Annotations (4 URIs instead of 3): Because a[href]::after expanded github.com/arivera-sys into github.com/arivera-sys (https://github.com/arivera-sys), the anchor wrapped across two lines and fractured into two separate /URI link boxes.
  3. Stripped Header Contrast (fill=(1.0, 1.0, 1.0), title_color=0xababab in pw_default.pdf): Without print-color-adjust: exact or printBackground: true, Playwright replaced the #0f172a ((0.0588, 0.0902, 0.1647)) header rectangle with white (1.0, 1.0, 1.0) and converted the #ffffff heading text to 0xababab light gray.
  4. Clean 1-Page Vector Output (resume_fixed.pdf & pw_fixed.pdf): Both fixed builds fit cleanly on 1 US Letter page (612.0 x 792.0 pt), preserve the exact (0.0588, 0.0902, 0.1647) navy fill and 0xffffff heading text, retain 3 clickable /URI annotations, and eliminate file:// leaks.

Fixing Page-Break Slicing and 100vh Truncation on 2-Page Resumes

For staff engineers, executives, or academic CVs that intentionally span two pages, two structural CSS bugs occur near the 792 pt page boundary:

  • Missing break-inside: avoid on .job-entry: A role straddling the bottom of Page 1 prints its job title and dates at the bottom of Page 1 (Systems Architect, Storage Engine | 2014 - 2015) and orphans its company name (Stratos Cloud DB) and bullet points at the top of Page 2.
  • Leftover SPA height: 100vh; overflow: hidden; rules: If a React, Vue, or Tailwind wrapper leaves height: 100vh; overflow: hidden; active during print, Chromium locks the document height to Page 1 and silently drops every job, skill, and degree that would have flowed onto Page 2.

We can verify all three behaviors by adding two earlier roles to create a 6-job, 2-page CV and running verify_page_breaks.py:

import os
import subprocess
import pymupdf

chrome_bin = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"

with open("resume_fixed.html") as f:
    base = f.read()

two_more_jobs = """
  <article class="job-entry">
    <div class="job-header"><span>Systems Architect, Storage Engine</span><span>2014 - 2015</span></div>
    <div class="job-sub"><span>Stratos Cloud DB</span><span>San Jose, CA</span></div>
    <ul>
      <li>Designed LSM-tree compaction scheduler reducing write amplification from 14.2x to 4.8x on NVMe arrays.</li>
      <li>Implemented vectorized Parquet column scanner in Rust achieving 18 GB/s predicate pushdown throughput.</li>
      <li>Built automated Jepsen fault-injection pipeline testing linearizability across 5-node consensus groups.</li>
    </ul>
  </article>

  <article class="job-entry">
    <div class="job-header"><span>Kernel & Network Performance Engineer</span><span>2012 - 2014</span></div>
    <div class="job-sub"><span>PacketScale Networks</span><span>Palo Alto, CA</span></div>
    <ul>
      <li>Wrote zero-copy DPDK packet-forwarding poll-mode driver saturating dual 40GbE NICs at 64-byte frame sizes.</li>
      <li>Tuned Linux TCP congestion window and NUMA IRQ affinity across 1,200 bare-metal edge routing appliances.</li>
      <li>Authored Wireshark Lua dissectors and perf flamegraph automation for L4 load-balancer regression testing.</li>
    </ul>
  </article>
"""

html_2p_with = (
    base.replace('  <h2 class="section-title">Technical Skills</h2>', two_more_jobs + '\n  <h2 class="section-title">Technical Skills</h2>')
        .replace("font-size: 9.8pt;", "font-size: 10.5pt;")
        .replace("line-height: 1.42;", "line-height: 1.52;")
)
html_2p_no = html_2p_with.replace("break-inside: avoid;\n        page-break-inside: avoid;", "/* break-inside disabled */")
html_2p_vh = html_2p_no.replace("height: auto !important;\n        min-height: 0 !important;\n        overflow: visible !important;", "height: 100vh;\n        overflow: hidden;")

variants = [
    ("2p_no_break_inside", html_2p_no),
    ("2p_with_break_inside", html_2p_with),
    ("2p_vh_overflow_hidden", html_2p_vh),
]

for name, html_str in variants:
    with open(f"{name}.html", "w") as f:
        f.write(html_str)
    subprocess.run([
        chrome_bin, "--headless", "--disable-gpu", "--no-pdf-header-footer",
        f"--print-to-pdf={name}.pdf", f"file://{os.path.abspath(name + '.html')}"
    ], check=True, stderr=subprocess.DEVNULL)

    doc = pymupdf.open(f"{name}.pdf")
    p1_lines = [l.strip() for l in doc[0].get_text().splitlines() if l.strip()]
    has_edu = any("Education" in p.get_text() or "EDUCATION" in p.get_text() for p in doc)
    print(f"=== {name}.pdf (pages={len(doc)}, has_education={has_edu}) ===")
    print("  Page 1 bottom 3 lines:", p1_lines[-3:])
    if len(doc) > 1:
        p2_lines = [l.strip() for l in doc[1].get_text().splitlines() if l.strip()]
        print("  Page 2 top 3 lines   :", p2_lines[:3])
    doc.close()

Output:

=== 2p_no_break_inside.pdf (pages=2, has_education=True) ===
  Page 1 bottom 3 lines: ['to 15s.', 'Systems Architect, Storage Engine', '2014 - 2015']
  Page 2 top 3 lines   : ['Stratos Cloud DB', 'San Jose, CA', 'Designed LSM-tree compaction scheduler reducing write amplification from 14.2x to 4.8x on NVMe arrays.']
=== 2p_with_break_inside.pdf (pages=2, has_education=True) ===
  Page 1 bottom 3 lines: ['depths.', 'Replaced nightly batch ETL jobs with incremental Kafka Streams aggregations, cutting reporting lag from 6h', 'to 15s.']
  Page 2 top 3 lines   : ['Systems Architect, Storage Engine', '2014 - 2015', 'Stratos Cloud DB']
=== 2p_vh_overflow_hidden.pdf (pages=1, has_education=False) ===
  Page 1 bottom 3 lines: ['2014 - 2015', 'Stratos Cloud DB', 'San Jose CA']

Adding break-inside: avoid keeps the entire Systems Architect, Storage Engine <article> block together and moves it cleanly to the top of Page 2 (2p_with_break_inside.pdf), whereas leaving height: 100vh; overflow: hidden; active (2p_vh_overflow_hidden.pdf) clips the page mid-line at San Jose CA and discards the second page (has_education=False).


Browser Tools vs. Print Engines: Choosing the Right Utiliome Workflow

If you want to generate a PDF in your browser without setting up a CLI environment, it is important to understand what each browser tool actually executes under the hood so you pick the right tool for your markup. All Utiliome tools run locally in browser memory without uploading your files.

1. What HTML to PDF Can and Cannot Do

Utiliome’s HTML to PDF tool is a text converter, not a CSS layout engine:

  1. Regex HTML-to-Markdown Pass: It deletes <script> and <style> blocks entirely, rewrites <h1>–<h6>, <li>, <pre>, <blockquote>, <strong> / <b>, <em> / <i>, and <code> into their Markdown equivalents, turns closing </p>, </div>, </section>, </article>, </header>, </footer>, and </tr> tags into paragraph breaks, and then strips every remaining tag.
  2. Text Rendering With the Markdown to PDF Engine: The resulting Markdown is drawn with pdf-lib by the same engine as Markdown to PDF with its defaults—A4 pages (595.28 x 841.89 pt), 44 pt margins, and 11 pt Helvetica—so long lines wrap by measured glyph width, content flows onto as many pages as needed, and every page gets a centered Page X of Y footer.

Because the tool never runs a CSS layout engine, keep these limits in mind:

  • All CSS is dropped: Class rules, inline style attributes, @page, @media print, Flexbox/Grid layout, background fills, and text colors never reach the PDF, so none of the print fixes in this guide have any effect on its output.
  • Adjacent inline elements can run together: Only block-level closing tags insert breaks, so a Flexbox row such as <div class="job-header"><span>Principal Infrastructure Engineer</span><span>2023 - Present</span></div> comes out as Principal Infrastructure Engineer2023 - Present. Text from <head>, such as the <title>, is also kept as a plain paragraph.
  • Links lose their targets: <a href> tags are stripped like any other tag, so mailto: and https:// URLs disappear from the text and no clickable /URI annotations are created.

Use HTML to PDF for text-only snippets—plain HTML notes, simple invoice summaries, or body fragments—where you only need the words on a page. It is not a way to preserve resume CSS; for that, use the Headless Chrome or Playwright workflows above.

2. Better Browser Alternatives for Resumes: CV Builder and Markdown to PDF

If you want to generate a formatted, multi-section resume PDF directly in the browser without running Headless Chrome:

  • For a new multi-column or single-column ATS resume (CV Builder): CV Builder does not import HTML—you fill in its form or upload a JSON Resume (.json) file. It uses @react-pdf/renderer to compile those fields directly into a true vector A4 PDF (595 pt wide) that keeps the proportions of its on-screen preview. It uses the 12 standard PDF fonts (Helvetica, Times-Roman, Courier and their bold/italic variants) and turns hyphenation off, so URLs, email addresses, and technology names are never hyphenated across lines. Two limits matter for this guide: every template exports on A4 only, so US and Canadian applications that expect US Letter (see the checklist below) still need the Headless Chrome or Playwright route; and email addresses and profile URLs are drawn as plain text without clickable /URI annotations.
  • For semantic single-column HTML (HTML to Markdown + Markdown to PDF): Paste your resume <body> markup into HTML to Markdown (which converts <h1>–<h3>, <ul>, <p>, and <pre> tags into clean Markdown via turndown), then paste the Markdown into Markdown to PDF. It uses the same renderer as HTML to PDF, so CSS is still discarded, but it lets you choose A4, LETTER (612 x 792 pt), or LEGAL (612 x 1008 pt) page sizes, and Markdown links are printed as Label (URL) text instead of being stripped.

Comparing All 4 Chrome and Playwright Conversion Outputs

We verified the final byte sizes of all four generated PDFs on disk using stat (stat -f "%N: %z bytes" on macOS BSD stat, or stat -c "%n: %s bytes" on Linux GNU coreutils):

# macOS (BSD stat)
stat -f "%N: %z bytes" resume_broken.pdf pw_default.pdf resume_fixed.pdf pw_fixed.pdf

# Linux (GNU coreutils)
# stat -c "%n: %s bytes" resume_broken.pdf pw_default.pdf resume_fixed.pdf pw_fixed.pdf

Output:

resume_broken.pdf: 221641 bytes
pw_default.pdf: 190355 bytes
resume_fixed.pdf: 199492 bytes
pw_fixed.pdf: 187804 bytes
Workflow / Output FilePage Size & CountFile SizeHeader #0f172a Fill & #ffffff TextClickable /URI LinksEducation Section Intact?Best Use Case
Chrome CLI Default (resume_broken.pdf)US Letter (2 pages)221,641 BPreserved (#0f172a / #ffffff)4 (split across wrapped line + file:// leak)No (split across Pages 1 & 2)Baseline showing unconfigured print failures
Playwright Default (pw_default.pdf)US Letter (1 page)190,355 BFailed (white fill + #ababab gray text)3 (with (mailto:...) text bloat)YesBaseline showing economy color stripping
Chrome CLI Fixed (resume_fixed.pdf)US Letter (1 page)199,492 BPreserved (#0f172a / #ffffff)3 clean /URI linksYes (1 page, zero leaks)Local CLI export of custom CSS Grid/Flex resumes
Playwright Fixed (pw_fixed.pdf)US Letter (1 page)187,804 BPreserved (#0f172a / #ffffff)3 clean /URI linksYes (1 page, zero leaks)Automated CI/CD HTML resume pipelines

HTML to PDF is left out of this comparison on purpose: it drops all CSS by design, so it cannot preserve header fills, link annotations, or page-break rules (see the section above).


5-Point Pre-Flight Checklist Before Submitting a PDF Resume

Before uploading your exported PDF resume to an employer’s ATS portal (Greenhouse, Lever, Workday, or Ashby), verify these five items:

  1. Confirm Page Dimensions Match Your Target Region: US and Canadian employers expect US Letter (612 x 792 pt / 8.5 x 11 in), whereas European and APAC employers expect ISO A4 (595.28 x 841.89 pt / 210 x 297 mm). Drop your exported file into PDF Page Size Inspector to verify the exact /MediaBox dimensions in points, inches, and millimeters.
  2. Test the Raw Text Layer for file:// Paths and a[href]::after Bloat: Select all text (Cmd+A / Ctrl+A) in your PDF viewer and paste it into a plain text editor. Ensure there are no file:///tmp/resume.html-style footer strings or duplicated (https://...) link URLs breaking up your contact header.
  3. Verify Dark Headers Render with print-color-adjust: exact: Check that dark sidebars or banners retain their background fill and high-contrast text rather than washing out to #ababab light gray on white.
  4. Check Every Page Boundary for Sliced .job-entry Blocks: On 2-page resumes, confirm that every role header, company subheader, and bullet list stays together on the same page (break-inside: avoid) and that no section heading sits orphaned at the bottom of Page 1 (break-after: avoid).
  5. Clean Up Document /Info Metadata: Headless browser exports often set the PDF /Title to the raw HTML filename (resume_fixed.html) and /Creator to Chromium. You can inspect or strip hidden /Info and XMP tags with PDF Metadata Remover, or set a clean First Last - Resume document title using our guide on how to change PDF title, author, and subject metadata.