# How ATS-Optimized PDF Generation Works in career-ops: A Technical Deep Dive

> Discover how career-ops creates ATS-optimized PDFs with a 10-step pipeline normalizing text, validating sections, inlining fonts, and managing page budgets via headless Chromium. Enhance your CV today.

- Repository: [Santiago Fernández de Valderrama/career-ops](https://github.com/santifer/career-ops)
- Tags: deep-dive
- Published: 2026-08-19

---

**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`](https://github.com/santifer/career-ops/blob/main/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`](https://github.com/santifer/career-ops/blob/main/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:

```bash
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):

```bash
node generate-pdf.mjs cv.html cv.pdf --allow-reorder

```

Enforce a hard failure if the CV exceeds one page:

```bash
node generate-pdf.mjs cv.html cv.pdf --max-pages=1 --strict-pages

```

Process multiple CVs in a single batch:

```json
[
  { "input": "job1.html", "output": "job1.pdf", "reportNum": "021" },
  { "input": "job2.html", "output": "job2.pdf", "format": "letter" }
]

```

```bash
node generate-pdf.mjs --batch=manifest.json --max-pages=2

```

Integrate programmatically into another Node.js script:

```javascript
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 `assertInsideProject` guard ensures all file operations stay within the repository boundary.
- **Semantic integrity**: Section order validation in `validateCvSectionOrder` maintains CV structure unless `--allow-reorder` is specified.
- **ATS-safe text**: `normalizeTextForATS` replaces 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`](https://github.com/santifer/career-ops/blob/main/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`](https://github.com/santifer/career-ops/blob/main/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.