diff --git a/src/lib/heuristics/openresume.ts b/src/lib/heuristics/openresume.ts index 781247ae..5d9e0c19 100644 --- a/src/lib/heuristics/openresume.ts +++ b/src/lib/heuristics/openresume.ts @@ -73,7 +73,7 @@ export function parseHeuristic( sectionSource = "markdown"; } } - if (!sections) sections = splitIntoSections(lines); + if (!sections) sections = splitIntoSections(lines, boundaries); return buildHeuristicResult(lines, sections, sectionSource, annotations); } diff --git a/src/lib/heuristics/regex.test.ts b/src/lib/heuristics/regex.test.ts index 6b675177..791603db 100644 --- a/src/lib/heuristics/regex.test.ts +++ b/src/lib/heuristics/regex.test.ts @@ -2,7 +2,7 @@ // Copyright 2026 The resumelint Authors import { describe, it, expect } from "vitest"; -import { matchSectionHeader } from "./regex.ts"; +import { matchSectionHeader, matchSectionAnchorToken } from "./regex.ts"; describe("matchSectionHeader — split-letter headers (#56)", () => { it("matches a clean header unchanged", () => { @@ -84,6 +84,11 @@ describe("matchSectionHeader — head-noun anchor fallback (#108 / #111)", () => expect(matchSectionHeader("5 Years Experience")).toBeNull(); expect(matchSectionHeader("10+ Years Experience")).toBeNull(); expect(matchSectionHeader("3 Years Experience")).toBeNull(); + // AC pin (#117): the text-only path stays unchanged when the L3 visual + // recovery lands — "20% Experience" must remain null here. The recovery + // for "20% Projects" lives in classifyLine's font-gated visual branch via + // matchSectionAnchorToken, never on this path. + expect(matchSectionHeader("20% Experience")).toBeNull(); }); it("rejects a header-shaped line ending in terminal punctuation", () => { @@ -118,3 +123,30 @@ describe("matchSectionHeader — head-noun anchor fallback (#108 / #111)", () => expect(matchSectionHeader("Spoken Languages")).toBeNull(); }); }); + +describe("matchSectionAnchorToken — visual-path trailing anchor (#117)", () => { + it("recovers a section from a sidebar artifact glued onto the header", () => { + // The two-column flatten that motivates #117: a sidebar value `20%` is + // glued onto the real `Projects` header. The trailing token is the anchor, + // so the unguarded lookup recovers `projects`. (The font signal at the call + // site is what licenses skipping the prose guards.) + expect(matchSectionAnchorToken("20% Projects")).toBe("projects"); + }); + + it("recovers past a leading noise-prefix glyph", () => { + // A leading bullet/box glyph is a sidebar/list artifact, not a prose marker + // on this font-gated path; the trailing anchor still wins. + expect(matchSectionAnchorToken("▪ Experience")).toBe("experience"); + }); + + it("does NOT match a section whose anchorFallback is false (skills)", () => { + // `skills` has anchorFallback:false in the config, so even though `skills` + // is its anchor, the trailing-token lookup must reject it — matching the + // text-only path's treatment. + expect(matchSectionAnchorToken("Random Skills")).toBeNull(); + }); + + it("returns null when the last token is not an anchor", () => { + expect(matchSectionAnchorToken("just some prose")).toBeNull(); + }); +}); diff --git a/src/lib/heuristics/regex.ts b/src/lib/heuristics/regex.ts index b61d8307..b0efd432 100644 --- a/src/lib/heuristics/regex.ts +++ b/src/lib/heuristics/regex.ts @@ -187,6 +187,46 @@ function matchAnchorFallback( return null; } +/** + * Unguarded trailing-anchor lookup for the column-gated sidebar-header recovery + * path (#117). + * + * The unguarded cousin of {@link matchAnchorFallback}: it normalizes the text + * (trim, lowercase, strip a trailing `:·•`), splits into tokens, and returns + * the section whose anchor set contains the LAST token — provided that section + * has `anchorFallback` enabled (so `skills`/`other` are excluded by config). + * + * Unlike `matchAnchorFallback`, it applies NONE of the prose guards: no casing + * guard, no numeric-lead guard, no token-count guard, no terminal-punctuation + * guard. That is deliberate — this function recovers a real header that a + * two-column flatten glued a sidebar artifact onto (`"20% Projects"` → the + * `20%` is a sidebar value, `Projects` is the header). The guards that + * `matchAnchorFallback` uses to tell a heading from prose are replaced here by + * the CALLER'S column-membership signal: only `classifyLine`'s column-gated + * branch may call this, and only for a header-shaped line that sits in the + * SECONDARY column of a detected two-column layout (`line.x >= columnSplitX`). + * The digit-lead / prose forms it would otherwise admit (`"5 Years Experience"`, + * `"20% Experience"`) live in the MAIN column of those same documents — and in + * single-column documents the gate is absent entirely — so the column signal + * keeps them out. + * + * MUST NEVER be called on the text-only path (`matchSectionHeader`), which has + * no column signal to lean on — doing so would reopen the prose FP class that + * #115 closed. + */ +export function matchSectionAnchorToken(text: string): SectionName | null { + const normalized = text.trim().toLowerCase().replace(/[:·•]+$/, "").trim(); + const tokens = normalized.split(/\s+/).filter((t) => t.length > 0); + if (tokens.length === 0) return null; + const last = tokens[tokens.length - 1]; + for (const [name, anchors] of Object.entries(SECTION_ANCHORS) as Array< + [SectionName, ReadonlySet] + >) { + if (anchors.has(last) && SECTION_ANCHOR_FALLBACKS.has(name)) return name; + } + return null; +} + /** True if the normalized line text matches any known section header. */ export function matchSectionHeader(text: string): SectionName | null { const normalized = text.trim().toLowerCase().replace(/[:·•]+$/, "").trim(); diff --git a/src/lib/heuristics/sections.test.ts b/src/lib/heuristics/sections.test.ts index 8a06dd36..2142eec9 100644 --- a/src/lib/heuristics/sections.test.ts +++ b/src/lib/heuristics/sections.test.ts @@ -25,8 +25,15 @@ import { mkItems } from "./__test-utils__/mkItem.ts"; const HERE = dirname(fileURLToPath(import.meta.url)); const FIXTURE_ROOT = join(HERE, "../../..", "tests/fixtures/pdfs"); -function build(specs: Array<{ text: string; fontSize?: number }>): PdfSection[] { - return splitIntoSections(groupIntoLines(mkItems(specs))); +function build( + specs: Array<{ text: string; fontSize?: number; x?: number }>, + columnBoundaries?: Map, +): PdfSection[] { + const items = mkItems(specs); + return splitIntoSections( + groupIntoLines(items, columnBoundaries), + columnBoundaries, + ); } /** Section names in document order (for boundary assertions). */ @@ -227,6 +234,73 @@ describe("splitIntoSections — visual-primary boundary path (#112)", () => { "education", ]); }); + + describe("column-gated sidebar-header recovery (#117)", () => { + // A two-column flatten glues a sidebar value ("20%") onto the "Projects" + // header, producing a body-size, text-identical-to-prose line. The ONLY + // signal that separates "20% Projects" (a real header in the secondary + // column) from main-column prose like "20% Experience" is column + // membership: line.x >= the page's column split-x. The maxFontSize is kept + // at body size in every case so these pin the COLUMN gate, not the L3 font + // path. + const TWO_COLUMN: Map = new Map([[1, 384]]); + + it("(a) recovers `projects` for a sidebar line in the secondary column", () => { + const sections = build( + [ + { text: "Drew Hayes", fontSize: 20 }, // name + { text: "drew.hayes@example.com | (312) 555-0133", fontSize: 10 }, // contact + { text: "EXPERIENCE", fontSize: 13 }, // real keyword section — past the name block + { text: "Lead Engineer, Acme 02/2019 - Present", fontSize: 10 }, + // Body-size (NOT font-distinct), secondary column (x=405 >= 384). + { text: "20% Projects", fontSize: 10, x: 405 }, + { text: "Launched 10 new web fonts with external non-profit partners.", fontSize: 10, x: 405 }, + ], + TWO_COLUMN, + ); + + // The sidebar-prefixed line opened a `projects` section, not `other`. + const projects = sectionContaining(sections, "Launched 10 new web fonts"); + expect(projects).toBeDefined(); + expect(projects!.name).toBe("projects"); + expect(names(sections)).toContain("projects"); + // No `other` sink was opened for the recovered header. + expect(names(sections).filter((n) => n === "other")).toHaveLength(0); + }); + + it("(b) does NOT recover the same line in the MAIN column (x < split)", () => { + const sections = build( + [ + { text: "Drew Hayes", fontSize: 20 }, + { text: "drew.hayes@example.com | (312) 555-0133", fontSize: 10 }, + { text: "EXPERIENCE", fontSize: 13 }, + { text: "Lead Engineer, Acme 02/2019 - Present", fontSize: 10 }, + // Same text, body-size, MAIN column (x=50 < 384) — must NOT recover. + { text: "20% Projects", fontSize: 10, x: 50 }, + { text: "Launched 10 new web fonts with external non-profit partners.", fontSize: 10, x: 50 }, + ], + TWO_COLUMN, + ); + + // No `projects` section: the main-column line is treated as prose and + // stays appended to the open experience section. + expect(names(sections)).not.toContain("projects"); + }); + + it("(c) does NOT recover in a single-column doc (no column boundaries)", () => { + const sections = build([ + { text: "Drew Hayes", fontSize: 20 }, + { text: "drew.hayes@example.com | (312) 555-0133", fontSize: 10 }, + { text: "EXPERIENCE", fontSize: 13 }, + { text: "Lead Engineer, Acme 02/2019 - Present", fontSize: 10 }, + // Same body-size line, but no columnBoundaries passed — gate absent. + { text: "20% Projects", fontSize: 10 }, + { text: "Launched 10 new web fonts with external non-profit partners.", fontSize: 10 }, + ]); + + expect(names(sections)).not.toContain("projects"); + }); + }); }); /** diff --git a/src/lib/heuristics/sections.ts b/src/lib/heuristics/sections.ts index bcea15ac..11aa4e1e 100644 --- a/src/lib/heuristics/sections.ts +++ b/src/lib/heuristics/sections.ts @@ -17,6 +17,7 @@ import type { PdfTextItem } from "./types.ts"; import { matchSectionHeader, + matchSectionAnchorToken, EMAIL_RE, PHONE_RE, LINKEDIN_RE, @@ -281,19 +282,31 @@ function computeBodyBaseline(lines: PdfLine[]): number { } /** - * True when a line is *visually* a header: short, unpunctuated, not a bullet, - * and meaningfully larger than the body baseline. This is the L3 fallback - * signal — it fires only after `matchSectionHeader` has already declined the - * line (keyword path), so a line passing this test opens a boundary-only - * `other` section (terminates the prior section without rendering). + * Header *shape* test, independent of font: short (≤ `VISUAL_HEADER_MAX_CHARS` + * chars, ≤ `VISUAL_HEADER_MAX_WORDS` words), not a bullet line, and not ending + * in terminal sentence punctuation. This is the structural half of + * `isVisualHeader`; the column-gated sidebar-header recovery (#117) reuses the + * exact same predicate so the two paths can never drift on what counts as + * header-shaped. + */ +function isHeaderShort(text: string): boolean { + const t = text.trim(); + if (t.length === 0 || t.length > VISUAL_HEADER_MAX_CHARS) return false; + if (VISUAL_BULLET_RE.test(t)) return false; + if (TERMINAL_PUNCT_RE.test(t)) return false; + const words = t.split(/\s+/).filter((w) => w.length > 0); + return words.length <= VISUAL_HEADER_MAX_WORDS; +} + +/** + * True when a line is *visually* a header: header-shaped (`isHeaderShort`) and + * meaningfully larger than the body baseline. This is the L3 fallback signal — + * it fires only after `matchSectionHeader` has already declined the line + * (keyword path), so a line passing this test opens a boundary-only `other` + * section (terminates the prior section without rendering). */ function isVisualHeader(line: PdfLine, bodyBaseline: number): boolean { - const text = line.text.trim(); - if (text.length === 0 || text.length > VISUAL_HEADER_MAX_CHARS) return false; - if (VISUAL_BULLET_RE.test(text)) return false; - if (TERMINAL_PUNCT_RE.test(text)) return false; - const words = text.split(/\s+/).filter((w) => w.length > 0); - if (words.length > VISUAL_HEADER_MAX_WORDS) return false; + if (!isHeaderShort(line.text)) return false; return line.maxFontSize >= bodyBaseline * VISUAL_HEADER_FONT_RATIO; } @@ -330,7 +343,7 @@ function hasContactShape(text: string): boolean { * everything between headers. Content above the first header lands in the * synthetic `profile` section. * - * A line opens a section boundary when EITHER: + * A line opens a section boundary when ANY of: * - keyword path: `matchSectionHeader` (L1 exact alias → L2 head-noun anchor) * returns a canonical name → label = that section; or * - visual path (L3 / #112): the line is visually a header (`isVisualHeader`) @@ -338,7 +351,15 @@ function hasContactShape(text: string): boolean { * keyword path has already declined the line by this point, so the label is * always `other` — the boundary-only sink that terminates the prior section * without rendering (`regex.ts` keeps `other` out of the anchor path and out - * of every `findSection` lookup in `openresume.ts`). + * of every `findSection` lookup in `openresume.ts`); or + * - column-gated sidebar recovery (#117): a body-size, header-shaped line in + * the SECONDARY column of a detected two-column layout (`columnBoundaries`) + * whose trailing token is a fallback-enabled section anchor → label = that + * section. This recovers a real header that a two-column flatten glued a + * sidebar bar-value onto ("20% Projects"). The column signal stands in for + * the prose guards the unguarded `matchSectionAnchorToken` lookup drops, so + * it never fires on main-column prose ("5 Years Experience") or single- + * column docs. * * Name/contact disambiguation: the leading profile region opens with a cluster * of large-font name / title / tagline lines (a résumé header), then the @@ -351,8 +372,18 @@ function hasContactShape(text: string): boolean { * while still letting a font-distinct invented header below the contact block * open a boundary. Once any section has opened, the disambiguation no longer * applies (a visual header is then unconditionally a real boundary). + * + * `columnBoundaries` is the per-page split-x map from `detectColumnBoundaries` + * (present only for detected two-column pages; undefined/empty otherwise). It + * feeds the sidebar-header recovery branch in `classifyLine` (#117): a glued + * sidebar artifact like `"20% Projects"` in the secondary column recovers its + * real section name. For single-column docs the map is absent and that branch + * never fires — output stays byte-identical to the pre-#117 behavior. */ -export function splitIntoSections(lines: PdfLine[]): PdfSection[] { +export function splitIntoSections( + lines: PdfLine[], + columnBoundaries?: Map, +): PdfSection[] { const sections: PdfSection[] = [{ name: "profile", lines: [] }]; const bodyBaseline = computeBodyBaseline(lines); // True until the first non-profile section (keyword or visual) opens. @@ -362,11 +393,14 @@ export function splitIntoSections(lines: PdfLine[]): PdfSection[] { let seenContactInProfile = false; for (const line of lines) { + // Per-line column split-x (undefined for single-column pages / docs). + const columnSplitX = columnBoundaries?.get(line.page); const action = classifyLine( line, bodyBaseline, openedRealSection, seenContactInProfile, + columnSplitX, ); if (action.kind === "open") { sections.push({ name: action.name, lines: [] }); @@ -398,6 +432,7 @@ function classifyLine( bodyBaseline: number, openedRealSection: boolean, seenContactInProfile: boolean, + columnSplitX: number | undefined, ): LineAction { const header = matchSectionHeader(line.text); if (header) return { kind: "open", name: header }; @@ -416,6 +451,23 @@ function classifyLine( return { kind: "open", name: "other" }; } + // Two-column sidebar artifact: a flatten can glue a sidebar bar-value onto a + // real header in the secondary column ("20% Projects"). The line is body-size + // (no visual signal, so the branch above did not fire) and a single glued run + // (no x-gap), so the only signal that separates it from main-column prose like + // "5 Years Experience" is that it sits in the secondary column of a detected + // two-column layout. Gate the unguarded trailing-anchor lookup on that column + // signal + a header-short shape. Skipped entirely for single-column docs + // (columnSplitX undefined) and main-column lines (line.x < split). + if ( + columnSplitX !== undefined && + line.x >= columnSplitX && + isHeaderShort(line.text) + ) { + const recovered = matchSectionAnchorToken(line.text); + if (recovered) return { kind: "open", name: recovered }; + } + return { kind: "append", marksContactEnd: !openedRealSection && contactShaped }; } diff --git a/tests/fixtures/pdfs/unknown/chromium-two-column-sidebar.expected.json b/tests/fixtures/pdfs/unknown/chromium-two-column-sidebar.expected.json index 6f731a64..8e9bb578 100644 --- a/tests/fixtures/pdfs/unknown/chromium-two-column-sidebar.expected.json +++ b/tests/fixtures/pdfs/unknown/chromium-two-column-sidebar.expected.json @@ -22,6 +22,7 @@ "heuristic_achievements", "location", "phone", + "projects", "skills", "summary", "website_url" @@ -29,7 +30,7 @@ "skillsCount": 10, "experienceCount": 5, "educationCount": 1, - "projectsCount": 0, + "projectsCount": 2, "achievementsCount": 1, "rawTextCharCount": 3905, "pageCount": 2,