Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion src/lib/heuristics/openresume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
34 changes: 33 additions & 1 deletion src/lib/heuristics/regex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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();
});
});
40 changes: 40 additions & 0 deletions src/lib/heuristics/regex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>]
>) {
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();
Expand Down
78 changes: 76 additions & 2 deletions src/lib/heuristics/sections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, number>,
): PdfSection[] {
const items = mkItems(specs);
return splitIntoSections(
groupIntoLines(items, columnBoundaries),
columnBoundaries,
);
}

/** Section names in document order (for boundary assertions). */
Expand Down Expand Up @@ -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<number, number> = 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");
});
});
});

/**
Expand Down
80 changes: 66 additions & 14 deletions src/lib/heuristics/sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import type { PdfTextItem } from "./types.ts";
import {
matchSectionHeader,
matchSectionAnchorToken,
EMAIL_RE,
PHONE_RE,
LINKEDIN_RE,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -330,15 +343,23 @@ 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`)
* and is not a leading name/contact line → open an `other` boundary. The
* 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
Expand All @@ -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<number, number>,
): PdfSection[] {
const sections: PdfSection[] = [{ name: "profile", lines: [] }];
const bodyBaseline = computeBodyBaseline(lines);
// True until the first non-profile section (keyword or visual) opens.
Expand All @@ -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: [] });
Expand Down Expand Up @@ -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 };
Expand All @@ -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 };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@
"heuristic_achievements",
"location",
"phone",
"projects",
"skills",
"summary",
"website_url"
],
"skillsCount": 10,
"experienceCount": 5,
"educationCount": 1,
"projectsCount": 0,
"projectsCount": 2,
"achievementsCount": 1,
"rawTextCharCount": 3905,
"pageCount": 2,
Expand Down
Loading