Skip to content

Latest commit

 

History

History
434 lines (345 loc) · 9.66 KB

File metadata and controls

434 lines (345 loc) · 9.66 KB

Implementation Guide

Overview

This guide explains how the resume parser works internally and how to customize it for your specific needs.

Architecture

Input PDF File
    ↓
[PDF Extraction Module]
  - Read PDF using pdfjs-dist
  - Extract text elements with positioning
  - Normalize formatting
    ↓
[Text Processing Module]
  - Group elements into lines (Y-coordinate clustering)
  - Merge adjacent elements (distance-based)
  - Group lines into sections (header detection)
  - Divide sections into subsections (gap analysis)
    ↓
[Feature Scoring Module]
  - Define feature matchers for each attribute
  - Score candidates based on weighted features
  - Select best candidate (highest score)
    ↓
[Extraction Modules]
  - Extract profile (name, email, phone, etc.)
  - Extract work experience
  - Extract education
  - Extract projects
  - Extract skills
    ↓
Output: Structured Resume Data

Core Concepts

1. Feature Scoring System

The heart of the parser uses a weighted feature matching algorithm:

Score = Σ(weight × matches)

For each resume attribute (name, email, etc.):

  1. Define a set of Feature objects

  2. Each Feature has:

    • A matcher function (returns boolean or matched string)
    • A weight (typically -4 to +4)
    • Optional extractText flag
  3. For each candidate text line:

    • Run through all features
    • Sum up weights for matches
    • Select line with highest score

Example: Name Extraction

const NAME_FEATURES = [
  // Positive signals
  {
    matcher: (line) => /^[A-Z][a-z]+ [A-Z][a-z]+$/.test(line.text),
    weight: 4,  // Strong match
  },
  {
    matcher: (line) => line.text.length < 30,
    weight: 1,  // Weak signal
  },
  // Negative signals (exclude)
  {
    matcher: (line) => line.text.includes('@'),
    weight: -4, // This is an email, not a name
  },
  {
    matcher: (line) => line.text.split(' ').length > 5,
    weight: -2, // Too many words, likely description
  },
];

const bestName = findBestCandidate(lines, NAME_FEATURES);

2. Section Detection

Resumes have clearly marked sections like "EXPERIENCE", "EDUCATION", etc.

Algorithm:

for each line:
  if line is "BOLD" and "ALL UPPERCASE":
    → This is a section header
    → Start new section
  else if line has keyword (experience, education, etc.):
    → Fallback: might be a section header
  else:
    → Add to current section

3. Subsection Division

Sections often have multiple entries (multiple jobs, schools):

Algorithm:

1. Calculate typical line gap in section
2. For each adjacent pair of lines:
   if gap > 1.4 × typical gap:
     → New subsection boundary
3. Divide section at boundaries

Fallback: If gap method fails, use bold text transitions:

if line is bold AND previous line is not bold:
  → New subsection

4. Text Line Grouping

PDF text is often split into multiple elements:

PDF raw: "John" (x:100) | "Smith" (x:125)
↓
Grouped: "John Smith"

Algorithm:

1. Calculate typical character width
2. For adjacent elements on same Y position:
   if distance ≤ char_width × 1.2:
     → Merge them
   else:
     → Keep separate

Customization Examples

Add Custom Section Extractor

import {
  TextLine,
  ResumeSection,
  findBestCandidate,
  hasAnyOf,
  Feature,
} from '@yourusername/resume-parser';

export function extractCertifications(section: ResumeSection): string[] {
  const certs: string[] = [];

  for (const line of section.lines) {
    // Each certification typically on its own line
    if (line.text.length > 5 && line.text.length < 80) {
      certs.push(line.text);
    }
  }

  return certs;
}

Adjust Scoring Weights

Make name detection more lenient:

const LENIENT_NAME_FEATURES: Feature[] = [
  // Lower weight threshold
  {
    matcher: (line) => line.text.length < 40,
    weight: 2,
  },
  {
    matcher: (line) => /^[A-Z]/.test(line.text),
    weight: 2,
  },
  // Weaker negative signals
  {
    matcher: (line) => line.text.includes('@'),
    weight: -2, // was -4
  },
];

Create Custom Features

import { Feature } from '@yourusername/resume-parser';

// Feature for detecting job titles at top of experience subsection
export const isLikelyJobTitle = (line): Feature => ({
  matcher: (l) => {
    const jobTitles = [
      'Engineer', 'Manager', 'Director',
      'Analyst', 'Architect', 'Lead'
    ];
    return jobTitles.some(title =>
      l.text.includes(title)
    );
  },
  weight: 4,
});

// Feature for excluding generic words
export const excludesCommonWords: Feature = {
  matcher: (line) => {
    const words = ['the', 'a', 'and', 'or'];
    const lineWords = line.text.toLowerCase().split(/\s+/);
    return !lineWords.every(w => words.includes(w));
  },
  weight: 1,
};

Override Section Extractor

import { extractExperienceSection } from '@yourusername/resume-parser';
import type { ResumeSection, WorkExperience } from '@yourusername/resume-parser';

export function extractExperienceCustom(
  section: ResumeSection
): WorkExperience[] {
  // Your custom logic here
  // Maybe parse dates differently
  // Or handle special company formats
  // Then fall back to default
  return extractExperienceSection(section);
}

Debugging

Enable Debug Mode

const parser = new ResumeParser({ debug: true });

Output:

[ResumeParser] ℹ️ Extracting text from PDF...
[ResumeParser] ℹ️ Extracted 245 text elements
[ResumeParser] ℹ️ Grouping elements into lines...
[ResumeParser] ℹ️ Created 48 text lines
[ResumeParser] ℹ️ Identifying sections...
[ResumeParser] ℹ️ Detected 6 sections
[ResumeParser] ℹ️ Extracting resume data...
[ResumeParser] ℹ️ Parsing completed in 127.45ms

Inspect Intermediate Results

import {
  extractPdfText,
  groupElementsIntoLines,
  groupLinesIntoSections,
} from '@yourusername/resume-parser';

const elements = await extractPdfText(fileUrl);
console.log('Elements:', elements);

const lines = groupElementsIntoLines(elements);
console.log('Lines:', lines);

const sections = groupLinesIntoSections(lines);
console.log('Sections:', sections);

Performance Optimization

For Large Resumes

const parser = new ResumeParser({
  timeout: 60000, // Increase from default 30000
});

Parallel Processing

async function parseMultipleResumes(files: File[]) {
  const parser = new ResumeParser();

  const results = await Promise.all(
    files.map(file => {
      const url = URL.createObjectURL(file);
      return parser.parse(url).finally(() => {
        URL.revokeObjectURL(url);
      });
    })
  );

  return results;
}

Testing

Unit Test Example

import { findBestCandidate, Feature } from '@yourusername/resume-parser';
import type { TextLine } from '@yourusername/resume-parser';

describe('Name Extraction', () => {
  it('should extract name correctly', () => {
    const lines: TextLine[] = [
      { text: 'john@example.com', posY: 100, elements: [] },
      { text: 'John Doe', posY: 110, elements: [] },
      { text: 'Senior Engineer', posY: 120, elements: [] },
    ];

    const features: Feature[] = [
      {
        matcher: (l) => /^[A-Z][a-z]+ [A-Z][a-z]+$/.test(l.text),
        weight: 4,
      },
      {
        matcher: (l) => l.text.includes('@'),
        weight: -4,
      },
      {
        matcher: (l) => l.text.includes('Engineer'),
        weight: -3,
      },
    ];

    const result = findBestCandidate(lines, features);
    expect(result.text).toBe('John Doe');
  });
});

Known Limitations & Workarounds

Multi-column Resumes

Problem: Text may not be in reading order

Workaround: Sort elements by position before grouping

import { sortElementsByReadingOrder } from '@yourusername/resume-parser';

const elements = await extractPdfText(url);
const sorted = sortElementsByReadingOrder(elements);

Scanned PDFs

Problem: No text layer, need OCR

Workaround: Use OCR library first

import Tesseract from 'tesseract.js';

// Convert PDF to image, then OCR
const imageUrl = await pdfToImage(pdfUrl);
const { data: { text } } = await Tesseract.recognize(imageUrl);
// Then use as text

Non-English Resumes

Problem: Keywords and patterns are English-specific

Workaround: Create language-specific feature sets

const SPANISH_KEYWORDS = {
  experience: ['EXPERIENCIA', 'TRABAJO', 'EMPLEO'],
  education: ['EDUCACIÓN', 'FORMACIÓN', 'ESTUDIOS'],
  skills: ['HABILIDADES', 'COMPETENCIAS'],
};

Contributing New Features

  1. Add type definitions to types/
  2. Implement feature in appropriate module
  3. Add tests in __tests__/
  4. Update exports in index.ts
  5. Add documentation in README.md

Performance Benchmarks

Resume Type Pages Elements Lines Time
Minimal 1 50 15 25ms
Standard 1 150 40 75ms
Detailed 2 300 80 150ms
Complex 3+ 500+ 150+ 300ms+

Troubleshooting

"PDF.js not initialized"

// Must call this first
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.mjs';

initializePdfJs(pdfjsLib, pdfjsWorker);

Incorrect name extraction

// Check debug output to see scores
const parser = new ResumeParser({ debug: true });
await parser.parse(url);
// Look for name scoring in console

Empty results

  1. Verify PDF is readable (not encrypted)
  2. Check if text is actual text (not an image)
  3. Try with a simpler resume first
  4. Enable debug mode to see extraction progress

For more examples, see the examples/ directory.