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:
- 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 at2.30:1). - 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. @pageMargin Collisions &file://Path Leaks: Default browser print margins stack on top ofbodypadding and stampfile:///tmp/resume.htmland1/2into the top and bottom margin boxes, pushing the last two lines of a 1-page resume onto a second page.- 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. - 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 after792 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; usesize: A4for595.28 x 841.89 pt) and eliminates double-stacked margins between@pageandbody.-webkit-print-color-adjust: exact; print-color-adjust: exact;: Overrides CSS Color Adjustment Module Level 1economymode so dark headers (#0f172a) and.skill-pillbackgrounds (#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/URIannotations.height: auto !important; overflow: visible !important;: Prevents100vhoroverflow: hiddenwrappers from clipping content after Page 1.break-inside: avoid&break-after: avoid: Asks LayoutNG’s block fragmentation engine to avoid orphaning a.section-titleat the bottom of a page or splitting a.job-entryacross a page boundary.avoidis 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:
printBackgrounddefaults tofalse: If your CSS lacksprint-color-adjust: exactand you callawait page.pdf({ path: 'pw_default.pdf' }), Chromium strips every background rectangle to white(1.0, 1.0, 1.0)and converts#fffffftext to#ababablight gray.- Web fonts load asynchronously: Calling
page.pdf()immediately aftergoto()can snapshot fallback system fonts before@font-facefiles finish decoding, shifting line wraps. Waiting onawait 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:
- Sliced Education Section (
edu_on_pages=[1, 2]inresume_broken.pdf): Inresume_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 byfile:///.../resume_broken.html(file_url=True). - Split Hyperlink Annotations (
4URIs instead of3): Becausea[href]::afterexpandedgithub.com/arivera-sysintogithub.com/arivera-sys (https://github.com/arivera-sys), the anchor wrapped across two lines and fractured into two separate/URIlink boxes. - Stripped Header Contrast (
fill=(1.0, 1.0, 1.0), title_color=0xabababinpw_default.pdf): Withoutprint-color-adjust: exactorprintBackground: true, Playwright replaced the#0f172a((0.0588, 0.0902, 0.1647)) header rectangle with white(1.0, 1.0, 1.0)and converted the#ffffffheading text to0xababablight gray. - Clean 1-Page Vector Output (
resume_fixed.pdf&pw_fixed.pdf): Both fixed builds fit cleanly on1US Letter page (612.0 x 792.0 pt), preserve the exact(0.0588, 0.0902, 0.1647)navy fill and0xffffffheading text, retain3clickable/URIannotations, and eliminatefile://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: avoidon.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 leavesheight: 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:
- 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. - Text Rendering With the Markdown to PDF Engine: The resulting Markdown is drawn with
pdf-libby the same engine as Markdown to PDF with its defaults—A4 pages (595.28 x 841.89 pt),44 ptmargins, and11 ptHelvetica—so long lines wrap by measured glyph width, content flows onto as many pages as needed, and every page gets a centeredPage X of Yfooter.
Because the tool never runs a CSS layout engine, keep these limits in mind:
- All CSS is dropped: Class rules, inline
styleattributes,@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 asPrincipal 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, somailto:andhttps://URLs disappear from the text and no clickable/URIannotations 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/rendererto compile those fields directly into a true vector A4 PDF (595 ptwide) that keeps the proportions of its on-screen preview. It uses the 12 standard PDF fonts (Helvetica,Times-Roman,Courierand 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/URIannotations. - 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 viaturndown), 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 chooseA4,LETTER(612 x 792 pt), orLEGAL(612 x 1008 pt) page sizes, and Markdown links are printed asLabel (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 File | Page Size & Count | File Size | Header #0f172a Fill & #ffffff Text | Clickable /URI Links | Education Section Intact? | Best Use Case |
|---|---|---|---|---|---|---|
Chrome CLI Default (resume_broken.pdf) | US Letter (2 pages) | 221,641 B | Preserved (#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 B | Failed (white fill + #ababab gray text) | 3 (with (mailto:...) text bloat) | Yes | Baseline showing economy color stripping |
Chrome CLI Fixed (resume_fixed.pdf) | US Letter (1 page) | 199,492 B | Preserved (#0f172a / #ffffff) | 3 clean /URI links | Yes (1 page, zero leaks) | Local CLI export of custom CSS Grid/Flex resumes |
Playwright Fixed (pw_fixed.pdf) | US Letter (1 page) | 187,804 B | Preserved (#0f172a / #ffffff) | 3 clean /URI links | Yes (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:
- 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/MediaBoxdimensions in points, inches, and millimeters. - Test the Raw Text Layer for
file://Paths anda[href]::afterBloat: Select all text (Cmd+A/Ctrl+A) in your PDF viewer and paste it into a plain text editor. Ensure there are nofile:///tmp/resume.html-style footer strings or duplicated(https://...)link URLs breaking up your contact header. - 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#ababablight gray on white. - Check Every Page Boundary for Sliced
.job-entryBlocks: 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). - Clean Up Document
/InfoMetadata: Headless browser exports often set the PDF/Titleto the raw HTML filename (resume_fixed.html) and/CreatortoChromium. You can inspect or strip hidden/Infoand XMP tags with PDF Metadata Remover, or set a cleanFirst Last - Resumedocument title using our guide on how to change PDF title, author, and subject metadata.