[parser] Section headers unrecognized when a decorative underscore rule is drawn on the header's baseline — every section falls into profile
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 72/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Quiet
- Tech stack
- typescript
- Domain
- backend
Research direction
Start in src/lib/heuristics/regex.ts at matchSectionHeaderDetailed, then read the section-routing tests and the generator convention in scripts/fixtures/gen-top-skills-header.mjs. Add the synthetic fixture at tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-underscore-rule-headers.pdf and a *.repro.test.ts covering long and short trailing rules plus end-to-end routing. Run npm run check:fixtures and npm run verify; done means the fixture yields non-zero experience and education counts without changing unrelated snapshots.
Written by the indexing model from the issue text.
Description
Problem
A well-formed, single-column résumé parses to contact fields only. Name, email, phone, location and LinkedIn come through; Experience, Education and Skills are all empty. No layout trigger fires, the résumé is not scanned, bullets are of normal length, and the "extracted text" box shows the full document — so every user-visible signal says the parse succeeded.
Originally reported from a friend-and-family test. The reproducing résumé is real and is not attached here or committed anywhere — see Reproducing for the synthetic fixture that stands in for it.
What the parser actually does
Probed with /probe-resume (read-only, PII-free output). Numbers below are from that run:
Sections detected (rawCharCount=2388 extractedCharCount=56): profile(26)
DEFECTS FOUND (0)
parsedCounts: hasFullName ✓ hasEmail ✓ hasPhone ✓ hasLocation ✓
experienceCount 0 educationCount 0 skillsCount 0
Tier 0 extraction is fine — 2388 characters of rawText, 90 positioned items, 1 page, zero layout triggers. Line assembly is also fine: groupIntoLines produces 39 correct, complete lines with the right text, y-order and wrap behaviour.
The failure is entirely in section routing. splitIntoSections returns exactly one section — profile — holding the whole document. Contact extraction is profile-banded, so it still works; every other extractor is scoped to a section that never opened, so all of them yield zero.
Root cause
The résumé draws its section rules as underscore runs, and the exporter emits each rule as its own text item on the same baseline as the header word:
{"str":"Education", "x":72, "y":122.359, "fontSize":14, "fontName":"g_d0_f1"}
{"str":"_____…____","x":139.631, "y":122.359, "fontSize":13, "fontName":"g_d0_f3"}
Line assembly correctly merges same-baseline items, so the header reaches the router as one line:
Education__________________________________________________________________________
Summary of Qualifications__________________________________________________________
Experience______________________________________________________________________
matchSectionHeaderDetailed (src/lib/heuristics/regex.ts:545) normalizes a candidate header by trimming, lowercasing and stripping a trailing [:·•]+ run (regex.ts:548), then strips a leading decorative glyph run (regex.ts:556-558, added for #414). Nothing strips a trailing decorative rule. So education__________… never equals the education alias and the match fails.
Verified directly:
| Header text | Length | matchSectionHeaderDetailed |
|---|---|---|
Education |
9 | {section: "education"} |
Education__________… (as drawn) |
83 | null |
Summary of Qualifications____… |
83 | null |
Experience____________… |
80 | null |
Education____ |
13 | null |
⚠️ The 40-character cap at regex.ts:559 is a short-circuit, not the cause. The last row above is well under the cap and still fails, because the exact-alias tier compares education____ against education. Raising or removing the cap does not fix this — the normalization has to drop the rule.
Every downstream zero follows from this one rejection: no header matches → no section opens → all 39 lines land in profile → experience/education/skills extractors have no region to read.
This is a #414-family bug, not a #493 zero-yield case
#493 ("Zero-yield expected sections") deliberately declines to teach the heuristic to recover sections a simpler parser would also miss — the right response there is detect, penalize, explain. That reasoning does not apply here. The headers are ordinary, standard-vocabulary section names (Education, Experience); only a purely visual decoration is glued to them. Any parser that normalizes header text handles this, so a penalty would ding a well-formed résumé for our own normalization gap.
The shipped precedent is #414 — a leading decorative glyph (¥Skills) dropped the skills section, and the fix was to strip the decoration in the same function. This is the mirror image of that bug.
Fix
Strip a trailing decorative rule in matchSectionHeaderDetailed, symmetric with the existing leading-glyph strip:
// src/lib/heuristics/regex.ts, matchSectionHeaderDetailed
let normalized = text.trim().toLowerCase().replace(/[:·•]+$/, "").trim();
// NEW — trailing decorative rule run (mirror of the leading-glyph strip, #414).
// Google Docs and Word draw a section rule as a run of underscores/dashes that
// line assembly merges onto the header's baseline; without this the exact-alias
// tier can never match. Bounded to >=3 to keep hyphenated headers intact.
normalized = normalized.replace(/[_—–=-]{3,}\s*$/, "").trim();
Do this before the length check at regex.ts:559, so a long rule no longer short-circuits the match.
Notes for the implementer:
- The
>= 3floor is load-bearing: a 1–2 character trailing dash must stay, so headers likeSkills -or a hyphenated alias are unaffected. - Keep the change in the matcher, not in line assembly or
dropDecorativeGlyphs. Filtering the rule item upstream would also changerawText, the emitted markdown, the round-trip export and all 58 baked fixture snapshots — a much wider blast radius for the same user-visible outcome. The rule staying visible in the extracted-text box is correct: it is what the PDF draws. - Verified locally by temporarily applying exactly this line:
profile(39)→profile(3) education(10) experience(13),extractedCharCount56 → 1462,experienceCount0 → 3,educationCount0 → 2. The only remaining defect the probe reports afterwards isroundtrip-experience-value-changed, which the corpus already covers viatests/fixtures/pdfs/google-docs/google-docs-skia-proxy-classic.pdf.
Reproducing
No fixture in the corpus reproduces this today. All 58 baked fixtures were swept for a _{5,} run in their extracted text; none carries one. A new synthetic fixture is required.
Mint it under the existing generator convention (scripts/fixtures/gen-*.mjs → npm run bake-fixtures):
- Path:
tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-underscore-rule-headers.pdf - Generator:
scripts/fixtures/gen-underscore-rule-headers.mjs, modelled ongen-top-skills-header.mjs - Shape that must be preserved — the defect lives in the item geometry, not the text. The rule must be a separate text item at the same
yas the header word, so line assembly merges the two. A fixture that bakesEducation____as one item still reproduces the matcher failure, but not the assembly step that produces it; emit two items. - Sections: at least
EducationandExperience, each followed by an underscore rule of ~70 characters, with dated entries and bullets beneath. - PII: synthetic persona only, per
tests/fixtures/pdfs/README.md— fake name,@example.comemail, and a phone using a real area code with the555exchange and a0100–0199subscriber (e.g.(312) 555-0123). Runnpm run check:fixturesand readpdftotext <file>.pdf - | head -40before committing.
Pin the behaviour with a *.repro.test.ts that fails before the fix:
matchSectionHeaderDetailed("Education" + "_".repeat(74))→{section: "education"}matchSectionHeaderDetailed("Skills -")→ unchanged (guards the>= 3floor)- End-to-end over the new fixture:
experienceCount >= 2,educationCount >= 1, and the routed section list is not["profile"]alone.
Acceptance criteria
- A trailing run of ≥3
_/—/–/=/-characters is stripped during header normalization, before the length check. - A trailing run of 1–2 such characters is not stripped (no regression on hyphenated or punctuated headers).
- The new synthetic fixture parses to a multi-section split, with non-zero experience and education counts.
- A
*.repro.test.tspins both the matcher-level and end-to-end assertions, and fails onmainbefore the fix. -
npm run check:fixturespasses;pdftotextdump of the new fixture reviewed by a human. - Any baked snapshot that moves is reviewed and re-baked deliberately, with the reason in the commit body.
-
npm run verifygreen.
Secondary finding — out of scope here
The same résumé also carries a second, independent defect: it labels its skills block Summary of Qualifications, which is not in any section alias list, so matchSectionHeaderDetailed("Summary of Qualifications") returns null even with no decoration attached. That block therefore routes to other and skillsCount stays 0 after the rule-strip fix lands.
That is an alias-vocabulary gap, the same class as #575 ("Top Skills" heading unrecognized). Track it there, not here — this issue stays scoped to the decorative-rule normalization.
Related
- #414 — skills section dropped on a leading decorative glyph. Direct precedent; this is the trailing-side mirror.
- #374 — skills header wrapping across two visual lines. Same family: a header shape the exact-alias tier can't see.
- #355 — inline & stacked section headers unrecognized.
- #575 —
Top Skillsheading unrecognized (open). Owns the secondary finding above. - #493 — Zero-yield expected sections epic. Deliberately not the parent; see the reasoning above.
Provenance of this write-up
Symptom reported by Sriya Uppalapati from a friend-and-family test; the reproducing résumé was shared privately in #offlinecv and is deliberately absent from this issue and from the repo. Root cause localized with /probe-resume plus a throwaway diagnostic harness (not committed). All figures quoted above come from those runs.
- Dominant language
- TypeScript
- Stars
- 11
- Forks
- 4
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 45
Getting set up
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from offlinecv/OfflineCV
-
refactor testing
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
Maintainers usually reply within 1 day
-
chore
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
Maintainers usually reply within 1 day
-
documentation
Difficulty 1/5 Under an hour Newbie friendliness 92/100
Maintainers usually reply within 1 day
-
improvement ux:job-search
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
Maintainers usually reply within 1 day
-
documentation
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
Maintainers usually reply within 1 day
All issues in offlinecv/OfflineCV
Similar issues
-
ADD openalgoOpentemplate
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
Maintainers usually reply within 1 day
-
factory-active factory-automatic task-bug-reproduction-success task-identify-harness-labels-done task-identify-issue-type-done
Difficulty 2/5 1-3 hours Newbie friendliness 90/100
vercel/ai#21528 · 3 comments ·
Maintainers usually reply within 1 day
-
bug Needs: Triage :mag:
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
microsoft/fluentui-contrib#671 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
sveltejs/acorn-typescript#150 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100