This guide explains how the resume parser works internally and how to customize it for your specific needs.
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
The heart of the parser uses a weighted feature matching algorithm:
Score = Σ(weight × matches)For each resume attribute (name, email, etc.):
-
Define a set of Feature objects
-
Each Feature has:
- A matcher function (returns boolean or matched string)
- A weight (typically -4 to +4)
- Optional
extractTextflag
-
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);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
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
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
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;
}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
},
];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,
};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);
}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
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);const parser = new ResumeParser({
timeout: 60000, // Increase from default 30000
});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;
}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');
});
});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);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 textProblem: 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'],
};- Add type definitions to
types/ - Implement feature in appropriate module
- Add tests in
__tests__/ - Update exports in
index.ts - Add documentation in
README.md
| 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+ |
// Must call this first
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.mjs';
initializePdfJs(pdfjsLib, pdfjsWorker);// Check debug output to see scores
const parser = new ResumeParser({ debug: true });
await parser.parse(url);
// Look for name scoring in console- Verify PDF is readable (not encrypted)
- Check if text is actual text (not an image)
- Try with a simpler resume first
- Enable debug mode to see extraction progress
For more examples, see the examples/ directory.