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
77 changes: 76 additions & 1 deletion src/lib/heuristics/entry-blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import { describe, it, expect } from "vitest";
import { groupIntoLines, splitIntoSections, findSection } from "./sections.ts";
import { parseEntryBlocks } from "./entry-blocks.ts";
import { parseEntryBlocks, mergeWrappedContinuations } from "./entry-blocks.ts";
import { mkItems } from "./__test-utils__/mkItem.ts";
import type { PdfSection, PdfLine } from "./sections.ts";

Expand Down Expand Up @@ -364,3 +364,78 @@ describe("parseEntryBlocks — first_line anchor (projects / date-optional secti
]);
});
});

describe("mergeWrappedContinuations (#162)", () => {
// x-aware line builder: text + left-x, document-ordered y. Mirrors the real
// PdfLine geometry the merge keys on (the bullet marker margin vs. the wrapped
// bullet-text indent).
function lines(rows: Array<{ text: string; x: number }>): PdfLine[] {
return rows.map((r, i) => ({
page: 1,
y: 72 + i * 14,
x: r.x,
items: [],
text: r.text,
maxFontSize: 11,
allCaps: false,
}));
}

it("returns the array unchanged for an empty section", () => {
expect(mergeWrappedContinuations([])).toEqual([]);
});

it("folds a marker-less continuation (indented past the marker) into its bullet", () => {
// Marker at x=81; the wrapped tail at x=90 aligns with the bullet TEXT, so
// it folds onto the bullet rather than surviving as a standalone (and thus
// marker-less, droppable) line.
const merged = mergeWrappedContinuations(
lines([
{ text: "Project A", x: 70 },
{ text: "● Collected revenue using 10-K and 10-Q filings", x: 81 },
{ text: "across several reporting periods", x: 90 }, // wrap
{ text: "● Used five forecasting methods including MA3", x: 81 },
{ text: "on deseasonalized revenue data", x: 90 }, // wrap
]),
);
expect(merged.map((l) => l.text)).toEqual([
"Project A",
"● Collected revenue using 10-K and 10-Q filings across several reporting periods",
"● Used five forecasting methods including MA3 on deseasonalized revenue data",
]);
// Items from both physical lines are carried onto the merged line.
expect(merged.map((l) => l.x)).toEqual([70, 81, 81]); // anchor x preserved
});

it("does NOT fold header / non-continuation lines at or left of the marker margin", () => {
// Headers (x≤marker) and a fresh bullet are continuations of nothing — they
// must each stay their own line so titles and new bullets are preserved.
const merged = mergeWrappedContinuations(
lines([
{ text: "Revenue Forecasting Project", x: 70 },
{ text: "● First bullet that does not wrap", x: 81 },
{ text: "Global Entry Strategy Project", x: 70 }, // real header, not a wrap
{ text: "● Second bullet", x: 81 },
]),
);
expect(merged.map((l) => l.text)).toEqual([
"Revenue Forecasting Project",
"● First bullet that does not wrap",
"Global Entry Strategy Project",
"● Second bullet",
]);
});

it("is a no-op for a markerless section (markerX = Infinity)", () => {
// No bullet glyph anywhere → no marker margin → nothing folds, even though
// the lines carry distinct x. Profile / education-degree blocks rely on this
// so paragraph-spaced header lines are never collapsed into one another.
const rows = [
{ text: "Jane Smith", x: 253 },
{ text: "San Jose, CA", x: 276 },
{ text: "(312) 555-0123 | jane.smith@example.com", x: 125 },
];
const merged = mergeWrappedContinuations(lines(rows));
expect(merged.map((l) => l.text)).toEqual(rows.map((r) => r.text));
});
});
58 changes: 58 additions & 0 deletions src/lib/heuristics/entry-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,64 @@ function isWrappedContinuation(line: PdfLine, markerX: number): boolean {
return Number.isFinite(markerX) && line.x > markerX + 2;
}

/**
* Fold every wrapped-bullet continuation line in a section into the `PdfLine`
* it continues, returning a new line array where each bullet carries its full
* text on one line. This is the upstream twin of the body-fold logic inside
* {@link buildEntryBlock}, narrowed to the one fold signal that is
* geometrically unambiguous across all section types: an x-indent past the
* bullet *marker* margin (`isWrappedContinuation`), where a long glyph bullet's
* tail wraps onto a marker-less second line that aligns with the bullet TEXT.
*
* Why a separate pass: `SectionedResume.byName` flattens each section's
* `PdfLine`s to trimmed strings (`toSectionedResume`), discarding the x the
* fold needs. Running the fold here — before that flatten — lets the
* string-level bullet pool (`extractBulletsFromLines`, which keeps only
* marker-led lines and would otherwise drop a glyph-less continuation, leaving
* the bullet truncated at the wrap) recover the full bullet text for EVERY
* section, including untyped ones (volunteer, coursework) that never reach
* `experience[]`. By construction the pool then agrees with the merged
* `experience[]/projects[].description` the entry-block parser produces. See
* #162.
*
* The prose-wrap y-gap signal `buildEntryBlock` also uses is deliberately NOT
* applied here: the bullet pool is bullet-marker-gated, so a marker-less prose
* template never contributes pool lines for a prose continuation to extend —
* the signal would add no pool benefit while collaterally collapsing
* paragraph-spaced header/contact/education lines (which sit at or left of the
* margin and are NOT continuations) into one another. The x-indent signal
* touches only lines that wrapped past a real bullet marker, so headers, entry
* titles, and contact blocks are left one-to-one.
*
* A no-op when the section has no bullets (markerX = Infinity) or carries no
* usable x (markdown/DOCX, every x = 0 → nothing indents past the marker): the
* array is returned one line per input, byte-identical to the pre-merge flatten.
*/
export function mergeWrappedContinuations(lines: PdfLine[]): PdfLine[] {
if (lines.length === 0) return lines;
const markerX = bulletMarkerX(lines);
const out: PdfLine[] = [];
for (const line of lines) {
if (
out.length > 0 &&
!isBulletLine(line) &&
isWrappedContinuation(line, markerX)
) {
// Fold this continuation onto the line it wraps from: clone the previous
// emitted line and append the continuation's text + items.
const prev = out[out.length - 1];
out[out.length - 1] = {
...prev,
text: `${prev.text} ${line.text.trim()}`.trim(),
items: [...prev.items, ...line.items],
};
} else {
out.push(line);
}
}
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
114 changes: 109 additions & 5 deletions src/lib/heuristics/sections-column.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,122 @@ describe("groupIntoLines column awareness", () => {
]);
});

it("interleaves by (y, x) when no boundary is supplied (legacy path)", () => {
it("de-interleaves an embedded multi-column run even without a page boundary (#164)", () => {
// Before #164 this no-boundary path emitted the rows interleaved
// L,R,L,R,L,R — a localized multi-column block (≥2 shared-baseline rows with
// a column-sized gap) that the page-level ink-projection probe never sees,
// so `boundaries` is empty. `reorderEmbeddedColumns` now detects the run at
// the item level and re-emits it column-major: the whole left column, then
// the whole right column — matching the boundary-supplied path above.
const lines = groupIntoLines(twoColumnItems);
// Shared baselines split at the wide column gap, so the global (y, x) sort
// emits the rows interleaved L, R, L, R… — the scrambling this fix targets.
const texts = lines.map((l) => l.text);
expect(texts).toEqual([
"left-1",
"right-1",
"left-2",
"right-2",
"left-3",
"right-1",
"right-2",
"right-3",
]);
});
});

describe("embedded multi-column reading order (#164)", () => {
// A 3-column "Relevant Coursework" grid embedded in an otherwise single-column
// page (column markers at x≈81 / 244 / 407, with wrapped course-name tails
// indented a few points past their marker). The page-level probe never bands
// this (the single-column body inks straight across its gutters), so the
// reorder must happen at the item level. Geometry mirrors the
// google-docs-skia-proxy-multiline-bullets-coursework fixture.
// Realistic glyph widths (~5.2pt/char, as pdfjs emits for 11pt text) so the
// inter-column gutters (col1→2 ≈ 244-195, col2→3 ≈ 407-356) clear the 50pt
// column-split threshold the same way the real fixture does.
const narrow = (x: number, y: number, str: string): PdfTextItem => ({
page: 1,
x,
y,
str,
width: str.length * 5.0,
height: 11,
fontSize: 11,
fontName: "font-11",
hasEOL: true,
});
const courseworkItems: PdfTextItem[] = [
narrow(81, 695, "● Global Dimensions of"),
narrow(244, 695, "● Financial Accounting"),
narrow(407, 695, "● Microeconomics"),
narrow(90, 709, "Business"), // wrap of col-1 row-1
narrow(244, 711, "● Fundamentals of"),
narrow(407, 711, "● Macroeconomics"),
narrow(81, 724, "● Fundamentals of HR"),
narrow(253, 724, "Operational Management"), // wrap of col-2 row-2
narrow(407, 726, "● Legal Environment of"),
narrow(90, 738, "Management"), // wrap of col-1 row-3
narrow(416, 739, "Business"), // wrap of col-3 row-3
];

it("emits each column top-to-bottom, columns left-to-right (not row zig-zag)", () => {
const lines = groupIntoLines(courseworkItems);
const texts = lines.map((l) => l.text);
// Column-major: every column-1 line (incl. its wrap), then column-2, then
// column-3. A row-major (y, x) sort would interleave them — the bug.
expect(texts).toEqual([
"● Global Dimensions of",
"Business",
"● Fundamentals of HR",
"Management",
"● Financial Accounting",
"● Fundamentals of",
"Operational Management",
"● Microeconomics",
"● Macroeconomics",
"● Legal Environment of",
"Business",
]);
// Guard against regression to interleaving: column-1's wrap ("Business",
// the second line) must precede any column-3 content.
expect(texts.indexOf("● Microeconomics")).toBeGreaterThan(
texts.indexOf("● Global Dimensions of"),
);
expect(texts.indexOf("● Microeconomics")).toBeGreaterThan(
texts.indexOf("● Financial Accounting"),
);
});

it("leaves a single-column block untouched (no spurious reorder)", () => {
const singleCol: PdfTextItem[] = [
mkItem(72, 100, "first line of body text"),
mkItem(72, 114, "second line of body text"),
mkItem(72, 128, "third line of body text"),
];
const texts = groupIntoLines(singleCol).map((l) => l.text);
expect(texts).toEqual([
"first line of body text",
"second line of body text",
"third line of body text",
]);
});

it("does not reorder an isolated single multi-column row (date rail)", () => {
// One header line with a right-aligned date — a single multi-column row,
// below the ≥2-row run threshold, so it must NOT be treated as a column
// grid and reordered away from the body that follows it.
const dateRail: PdfTextItem[] = [
mkItem(72, 100, "Senior Engineer, Acme Corp"),
mkItem(420, 100, "2020 – 2023"),
mkItem(72, 116, "● Built the thing that did the stuff"),
mkItem(72, 132, "● Shipped it on time"),
];
const texts = groupIntoLines(dateRail).map((l) => l.text);
// The date stays grouped with its header row (split into its own line at the
// column gap, but in row order), and the bullets follow in order — the body
// is not pulled above the date.
expect(texts[0]).toBe("Senior Engineer, Acme Corp");
expect(texts[1]).toBe("2020 – 2023");
expect(texts.slice(2)).toEqual([
"● Built the thing that did the stuff",
"● Shipped it on time",
]);
});
});
6 changes: 4 additions & 2 deletions src/lib/heuristics/sections.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@
"education",
"academic background",
"academics",
"qualifications"
"qualifications",
"coursework",
"relevant coursework"
],
"anchors": ["education", "academics", "qualifications"],
"anchors": ["education", "academics", "qualifications", "coursework"],
"splitLetterNormalizable": true,
"anchorFallback": true
},
Expand Down
Loading
Loading