Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/lib/heuristics/entry-blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
144 changes: 142 additions & 2 deletions src/lib/heuristics/entry-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -179,6 +186,131 @@
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".
*/
export function mergeWrappedHeaderRows(lines: PdfLine[]): PdfLine[] {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
if (lines.length === 0) return lines;
const markerX = bulletMarkerX(lines);
const out: PdfLine[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const dateIdx = dateRegionStart(line.text);
if (
!isBulletLine(line) &&
!hasCompleteDateRange(line.text) &&
dateIdx >= 0
) {
// Gather 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) {
const textPart = line.text.slice(0, dateIdx).trim();
const datePart = line.text.slice(dateIdx).trim();
const leftFrags: string[] = [];
const rightFrags: string[] = [];
for (const c of conts) {
(isDateColumnFragment(c, markerX) ? rightFrags : leftFrags).push(
c.text.trim(),
);
}
const folded = [textPart, ...leftFrags, datePart, ...rightFrags]
.filter(Boolean)
.join(" ")
.replace(/\s+/g, " ")
.trim();
if (hasCompleteDateRange(folded)) {
out.push({
...line,
text: folded,
items: [...line.items, ...conts.flatMap((c) => c.items)],
});
i = j;
continue;
}
}
}
out.push(line);
i++;
}
return out;
}

/**
* 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
Expand Down Expand Up @@ -238,7 +370,15 @@
): 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -26,7 +26,7 @@
"website_url"
],
"skillsCount": 13,
"experienceCount": 2,
"experienceCount": 3,
"educationCount": 2,
"projectsCount": 2,
"achievementsCount": 0,
Expand Down
Loading