Files
santifer--career-ops/modes/pdf.md
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

18 KiB

Mode: pdf — ATS-Optimized PDF Generation

Full pipeline

  1. Read cv.md as the source of truth
  2. Ask the user for the JD if it is not in context (text or URL)
  3. Extract 15-20 keywords from the JD
  4. Detect JD language → CV language (EN default)
  5. Detect company location → paper format:
    • US/Canada → letter
    • Rest of the world → a4
  6. Detect role archetype → adapt framing
  7. Build an internal recruiter-side risk map from the JD using modes/heuristics/recruiter-side.md: likely doubts, matching evidence, and which document section should address each doubt
  8. Rewrite Professional Summary by injecting JD keywords + exit narrative bridge ("Built and sold a business. Now applying systems thinking to [JD domain].")
  9. Select top 3-4 most relevant projects for the job
  10. Reorder experience bullets by JD relevance and by the risk map: strongest matching evidence first
  11. Build competency grid from JD requirements (6-8 keyword phrases)
  12. Inject keywords naturally into existing achievements (NEVER invent)
  13. Apply the six-second clarity gate from modes/heuristics/recruiter-side.md: top third must make target role, strongest fit, and proof obvious
  14. Read name from config/profile.yml → normalize to kebab-case lowercase (e.g. "John Doe" → "john-doe") → {candidate}
  15. Build the render payload (see the JSON Input Schema below) from the tailored content — emit compact structured JSON, not full HTML markup — and write it to /tmp/cv-{candidate}-{company}.json
  16. Run: node build-cv-html.mjs /tmp/cv-{candidate}-{company}.json output/cv-{candidate}-{company}.html {template} — where {template} is the path printed by Selecting the template below (omit the argument to use the base cv-template.html). The script merges the payload into that template, owning every tag, CSS class, and the HTML escaping. Write to output/ (NOT a temp dir — the recorded HTML is what the dashboard's D hotkey regenerates from, so it must survive temp cleanup)
  17. Run the fact gate: node verify-cv-facts.mjs output/cv-{candidate}-{company}.html
    • This is a hard gate before PDF rendering.
    • If it fails, stop and fix the generated HTML by removing invented metrics or adding verified evidence to cv.md, article-digest.md, or config/cv-facts.json.
  18. Execute: node generate-pdf.mjs output/cv-{candidate}-{company}.html output/cv-{candidate}-{company}-{YYYY-MM-DD}.pdf --format={letter|a4} --report={report number}{report number} is the NNN from the report filename/link (e.g. 008 for reports/008-acme-….md), not the tracker # column. Pass it whenever the application has (or will have) a report; it records the PDF↔report linkage in data/pdf-index.tsv so the dashboard can open and regenerate the exact PDF. Omit it only for one-off CVs with no tracker entry.
  19. Report: PDF path, number of pages, keyword coverage %

ATS Rules (clean parsing)

  • Single-column layout (no sidebars, no parallel columns)
  • Standard headers: "Professional Summary", "Work Experience", "Education", "Skills", "Certifications", "Projects"
  • No text in images/SVGs
  • No critical info in PDF headers/footers (ATS ignores them)
  • UTF-8, selectable text (not rasterized)
  • No nested tables
  • Distributed JD keywords: Summary (top 5), first bullet of each role, Skills section
  • No hidden text, keyword stuffing, or white-font tricks. Optimize for parseability plus human review.

Recruiter Review Gates

  • The summary should answer: "What role is this person targeting, and why this one?"
  • The first screen should show 1-2 proof points that map to the JD's highest-risk requirements.
  • Bullets should emphasize outcomes, systems, users, or business effects rather than task history.
  • Logistics such as location, work authorization, salary, and availability belong in the CV only when appropriate for the market and profile; otherwise handle them in form answers or recruiter scripts.

PDF Design

  • Fonts: Space Grotesk (headings, 600-700) + DM Sans (body, 400-500)
  • Fonts self-hosted: fonts/
  • Header: name in Space Grotesk 24px bold + gradient line linear-gradient(to right, hsl(187,74%,32%), hsl(270,70%,45%)) 2px + contact row
  • Section headers: Space Grotesk 13px, uppercase, letter-spacing 0.05em, color cyan primary
  • Body: DM Sans 11px, line-height 1.5
  • Company names: accent purple color hsl(270,70%,45%)
  • Margins: 0.6in
  • Background: pure white

Section order (optimized "6-second recruiter scan")

  1. Header (large name, gradient, contact, portfolio link)
  2. Professional Summary (3-4 lines, keyword-dense)
  3. Core Competencies (6-8 keyword phrases in flex-grid)
  4. Work Experience (reverse chronological)
  5. Projects (top 3-4 most relevant)
  6. Education & Certifications
  7. Skills (languages + technical)

Keyword injection strategy (ethical, truth-based)

Examples of legitimate reformulation:

  • JD says "RAG pipelines" and CV says "LLM workflows with retrieval" → change to "RAG pipeline design and LLM orchestration workflows"
  • JD says "MLOps" and CV says "observability, evals, error handling" → change to "MLOps and observability: evals, error handling, cost monitoring"
  • JD says "stakeholder management" and CV says "collaborated with team" → change to "stakeholder management across engineering, operations, and business"

NEVER add skills that the candidate does not have. Only reword real experience using the exact JD vocabulary.

Template HTML

Before generating: read modes/_custom.md (if it exists) and apply its formatting/content house rules to every CV in this session — including every item of a batch. Rules recorded there (date formats, section-order preferences, content to always/never include) are persistent user instructions, not suggestions; if the user corrects the same thing twice in conversation, write it into modes/_custom.md so it stops drifting.

Selecting the template

Resolve which template to fill with the shared resolver (do not hardcode cv-template.html):

  • If the user named a template this turn (e.g. "use the modern template"), run: node cv-templates.mjs resolve cv "<name>"
  • Otherwise run: node cv-templates.mjs resolve cv (this returns the cv.template default from config/profile.yml, or the base cv-template.html when unset).

The command prints the absolute path of the template to fill; a non-zero exit means the named template is missing or invalid — surface that message to the user instead of silently falling back.

To show the user their options (e.g. "what CV templates do I have?"), run node cv-templates.mjs list cv and present each displayName.

build-cv-html.mjs fills that resolved template from the JSON payload you build — it owns every tag, CSS class, and the HTML escaping, so you never emit full HTML markup and do not escape &/</>/quotes yourself. Pass the resolved path as the third argument (node build-cv-html.mjs <input.json> <output.html> <template.html>); omit it to fall back to the base cv-template.html. This is the HTML twin of build-cv-latex.mjs (see modes/latex.md) and cuts the PDF step's output tokens from full markup down to the compact payload below (#557).

JSON Input Schema

Write a JSON file with this structure, then run node build-cv-html.mjs <input.json> <output.html> [template.html] (the optional third argument is the template path from Selecting the template; omit it for the base cv-template.html).

{
  "lang": "en",
  "page_format": "letter",
  "candidate": {
    "name": "Jane Smith",
    "phone": "+1 415 555 0100",
    "email": "jane@example.com",
    "linkedin": { "url": "https://linkedin.com/in/janesmith", "display": "linkedin.com/in/janesmith" },
    "portfolio": { "url": "https://janesmith.dev", "display": "janesmith.dev" },
    "location": "San Francisco, CA",
    "photo": ""
  },
  "sections": {
    "summary": "Professional Summary",
    "competencies": "Core Competencies",
    "experience": "Work Experience",
    "projects": "Projects",
    "education": "Education",
    "certifications": "Certifications",
    "skills": "Skills"
  },
  "summary": "Personalized summary with JD keywords injected (honest vs cv.md).",
  "competencies": ["RAG Pipelines", "LLMOps", "Kubernetes & Docker"],
  "experience": [
    {
      "company": "Company Name",
      "role": "Job Title",
      "location": "Remote",
      "dates": "June 2022 - Present",
      "bullets": ["Achievement bullet with JD keywords injected", "Another quantified-impact bullet"]
    }
  ],
  "projects": [
    { "name": "Project Name", "badge": "Open Source", "tech": "Python, FastAPI", "description": "What it does." }
  ],
  "education": [
    { "title": "B.S. Computer Science", "org": "University Name", "year": "2022", "description": "Optional line." }
  ],
  "certifications": [
    { "title": "Certified Kubernetes Administrator", "org": "CNCF", "year": "2024" }
  ],
  "skills": [
    { "category": "Languages", "items": "Python, JavaScript, C++" },
    { "category": "Frameworks", "items": ["FastAPI", "React", "PyTorch"] }
  ]
}

Field reference

Field Type Notes
lang string CV language code (en, es, ja, ar). Drives language-specific CSS: ja enables a CJK font fallback so Japanese renders instead of tofu (□); ar enables RTL + Arabic fonts. Defaults to en.
page_format string letter8.5in page width, a4210mm. Defaults to letter. Pass the SAME value to generate-pdf.mjs --format.
candidate.name string From profile.yml.
candidate.phone string Optional — omit or leave empty to drop the tel: link and its separator (no empty cell).
candidate.email string From profile.yml.
candidate.linkedin {url, display} Optional — omit to drop the item and its separator.
candidate.portfolio {url, display} Optional — omit to drop the item and its separator.
candidate.location string From profile.yml.
candidate.photo string Opt-in profile photo (#264): a local path or data: URL. Empty/absent emits no <img>, rendering pixel-for-pixel identical to the photoless layout (US/UK/many-market ATS penalize photos; opt in for DACH/European markets).
sections object Optional localized section titles; any omitted key falls back to the English default shown above.
summary string Personalized summary with keywords.
competencies string[] 6-8 keyword phrases → competency tags.
experience[] object company, role, location (optional), dates, bullets (reordered, keyword-injected).
projects[] object name, badge (optional), tech (optional), description (a bullets array is also accepted and joined into the description line).
education[] object title (degree), org (institution), year, description (optional).
certifications[] object title, org, year.
skills[] object category + items (comma-separated string or string array).

build-cv-html.mjs errors out (non-zero exit) if any template placeholder is left unresolved, so a malformed payload fails loudly instead of shipping a broken CV. Run node build-cv-html.mjs --test for a self-test render.

Profile photo (opt-in, market-specific)

The {{PHOTO}} slot is off by default and intentionally market-specific:

  • DACH / much of continental Europe (Germany, Austria, Switzerland): a professional photo is standard and often expected. Opt in by setting candidate.photo in config/profile.yml (a local file path or a data: URL).
  • US / UK / Canada / Australia and many ATS-first markets: photos are discouraged and can trip bias-avoidance filters. Leave candidate.photo empty — the {{PHOTO}} line is dropped entirely, no <img> is emitted, and the CV renders pixel-for-pixel identical to today's photoless layout.

When set, the photo floats into the top corner (mirrored for RTL/Arabic) and the header/summary text wraps beside it; .cv-photo in cv-template.html controls its size and framing.

Canva CV Generation (optional)

If config/profile.yml has cv.canva_resume_design_id set, offer the user a choice before generating:

  • "HTML/PDF (fast, ATS-optimized)" — existing flow above
  • "Canva CV (visual, design-preserving)" — new flow below

If the user has no cv.canva_resume_design_id, skip this prompt and use the HTML/PDF flow.

Canva workflow

Step 1 — Duplicate the base design

a. export-design the base design (using cv.canva_resume_design_id) as PDF → get download URL b. import-design-from-url using that download URL → creates a new editable design (the duplicate) c. Note the new design_id for the duplicate

Step 2 — Read the design structure

a. get-design-content on the new design → returns all text elements (richtexts) with their content b. Map text elements to CV sections by content matching:

  • Look for the candidate's name → header section
  • Look for "Summary" or "Professional Summary" → summary section
  • Look for company names from cv.md → experience sections
  • Look for degree/school names → education section
  • Look for skill keywords → skills section c. If mapping fails, show the user what was found and ask for guidance

Step 3 — Generate tailored content

Same content generation as the HTML flow (Steps 1-11 above):

  • Rewrite Professional Summary with JD keywords + exit narrative
  • Reorder experience bullets by JD relevance
  • Select top competencies from JD requirements
  • Inject keywords naturally (NEVER invent)

IMPORTANT — Character budget rule: Each replacement text MUST be approximately the same length as the original text it replaces (within ±15% character count). If tailored content is longer, condense it. The Canva design has fixed-size text boxes — longer text causes overlapping with adjacent elements. Count the characters in each original element from Step 2 and enforce this budget when generating replacements.

Step 4 — Apply edits

a. start-editing-transaction on the duplicate design b. perform-editing-operations with find_and_replace_text for each section:

  • Replace summary text with tailored summary
  • Replace each experience bullet with reordered/rewritten bullets
  • Replace competency/skills text with JD-matched terms
  • Replace project descriptions with top relevant projects c. Reflow layout after text replacement: After applying all text replacements, the text boxes auto-resize but neighboring elements stay in place. This causes uneven spacing between work experience sections. Fix this:
  1. Read the updated element positions and dimensions from the perform-editing-operations response
  2. For each work experience section (top to bottom), calculate where the bullets text box ends: end_y = top + height
  3. The next section's header should start at end_y + consistent_gap (use the original gap from the template, typically ~30px)
  4. Use position_element to move the next section's date, company name, role title, and bullets elements to maintain even spacing
  5. Repeat for all work experience sections d. Verify layout before commit:
  • get-design-thumbnail with the transaction_id and page_index=1
  • Visually inspect the thumbnail for: text overlapping, uneven spacing, text cut off, text too small
  • If issues remain, adjust with position_element, resize_element, or format_text
  • Repeat until layout is clean e. Show the user the final preview and ask for approval f. commit-editing-transaction to save (ONLY after user approval)

Step 5 — Export and download PDF

a. export-design the duplicate as PDF (format: a4 or letter based on JD location) b. IMMEDIATELY download the PDF using Bash:

curl -sL -o "output/cv-{candidate}-{company}-canva-{YYYY-MM-DD}.pdf" "{download_url}"

The export URL is a pre-signed S3 link that expires in ~2 hours. Download it right away. c. Verify the download:

file output/cv-{candidate}-{company}-canva-{YYYY-MM-DD}.pdf

Must show "PDF document". If it shows XML or HTML, the URL expired — re-export and retry. d. Report: PDF path, file size, Canva design URL (for manual tweaking)

Error handling

  • If import-design-from-url fails → fall back to HTML/PDF pipeline with message
  • If text elements can't be mapped → warn user, show what was found, ask for manual mapping
  • If find_and_replace_text finds no matches → try broader substring matching
  • Always provide the Canva design URL so the user can edit manually if auto-edit fails

Cover Letter Sub-flow

After generating the CV PDF, offer to generate a cover letter:

CV PDF generated: output/{path}

Want a cover letter for this role too?
- Say "yes" or "cover letter" to generate one now
- Or run `/career-ops cover {slug}` later

Apply voice-dna.md (if present) to the cover letter — full guardrail, conversational voice included (Tier 1 + Tier 2). The CV PDF itself stays Tier 1 only (formal ATS register). See _shared.md → Voice DNA.

If the user says yes, run the full cover letter flow from modes/cover.md in slug mode:

  1. Load the existing ## Cover Letter Draft from the evaluation report as a starting point
  2. Run company research (Step 3 of cover.md)
  3. Present keyword list for confirmation (Step 4)
  4. Surface any gaps (Step 5)
  5. Ask the four prompts: why / problems / approach / tone (Step 6)
  6. Draft in chat, wait for approval (Steps 7-8)
  7. Generate cover letter PDF via node generate-cover-letter.mjs (Step 9)
  8. Report both PDF paths

Do not auto-generate the cover letter PDF without going through the interactive steps above.

Post-generation

Update tracker if the job is already registered: change PDF from to .