How ATS-Optimized PDF Generation Works in career-ops: A Technical Deep Dive
career-ops generates ATS-friendly PDFs through a 10-step pipeline that normalizes text to ASCII-compatible characters, validates CV section order, inlines fonts, and enforces page budgets using headless Chromium.
The santifer/career-ops repository automates the creation of Applicant Tracking System (ATS) compatible resumes through a carefully engineered PDF generation pipeline. Understanding how ATS-optimized PDF generation works in career-ops helps users customize their CV outputs while ensuring parsers can correctly extract text content. The core logic resides in generate-pdf.mjs, which orchestrates validation, normalization, rendering, and cataloging through a series of discrete, composable functions.
The PDF Generation Pipeline
At the heart of career-ops is generate-pdf.mjs, a Node.js module that transforms HTML CV templates into machine-readable PDFs. The pipeline prioritizes text normalization over visual fidelity, ensuring that smart quotes, em-dashes, and unicode symbols—which often break legacy ATS parsers—are replaced with ASCII-safe alternatives before rendering.
The process combines security checks, content validation, and resource optimization before handing off to Playwright for headless Chromium rendering. Each stage is designed to fail fast with descriptive errors, preventing malformed PDFs from entering your job application workflow.
Step-by-Step ATS Optimization Process
1. Input Validation and Security
Before processing begins, the script resolves input and output paths through assertInsideProject (lines 68–90). This function uses real-path resolution to guarantee that both the source HTML and destination PDF remain within the repository boundaries, preventing directory traversal attacks when processing untrusted templates.
2. CV Section Order Validation
To maintain semantic consistency, validateCvSectionOrder (lines 99–124) loads the original cv.md source file and compares its section hierarchy against the rendered HTML. If the order diverges—for example, if a template renders "Projects" before "Education" while the markdown lists them differently—the script throws an error unless the --allow-reorder flag is explicitly provided.
3. Text Normalization for ATS Compatibility
The normalizeTextForATS function (lines 96–106) processes the HTML body to replace characters known to confuse ATS parsers. This includes converting smart quotes to straight quotes, em- and en-dashes to hyphens, removing zero-width spaces, and replacing arrows and currency symbols with text equivalents. The function preserves HTML tags, CSS classes, and URLs, ensuring only visible text content is sanitized.
4. Theme and Page Style Injection
Custom visual styles are injected via injectThemeStyle imported from theme-style.mjs, which reads user-defined tokens from config/profile.yml and converts them to CSS custom properties. The injectPrintPageCss function (lines 108–118) then appends a @page rule to enforce the requested paper format—either a4 or letter—ensuring consistent pagination across different environments.
5. Font Inlining
To avoid font substitution issues during PDF generation, inlineLocalFonts (lines 99–114) scans the CSS for url('./fonts/...') references, reads the local font files, base64-encodes them, and replaces the URLs with data URIs. This guarantees that the Chromium renderer embeds the exact font glyphs needed for proper text extraction by ATS software.
6. Headless Rendering with Playwright
The renderInPage function (lines 123–140) launches a headless Chromium instance via Playwright, loads the prepared HTML via a temporary file:// URL, and configures a secure browsing context. JavaScript execution is disabled, and network requests are blocked except for file: and data: protocols, preventing external resource loading that could introduce non-deterministic content.
7. PDF Rendering
Chromium’s native page.pdf method (lines 156–166) generates the final document with printBackground enabled and zero margins. This ensures that background colors and borders render correctly while maintaining precise control over the content box dimensions, critical for the subsequent page-budget calculation.
8. Page Budget Enforcement
After rendering, enforcePageBudget (lines 138–152) uses countRenderedPdfPages to read the PDF catalog and verify the page count against the --max-pages threshold. By default, exceeding the limit emits a warning; when --strict-pages is enabled, the violation becomes a hard error that exits the process with a non-zero status code.
9. Manifest Cataloging
When a --report number is supplied, updatePDFManifest (lines 145–165) records the PDF’s path, source HTML path, format, and generation timestamp in data/pdf-index.tsv. This TSV manifest allows external tools—such as TUI dashboards or CI pipelines—to locate specific PDF versions by their report identifiers, enabling traceable application tracking.
10. Resource Cleanup
A finally block within renderInPage (lines 190–216) ensures that temporary HTML files, Playwright page contexts, and the browser instance are explicitly closed. This prevents memory leaks and zombie processes, even when preceding stages throw exceptions.
Batch Processing Mode
For high-volume workflows, career-ops supports batch rendering via the --batch=<manifest.json> flag. The runBatchFromManifest function (lines 227–262) parses a JSON manifest containing multiple input/output pairs, while renderBatch (lines 334–354) processes each entry through the same ATS-normalization and page-budget pipeline. A single Chromium instance is reused across all renders for efficiency, and failures are recorded individually in a companion <manifest>.results.json file without halting the entire batch.
Practical Usage Examples
Render a single CV with a strict two-page limit and link it to report #018:
node generate-pdf.mjs cv-template.html cv-output.pdf --report=018 --max-pages=2
Allow non-standard section ordering (e.g., placing Projects before Education):
node generate-pdf.mjs cv.html cv.pdf --allow-reorder
Enforce a hard failure if the CV exceeds one page:
node generate-pdf.mjs cv.html cv.pdf --max-pages=1 --strict-pages
Process multiple CVs in a single batch:
[
{ "input": "job1.html", "output": "job1.pdf", "reportNum": "021" },
{ "input": "job2.html", "output": "job2.pdf", "format": "letter" }
]
node generate-pdf.mjs --batch=manifest.json --max-pages=2
Integrate programmatically into another Node.js script:
import { renderHtmlToPdf, normalizeTextForATS } from './generate-pdf.mjs';
import { readFile } from 'fs/promises';
const html = await readFile('cv.html', 'utf-8');
const { html: safeHtml } = normalizeTextForATS(html);
await renderHtmlToPdf(safeHtml, 'cv.pdf', { format: 'a4', maxPages: 2 });
Summary
- Security-first validation: The
assertInsideProjectguard ensures all file operations stay within the repository boundary. - Semantic integrity: Section order validation in
validateCvSectionOrdermaintains CV structure unless--allow-reorderis specified. - ATS-safe text:
normalizeTextForATSreplaces unicode smart quotes, dashes, and symbols with ASCII equivalents to maximize parser compatibility. - Self-contained rendering: Font inlining and local resource loading eliminate external dependencies that could break text extraction.
- Configurable constraints: Page budgets (
--max-pages,--strict-pages) and manifest cataloging (--report) support workflow automation and compliance checking.
Frequently Asked Questions
What makes a PDF "ATS-optimized" in career-ops?
ATS optimization in career-ops refers to the normalizeTextForATS process that strips unicode characters—such as smart quotes, em-dashes, and zero-width spaces—that often cause legacy ATS parsers to fail or mangle text extraction. The system also inlines fonts and disables JavaScript to ensure the PDF contains only static, embedded text content that parsers can reliably index.
How do I prevent section reordering errors?
The pipeline compares the HTML section order against the canonical cv.md source file using validateCvSectionOrder. If your template intentionally reorders sections (e.g., moving Skills above Experience), pass the --allow-reorder flag to bypass this validation, or update cv.md to match your preferred hierarchy.
Can I use custom fonts with career-ops PDF generation?
Yes. The inlineLocalFonts function automatically detects url('./fonts/...') references in your CSS, base64-encodes the font files, and converts them to data URIs before rendering. This ensures your custom typography renders correctly in the final PDF without relying on system fonts that may differ between machines.
How does batch processing improve performance?
The runBatchFromManifest and renderBatch functions reuse a single Playwright browser instance across multiple PDF renders, eliminating the overhead of repeated Chromium launches. This architecture significantly reduces total processing time when generating multiple application variants, while still applying individual ATS normalization and page-budget checks to each output.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →