# How CareerOps Handles Generated PDFs and Evaluation Reports: A Technical Deep Dive

> Learn how CareerOps handles generated PDFs and evaluation reports. Discover its automated pipeline for ATS-compliant PDF generation and synchronization.

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

---

**CareerOps generates ATS-compliant PDFs from HTML CVs using Playwright, indexes them by report number in a TSV manifest, and synchronizes them with evaluation reports through an automated pipeline.**

CareerOps is an open-source job application tracker that automates the creation of professional CVs and their associated evaluation reports. The system treats **generated PDFs and evaluation reports** as tightly coupled artefacts, ensuring that every evaluation report can automatically reference its corresponding PDF through a persistent manifest-based indexing system.

## The PDF Generation Pipeline

The core conversion logic resides in `generate-pdf.mjs`, which orchestrates HTML normalization, rendering, and manifest registration.

### HTML Normalization for ATS Compatibility

Before rendering, CareerOps sanitizes the input HTML to maximize compatibility with Applicant Tracking Systems (ATS). The script replaces Unicode characters that break parser logic—such as em-dashes, smart quotes, and zero-width spaces—with safe ASCII equivalents. This **ATS normalization** happens in `generate-pdf.mjs` prior to any PDF generation, ensuring that the final document passes through automated screening systems without character encoding issues.

### Playwright Rendering and Page Budget Enforcement

The script invokes **Playwright’s Chromium** engine to convert the normalized HTML into PDF. After rendering, `countRenderedPdfPages` (lines 651-682 in `generate-pdf.mjs`) validates the page count against the `--max-pages` parameter. If `--strict-pages` is enabled and the document exceeds the limit, the process aborts; otherwise, it emits a warning. This **page-budget** enforcement prevents excessively long CVs from being submitted inadvertently.

### Manifest Registration

When the `--report=NNN` flag is provided, the function `updatePDFManifest` (lines 1013-1035) appends a tab-separated entry to `data/pdf-index.tsv`. The format follows:

```

[report_number]\t[pdf_path]\t[html_path]\t[format]\t[date]

```

Old entries for the same report number are removed first, maintaining a strict one-to-one mapping between a report and its PDF.

## Linking PDFs to Evaluation Reports

The relationship between evaluation reports and their PDFs relies on a **report number** convention and a synchronization pipeline.

### The Report Number Convention

Every evaluation report follows the naming pattern [`NNN-company-date.md`](https://github.com/santifer/career-ops/blob/main/NNN-company-date.md) in the `reports/` directory. When generating a PDF, the same `NNN` identifier is passed via `--report=NNN`, creating a persistent link in the manifest. This convention allows downstream components to locate the correct PDF for any given report without parsing markdown files.

### Tracker Synchronization via `merge-tracker.mjs`

The `merge-tracker.mjs` script (specifically lines 408-415) reads `data/pdf-index.tsv` and updates the **PDF column** in [`data/applications.md`](https://github.com/santifer/career-ops/blob/main/data/applications.md). This synchronization ensures that the tracker interface displays a binary status indicator (✅/❌) showing whether a PDF has been generated for each application, keeping the tracker state consistent with the filesystem.

### Resolution with `resolvePdfIndexPath`

Components that need to access a PDF—such as the TUI dashboard or the `email` mode—call `resolvePdfIndexPath` from `tracker-utils.mjs`. This function loads the manifest, locates the row matching the current report number, and returns the absolute path to the PDF. This abstraction ensures that all modes consume the **single source of truth** stored in `data/pdf-index.tsv`.

## Safety and Validation Mechanisms

CareerOps implements multiple guards to ensure integrity and security throughout the pipeline.

### Workspace Confinement

The functions `assertInsideWorkspace` and `isWorkspaceOutputPath` validate that both input HTML and output PDF paths reside within the project’s workspace directory. This prevents **path-traversal attacks** where malicious input might attempt to write files outside the intended scope.

### Section Order Validation

When `--allow-reorder` is not set, `validateCvSectionOrder` compares the section sequence in the rendered HTML against the canonical order defined in [`config/profile.yml`](https://github.com/santifer/career-ops/blob/main/config/profile.yml). If divergence is detected, the script throws an error, ensuring that manually curated CV structures are not accidentally altered during the generation process.

## Practical Usage Examples

### Generate a PDF for a Specific Report

```bash
node generate-pdf.mjs output/cv.html output/cv.pdf \
  --format=letter \
  --report=018 \
  --max-pages=2 \
  --allow-reorder

```

This command produces `output/cv.pdf`, validates it does not exceed two pages, and registers it in the manifest under report `018`.

### Batch Generation Mode

```bash
node generate-pdf.mjs --batch=manifest.json \
  --format=a4 \
  --max-pages=1 \
  --strict-pages

```

The batch mode processes multiple inputs defined in [`manifest.json`](https://github.com/santifer/career-ops/blob/main/manifest.json), applying uniform page budgets and ATS normalization to each entry.

### Programmatic PDF Lookup

```javascript
import { resolvePdfIndexPath } from './tracker-utils.mjs';

const pdfPath = resolvePdfIndexPath(trackerPath);
console.log('PDF for this report:', pdfPath);

```

This pattern is used internally by the `oferta` mode (documented in [`modes/oferta.md`](https://github.com/santifer/career-ops/blob/main/modes/oferta.md)) to inject PDF links into evaluation reports, and by the `email` mode to attach the correct file to outgoing messages.

## Summary

- **ATS normalization** in `generate-pdf.mjs` replaces problematic Unicode characters before Playwright rendering.
- The **page-budget** system (`--max-pages`/`--strict-pages`) enforces length constraints via `countRenderedPdfPages`.
- `data/pdf-index.tsv` serves as the central manifest, mapping **report numbers** to PDF paths using tab-separated values.
- `merge-tracker.mjs` synchronizes the manifest with [`data/applications.md`](https://github.com/santifer/career-ops/blob/main/data/applications.md), maintaining tracker accuracy.
- **Workspace confinement** and **section-order validation** prevent security issues and structural drift.
- The `oferta` evaluation report mode and `email` mode both rely on `resolvePdfIndexPath` to consume the manifest.

## Frequently Asked Questions

### How does CareerOps ensure generated PDFs are ATS-compliant?

CareerOps normalizes Unicode characters that commonly break ATS parsers—such as smart quotes, em-dashes, and zero-width spaces—replacing them with ASCII equivalents before the HTML is passed to Playwright. This normalization occurs in `generate-pdf.mjs` prior to PDF rendering, ensuring the final document contains only safe, parseable text.

### What is the purpose of the `pdf-index.tsv` manifest?

The manifest acts as a single source of truth that links **report numbers** to their corresponding PDF and HTML file paths. Stored in `data/pdf-index.tsv`, it allows the tracker dashboard, email mode, and evaluation reports to resolve the correct PDF path for any given application without scanning directories or parsing filenames.

### How do I generate a PDF linked to a specific evaluation report?

Pass the `--report` flag with the report number when running `generate-pdf.mjs`:

```bash
node generate-pdf.mjs input.html output.pdf --report=042

```

This registers the PDF in the manifest under report `042`, enabling automatic attachment in evaluation reports and email drafts.

### What happens if a generated PDF exceeds the page budget?

If the rendered PDF exceeds the `--max-pages` limit and `--strict-pages` is enabled, the process aborts with an error. Without strict mode, the script emits a warning but continues execution. The page count is determined by `countRenderedPdfPages` in `generate-pdf.mjs` immediately after Playwright renders the document.