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
39 changes: 39 additions & 0 deletions src/lib/heuristics/entry-blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,45 @@ describe("parseEntryBlocks — first_line anchor (projects / date-optional secti
};
}

it("splits a flat bullet list (awards) into one entry per top-level bullet (#131)", () => {
// An achievements/awards section where every item is itself a bullet — there
// is no non-bullet header line for the first_line anchor to latch onto. Each
// bullet must become its own entry, with a marker-less year line below it
// (a wrapped tail at x=54 > the bullet margin x=48) folding into that entry.
const blocks = parseEntryBlocks(
sectionX([
{ text: "• Globex Engineering Excellence,", x: 48 },
{ text: "2021", x: 54 },
{ text: "• Acme Innovation Prize, 2023", x: 48 },
]),
{ anchor: "first_line", collectBody: true },
);
expect(blocks).toHaveLength(2);
expect(blocks[0].headerLines).toEqual(["Globex Engineering Excellence"]);
expect(blocks[0].dates.start_date).toBe("2021");
expect(blocks[1].headerLines).toEqual(["Acme Innovation Prize"]);
expect(blocks[1].dates.start_date).toBe("2023");
});

it("keeps a deeper sub-bullet as the entry body, not a new entry (#131)", () => {
// A top-level award bullet (x=48) with a deeper-indented detail bullet
// (x=72) under it: the sub-bullet is the body of that one award, not a
// second entry.
const blocks = parseEntryBlocks(
sectionX([
{ text: "• Employee of the Year, 2024", x: 48 },
{ text: "• Recognized for cross-team leadership.", x: 72 },
{ text: "• Best Demo Award, 2023", x: 48 },
]),
{ anchor: "first_line", collectBody: true },
);
expect(blocks).toHaveLength(2);
expect(blocks[0].headerLines).toEqual(["Employee of the Year"]);
expect(blocks[0].body).toContain("cross-team leadership");
expect(blocks[0].bulletCount).toBe(1);
expect(blocks[1].headerLines).toEqual(["Best Demo Award"]);
});

it("treats an indented wrapped-bullet line as a continuation, not a new entry", () => {
// Headers sit at the section margin (x=50); the bullet text (and thus a
// wrapped continuation of it) is indented to x=73. The two wrap lines must
Expand Down
78 changes: 77 additions & 1 deletion src/lib/heuristics/entry-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,12 +196,88 @@ export function parseEntryBlocks(

const lines = section.lines;
const anchors = collectAnchors(lines, cfg.anchor);
if (anchors.length === 0) return [];
if (anchors.length === 0) {
// A `first_line` section with no anchorable header line is a flat bullet
// list (an awards / achievements list where every item is itself a bullet,
// so there is no name-led header for `collectAnchors` to latch onto). Rather
// than drop the whole section, fall back to anchoring on the bullets. The
// other anchors have no such list shape, so they keep returning [].
return cfg.anchor === "first_line" ? parseBulletList(lines, cfg) : [];
}

const lookback = cfg.headerLookback ?? 0;
return anchors.map((_, a) => buildEntryBlock(lines, anchors, a, cfg, lookback));
}

/**
* Fallback parser for a `first_line` section that is a flat bullet list — every
* entry is itself a bullet ("• Award name, 2023"), so `collectAnchors` found no
* non-bullet header line and returned zero anchors. Each TOP-LEVEL bullet (one
* sitting at the bullet-marker margin) becomes its own entry; any marker-less
* lines below it (a year on its own line, a wrapped award name) fold into that
* entry's title, and deeper-indented sub-bullets become its body.
*
* This assumes upstream column banding (`detectColumnBoundaries` in
* `pdf-extract.ts`) has already separated a two-column layout into single-column
* sections, so the lines here are one column's list — not two bullet margins
* interleaved. That banding is what makes a single per-section bullet margin a
* valid assumption (see #131).
*/
function parseBulletList(lines: PdfLine[], cfg: EntryBlockConfig): EntryBlock[] {
const markerX = bulletMarkerX(lines);
if (!Number.isFinite(markerX)) return [];
const anchors: number[] = [];
for (let i = 0; i < lines.length; i++) {
// Top-level bullets only: a deeper-indented bullet is a sub-item of the
// entry above it, not a new entry.
if (isBulletLine(lines[i]) && lines[i].x <= markerX + 2) anchors.push(i);
}
if (anchors.length === 0) return [];
return anchors.map((_, a) => buildBulletEntry(lines, anchors, a, cfg));
}

/**
* Build the single bullet-list `EntryBlock` anchored at `anchors[a]`. The entry
* spans to just before the next top-level bullet: the anchor bullet's text plus
* any marker-less continuation lines below it form the title (date stripped off,
* parsed onto `dates`); deeper sub-bullets form the body. Extracted from
* `parseBulletList` to keep each function below the cognitive-complexity bar.
*/
function buildBulletEntry(
lines: PdfLine[],
anchors: number[],
a: number,
cfg: EntryBlockConfig,
): EntryBlock {
const anchorIdx = anchors[a];
const nextIdx = a + 1 < anchors.length ? anchors[a + 1] : lines.length;

const titleParts = [stripBullet(lines[anchorIdx].text)];
const bodyBullets: PdfLine[] = [];
for (let i = anchorIdx + 1; i < nextIdx; i++) {
if (isBulletLine(lines[i])) bodyBullets.push(lines[i]);
else titleParts.push(lines[i].text.trim());
Comment thread
rohithgollapalli marked this conversation as resolved.
Outdated
}

const combined = titleParts.join(" ").replace(/\s+/g, " ").trim();
const dates = parseDateRange(combined);
const title = stripDateRange(combined);

const body = cfg.collectBody
? bodyBullets
.map((l) => stripBullet(l.text))
.join("\n")
.trim() || undefined
: undefined;

return {
headerLines: title ? [title] : [],
dates,
body,
bulletCount: cfg.collectBody ? bodyBullets.length : 0,
};
}

/**
* Build the single `EntryBlock` anchored at `anchors[a]`. The entry spans from
* just after the previous anchor to just before the next: header lines are the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"full_name",
"github_url",
"given_name",
"heuristic_achievements",
"linkedin_url",
"location",
"phone",
Expand All @@ -31,7 +32,7 @@
"experienceCount": 3,
"educationCount": 1,
"projectsCount": 1,
"achievementsCount": 0,
"achievementsCount": 2,
"rawTextCharCount": 1720,
"pageCount": 1,
"linkAnnotationCount": 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"full_name",
"github_url",
"given_name",
"heuristic_achievements",
"linkedin_url",
"location",
"phone",
Expand All @@ -31,7 +32,7 @@
"experienceCount": 3,
"educationCount": 1,
"projectsCount": 1,
"achievementsCount": 0,
"achievementsCount": 2,
"rawTextCharCount": 1722,
"pageCount": 1,
"linkAnnotationCount": 0,
Expand Down
Loading