hugo palma.work

Thought Logged Jan 20, 2026

Me, Claude vs jsPDF - The Saga

If you've ever tried to build a resume generator, you've probably experienced the same frustration I did: ATS systems are absolute garbage at parsing PDFs. They take your beautifully formatted resume, run it through some ancient OCR algorithm from 2003, and somehow extract "John Do" as your name because they got confused by a font ligature. Your years of experience at "Google" become "G o o g 1 e" and suddenly you're unemployable.

So I decided to build a better solution: a privacy-first, client-side resume generator that creates ATS-friendly PDFs with invisible semantic markdown tags. Three hard requirements:

  • Zero server processing: No user data leaves their browser (privacy first)
  • Sub-second generation: Fast for users (no waiting)
  • Static hosting friendly: Light on our server (or no server at all)

Sounds simple, right? Just use jsPDF, match the CSS preview, done.

I was wrong...

This is the story of how technical brilliance and obvious stupidity collided over 8 days of development. We made some brilliant architectural decisions. We also made some hilariously dumb mistakes. Sometimes within the same hour.


The Problem: jsPDF Doesn't Speak CSS

Here's the fundamental issue: jsPDF has zero CSS support. It's a coordinate-based PDF API that thinks in absolute millimeter positions:

doc.text("Hello World", 10, 20); // x=10mm, y=20mm from top-left

Your browser's rendering engine, on the other hand, speaks CSS:

h1 {
  font-size: 18pt;
  margin-bottom: 4px;
  line-height: 1.4;
}

These are fundamentally incompatible paradigms. CSS thinks in relationships (margins, padding, flow). jsPDF thinks in absolute coordinates. It's like trying to translate poetry into assembly language.

The naive approach would be: "Just measure the CSS elements and convert to PDF coordinates!"

const element = document.querySelector('h1');
const rect = element.getBoundingClientRect();
doc.text(element.textContent, rect.left, rect.top); // WRONG

This fails spectacularly because:

  • Browser pixels ≠ PDF millimeters: Browsers use 96 DPI logical pixels. jsPDF uses millimeters internally.
  • Retina displays exist: My MacBook Pro reports devicePixelRatio: 2.0, but CSS doesn't care about that.
  • Baseline vs top: CSS measures from the top of the line box. jsPDF draws from the text baseline.
  • Line height is a mystery: CSS line-height: 1.4 on font-size: 9pt creates a 12.6pt line box, but where does the first line actually start?

Most developers give up here and reach for Puppeteer or some server-side HTML-to-PDF solution.

But I had a hard requirement: zero server processing. Resumes contain sensitive data (names, addresses, employment history). Sending that to a server creates liability, GDPR nightmares, and trust issues. The solution had to be 100% client-side.


Day 1: The Retina Display Trap (Dumb Edition)

I started brilliantly: "I'll measure the screen with an actual A4 sheet of paper!" (I'm a robotics engineer, measuring things with physical objects is my jam.)

  • A4 page: 210mm × 297mm (standard)
  • Physical screen pixels: 860 pixels = 210mm width
  • Calculation: 860px ÷ 210mm = 4.1 pixels per millimeter

Feeling like a genius, I wrote my formula:

const PX_TO_MM = 210 / 860; // 0.244 mm/px
// I am a god of measurement

Generated a PDF. Opened it.

Everything was cramped and compressed. Sections overlapped. Text was squished. It looked like a resume that went through a trash compactor.

The stupidity: I measured physical screen pixels but CSS works in logical pixels. On a Retina display with devicePixelRatio=2.0, these are not the same thing. I was converting 430 CSS pixels thinking they were 860 physical pixels.

I spent 3 hours debugging this. Three hours. Because I was so confident in my physical measurement that I refused to believe the formula was wrong.

Lesson: Physical measurements don't mean shit when software uses logical units. Stop being clever. Read the specs.

The First Failure

User (me): "Button not working on CV builder. PDF output doesn't match preview. Spacing severely compressed. Header cramped, sections collapsed."

Claude: "Attempted spacing translator to measure Shadow DOM and convert CSS px to PDF mm. Used hardcoded 0.264583 px/mm conversion, didn't account for devicePixelRatio."

Me: "I'll research proper CSS-to-PDF conversion independently. This is complex as fuck: involves CSS pixels, device pixels, Retina scaling, DPI, and mm conversion."

Status: Backed up production code. Paused for deep research.


Day 2: The DPI Revelation

After diving into W3C CSS specs and jsPDF GitHub issues (yes, I read specs for fun), I found the answer:

CSS pixels are ALWAYS logical units at 96 DPI, regardless of Retina displays.

The correct formula:

const CSS_TO_MM = 25.4 / 96;  // 0.26458333 mm per CSS pixel
const PT_TO_MM = 25.4 / 72;   // 0.35277778 mm per point

Why 25.4? Because there are 25.4mm per inch. It's literally that simple.

Why 96? CSS defines 1 inch = 96 pixels (logical).

Why 72? PostScript/PDF defines 1 inch = 72 points.

Critical insight: getBoundingClientRect() returns logical CSS pixels, not physical screen pixels. The browser already handled devicePixelRatio for me. I was trying to be too clever.


Day 3: The Deep Research Thesis (Smart but Useless)

After the Retina disaster, I asked Claude (the AI helping me) to research the proper CSS-to-PDF conversion methodology.

Claude went into Deep Research mode and produced:

  • A 20-page academic paper on CSS pixels, device pixels, DPI, and coordinate systems
  • A practical markdown implementation guide
  • Mathematical validation of BOTH approaches (DOM measurement AND hardcoded formulas)
  • References to W3C specs, PDF specifications, and PostScript documentation

It was technically flawless. Beautifully researched. Completely academic. The kind of document you'd cite in a dissertation.

And solving the wrong damn problem.

We didn't need a thesis on coordinate systems. We needed a working PDF generator. The research was brilliant. The timing was idiotic.

The Comedy Moment

Me (after reviewing the 20-page thesis): "So we did all of this research to prove we were both right? And none of it worked?"

Claude: "Deep Research validated both approaches mathematically..."

Me: [staring at screen in disbelief]

We had just spent hours researching to prove that two different approaches were both technically correct, when the real problem was that we were using the wrong approach entirely. This is peak engineer energy: being precisely right about the wrong thing.


Day 4: The "FUCK What the Person Sees" Moment

This was the breakthrough. The moment of clarity that cut through all the academic bullshit.

I realized: We don't need dynamic measurement if our CSS is fixed.

The Breakthrough That Changed Everything

Me: "FUCK what the person sees. The user is seeing OUR CSS. As long as we have defined numbers in our CSS; not auto; not 100%, but actual numbers. We can use the formula. That way it will generate PDFs even on a phone."

This was the pivotal realization. Stop trying to measure the DOM. Stop caring about what the browser renders. We control the CSS. We know the exact values. Just extract them from the source code and apply the formula.

Claude: "Device-independent: Works on phone, desktop, Retina displays. No DOM measurement needed."

It was neither my approach (hardcoded values) nor Claude's approach (dynamic measurement) alone. It was a hybrid: use the formulas (Claude was right), but apply them to CSS source values (I was right), not rendered DOM.

Both approaches were correct in isolation. The combination was the breakthrough.

Since the resume builder uses predefined CSS (not user-provided), we can extract spacing values directly from our stylesheets:

const cssSpacing = {
  headerBottomMargin: 12,     // pixels
  h1MarginBottom: 4,          // pixels
  h2MarginTop: 12,            // pixels
  h2MarginBottom: 6,          // pixels
  // ... etc
};

// Convert to PDF mm
function px(value) {
  return value * (25.4 / 96);  // pixels → mm
}

y += px(cssSpacing.h2MarginBottom); // Add 1.587mm

Advantages:

  • No DOM measurement required
  • Works even before preview is rendered
  • Portable to Node.js for server builds
  • Zero browser zoom issues
  • Device-independent (phones, tablets, 8K monitors, doesn't matter)

This became the foundation of jsPDF++.


Day 5: The Custom Fonts Breakthrough

The PDFs worked. Spacing was perfect. Everything aligned.

But they looked like shit.

jsPDF defaults to Helvetica. Every PDF looked like a generic document from 1995. I wanted professional typography.

The Font Quality Conversation

Me: "All themes using same font (Helvetica). What about Inter and JetBrains Mono? How hard is it to add custom fonts? I have TTF files. What's the overhead?"

Claude: "Base64 conversion: NOT turning fonts into images, just encoding binary TTF as text. One-line command per font. ~100KB overhead for 3 fonts."

Me: "100KB? That's approved. Do it."

The solution: Embed custom fonts as base64-encoded strings.

# One command per font
base64 -i Inter-Regular.ttf -o inter-regular-base64.txt
base64 -i Inter-Medium.ttf -o inter-medium-base64.txt
base64 -i JetBrainsMono-Regular.ttf -o jetbrains-mono-base64.txt

Result: 3 base64 text files (~1MB total). Not images; the TTF vector data encoded as text strings.

Integration:

// Load custom fonts (browser version using fetch)
const interRegular = await loadFontAsBase64('/static/fonts/Inter-Regular.ttf');
doc.addFileToVFS('Inter-Regular.ttf', interRegular);
doc.addFont('Inter-Regular.ttf', 'Inter', 'normal');

// Use in PDF
doc.setFont('Inter', 'normal');
doc.text('Beautiful typography', x, y);

Font mapping per theme:

  • Modern/Classic/Creative/Creative2: Inter (clean, modern sans-serif)
  • Terminal/Compact: JetBrains Mono (monospace for that hacker aesthetic)

The PDFs went from "generic 1995 document" to "modern professional resume." Inter's kerning and hinting make 9pt body text crystal clear at print resolution.


Day 6: The Gradient PNG Sizing Disaster (A Study in Obvious Mistakes)

For the Creative themes, I wanted gradient accent bars. jsPDF doesn't support CSS gradients, so I decided to create PNG images.

The dumb part: I created them in Photoshop using a touchpad. Not a mouse. A touchpad. Because I'm a robotics engineer with literally zero design experience. As in, I have never taken a design class, never read a design book, never even looked at a color wheel with purpose. I work with robots. Robots don't care if things are pretty.

The engineer brain part: I used Photoshop's ruler tool to measure the exact pixel coordinates of where the gradient should be placed. Not design tools. Not guides. The ruler tool. Because when you have zero design intuition, you compensate with precision measurements.

This is peak engineer energy: using design software like it's CAD. Measuring gradients like they're mechanical tolerances. Being incredibly precise about something I'm completely unqualified to do.

The even dumber part: I didn't calculate the dimensions first. I just eyeballed it. "7mm seems reasonable for a gradient bar," I thought, while having absolutely no design intuition to base this on.

The dumbest part: I was proud of these gradient PNGs. I thought they looked good. This is the confidence of someone who has never had their design work reviewed by an actual designer.

First attempt:

  • Section bar height I created: 7mm (seemed reasonable)
  • Actual h2 text height: 10pt ≈ 3.5mm
  • Math I should have done first: 7mm ÷ 3.5mm = 2× too tall

Result: The bars were comically huge. They looked like low-resolution stretched images from a 2005 PowerPoint presentation. Completely unprofessional. My "Creative Designer" theme looked like it was designed by someone who had never seen good design.

Which is accurate, because I literally haven't. I'm a robotics engineer. My design experience consists of: making robots not fall over, and choosing between red or green LEDs. That's it. And now I'm trying to create "designer" themes with gradient accents using a touchpad and Photoshop's ruler tool.

This is the software equivalent of a chef deciding to perform surgery because "how hard can it be?" Except the chef is using kitchen scales to measure incision depth because precision must compensate for incompetence.

Somehow, it worked. Not because I knew what I was doing. But because I measured everything with engineering precision and iterated until the numbers aligned. The gradients don't look good because I have taste. They look good because I measured them down to the pixel until they matched the text height mathematically.

Designers use intuition. Engineers use rulers. When you're an engineer doing design work, you use rulers and hope nobody asks why.

The Sizing Disaster

Me (after seeing first attempt): "Section pills too big and stretched (low resolution appearance). Section pills should be same height as section title text."

Claude's analysis:

  • 1. Section bar height: 7mm (too tall!) vs h2 text: 10pt ≈ 3.5mm

  • 2. Aspect ratio broken: PNG is 10×28px, needs proper scaling

  • 3. Header bar placed BEFORE contact info, should be AFTER

The fix:

  • sectionBarHeight: 3.5mm (matches h2 text)

  • sectionBarWidth: 1.25mm (maintains 10:28 aspect ratio)

  • sectionBarOffset: -2.5mm (left of title)

But wait, there was another problem: vertical alignment.

The bars were the right size but positioned a bit lower than the section titles. I was using an arbitrary offset:

const barY = y - 2; // ❌ Magic number

The proper solution: Calculate vertical centering mathematically.

const fontSize = pt(10);           // 3.528mm
const capHeight = fontSize * 0.7;  // 2.47mm (capital letter height)
const capCenter = y - (capHeight / 2);
const barY = capCenter - (barHeight / 2);

// barY = baseline - (text_height/2) - (bar_height/2)

The lesson: When working with pixel-perfect layouts, calculate everything. No magic numbers. No arbitrary offsets. Pure math.


Day 7: The Unicode Bullet Nightmare (When Fonts Betray You)

For the Creative theme, I wanted fancy bullet points: (4-point diamond) for Creative 1, (arrow?) for Creative 2.

First attempt: Use Unicode characters. This should be trivial in 2026, right?

prefixes: {
  bullet: '✦', // U+2726 Dingbats (looks beautiful in my code editor)
}

Result in PDF: & (ampersand) showed up instead.

What the actual fuck?

Spent 2 hours debugging. Checked encoding. Checked the PDF text layer. Checked if I was going insane. The ampersand mocked me from every bullet point.

The Unicode Hunt

Me: "Bullet points showing '&' instead of unicode star ✦"

Claude: "jsPDF's Helvetica font doesn't support ✦ (U+2726). Try ● (U+25CF solid circle) or ▶ (U+25B6 solid arrow) instead."

Me (after testing with Inter font): "★ (U+2605) renders successfully with Inter font. Why does ★ work but ✦ doesn't?"

Claude's explanation: "Unicode block subsetting. ★ (U+2605) is in Miscellaneous Symbols block which jsPDF includes. ✦ (U+2726) is in Dingbats block which gets skipped. jsPDF embeds ~500-1000 common glyphs, not all 10,000+ from the TTF."

Me: "Fuck Unicode. Draw it as a vector shape."

The breakthrough (dumb but effective): Stop fighting Unicode limitations. Forget about elegant character rendering. Just draw the damn shapes manually using jsPDF's triangle() API like it's 1995.

Is this elegant? No. Is it sophisticated? Absolutely not. Does it work perfectly? Yes.

function drawVectorBullet(doc, theme, x, y) {
  if (theme.name === 'Creative Designer') {
    // Draw 4-pointed diamond star
    doc.setFillColor(...theme.colors.bulletColor); // Purple

    const cx = x;      // Center X
    const cy = y - 1;  // Center Y (offset up)
    const size = 0.9;  // Radius in mm

    // Top petal
    doc.triangle(
      cx, cy - size,              // Top point
      cx - size*0.3, cy - size*0.3, // Left
      cx + size*0.3, cy - size*0.3  // Right
    ).fill();

    // Right petal
    doc.triangle(
      cx + size, cy,              // Right point
      cx + size*0.3, cy - size*0.3, // Top
      cx + size*0.3, cy + size*0.3  // Bottom
    ).fill();

    // Bottom petal
    doc.triangle(
      cx, cy + size,              // Bottom point
      cx + size*0.3, cy + size*0.3, // Right
      cx - size*0.3, cy + size*0.3  // Left
    ).fill();

    // Left petal
    doc.triangle(
      cx - size, cy,              // Left point
      cx - size*0.3, cy + size*0.3, // Bottom
      cx - size*0.3, cy - size*0.3  // Top
    ).fill();
  }
}

Result: Perfect vector bullets that scale with PDF zoom. No Unicode, no font dependencies, just pure geometry. Eight triangles forming a diamond. Brute force over elegance.

Sometimes the elegant solution is abandoning elegance and going low-level. This is the software engineering equivalent of "if it's stupid but it works, it's not stupid."


Day 8: The Compact Theme 7-Iteration Grind (No Breakthroughs, Just Work)

The Compact Executive theme needed bordered boxes around section headers. Simple, right?

Wrong.

I discovered pdftoppm (a command-line tool to convert PDFs to PNGs) and started doing pixel-perfect visual comparisons between my preview and the actual PDF output. This is what I should have been doing from Day 1, but I was too busy being clever with my Retina display measurements.

pdftoppm -png -f 1 -l 1 -r 200 output.pdf comparison

This revealed seven separate issues that required seven separate fixes:

  1. Border too thick: 3mm → 0.5mm
  2. Text not blue: Black → Blue (#2563eb)
  3. Text overlapping border: Added 2mm padding
  4. Wrong styling logic: Refactored to theme-based (not text-based string matching)
  5. Missing header divider: Added 0.5mm black line
  6. Header padding wrong: Spacing after divider adjusted to 6mm
  7. Bullets wrong color: Made > blue (#2563eb)

The Grind (The Unglamorous Part)

This wasn't a breakthrough moment. This was the grind. Seven separate iterations. Each discovered by converting the PDF to an image and comparing pixels using pdftoppm. No shortcuts. No clever insights. Just methodical debugging.

Generate PDF. Convert to PNG. Compare with preview. Find mismatch. Fix code. Repeat.

This is what real engineering looks like. Not eureka moments. Just iteration until perfect. Sometimes you can't think your way out of a problem. You have to grind your way out.


The Semantic Class Pivot: A Brilliant Realization

During the integration phase, we hit an architectural decision point. How should we structure the HTML classes for form inputs?

The obvious approach: context-specific classes.

<input class="job-title">
<input class="education-degree">
<input class="project-name">

This seems logical. Each field has a specific purpose, so give it a specific class name.

But then I realized something: What if the user wants to reorder sections? What if they want to use the "Experience" section for volunteer work? What if someone in a different country uses different terminology?

Context-specific classes lock you into a rigid structure. You can't be flexible.

The breakthrough: Use semantic classes based on what the content is, not where it appears.

<input class="name">      <!-- A name (could be person, company, institution) -->
<input class="role">      <!-- A title/role/position -->
<input class="company">   <!-- An organization -->
<input class="period">    <!-- A date range -->
<input class="section">   <!-- A section header -->
<input class="summary">   <!-- A paragraph of text -->
<input class="bullet">    <!-- A bullet point -->

Benefits:

  • Language-agnostic: Works in any language, any terminology
  • User-orderable: Sections can be reordered without breaking styling
  • Flexible: "Experience" section can hold jobs, volunteer work, projects, whatever
  • Single source of truth: One CSS rule per content type, regardless of context
  • Maintainable: Changing styling for "names" changes ALL names, not just job titles

This was a pivot that made the entire system more elegant. Instead of fighting structure, we embraced flexibility.


The Ghost Tags: Solving ATS Parsing

Remember the original problem? ATS systems suck at parsing PDFs.

The solution: invisible markdown tags.

jsPDF lets you set text color. What if we write text in the same color as the background?

// Write invisible tag in background color
doc.setTextColor(...theme.colors.bg); // White on white (invisible)
doc.text('# ', margin, y);

// Write visible name
doc.setTextColor(...theme.colors.name);
doc.text('Hugo Palma', margin, y);

Result in PDF text layer:

# Hugo Palma
Solutions Architect
[email protected]

## PROFESSIONAL SUMMARY
Experienced architect with...

## EXPERIENCE
### Senior Solutions Architect
* Led cloud migration project
* Reduced costs by 40%

ATS systems can now parse:

  • # Name → Candidate name
  • ## Section Title → Major sections
  • ### Job Title → Work history
  • * Bullet → Achievements

Overhead: ~500 bytes of invisible characters per resume.

Bonus: Text remains selectable and copyable by humans too. You can still Cmd+F to search the PDF.

This is the kind of hack that makes you feel like a wizard. It's technically correct (the best kind of correct), completely invisible to users, and solves a real problem.


The Architecture: Modular Themes

With spacing solved, fonts embedded, and rendering working, the next challenge: supporting 6 different themes without code duplication.

Each theme has different colors, fonts, spacing, decorations, and special features. How do we organize this without creating spaghetti code?

The solution: Theme objects + rendering pipelines.

const modernTheme = {
  name: 'Modern Professional',
  margin: 15,
  spacing: { h1MarginBottom: 4, ... },
  fonts: { h1: 18, h2: 10, ... },
  colors: { bg: [255,255,255], heading: [37,99,235], ... },
  prefixes: { bullet: '•' },
  images: null,
  fontFamily: 'Inter',
  render: {
    headerName(doc, text, x, y, theme, pageWidth) { ... },
    section(doc, text, x, y, theme, pageWidth, margin) { ... },
    bullet(doc, text, x, y, theme, pageWidth, margin) { ... },
  }
};

Dynamic module loading:

const [coreModule, themeModule] = await Promise.all([
  import('/static/js/cv/core.js'),
  import(`/static/js/cv/theme-${selectedTheme}.js`)
]);

await coreModule.generatePDF(themeModule.default, formData);

Result: Only load what you need. Modern theme = 12KB. Terminal theme = 8KB. No wasted bandwidth.

The old monolithic version was 45KB of JavaScript that loaded all 6 themes upfront. The new modular version:

  • Initial page load: 10KB (UI only)
  • PDF generation: 10KB core + 4-12KB theme (one theme only)
  • Total savings: 47% smaller

The Font Loading Overhead Question

Me: "45kb held all the fonts?"

Claude: "Confirmed 45KB included base64 fonts (not our fault). Built complete modular CV builder: core.js (9.9KB) = jsPDF++ engine with ghost tags, theme-modern.js (4.2KB) = Modern theme rendering rules, cv-builder.js (10KB) = UI with native HTML5 drag/drop. Total JS: 24KB vs 45KB before. Performance: 47% smaller, only loads 1 theme per PDF generation."

The lesson: Always question inherited file sizes. Modular architecture revealed that most of the bloat was unnecessary.


The Result: jsPDF++

After 8 days of iteration, breakthroughs, failures, and grinding, the result:

  • 6 professional themes (Modern, Classic, Compact, Terminal, Creative, Creative 2)
  • Sub-second PDF generation (800-1200ms)
  • Zero server processing (100% client-side)
  • ATS-friendly output (invisible markdown tags)
  • Custom fonts (Inter, JetBrains Mono)
  • Professional styling (gradient accents, vector bullets, bordered boxes)
  • 100-150KB file sizes (small PDFs)
  • Device-independent (works on phones, tablets, 8K monitors)
  • Privacy-first (no data leaves the browser)
  • MIT licensed (open source)

The Complete Unit Conversion Reference

// Based on international standards
const PT_TO_MM = 25.4 / 72;  // 0.35277778 (PostScript points)
const PX_TO_MM = 25.4 / 96;  // 0.26458333 (CSS logical pixels)

// Conversion functions
function px(cssPx) {
  return cssPx * PX_TO_MM;
}

function pt(fontSize) {
  return fontSize * PT_TO_MM;
}

function lineHeight(fontSize, lh) {
  return fontSize * lh * PT_TO_MM;
}

// Example usage
// CSS: h1 { font-size: 18pt; margin-bottom: 4px; }

doc.setFontSize(18); // jsPDF font size in points
doc.text("Name", x, y);
y += pt(18);         // Move down by font height (6.35mm)
y += px(4);          // Add CSS margin (1.058mm)

Theme Examples

Modern Professional: Clean blue headings, Inter font, standard bullets

Classic Minimalist: Centered uppercase name, horizontal line separator, black and white aesthetic

Compact Executive: Tight spacing, JetBrains Mono, grey bars with blue text, blue bullets

Terminal/Hacker: Dark background (#0d1117), green text, command-line prefixes ($ whoami >, [>], |--), JetBrains Mono

Creative Designer: Large purple headings, gradient PNG accent bars, vector diamond bullets, Inter font

Creative Designer 2: Teal color scheme, gradient accents, vector triangle bullets, modern aesthetic


The Lessons Learned

1. Sometimes "simple" is harder than "complex"

It would have been trivial to spin up a Node.js server with Puppeteer. But the constraint of client-side-only forced creative solutions: ghost tags, vector bullets, manual layout engines. (Also, Puppeteer uses chromium engine, which tries to be "clever" and strips out our hidden sematic tags, defeating the whole point)

The constraint made the solution better.

2. Abstractions leak, so learn the layer below

Understanding the difference between physical pixels, CSS pixels, and PDF points was critical. You can't debug what you don't understand.

Read the W3C specs. Read the jsPDF source. Read the PDF specification.

3. Modularity enables experimentation

By separating themes into modules, adding a new design takes 30 minutes instead of 3 days. The architecture enables rapid iteration.

Design for extension, not modification.

4. Performance is a feature

Users don't wait 5 seconds for a PDF. They wait 1 second, max. Optimization wasn't optional.

Lazy loading, caching, and async operations turned 3-second generation into 1 second.

5. Sometimes the elegant solution is going low-level

Unicode bullets didn't work. Instead of fighting font subsetting, I drew shapes manually with triangles.

Elegance is solving the problem, not using the fanciest API.

6. Collaboration between human and AI works (when you accept you're both wrong)

Claude proposed dynamic DOM measurement. I proposed hardcoded pixel values. We researched for hours to prove both approaches were mathematically valid.

Then we realized: both approaches were right, but both were also solving the wrong problem.

The breakthrough was combining both: use the formulas (Claude was right), but apply them to CSS source values (I was right), not rendered DOM. A hybrid approach that neither of us would have reached alone.

Sometimes "we're both right" means you're both wrong about what question you're answering.


Why We Went Through This Hell: Privacy & Performance

You might be wondering: "Why not just use Puppeteer on a server? Why torture yourself with client-side PDF generation?"

Three reasons:

1. Privacy is Non-Negotiable

Resumes contain:

  • Full names, addresses, phone numbers
  • Employment history (potentially confidential)
  • Skills, certifications, references
  • Sometimes salary expectations

Sending this to a server means:

  • Data breach liability (we're responsible if hacked)
  • GDPR compliance obligations (cookie banners, privacy policies, deletion requests)
  • User trust issues (do they really believe we don't store it?)

Client-side processing means:

  • Zero data breach risk (we never see the data)
  • Zero compliance burden (no data = no regulations)
  • Verifiable privacy (open source code, inspect Network tab: zero POST requests)
  • Works offline (after initial page load)

This wasn't optional. This was the whole point.

2. Performance is a Feature

Server-side PDF generation:

  • 200-500ms network round-trip
  • Users queue behind each other
  • Server costs scale with users
  • Server can be down/slow/overloaded

Client-side PDF generation:

  • Zero network latency (runs in browser)
  • Zero queuing (every user has their own CPU)
  • Scales to infinite users (no server load)
  • Never goes down (unless the user's browser crashes)

Total generation time: 1 second. From clicking "Generate PDF" to downloading a 120KB file. Try that with Puppeteer.

3. Infrastructure is Expensive (We're Lazy)

Server-side architecture requires:

  • Backend servers (cost money)
  • Database (cost money)
  • Monitoring (cost money)
  • Security updates (cost time)
  • Scaling infrastructure (cost sanity)

Static hosting with client-side generation:

  • GitHub Pages: FREE
  • Netlify: FREE
  • Cloudflare Pages: FREE
  • Maintenance: Upload new HTML files

We wanted to ship a privacy-first resume generator and never pay for servers. Mission accomplished.

Extensibility

Adding a new theme: Create one JavaScript file with theme config, add one HTML element to theme picker. Core engine handles the rest.

Adding a new section type: Add rendering function to theme object, handle in core loop. No architectural changes needed.


The Numbers

Component Size Notes
JavaScript
core.js 8KB Core rendering engine
theme-modern.js 4KB Modern theme
theme-creative.js 12KB Creative theme (vector bullets)
Fonts
Inter-Regular.ttf 310KB Base font
JetBrainsMono-Regular.ttf 180KB Monospace font
Images
creative-header-bar.png 1.5KB Horizontal gradient
creative-section-bar.png 652B Vertical gradient pill
Output
resume.pdf (typical) 120KB With Inter font + 1 page
resume.pdf (compact) 85KB JetBrainsMono + compact spacing
resume.pdf (creative) 150KB Inter font + PNG images

Conclusion: Was It Worth It?

Building jsPDF++ required solving problems I didn't know existed:

  • CSS pixels vs physical pixels vs PDF millimeters (physical measurements don't matter)
  • Font subsetting and Unicode block limitations (just draw triangles)
  • Manual layout engines with Y-position tracking (goodbye automatic flow)
  • Baseline offset calculations for text alignment (math is hard)
  • Theme architecture for modular rendering (save 47% bandwidth)
  • Vector shape drawing for bullets (elegant is overrated)
  • Invisible markdown tags for ATS parsing (white on white is brilliant)
  • Dynamic module loading for performance (only load what you need)

The journey had:

  • Breakthroughs: "FUCK what the person sees" (use CSS source values)
  • Failures: The Retina display trap (3 hours debugging physical pixels)
  • Comedy: "So we researched to prove we were both right?" (academic thesis for wrong problem)
  • Grinding: 7 iterations on Compact theme (pdftoppm is your friend)
  • Collaboration: The hybrid approach (both right, both wrong, then correct together)
  • Stupidity: Gradient PNGs created with a touchpad (I have no design skills)
  • Cleverness: Vector bullets drawn with 8 triangles (Unicode can't stop us)

The result:

  • Privacy: Zero data leaves the browser. Verifiable. No servers. No liability.
  • Performance: 1-second PDF generation. Scales to infinite users. Works offline.
  • Infrastructure: Static hosting. GitHub Pages. FREE.
  • Quality: 6 professional themes, custom fonts, ATS-friendly, 100-150KB PDFs.

Was it harder than Puppeteer on a server? Absolutely.

Did we make dumb mistakes? Constantly.

Was it worth it? Hell yes.

jsPDF doesn't understand CSS. But now we've built a bridge. A bridge constructed from brilliant insights, obvious stupidity, academic theses that missed the point, 8-triangle vector bullets, invisible markdown tags, and the stubborn refusal to send user data to a server.

Sometimes the constraint makes the solution better. Sometimes you have to measure your screen with a piece of paper and realize you're an idiot. Sometimes you research for 20 pages to prove you're both right about the wrong thing.

But in the end: privacy-first, sub-second, ATS-friendly, client-side PDF generation. Mission accomplished.


Project Info

  • License: MIT

  • Author: Hugo Palma (Robotics Engineer, literally zero design experience)

  • Built with: jsPDF, Inter Font, JetBrains Mono, a touchpad, and hubris

  • Development time: 8 days (January 2026)

  • Lines of code: ~2000 (core + 6 themes)

  • Success rate: 274/274 test files generated with zero errors

  • Design talent: 0/10 (but the PDFs work)

  • Gradient PNGs created with: Touchpad in Photoshop (send help)

End of journal

Status: ARCHIVED