diff --git a/src/lib/heuristics/entry-blocks.test.ts b/src/lib/heuristics/entry-blocks.test.ts index da0b0fa4..613e5588 100644 --- a/src/lib/heuristics/entry-blocks.test.ts +++ b/src/lib/heuristics/entry-blocks.test.ts @@ -364,3 +364,56 @@ describe("parseEntryBlocks — first_line anchor (projects / date-optional secti ]); }); }); + +describe("parseEntryBlocks — wrapped multi-line role header (#166)", () => { + it("reassembles a 3-line wrapped header (org tail + date-year wrap) into one dated block", () => { + // The Docent shape from + // google-docs-skia-proxy-multiline-bullets-coursework.pdf: the org name and + // the closing date year each wrap onto a second physical row — the org tail + // ("Museum") to the left margin, the date tail ("2024") to the far right. + const section = xSection("experience", [ + { + text: "Docent, Library Collections Assistant | Community Heritage May 2023 - June", + x: 70.5, + }, + { text: "Museum", x: 70.5 }, // left-column org tail + { text: "2024", x: 438 }, // right-column date tail + { text: "● Represented and promoted the museum at community events.", x: 81 }, + { text: "● Conducted tours for museum guests.", x: 81 }, + ]); + const blocks = parseEntryBlocks(section, { + anchor: "date_range", + collectBody: true, + headerLookback: 2, + }); + expect(blocks).toHaveLength(1); + const [b] = blocks; + // Date range reassembled across the wrap: "May 2023 - June" + "2024". + expect(b.dates.start_date).toBe("May 2023"); + expect(b.dates.end_date).toBe("June 2024"); + // Org tail folded back: "Community Heritage" + "Museum". + expect(b.headerLines.join(" ")).toContain("Community Heritage Museum"); + // Both bullets attribute to the role (no longer stranded in "Other"). + expect(b.bulletCount).toBe(2); + }); + + it("does not fold a complete single-line header (no regression on the common shape)", () => { + // A "Company Dates / Title / bullets" stack already carries a full range on + // the anchor line; the fold's complete-range gate must leave it untouched so + // the title stays a separate header line rather than collapsing into the date. + const section = xSection("experience", [ + { text: "Acme Corp Jan 2020 - Dec 2021", x: 70 }, + { text: "Senior Engineer", x: 70 }, + { text: "● Shipped the billing service.", x: 81 }, + ]); + const blocks = parseEntryBlocks(section, { + anchor: "date_range", + collectBody: true, + headerLookback: 2, + }); + expect(blocks).toHaveLength(1); + expect(blocks[0].dates.start_date).toBe("Jan 2020"); + expect(blocks[0].dates.end_date).toBe("Dec 2021"); + expect(blocks[0].headerLines).toContain("Senior Engineer"); + }); +}); diff --git a/src/lib/heuristics/entry-blocks.ts b/src/lib/heuristics/entry-blocks.ts index 18a6f1d2..5d45ced2 100644 --- a/src/lib/heuristics/entry-blocks.ts +++ b/src/lib/heuristics/entry-blocks.ts @@ -27,7 +27,14 @@ */ import type { PdfLine, PdfSection } from "./sections.ts"; -import { DATE_RANGE_RE, PRESENT_RE, INSTITUTION_HINTS } from "./regex.ts"; +import { + DATE_RANGE_RE, + PRESENT_RE, + INSTITUTION_HINTS, + MONTH_YEAR_RE, + NUMERIC_MONTH_YEAR_RE, + YEAR_RE, +} from "./regex.ts"; import { parseDateRange, stripDateRange, @@ -179,6 +186,156 @@ function isWrappedContinuation(line: PdfLine, markerX: number): boolean { return Number.isFinite(markerX) && line.x > markerX + 2; } +/** True when `text` carries a complete, parseable date RANGE — i.e. it would + * anchor a `date_range` entry on its own. `DATE_RANGE_RE` is non-global but + * `.test` advances `lastIndex` on some engines; reset so calls are idempotent. */ +function hasCompleteDateRange(text: string): boolean { + const hit = DATE_RANGE_RE.test(text) || PRESENT_RE.test(text); + DATE_RANGE_RE.lastIndex = 0; + return hit; +} + +/** Index of the earliest date-region token (month-year, numeric month/year, or + * a bare year) in `text`, or -1 if none. Marks where the right-hand date column + * begins so a wrapped header's left (org) and right (date) continuations fold + * back onto the correct side. The three source regexes are global; reset + * `lastIndex` before each scan so repeated calls are idempotent. */ +function dateRegionStart(text: string): number { + let idx = -1; + for (const re of [MONTH_YEAR_RE, NUMERIC_MONTH_YEAR_RE, YEAR_RE]) { + re.lastIndex = 0; + const m = re.exec(text); + re.lastIndex = 0; + if (m && (idx === -1 || m.index < idx)) idx = m.index; + } + return idx; +} + +/** A continuation fragment belongs to the right-hand date column when it sits + * past the bullet-marker margin (geometry) OR reads as a bare date tail — just + * a year / month-year / "Present" (content). The content test rescues the + * no-bullet case (`markerX` = Infinity) where geometry can't classify. */ +function isDateColumnFragment(line: PdfLine, markerX: number): boolean { + if (Number.isFinite(markerX) && line.x > markerX + 2) return true; + const t = line.text.trim(); + return /^(?:\d{4}|'\d{2})$/.test(t) || hasCompleteDateRange(t) || PRESENT_RE.test(t); +} + +/** + * Fold a wrapped multi-line ROLE HEADER back into one logical header line so a + * `date_range` entry block opens for it. The motivating shape (#166): a header + * whose org and date span two physical rows, where the date's closing year + * wraps onto its own far-right line — + * + * "Docent … | Community Heritage May 2023 - June" ← anchor row (no full range) + * "Museum" ← left-column org tail + * "2024" ← right-column date tail + * + * Because the anchor row reads "… May 2023 - June" (an incomplete range), + * `DATE_RANGE_RE` misses it, no anchor forms, no entry is built, and the role's + * bullets fall into the unmatched "Other" group. This pass reassembles the three + * rows into "… Community Heritage Museum May 2023 - June 2024", which DOES + * match, so the block opens normally and the bullets attribute to the role. + * + * The fold is the role-header analogue of {@link mergeWrappedContinuations} + * (which folds wrapped *bullet bodies*). It fires ONLY when: + * - the candidate row is a non-bullet line that does NOT already carry a + * complete range (so a normal "Company Jan 2020 - Dec 2021" header, or a + * "Company Dates / Title / bullets" stack, never folds — no regression), and + * - it carries a date-region start (the dangling "… - June"), and + * - folding the continuation rows directly below it (consecutive non-bullet, + * non-anchor lines before the first bullet) yields text that NOW matches + * `DATE_RANGE_RE`. + * The final match gate is the safety net: if the continuations don't complete a + * range, the rows are left untouched. + * + * Left-column fragments (at/left of the bullet-marker margin, e.g. "Museum") + * append to the text before the date; right-column fragments ("2024") append to + * the date region — keyed off `dateRegionStart` so "June" and "2024" reassemble + * adjacently rather than "June Museum 2024". + */ +function mergeWrappedHeaderRows(lines: PdfLine[]): PdfLine[] { + if (lines.length === 0) return lines; + const markerX = bulletMarkerX(lines); + const out: PdfLine[] = []; + let i = 0; + while (i < lines.length) { + const folded = tryFoldHeaderAt(lines, i, markerX); + if (folded) { + out.push(folded.line); + i = folded.next; + } else { + out.push(lines[i]); + i++; + } + } + return out; +} + +/** + * Attempt to fold the wrapped header that starts at `lines[i]`. Returns the + * folded header line plus the index just past the continuation rows it consumed, + * or null when `lines[i]` is not a foldable dangling-date header. Extracted from + * {@link mergeWrappedHeaderRows} to keep each function below the + * cognitive-complexity threshold. + */ +function tryFoldHeaderAt( + lines: PdfLine[], + i: number, + markerX: number, +): { line: PdfLine; next: number } | null { + const line = lines[i]; + const dateIdx = dateRegionStart(line.text); + if (isBulletLine(line) || hasCompleteDateRange(line.text) || dateIdx < 0) { + return null; + } + // Continuation rows directly below: non-bullet, non-anchor lines before the + // first bullet / next complete-date anchor. + const conts: PdfLine[] = []; + let j = i + 1; + while ( + j < lines.length && + !isBulletLine(lines[j]) && + !hasCompleteDateRange(lines[j].text) + ) { + conts.push(lines[j]); + j++; + } + if (conts.length === 0) return null; + + const folded = foldHeaderText(line.text, dateIdx, conts, markerX); + // Match gate: only commit the fold when it produced a complete range. + if (!hasCompleteDateRange(folded)) return null; + return { + line: { ...line, text: folded, items: [...line.items, ...conts.flatMap((c) => c.items)] }, + next: j, + }; +} + +/** Reassemble a dangling-date header at split point `dateIdx`: left-column + * continuations (org tail) append to the text before the date, right-column + * continuations (the wrapped year) append to the date region — so "June" and + * "2024" land adjacently rather than "June Museum 2024". */ +function foldHeaderText( + text: string, + dateIdx: number, + conts: PdfLine[], + markerX: number, +): string { + const textPart = text.slice(0, dateIdx).trim(); + const datePart = text.slice(dateIdx).trim(); + const leftFrags: string[] = []; + const rightFrags: string[] = []; + for (const c of conts) { + (isDateColumnFragment(c, markerX) ? rightFrags : leftFrags).push(c.text.trim()); + } + return [textPart, ...leftFrags, datePart, ...rightFrags] + .filter(Boolean) + .join(" ") + .replace(/\s+/g, " ") + .trim(); +} + /** * A description paragraph begins after a vertical gap wider than this multiple * of the section's single line-height. Word/Office templates write the role @@ -238,7 +395,15 @@ export function parseEntryBlocks( ): EntryBlock[] { if (!section || section.lines.length === 0) return []; - const lines = section.lines; + // Fold wrapped multi-line role headers (an org/date that spilled onto extra + // physical rows) back into one logical header BEFORE anchor detection, so a + // header whose closing date-year wrapped still opens a `date_range` entry + // (#166). Scoped to `date_range`: the other anchors key off an institution + // hint / first line, not a date range that can wrap incomplete. + const lines = + cfg.anchor === "date_range" + ? mergeWrappedHeaderRows(section.lines) + : section.lines; const anchors = collectAnchors(lines, cfg.anchor); if (anchors.length === 0) { // A `first_line` section with no anchorable header line is a flat bullet diff --git a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-multiline-bullets-coursework.expected.json b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-multiline-bullets-coursework.expected.json index 302e4679..64c49163 100644 --- a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-multiline-bullets-coursework.expected.json +++ b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-multiline-bullets-coursework.expected.json @@ -1,13 +1,13 @@ { "schemaVersion": 3, "cascade": { - "confidence": 0, + "confidence": 0.88, "triggers": [], "tiers": [ "t0_layout", "t1_openresume" ], - "suggestedEscalation": "ocr", + "suggestedEscalation": "none", "fieldsPopulated": [ "current_company", "current_title", @@ -26,7 +26,7 @@ "website_url" ], "skillsCount": 13, - "experienceCount": 2, + "experienceCount": 3, "educationCount": 2, "projectsCount": 2, "achievementsCount": 0,