Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

[parser] Section headers unrecognized when a decorative underscore rule is drawn on the header's baseline — every section falls into profile

Open
#820 1 comment 0 reactions 0 assignees View on GitHub

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 >= 3 floor is load-bearing: a 1–2 character trailing dash must stay, so headers like Skills - 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 change rawText, 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), extractedCharCount 56 → 1462, experienceCount 0 → 3, educationCount 0 → 2. The only remaining defect the probe reports afterwards is roundtrip-experience-value-changed, which the corpus already covers via tests/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 on gen-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 y as the header word, so line assembly merges the two. A fixture that bakes Education____ as one item still reproduces the matcher failure, but not the assembly step that produces it; emit two items.
  • Sections: at least Education and Experience, 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.com email, and a phone using a real area code with the 555 exchange and a 0100–0199 subscriber (e.g. (312) 555-0123). Run npm run check:fixtures and read pdftotext <file>.pdf - | head -40 before 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 >= 3 floor)
  • 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.ts pins both the matcher-level and end-to-end assertions, and fails on main before the fix.
  • npm run check:fixtures passes; pdftotext dump 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 verify green.

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 Skills heading 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from offlinecv/OfflineCV

All issues in offlinecv/OfflineCV

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.