fix: bullet count under-reports on short bullets and two-column layouts - #18
Conversation
…op them Closes part of #9. Resolves the reported 19→18 / N→N-1 pattern; flags the Deedy 8→1 catastrophic case as a separate root cause needing its own fix. `extractBulletsFromText` was applying `ANON_BULLET_MIN_WORDS = 4` as a hard filter, dropping any marker-prefixed line with fewer than 4 words after the marker. That conflated *bullet detection* with *bullet grading*: short low-quality bullets were hidden from the displayed `bulletCount` even though they were visible in the PDF, and that hid their natural drag on Specificity / Structure ratios. The reporter saw this as "20+ visible · 19 in extracted text · 18 reported" — the 19→18 gap is the under-count caused by exactly one short bullet getting filtered post-extraction. Localised the bug by tracing every marker-prefixed line through the pipeline. The dropped bullet on both Awesome-CV fixtures was the same 3-word line `"• Everything that matters."`. Awesome-CV resume: 31 visible → 30 reported (off by 1). Awesome-CV cv: 59 → 58 (off by 1). Fix: - Lower `ANON_BULLET_MIN_WORDS` from 4 to 1. Empty marker-only lines (`"• "`) are still skipped because `split(/\s+/).filter(Boolean)` returns length 0; any line with at least one word now counts. - Update the doc comments on the constant and `extractBulletsFromText` to explain the split between "is this a bullet?" (count) and "is this a good bullet?" (grade) — the well-formed length window (8-30 words) in `analyzeBullets` and `scoreBulletPool` handles the quality side and naturally penalises the now-counted shorts. Snapshot impact (re-baked): - `awesome-cv-cv.expected.json`: `bulletCount` 58 → 59, `overall` 59 → 58 - `awesome-cv-resume.expected.json`: `bulletCount` 30 → 31, `overall` 64 → 63 - Other 5 fixtures: byte-identical (their visible counts already matched) The -1 overall on each Awesome-CV fixture is the *correct* score movement — the previously-hidden short bullet adds 1 to the denominators of both Specificity ratio and Structure ratio without adding to either numerator (no metric, outside 8-30 word window). The pre-fix score was inflated by silently dropping a bad bullet. Verification: - visible-bullet vs reported-bulletCount delta now 0 on all 5 fixtures that share this root cause (was -1 on the two Awesome-CV files) - npm run test: 175 / 175 (174 baseline + 1 new regression test in score.test.ts pinning the issue-#9 short-bullet behaviour) - npm run typecheck: clean - 5 non-Awesome-CV corpus snapshots: byte-identical, zero regression Deedy still off by 6-7 — different root cause ----------------------------------------------- The two Deedy fixtures both show 8 visible bullets but 1-2 reported. That gap is NOT from this filter — pipeline trace shows their `cascade.rawText` only contains 2 lines starting with a bullet glyph (vs. 8 in pdftotext output), with 10 additional lines where the glyph appears mid-line. The bullets are being mis-grouped at PDF extraction time, most likely by `groupIntoLines` / `assembleTextFromLines` reading the two-column experience section in an order that breaks bullet prefixes. That's an extraction-stage bug, not a counting-stage bug, and the fix lives in `src/lib/heuristics/sections.ts` rather than `src/lib/score/score.ts`. Flagging as a separate follow-up since the blast radius and risk profile are very different from the simple constant change here. Refs #9
Second half of issue #9. The previous commit (`126ad13`) fixed the score-side off-by-1 for Awesome-CV. This commit fixes the extraction-side catastrophic loss on Deedy-style two-column resumes where bullets in the right column share a y-baseline with prose in the left column. Root cause: `groupIntoLines` clusters items purely by y-proximity (`LINE_Y_EPS = 3.5`), with no concept of column structure. On Deedy's asymmetric 0.33/0.66 layout the left-column education text and right-column experience bullets end up at the same y, so the bullet glyph gets concatenated *after* the education text and never reaches line-start position. `extractBulletsFromText` then correctly skips it (it requires `^\s*[bullet glyph]\s+`), and the cascade reports e.g. `bulletCount: 1` against 8 visible bullets in the PDF. The asymmetric column layout doesn't trigger the existing `isTwoColumn` flag in `pdf-layout.ts` (which requires roughly equal columns and 60% density), so gating on that trigger isn't an option. Instead, fix it inside `groupIntoLines` itself. Fix: when flushing a same-y cluster of items, scan the (already x-sorted) items for any gap >= `COLUMN_GAP_THRESHOLD` (50pt) between consecutive items and emit each side as its own `PdfLine`. 50pt is well above any in-line word/run gap (Awesome-CV's `\hfill` lines produce 0pt gaps because LuaTeX includes trailing whitespace in item widths) and comfortably below the column gaps observed on real two-column resumes (Deedy's experience column starts ~70-130pt past the education column edge). Side effects (all positive) --------------------------- - `openresume-react-pdf.pdf` skillsCount 16 → 20. The skills line `HTML CSS Python TypeScript React C++` had ~160-175pt gaps between each item (visually distinct skill tokens). Splitting these into per-token lines exposed 4 additional skills the parser had been missing. Deedy snapshot impact (re-baked) -------------------------------- The line-grouping fix unblocks every downstream parser on Deedy, not just the bullet counter: | Field | macfonts before → after | openfonts before → after | |-----------------|------------------------|--------------------------| | `bulletCount` | 1 → 8 | 2 → 8 | | `skillsCount` | 0 → 25 | 0 → 24 | | `experienceCount` | 0 → 6 | 0 → 6 | | `metricBullets` | 0 → 1 | 0 → 1 | | `goodBullets` | 1 → 6 | 1 → 6 | | `overall` | 17 → 47 | 17 → 47 | The Deedy PDFs were essentially unparseable before this fix — the parser wasn't dropping individual fields, it was misreading the line structure end-to-end. Score jumping from 17 to 47 reflects that the parser can now see the resume's content rather than scoring it as near-empty. Awesome-CV, header-as-name, and laverne snapshots: byte-identical (no two-column layout, no impact). Visible-vs-reported bullet count delta is now 0 on all 7 corpus fixtures (was 1, 1, 7, 6 pre-fix on Awesome-CV cv / resume / Deedy macfonts / Deedy openfonts). Test added in `pdf-extract.test.ts`: - Two-column same-y items: bullet keeps line-start position - Single-column regression guard: small gaps don't fragment lines Verification ------------ - `npm run test`: 177 / 177 (175 baseline + 2 new in pdf-extract.test) - `npm run typecheck`: clean - `pdftotext` bullet count == reported `bulletCount` on all 7 fixtures Refs #9
s-annam
left a comment
There was a problem hiding this comment.
PR Review: fix: bullet count under-reports on short bullets and two-column layouts
Summary
Two independent bugs, two clean commits, both fixed correctly. Root cause analysis is thorough, the 50pt threshold is empirically calibrated against the real corpus, and all 7 fixtures are verified with zero regressions.
Highlights
- PR description quality sets a high bar — blast-radius self-assessment, measured gap distributions, per-fixture before/after table. Reference example for how to document a parser change.
- Two-commit structure cleanly isolates each bug, making bisect easy if either fix needs reverting.
buildLineextraction fromflush()is a clean refactor — makes the new split loop readable.assembleTextFromLines→groupIntoLineschain is correctly exercised by the new tests (confirmed:pdf-extract.ts:35).- Single-column regression guard is the right safety net for a change to a widely-called function.
- Deedy: 17 → 47 is not incremental improvement — the parser now actually reads the resume.
Key Findings
-
[Suggestion]
ANON_BULLET_MIN_WORDS = 1— single-word section header edge case — see inline comment onscore.ts. -
[Nit]
openresume-react-pdfskillsCount16 → 20 side-effect has no in-repo explanation — see inline comment on the fixture.
Verdict
Action: APPROVE
Rationale: No blocking issues. Implementation is correct, empirically grounded, and the single-column regression guard is specifically designed to catch the blast-radius risk. The ANON_BULLET_MIN_WORDS suggestion is future-hardening, not a merge blocker.
0 blocking · 1 suggestion · 1 nit
🤖 Reviewed with Claude Code
| * Issue #9 — previously set to 4, which silently dropped legitimate short | ||
| * bullets like "• Everything that matters." and caused bullet count to | ||
| * under-report vs. the visible PDF. */ | ||
| const ANON_BULLET_MIN_WORDS = 1; |
There was a problem hiding this comment.
[Suggestion]: The original comment on this constant explicitly noted: "raw-text extraction needs to skip headers / one-line section labels that share a leading marker." Lowering to 1 means "• Summary" or "• Education" (1 word after marker strip) now counts as a bullet. The corpus shows no regression across 7 fixtures, so this is not blocking — but the guard is removed without a test that pins the boundary. Consider a unit test in score.test.ts that feeds a resume text with a single-word bullet-prefixed section label and asserts it does not inflate the bullet count (or asserts the expected behavior explicitly), so future changes to this threshold can't silently regress.
| "website_url" | ||
| ], | ||
| "skillsCount": 16, | ||
| "skillsCount": 20, |
There was a problem hiding this comment.
[Nit]: The PR body explains this well (the skills line HTML CSS Python TypeScript React C++ has ~160–175pt inter-token gaps, so the column-split logic emits each token as its own PdfLine, surfacing 4 extra skills). But this is a side-effect of the column-gap fix, not the primary intent, and .expected.json has no comment slot. Consider adding a note in a fixture-level README or the snapshot test group explaining that this fixture's skills line has unusually wide inter-token gaps — otherwise a future reader sees the count jump with no in-repo explanation.
* test(corpus): add google-docs/ category with 4 chromium-headless fixtures Closes part of #1 (#12 tracks remaining manual-export categories). Adds 4 synthetic resume PDFs rendered via Chromium headless print-to-pdf, which uses the same Skia/PDF renderer family as Google Docs's "Download as PDF" export. Lands them in the previously-empty `google-docs/` category folder so the corpus now covers 4 source categories (latex, word, unknown, google-docs) — meeting issue #1's "≥4 source categories" acceptance criterion. Persona is synthetic end-to-end (Jane Smith / @example.com / 555-style phone / fictional Acme Corp / Globex / Initech / Springfield State University). Multi-stage PII preflight clean: body text, Info dict (Title only — "Resume", non-identifying), no XMP packet, raw byte grep against the known real-author token list returns 0 on every PDF. The 4 templates each exercise a different mix of parser paths: | Fixture | Score | Notable signal | |---------|-------|----------------| | `chromium-headless-classic` | 98 | Well-formed baseline — full single-column, every bullet has metric + verb + length, all sections detected. Happy-path anchor. | | `chromium-headless-two-column` | 53 | CSS grid sidebar + main column. Exercises the `COLUMN_GAP_THRESHOLD` split in `groupIntoLines` landed in PR #18; 23 bullets correctly counted across both columns. | | `chromium-headless-nonstandard-headers` | 84 | Uses `On Campus Involvement / Volunteer Experience / Internships` — none in `SECTION_KEYWORDS.experience`, so reports `experienceCount: 0` and `missing: ["work experience"]` despite 8 visible bullets in those sections. **Direct regression anchor for issue #19** — when the section-keyword expansion lands, this fixture's snapshot will update and pin the new behaviour. | | `chromium-headless-minimal` | 24 | Short bullets, no metrics, no summary, no LinkedIn. Confirms the post-#9 grading correctly penalises low-quality content rather than hiding it. | Two related findings surfaced during this work, neither blocking but worth flagging for follow-up: 1. **Chromium `--print-to-pdf` renders CSS list-style bullets as graphics, not text** — pdftotext sees no `•` glyph at line start, so the parser counts 0 bullets and grades dimensions as ungradable. All 4 fixtures here use explicit `<p>• text</p>` markup to work around this. Worth filing as a separate finding: Chrome-exported PDFs from apps that emit native `<ul>` markup (most modern web resume tools) may silently lose all bullet structure. Not files yet — leaving it for a focused issue with a dedicated CSS-bullets fixture. 2. **`nonstandard-headers` fixture validates issue #19's scope** — confirms the section-keyword gap affects not just laverne but any student/early-career resume layout with non-canonical experience headings. Already tracked in #19; this fixture adds independent evidence. Corpus state after this commit: | Category | PDFs | Status | |-------------|------|-------------------------------------------------------| | latex/ | 5 | awesome-cv (cv + resume), deedy (mac + open), header-as-name | | word/ | 1 | openresume-laverne-word-quartz | | google-docs/| 4 | chromium-headless × 4 (this commit) | | unknown/ | 1 | openresume-react-pdf | | mac-pages/ | 0 | needs manual export — tracked in #12 | | mac-preview/| 0 | tried cupsfilter, doesn't expose a clean Quartz | | | | re-rendering path on modern macOS — manual export | | | | needed, tracked in #12 | | indesign/ | 0 | needs InDesign access — tracked in #12 | Total: 11 PDFs across 4 categories. Issue #1's "≥4 categories" criterion met; "≥15 PDFs" still gapped at 11/15 — covered by #12's manual-export follow-up which was already filed for exactly this content work. Filename convention follows the README example (`google-docs-skia-m146.pdf`): `<renderer>-<variant>.pdf` inside the category folder. "chromium-headless" is honest about provenance (these are Chromium-rendered, not literal Google Docs exports, though they share the Skia/PDF renderer family — so they live in `google-docs/` for parser-path categorisation purposes). Verification: - `npm run typecheck`: clean - `npm run test`: 185 / 185 (181 baseline + 4 new corpus fixtures) - `npm run bake-fixtures` + `git diff` after: empty (snapshots round-trip) - All 4 PDFs: 0 hits on raw-byte grep for real-author tokens; no XMP packet - CONTRIBUTING.md test count refreshed 167 → 185 Refs #1, #12 * test(corpus): add 4 WeasyPrint Cairo-renderer fixtures to close ≥15 target Brings corpus to 16 PDFs across 4 categories — closes both of issue #1's remaining acceptance criteria. Renders the same 4 HTML templates from the prior commit through WeasyPrint (Cairo backend) instead of Chromium-headless (Skia). Drops them in the existing `unknown/` category per the README's "Generator unknown or one-off" classification — WeasyPrint isn't a category called out in #1's body, and re-using `unknown/` is honest about provenance (these aren't mac-pages / mac-preview / indesign exports, which genuinely require GUI apps I can't drive autonomously and remain tracked in #12). Why the same content twice with different renderers --------------------------------------------------- Each WeasyPrint PDF carries the same source HTML as its Chromium twin in google-docs/, but the rendered byte stream differs (different font subsetting, item layout, line breaks). That's a feature: identical content rendered by two distinct generators is exactly the test the corpus exists to do — surface renderer-dependent parser drift that a single-generator corpus would miss. Initial snapshot comparison shows the parser produces near-identical scores on Chrome vs WeasyPrint for the simpler layouts (98 vs 98, 24 vs 24, 84 vs 84), but diverges on the two-column case (53 vs 43). Worth a focused look later if that gap is a real Cairo-vs-Skia layout difference vs a parser regression — but not blocking this PR; the snapshots pin whatever the current behaviour is. Fixtures -------- - `weasyprint-cairo-classic.pdf` (score 98) - `weasyprint-cairo-two-column.pdf` (score 43) - `weasyprint-cairo-nonstandard-headers.pdf` (score 84 — second regression anchor for #19) - `weasyprint-cairo-minimal.pdf` (score 24) PII preflight (same multi-stage sweep as PR #13) ------------------------------------------------ - Body text grep against known real-author tokens: 0 hits per PDF - Info dict: `Title: Resume` only (HTML `<title>`, non-identifying) - XMP packet: absent - Raw byte grep: 0 hits Corpus state after this commit ------------------------------ | Category | PDFs | Notes | |-------------|------|------------------------------------------------| | latex/ | 5 | awesome-cv × 2, deedy × 2, header-as-name | | word/ | 1 | openresume-laverne (Quartz) | | unknown/ | 6 | openresume-react-pdf, name-set-apart-tagline, | | | | weasyprint-cairo × 4 (this commit) | | google-docs/| 4 | chromium-headless × 4 (prior commit) | | mac-pages/ | 0 | needs Apple Pages — tracked in #12 | | mac-preview/| 0 | needs macOS Preview re-save — tracked in #12 | | indesign/ | 0 | needs InDesign access — tracked in #12 | | **Total** | **16** | **≥15 ✓** (issue #1 acceptance) | Verification ------------ - npm run typecheck: clean - npm run test: 189 / 189 (185 baseline + 4 new corpus tests) - npm run bake-fixtures + git diff after: empty (snapshots round-trip) - All 4 weasyprint PDFs: 0 hits on raw-byte grep, no XMP, generic Title only - CONTRIBUTING.md test count refreshed 185 → 189 Install note for reviewers reproducing locally: WeasyPrint requires native Cairo/Pango/GLib libs. On macOS: pip install --user weasyprint brew install pango cairo glib export DYLD_FALLBACK_LIBRARY_PATH="/opt/homebrew/lib:$DYLD_FALLBACK_LIBRARY_PATH" weasyprint input.html output.pdf The committed `.pdf` files are the artifact — re-rendering isn't required to run tests; only baking new snapshots is, which uses the existing PDFs. Refs #1, #12 * test(corpus): address PR #26 review — rename to google-docs-skia-proxy + doc the taxonomy decision Addresses the [Blocking] + [Suggestion] findings from @s-annam's review on PR #26. The reviewer flagged that the 4 chromium-headless fixtures self-identify as `Creator: HeadlessChrome/149` / `Producer: Skia/PDF m149`, while `tests/fixtures/pdfs/README.md` previously routed "headless Chrome" to `unknown/` and reserved `google-docs/` for actual Google Docs exports. Without resolution, "Closes #1" rests on a mislabel. Choosing reviewer's option (b) — deliberately redefine `google-docs/` as a Skia/PDF category that accepts both real Google Docs exports and Chromium headless prints as a faithful Skia proxy. Google Docs's "Download as PDF" pipeline uses Skia/PDF; a `chrome --headless --print-to-pdf` of the same HTML produces structurally-equivalent output (same renderer family, same item-layout patterns, same font-subsetting behaviour the parser needs to handle). Treating them as the same category for parser-failure-mode purposes is honest about what the corpus is for — distinct renderers, not distinct product provenance. Changes: 1. Renamed 4 PDFs + 4 snapshots: chromium-headless-classic.{pdf,expected.json} → google-docs-skia-proxy-classic.{pdf,expected.json} chromium-headless-two-column.{pdf,expected.json} → google-docs-skia-proxy-two-column.{pdf,expected.json} chromium-headless-nonstandard-headers.{pdf,expected.json} → google-docs-skia-proxy-nonstandard-headers.{pdf,expected.json} chromium-headless-minimal.{pdf,expected.json} → google-docs-skia-proxy-minimal.{pdf,expected.json} New filename encodes the rationale per finding #2 — next contributor reading `google-docs-skia-proxy-*` immediately knows these are Skia prints used as proxies, not real Google Docs exports. 2. Updated `tests/fixtures/pdfs/README.md` taxonomy in two places: - `google-docs/` description now explicitly says it accepts Chromium `--print-to-pdf` Skia exports as a deliberate proxy, names the `google-docs-skia-proxy-*` filename prefix, and pre-empts confusion about the `Creator: HeadlessChrome/<v>` Info-dict string ("that is by design, not a mislabel"). - `unknown/` description no longer claims headless Chrome (it's now in `google-docs/` per the redefinition); WeasyPrint added as an example of what `unknown/` genuinely covers. 3. PDFs themselves are byte-identical to the prior commit — only the filenames changed. Snapshots re-baked to match the new filenames; counts and scores are unchanged. Re finding #3 (metricBullets drift, nit) --------------------------------------- Reviewer noted that on the `classic` variant, `metricBullets` drifts 8 → 6 between the chromium and weasyprint snapshots even though both score 98 — the difference gets absorbed by the 40-pt Specificity cap at the score boundary. Surfacing here so it's discoverable from `git log` rather than buried in the snapshot files: | variant | chromium metricBullets | weasyprint metricBullets | |---------|------------------------|--------------------------| | classic | 8 | 6 | | two-column | varies | varies | | nonstandard-headers | 8 | 8 | | minimal | 0 | 0 | The `classic` 8→6 gap suggests `bulletHasMetric` is sensitive to something in WeasyPrint's text layout that Chrome doesn't reproduce — likely word breaking or numeric-character spacing across line wraps. Worth a focused look as a separate parser investigation; not blocking this PR because the snapshot diff is fully captured either way. Re inline finding on nonstandard-headers ---------------------------------------- Reviewer asked the `Refs #19` link survive into the squash-merge commit body so the regression tripwire is discoverable from `git blame` later. Including the explicit trailer below. Verification ------------ - npm run typecheck: clean - npm run test: 189 / 189 (snapshots round-trip cleanly through the rename) - `git diff --cached` confirms no content changes on the 8 renamed files beyond the rename itself Refs #1, #12, #19 * test(corpus): include README taxonomy update missed from 284a6cb The previous commit (`284a6cb`) renamed the chromium fixtures and *claimed* in its body to also update `tests/fixtures/pdfs/README.md`, but the README diff didn't make it into the commit due to a stale cwd swallowing the relative path. The README change is what gives the rename its meaning — without it, the new filenames are arbitrary. This commit adds the actual README edit: - `google-docs/` description now explicitly states it accepts Chromium `--print-to-pdf` Skia exports as a deliberate proxy, names the `google-docs-skia-proxy-*` filename prefix, and pre-empts confusion about the `Creator: HeadlessChrome/<v>` Info-dict string. - `unknown/` description no longer lists "headless Chrome" (it's now in `google-docs/` per the redefinition); WeasyPrint added as an example of what `unknown/` genuinely covers. Together with `284a6cb`, this fully resolves the [Blocking] finding from @s-annam's review on PR #26 — taxonomy is now self-consistent and the corpus no longer contradicts its own README. Refs #1 --------- Co-authored-by: Srinivas Annam <annam@annam.org>
…ts (#18) * fix(score): count short bullets in displayed total, don't silently drop them Closes part of #9. Resolves the reported 19→18 / N→N-1 pattern; flags the Deedy 8→1 catastrophic case as a separate root cause needing its own fix. `extractBulletsFromText` was applying `ANON_BULLET_MIN_WORDS = 4` as a hard filter, dropping any marker-prefixed line with fewer than 4 words after the marker. That conflated *bullet detection* with *bullet grading*: short low-quality bullets were hidden from the displayed `bulletCount` even though they were visible in the PDF, and that hid their natural drag on Specificity / Structure ratios. The reporter saw this as "20+ visible · 19 in extracted text · 18 reported" — the 19→18 gap is the under-count caused by exactly one short bullet getting filtered post-extraction. Localised the bug by tracing every marker-prefixed line through the pipeline. The dropped bullet on both Awesome-CV fixtures was the same 3-word line `"• Everything that matters."`. Awesome-CV resume: 31 visible → 30 reported (off by 1). Awesome-CV cv: 59 → 58 (off by 1). Fix: - Lower `ANON_BULLET_MIN_WORDS` from 4 to 1. Empty marker-only lines (`"• "`) are still skipped because `split(/\s+/).filter(Boolean)` returns length 0; any line with at least one word now counts. - Update the doc comments on the constant and `extractBulletsFromText` to explain the split between "is this a bullet?" (count) and "is this a good bullet?" (grade) — the well-formed length window (8-30 words) in `analyzeBullets` and `scoreBulletPool` handles the quality side and naturally penalises the now-counted shorts. Snapshot impact (re-baked): - `awesome-cv-cv.expected.json`: `bulletCount` 58 → 59, `overall` 59 → 58 - `awesome-cv-resume.expected.json`: `bulletCount` 30 → 31, `overall` 64 → 63 - Other 5 fixtures: byte-identical (their visible counts already matched) The -1 overall on each Awesome-CV fixture is the *correct* score movement — the previously-hidden short bullet adds 1 to the denominators of both Specificity ratio and Structure ratio without adding to either numerator (no metric, outside 8-30 word window). The pre-fix score was inflated by silently dropping a bad bullet. Verification: - visible-bullet vs reported-bulletCount delta now 0 on all 5 fixtures that share this root cause (was -1 on the two Awesome-CV files) - npm run test: 175 / 175 (174 baseline + 1 new regression test in score.test.ts pinning the issue-#9 short-bullet behaviour) - npm run typecheck: clean - 5 non-Awesome-CV corpus snapshots: byte-identical, zero regression Deedy still off by 6-7 — different root cause ----------------------------------------------- The two Deedy fixtures both show 8 visible bullets but 1-2 reported. That gap is NOT from this filter — pipeline trace shows their `cascade.rawText` only contains 2 lines starting with a bullet glyph (vs. 8 in pdftotext output), with 10 additional lines where the glyph appears mid-line. The bullets are being mis-grouped at PDF extraction time, most likely by `groupIntoLines` / `assembleTextFromLines` reading the two-column experience section in an order that breaks bullet prefixes. That's an extraction-stage bug, not a counting-stage bug, and the fix lives in `src/lib/heuristics/sections.ts` rather than `src/lib/score/score.ts`. Flagging as a separate follow-up since the blast radius and risk profile are very different from the simple constant change here. Refs #9 * fix(heuristics): split two-column same-y items in groupIntoLines Second half of issue #9. The previous commit (`126ad13`) fixed the score-side off-by-1 for Awesome-CV. This commit fixes the extraction-side catastrophic loss on Deedy-style two-column resumes where bullets in the right column share a y-baseline with prose in the left column. Root cause: `groupIntoLines` clusters items purely by y-proximity (`LINE_Y_EPS = 3.5`), with no concept of column structure. On Deedy's asymmetric 0.33/0.66 layout the left-column education text and right-column experience bullets end up at the same y, so the bullet glyph gets concatenated *after* the education text and never reaches line-start position. `extractBulletsFromText` then correctly skips it (it requires `^\s*[bullet glyph]\s+`), and the cascade reports e.g. `bulletCount: 1` against 8 visible bullets in the PDF. The asymmetric column layout doesn't trigger the existing `isTwoColumn` flag in `pdf-layout.ts` (which requires roughly equal columns and 60% density), so gating on that trigger isn't an option. Instead, fix it inside `groupIntoLines` itself. Fix: when flushing a same-y cluster of items, scan the (already x-sorted) items for any gap >= `COLUMN_GAP_THRESHOLD` (50pt) between consecutive items and emit each side as its own `PdfLine`. 50pt is well above any in-line word/run gap (Awesome-CV's `\hfill` lines produce 0pt gaps because LuaTeX includes trailing whitespace in item widths) and comfortably below the column gaps observed on real two-column resumes (Deedy's experience column starts ~70-130pt past the education column edge). Side effects (all positive) --------------------------- - `openresume-react-pdf.pdf` skillsCount 16 → 20. The skills line `HTML CSS Python TypeScript React C++` had ~160-175pt gaps between each item (visually distinct skill tokens). Splitting these into per-token lines exposed 4 additional skills the parser had been missing. Deedy snapshot impact (re-baked) -------------------------------- The line-grouping fix unblocks every downstream parser on Deedy, not just the bullet counter: | Field | macfonts before → after | openfonts before → after | |-----------------|------------------------|--------------------------| | `bulletCount` | 1 → 8 | 2 → 8 | | `skillsCount` | 0 → 25 | 0 → 24 | | `experienceCount` | 0 → 6 | 0 → 6 | | `metricBullets` | 0 → 1 | 0 → 1 | | `goodBullets` | 1 → 6 | 1 → 6 | | `overall` | 17 → 47 | 17 → 47 | The Deedy PDFs were essentially unparseable before this fix — the parser wasn't dropping individual fields, it was misreading the line structure end-to-end. Score jumping from 17 to 47 reflects that the parser can now see the resume's content rather than scoring it as near-empty. Awesome-CV, header-as-name, and laverne snapshots: byte-identical (no two-column layout, no impact). Visible-vs-reported bullet count delta is now 0 on all 7 corpus fixtures (was 1, 1, 7, 6 pre-fix on Awesome-CV cv / resume / Deedy macfonts / Deedy openfonts). Test added in `pdf-extract.test.ts`: - Two-column same-y items: bullet keeps line-start position - Single-column regression guard: small gaps don't fragment lines Verification ------------ - `npm run test`: 177 / 177 (175 baseline + 2 new in pdf-extract.test) - `npm run typecheck`: clean - `pdftotext` bullet count == reported `bulletCount` on all 7 fixtures Refs #9
* test(corpus): add google-docs/ category with 4 chromium-headless fixtures Closes part of #1 (#12 tracks remaining manual-export categories). Adds 4 synthetic resume PDFs rendered via Chromium headless print-to-pdf, which uses the same Skia/PDF renderer family as Google Docs's "Download as PDF" export. Lands them in the previously-empty `google-docs/` category folder so the corpus now covers 4 source categories (latex, word, unknown, google-docs) — meeting issue #1's "≥4 source categories" acceptance criterion. Persona is synthetic end-to-end (Jane Smith / @example.com / 555-style phone / fictional Acme Corp / Globex / Initech / Springfield State University). Multi-stage PII preflight clean: body text, Info dict (Title only — "Resume", non-identifying), no XMP packet, raw byte grep against the known real-author token list returns 0 on every PDF. The 4 templates each exercise a different mix of parser paths: | Fixture | Score | Notable signal | |---------|-------|----------------| | `chromium-headless-classic` | 98 | Well-formed baseline — full single-column, every bullet has metric + verb + length, all sections detected. Happy-path anchor. | | `chromium-headless-two-column` | 53 | CSS grid sidebar + main column. Exercises the `COLUMN_GAP_THRESHOLD` split in `groupIntoLines` landed in PR #18; 23 bullets correctly counted across both columns. | | `chromium-headless-nonstandard-headers` | 84 | Uses `On Campus Involvement / Volunteer Experience / Internships` — none in `SECTION_KEYWORDS.experience`, so reports `experienceCount: 0` and `missing: ["work experience"]` despite 8 visible bullets in those sections. **Direct regression anchor for issue #19** — when the section-keyword expansion lands, this fixture's snapshot will update and pin the new behaviour. | | `chromium-headless-minimal` | 24 | Short bullets, no metrics, no summary, no LinkedIn. Confirms the post-#9 grading correctly penalises low-quality content rather than hiding it. | Two related findings surfaced during this work, neither blocking but worth flagging for follow-up: 1. **Chromium `--print-to-pdf` renders CSS list-style bullets as graphics, not text** — pdftotext sees no `•` glyph at line start, so the parser counts 0 bullets and grades dimensions as ungradable. All 4 fixtures here use explicit `<p>• text</p>` markup to work around this. Worth filing as a separate finding: Chrome-exported PDFs from apps that emit native `<ul>` markup (most modern web resume tools) may silently lose all bullet structure. Not files yet — leaving it for a focused issue with a dedicated CSS-bullets fixture. 2. **`nonstandard-headers` fixture validates issue #19's scope** — confirms the section-keyword gap affects not just laverne but any student/early-career resume layout with non-canonical experience headings. Already tracked in #19; this fixture adds independent evidence. Corpus state after this commit: | Category | PDFs | Status | |-------------|------|-------------------------------------------------------| | latex/ | 5 | awesome-cv (cv + resume), deedy (mac + open), header-as-name | | word/ | 1 | openresume-laverne-word-quartz | | google-docs/| 4 | chromium-headless × 4 (this commit) | | unknown/ | 1 | openresume-react-pdf | | mac-pages/ | 0 | needs manual export — tracked in #12 | | mac-preview/| 0 | tried cupsfilter, doesn't expose a clean Quartz | | | | re-rendering path on modern macOS — manual export | | | | needed, tracked in #12 | | indesign/ | 0 | needs InDesign access — tracked in #12 | Total: 11 PDFs across 4 categories. Issue #1's "≥4 categories" criterion met; "≥15 PDFs" still gapped at 11/15 — covered by #12's manual-export follow-up which was already filed for exactly this content work. Filename convention follows the README example (`google-docs-skia-m146.pdf`): `<renderer>-<variant>.pdf` inside the category folder. "chromium-headless" is honest about provenance (these are Chromium-rendered, not literal Google Docs exports, though they share the Skia/PDF renderer family — so they live in `google-docs/` for parser-path categorisation purposes). Verification: - `npm run typecheck`: clean - `npm run test`: 185 / 185 (181 baseline + 4 new corpus fixtures) - `npm run bake-fixtures` + `git diff` after: empty (snapshots round-trip) - All 4 PDFs: 0 hits on raw-byte grep for real-author tokens; no XMP packet - CONTRIBUTING.md test count refreshed 167 → 185 Refs #1, #12 * test(corpus): add 4 WeasyPrint Cairo-renderer fixtures to close ≥15 target Brings corpus to 16 PDFs across 4 categories — closes both of issue #1's remaining acceptance criteria. Renders the same 4 HTML templates from the prior commit through WeasyPrint (Cairo backend) instead of Chromium-headless (Skia). Drops them in the existing `unknown/` category per the README's "Generator unknown or one-off" classification — WeasyPrint isn't a category called out in #1's body, and re-using `unknown/` is honest about provenance (these aren't mac-pages / mac-preview / indesign exports, which genuinely require GUI apps I can't drive autonomously and remain tracked in #12). Why the same content twice with different renderers --------------------------------------------------- Each WeasyPrint PDF carries the same source HTML as its Chromium twin in google-docs/, but the rendered byte stream differs (different font subsetting, item layout, line breaks). That's a feature: identical content rendered by two distinct generators is exactly the test the corpus exists to do — surface renderer-dependent parser drift that a single-generator corpus would miss. Initial snapshot comparison shows the parser produces near-identical scores on Chrome vs WeasyPrint for the simpler layouts (98 vs 98, 24 vs 24, 84 vs 84), but diverges on the two-column case (53 vs 43). Worth a focused look later if that gap is a real Cairo-vs-Skia layout difference vs a parser regression — but not blocking this PR; the snapshots pin whatever the current behaviour is. Fixtures -------- - `weasyprint-cairo-classic.pdf` (score 98) - `weasyprint-cairo-two-column.pdf` (score 43) - `weasyprint-cairo-nonstandard-headers.pdf` (score 84 — second regression anchor for #19) - `weasyprint-cairo-minimal.pdf` (score 24) PII preflight (same multi-stage sweep as PR #13) ------------------------------------------------ - Body text grep against known real-author tokens: 0 hits per PDF - Info dict: `Title: Resume` only (HTML `<title>`, non-identifying) - XMP packet: absent - Raw byte grep: 0 hits Corpus state after this commit ------------------------------ | Category | PDFs | Notes | |-------------|------|------------------------------------------------| | latex/ | 5 | awesome-cv × 2, deedy × 2, header-as-name | | word/ | 1 | openresume-laverne (Quartz) | | unknown/ | 6 | openresume-react-pdf, name-set-apart-tagline, | | | | weasyprint-cairo × 4 (this commit) | | google-docs/| 4 | chromium-headless × 4 (prior commit) | | mac-pages/ | 0 | needs Apple Pages — tracked in #12 | | mac-preview/| 0 | needs macOS Preview re-save — tracked in #12 | | indesign/ | 0 | needs InDesign access — tracked in #12 | | **Total** | **16** | **≥15 ✓** (issue #1 acceptance) | Verification ------------ - npm run typecheck: clean - npm run test: 189 / 189 (185 baseline + 4 new corpus tests) - npm run bake-fixtures + git diff after: empty (snapshots round-trip) - All 4 weasyprint PDFs: 0 hits on raw-byte grep, no XMP, generic Title only - CONTRIBUTING.md test count refreshed 185 → 189 Install note for reviewers reproducing locally: WeasyPrint requires native Cairo/Pango/GLib libs. On macOS: pip install --user weasyprint brew install pango cairo glib export DYLD_FALLBACK_LIBRARY_PATH="/opt/homebrew/lib:$DYLD_FALLBACK_LIBRARY_PATH" weasyprint input.html output.pdf The committed `.pdf` files are the artifact — re-rendering isn't required to run tests; only baking new snapshots is, which uses the existing PDFs. Refs #1, #12 * test(corpus): address PR #26 review — rename to google-docs-skia-proxy + doc the taxonomy decision Addresses the [Blocking] + [Suggestion] findings from @s-annam's review on PR #26. The reviewer flagged that the 4 chromium-headless fixtures self-identify as `Creator: HeadlessChrome/149` / `Producer: Skia/PDF m149`, while `tests/fixtures/pdfs/README.md` previously routed "headless Chrome" to `unknown/` and reserved `google-docs/` for actual Google Docs exports. Without resolution, "Closes #1" rests on a mislabel. Choosing reviewer's option (b) — deliberately redefine `google-docs/` as a Skia/PDF category that accepts both real Google Docs exports and Chromium headless prints as a faithful Skia proxy. Google Docs's "Download as PDF" pipeline uses Skia/PDF; a `chrome --headless --print-to-pdf` of the same HTML produces structurally-equivalent output (same renderer family, same item-layout patterns, same font-subsetting behaviour the parser needs to handle). Treating them as the same category for parser-failure-mode purposes is honest about what the corpus is for — distinct renderers, not distinct product provenance. Changes: 1. Renamed 4 PDFs + 4 snapshots: chromium-headless-classic.{pdf,expected.json} → google-docs-skia-proxy-classic.{pdf,expected.json} chromium-headless-two-column.{pdf,expected.json} → google-docs-skia-proxy-two-column.{pdf,expected.json} chromium-headless-nonstandard-headers.{pdf,expected.json} → google-docs-skia-proxy-nonstandard-headers.{pdf,expected.json} chromium-headless-minimal.{pdf,expected.json} → google-docs-skia-proxy-minimal.{pdf,expected.json} New filename encodes the rationale per finding #2 — next contributor reading `google-docs-skia-proxy-*` immediately knows these are Skia prints used as proxies, not real Google Docs exports. 2. Updated `tests/fixtures/pdfs/README.md` taxonomy in two places: - `google-docs/` description now explicitly says it accepts Chromium `--print-to-pdf` Skia exports as a deliberate proxy, names the `google-docs-skia-proxy-*` filename prefix, and pre-empts confusion about the `Creator: HeadlessChrome/<v>` Info-dict string ("that is by design, not a mislabel"). - `unknown/` description no longer claims headless Chrome (it's now in `google-docs/` per the redefinition); WeasyPrint added as an example of what `unknown/` genuinely covers. 3. PDFs themselves are byte-identical to the prior commit — only the filenames changed. Snapshots re-baked to match the new filenames; counts and scores are unchanged. Re finding #3 (metricBullets drift, nit) --------------------------------------- Reviewer noted that on the `classic` variant, `metricBullets` drifts 8 → 6 between the chromium and weasyprint snapshots even though both score 98 — the difference gets absorbed by the 40-pt Specificity cap at the score boundary. Surfacing here so it's discoverable from `git log` rather than buried in the snapshot files: | variant | chromium metricBullets | weasyprint metricBullets | |---------|------------------------|--------------------------| | classic | 8 | 6 | | two-column | varies | varies | | nonstandard-headers | 8 | 8 | | minimal | 0 | 0 | The `classic` 8→6 gap suggests `bulletHasMetric` is sensitive to something in WeasyPrint's text layout that Chrome doesn't reproduce — likely word breaking or numeric-character spacing across line wraps. Worth a focused look as a separate parser investigation; not blocking this PR because the snapshot diff is fully captured either way. Re inline finding on nonstandard-headers ---------------------------------------- Reviewer asked the `Refs #19` link survive into the squash-merge commit body so the regression tripwire is discoverable from `git blame` later. Including the explicit trailer below. Verification ------------ - npm run typecheck: clean - npm run test: 189 / 189 (snapshots round-trip cleanly through the rename) - `git diff --cached` confirms no content changes on the 8 renamed files beyond the rename itself Refs #1, #12, #19 * test(corpus): include README taxonomy update missed from 284a6cb The previous commit (`284a6cb`) renamed the chromium fixtures and *claimed* in its body to also update `tests/fixtures/pdfs/README.md`, but the README diff didn't make it into the commit due to a stale cwd swallowing the relative path. The README change is what gives the rename its meaning — without it, the new filenames are arbitrary. This commit adds the actual README edit: - `google-docs/` description now explicitly states it accepts Chromium `--print-to-pdf` Skia exports as a deliberate proxy, names the `google-docs-skia-proxy-*` filename prefix, and pre-empts confusion about the `Creator: HeadlessChrome/<v>` Info-dict string. - `unknown/` description no longer lists "headless Chrome" (it's now in `google-docs/` per the redefinition); WeasyPrint added as an example of what `unknown/` genuinely covers. Together with `284a6cb`, this fully resolves the [Blocking] finding from @s-annam's review on PR #26 — taxonomy is now self-consistent and the corpus no longer contradicts its own README. Refs #1 --------- Co-authored-by: Srinivas Annam <annam@annam.org>
…ts (#18) * fix(score): count short bullets in displayed total, don't silently drop them Closes part of #9. Resolves the reported 19→18 / N→N-1 pattern; flags the Deedy 8→1 catastrophic case as a separate root cause needing its own fix. `extractBulletsFromText` was applying `ANON_BULLET_MIN_WORDS = 4` as a hard filter, dropping any marker-prefixed line with fewer than 4 words after the marker. That conflated *bullet detection* with *bullet grading*: short low-quality bullets were hidden from the displayed `bulletCount` even though they were visible in the PDF, and that hid their natural drag on Specificity / Structure ratios. The reporter saw this as "20+ visible · 19 in extracted text · 18 reported" — the 19→18 gap is the under-count caused by exactly one short bullet getting filtered post-extraction. Localised the bug by tracing every marker-prefixed line through the pipeline. The dropped bullet on both Awesome-CV fixtures was the same 3-word line `"• Everything that matters."`. Awesome-CV resume: 31 visible → 30 reported (off by 1). Awesome-CV cv: 59 → 58 (off by 1). Fix: - Lower `ANON_BULLET_MIN_WORDS` from 4 to 1. Empty marker-only lines (`"• "`) are still skipped because `split(/\s+/).filter(Boolean)` returns length 0; any line with at least one word now counts. - Update the doc comments on the constant and `extractBulletsFromText` to explain the split between "is this a bullet?" (count) and "is this a good bullet?" (grade) — the well-formed length window (8-30 words) in `analyzeBullets` and `scoreBulletPool` handles the quality side and naturally penalises the now-counted shorts. Snapshot impact (re-baked): - `awesome-cv-cv.expected.json`: `bulletCount` 58 → 59, `overall` 59 → 58 - `awesome-cv-resume.expected.json`: `bulletCount` 30 → 31, `overall` 64 → 63 - Other 5 fixtures: byte-identical (their visible counts already matched) The -1 overall on each Awesome-CV fixture is the *correct* score movement — the previously-hidden short bullet adds 1 to the denominators of both Specificity ratio and Structure ratio without adding to either numerator (no metric, outside 8-30 word window). The pre-fix score was inflated by silently dropping a bad bullet. Verification: - visible-bullet vs reported-bulletCount delta now 0 on all 5 fixtures that share this root cause (was -1 on the two Awesome-CV files) - npm run test: 175 / 175 (174 baseline + 1 new regression test in score.test.ts pinning the issue-#9 short-bullet behaviour) - npm run typecheck: clean - 5 non-Awesome-CV corpus snapshots: byte-identical, zero regression Deedy still off by 6-7 — different root cause ----------------------------------------------- The two Deedy fixtures both show 8 visible bullets but 1-2 reported. That gap is NOT from this filter — pipeline trace shows their `cascade.rawText` only contains 2 lines starting with a bullet glyph (vs. 8 in pdftotext output), with 10 additional lines where the glyph appears mid-line. The bullets are being mis-grouped at PDF extraction time, most likely by `groupIntoLines` / `assembleTextFromLines` reading the two-column experience section in an order that breaks bullet prefixes. That's an extraction-stage bug, not a counting-stage bug, and the fix lives in `src/lib/heuristics/sections.ts` rather than `src/lib/score/score.ts`. Flagging as a separate follow-up since the blast radius and risk profile are very different from the simple constant change here. Refs #9 * fix(heuristics): split two-column same-y items in groupIntoLines Second half of issue #9. The previous commit (`fbaf8a6`) fixed the score-side off-by-1 for Awesome-CV. This commit fixes the extraction-side catastrophic loss on Deedy-style two-column resumes where bullets in the right column share a y-baseline with prose in the left column. Root cause: `groupIntoLines` clusters items purely by y-proximity (`LINE_Y_EPS = 3.5`), with no concept of column structure. On Deedy's asymmetric 0.33/0.66 layout the left-column education text and right-column experience bullets end up at the same y, so the bullet glyph gets concatenated *after* the education text and never reaches line-start position. `extractBulletsFromText` then correctly skips it (it requires `^\s*[bullet glyph]\s+`), and the cascade reports e.g. `bulletCount: 1` against 8 visible bullets in the PDF. The asymmetric column layout doesn't trigger the existing `isTwoColumn` flag in `pdf-layout.ts` (which requires roughly equal columns and 60% density), so gating on that trigger isn't an option. Instead, fix it inside `groupIntoLines` itself. Fix: when flushing a same-y cluster of items, scan the (already x-sorted) items for any gap >= `COLUMN_GAP_THRESHOLD` (50pt) between consecutive items and emit each side as its own `PdfLine`. 50pt is well above any in-line word/run gap (Awesome-CV's `\hfill` lines produce 0pt gaps because LuaTeX includes trailing whitespace in item widths) and comfortably below the column gaps observed on real two-column resumes (Deedy's experience column starts ~70-130pt past the education column edge). Side effects (all positive) --------------------------- - `openresume-react-pdf.pdf` skillsCount 16 → 20. The skills line `HTML CSS Python TypeScript React C++` had ~160-175pt gaps between each item (visually distinct skill tokens). Splitting these into per-token lines exposed 4 additional skills the parser had been missing. Deedy snapshot impact (re-baked) -------------------------------- The line-grouping fix unblocks every downstream parser on Deedy, not just the bullet counter: | Field | macfonts before → after | openfonts before → after | |-----------------|------------------------|--------------------------| | `bulletCount` | 1 → 8 | 2 → 8 | | `skillsCount` | 0 → 25 | 0 → 24 | | `experienceCount` | 0 → 6 | 0 → 6 | | `metricBullets` | 0 → 1 | 0 → 1 | | `goodBullets` | 1 → 6 | 1 → 6 | | `overall` | 17 → 47 | 17 → 47 | The Deedy PDFs were essentially unparseable before this fix — the parser wasn't dropping individual fields, it was misreading the line structure end-to-end. Score jumping from 17 to 47 reflects that the parser can now see the resume's content rather than scoring it as near-empty. Awesome-CV, header-as-name, and laverne snapshots: byte-identical (no two-column layout, no impact). Visible-vs-reported bullet count delta is now 0 on all 7 corpus fixtures (was 1, 1, 7, 6 pre-fix on Awesome-CV cv / resume / Deedy macfonts / Deedy openfonts). Test added in `pdf-extract.test.ts`: - Two-column same-y items: bullet keeps line-start position - Single-column regression guard: small gaps don't fragment lines Verification ------------ - `npm run test`: 177 / 177 (175 baseline + 2 new in pdf-extract.test) - `npm run typecheck`: clean - `pdftotext` bullet count == reported `bulletCount` on all 7 fixtures Refs #9
* test(corpus): add google-docs/ category with 4 chromium-headless fixtures Closes part of #1 (#12 tracks remaining manual-export categories). Adds 4 synthetic resume PDFs rendered via Chromium headless print-to-pdf, which uses the same Skia/PDF renderer family as Google Docs's "Download as PDF" export. Lands them in the previously-empty `google-docs/` category folder so the corpus now covers 4 source categories (latex, word, unknown, google-docs) — meeting issue #1's "≥4 source categories" acceptance criterion. Persona is synthetic end-to-end (Jane Smith / @example.com / 555-style phone / fictional Acme Corp / Globex / Initech / Springfield State University). Multi-stage PII preflight clean: body text, Info dict (Title only — "Resume", non-identifying), no XMP packet, raw byte grep against the known real-author token list returns 0 on every PDF. The 4 templates each exercise a different mix of parser paths: | Fixture | Score | Notable signal | |---------|-------|----------------| | `chromium-headless-classic` | 98 | Well-formed baseline — full single-column, every bullet has metric + verb + length, all sections detected. Happy-path anchor. | | `chromium-headless-two-column` | 53 | CSS grid sidebar + main column. Exercises the `COLUMN_GAP_THRESHOLD` split in `groupIntoLines` landed in PR #18; 23 bullets correctly counted across both columns. | | `chromium-headless-nonstandard-headers` | 84 | Uses `On Campus Involvement / Volunteer Experience / Internships` — none in `SECTION_KEYWORDS.experience`, so reports `experienceCount: 0` and `missing: ["work experience"]` despite 8 visible bullets in those sections. **Direct regression anchor for issue #19** — when the section-keyword expansion lands, this fixture's snapshot will update and pin the new behaviour. | | `chromium-headless-minimal` | 24 | Short bullets, no metrics, no summary, no LinkedIn. Confirms the post-#9 grading correctly penalises low-quality content rather than hiding it. | Two related findings surfaced during this work, neither blocking but worth flagging for follow-up: 1. **Chromium `--print-to-pdf` renders CSS list-style bullets as graphics, not text** — pdftotext sees no `•` glyph at line start, so the parser counts 0 bullets and grades dimensions as ungradable. All 4 fixtures here use explicit `<p>• text</p>` markup to work around this. Worth filing as a separate finding: Chrome-exported PDFs from apps that emit native `<ul>` markup (most modern web resume tools) may silently lose all bullet structure. Not files yet — leaving it for a focused issue with a dedicated CSS-bullets fixture. 2. **`nonstandard-headers` fixture validates issue #19's scope** — confirms the section-keyword gap affects not just laverne but any student/early-career resume layout with non-canonical experience headings. Already tracked in #19; this fixture adds independent evidence. Corpus state after this commit: | Category | PDFs | Status | |-------------|------|-------------------------------------------------------| | latex/ | 5 | awesome-cv (cv + resume), deedy (mac + open), header-as-name | | word/ | 1 | openresume-laverne-word-quartz | | google-docs/| 4 | chromium-headless × 4 (this commit) | | unknown/ | 1 | openresume-react-pdf | | mac-pages/ | 0 | needs manual export — tracked in #12 | | mac-preview/| 0 | tried cupsfilter, doesn't expose a clean Quartz | | | | re-rendering path on modern macOS — manual export | | | | needed, tracked in #12 | | indesign/ | 0 | needs InDesign access — tracked in #12 | Total: 11 PDFs across 4 categories. Issue #1's "≥4 categories" criterion met; "≥15 PDFs" still gapped at 11/15 — covered by #12's manual-export follow-up which was already filed for exactly this content work. Filename convention follows the README example (`google-docs-skia-m146.pdf`): `<renderer>-<variant>.pdf` inside the category folder. "chromium-headless" is honest about provenance (these are Chromium-rendered, not literal Google Docs exports, though they share the Skia/PDF renderer family — so they live in `google-docs/` for parser-path categorisation purposes). Verification: - `npm run typecheck`: clean - `npm run test`: 185 / 185 (181 baseline + 4 new corpus fixtures) - `npm run bake-fixtures` + `git diff` after: empty (snapshots round-trip) - All 4 PDFs: 0 hits on raw-byte grep for real-author tokens; no XMP packet - CONTRIBUTING.md test count refreshed 167 → 185 Refs #1, #12 * test(corpus): add 4 WeasyPrint Cairo-renderer fixtures to close ≥15 target Brings corpus to 16 PDFs across 4 categories — closes both of issue #1's remaining acceptance criteria. Renders the same 4 HTML templates from the prior commit through WeasyPrint (Cairo backend) instead of Chromium-headless (Skia). Drops them in the existing `unknown/` category per the README's "Generator unknown or one-off" classification — WeasyPrint isn't a category called out in #1's body, and re-using `unknown/` is honest about provenance (these aren't mac-pages / mac-preview / indesign exports, which genuinely require GUI apps I can't drive autonomously and remain tracked in #12). Why the same content twice with different renderers --------------------------------------------------- Each WeasyPrint PDF carries the same source HTML as its Chromium twin in google-docs/, but the rendered byte stream differs (different font subsetting, item layout, line breaks). That's a feature: identical content rendered by two distinct generators is exactly the test the corpus exists to do — surface renderer-dependent parser drift that a single-generator corpus would miss. Initial snapshot comparison shows the parser produces near-identical scores on Chrome vs WeasyPrint for the simpler layouts (98 vs 98, 24 vs 24, 84 vs 84), but diverges on the two-column case (53 vs 43). Worth a focused look later if that gap is a real Cairo-vs-Skia layout difference vs a parser regression — but not blocking this PR; the snapshots pin whatever the current behaviour is. Fixtures -------- - `weasyprint-cairo-classic.pdf` (score 98) - `weasyprint-cairo-two-column.pdf` (score 43) - `weasyprint-cairo-nonstandard-headers.pdf` (score 84 — second regression anchor for #19) - `weasyprint-cairo-minimal.pdf` (score 24) PII preflight (same multi-stage sweep as PR #13) ------------------------------------------------ - Body text grep against known real-author tokens: 0 hits per PDF - Info dict: `Title: Resume` only (HTML `<title>`, non-identifying) - XMP packet: absent - Raw byte grep: 0 hits Corpus state after this commit ------------------------------ | Category | PDFs | Notes | |-------------|------|------------------------------------------------| | latex/ | 5 | awesome-cv × 2, deedy × 2, header-as-name | | word/ | 1 | openresume-laverne (Quartz) | | unknown/ | 6 | openresume-react-pdf, name-set-apart-tagline, | | | | weasyprint-cairo × 4 (this commit) | | google-docs/| 4 | chromium-headless × 4 (prior commit) | | mac-pages/ | 0 | needs Apple Pages — tracked in #12 | | mac-preview/| 0 | needs macOS Preview re-save — tracked in #12 | | indesign/ | 0 | needs InDesign access — tracked in #12 | | **Total** | **16** | **≥15 ✓** (issue #1 acceptance) | Verification ------------ - npm run typecheck: clean - npm run test: 189 / 189 (185 baseline + 4 new corpus tests) - npm run bake-fixtures + git diff after: empty (snapshots round-trip) - All 4 weasyprint PDFs: 0 hits on raw-byte grep, no XMP, generic Title only - CONTRIBUTING.md test count refreshed 185 → 189 Install note for reviewers reproducing locally: WeasyPrint requires native Cairo/Pango/GLib libs. On macOS: pip install --user weasyprint brew install pango cairo glib export DYLD_FALLBACK_LIBRARY_PATH="/opt/homebrew/lib:$DYLD_FALLBACK_LIBRARY_PATH" weasyprint input.html output.pdf The committed `.pdf` files are the artifact — re-rendering isn't required to run tests; only baking new snapshots is, which uses the existing PDFs. Refs #1, #12 * test(corpus): address PR #26 review — rename to google-docs-skia-proxy + doc the taxonomy decision Addresses the [Blocking] + [Suggestion] findings from @s-annam's review on PR #26. The reviewer flagged that the 4 chromium-headless fixtures self-identify as `Creator: HeadlessChrome/149` / `Producer: Skia/PDF m149`, while `tests/fixtures/pdfs/README.md` previously routed "headless Chrome" to `unknown/` and reserved `google-docs/` for actual Google Docs exports. Without resolution, "Closes #1" rests on a mislabel. Choosing reviewer's option (b) — deliberately redefine `google-docs/` as a Skia/PDF category that accepts both real Google Docs exports and Chromium headless prints as a faithful Skia proxy. Google Docs's "Download as PDF" pipeline uses Skia/PDF; a `chrome --headless --print-to-pdf` of the same HTML produces structurally-equivalent output (same renderer family, same item-layout patterns, same font-subsetting behaviour the parser needs to handle). Treating them as the same category for parser-failure-mode purposes is honest about what the corpus is for — distinct renderers, not distinct product provenance. Changes: 1. Renamed 4 PDFs + 4 snapshots: chromium-headless-classic.{pdf,expected.json} → google-docs-skia-proxy-classic.{pdf,expected.json} chromium-headless-two-column.{pdf,expected.json} → google-docs-skia-proxy-two-column.{pdf,expected.json} chromium-headless-nonstandard-headers.{pdf,expected.json} → google-docs-skia-proxy-nonstandard-headers.{pdf,expected.json} chromium-headless-minimal.{pdf,expected.json} → google-docs-skia-proxy-minimal.{pdf,expected.json} New filename encodes the rationale per finding #2 — next contributor reading `google-docs-skia-proxy-*` immediately knows these are Skia prints used as proxies, not real Google Docs exports. 2. Updated `tests/fixtures/pdfs/README.md` taxonomy in two places: - `google-docs/` description now explicitly says it accepts Chromium `--print-to-pdf` Skia exports as a deliberate proxy, names the `google-docs-skia-proxy-*` filename prefix, and pre-empts confusion about the `Creator: HeadlessChrome/<v>` Info-dict string ("that is by design, not a mislabel"). - `unknown/` description no longer claims headless Chrome (it's now in `google-docs/` per the redefinition); WeasyPrint added as an example of what `unknown/` genuinely covers. 3. PDFs themselves are byte-identical to the prior commit — only the filenames changed. Snapshots re-baked to match the new filenames; counts and scores are unchanged. Re finding #3 (metricBullets drift, nit) --------------------------------------- Reviewer noted that on the `classic` variant, `metricBullets` drifts 8 → 6 between the chromium and weasyprint snapshots even though both score 98 — the difference gets absorbed by the 40-pt Specificity cap at the score boundary. Surfacing here so it's discoverable from `git log` rather than buried in the snapshot files: | variant | chromium metricBullets | weasyprint metricBullets | |---------|------------------------|--------------------------| | classic | 8 | 6 | | two-column | varies | varies | | nonstandard-headers | 8 | 8 | | minimal | 0 | 0 | The `classic` 8→6 gap suggests `bulletHasMetric` is sensitive to something in WeasyPrint's text layout that Chrome doesn't reproduce — likely word breaking or numeric-character spacing across line wraps. Worth a focused look as a separate parser investigation; not blocking this PR because the snapshot diff is fully captured either way. Re inline finding on nonstandard-headers ---------------------------------------- Reviewer asked the `Refs #19` link survive into the squash-merge commit body so the regression tripwire is discoverable from `git blame` later. Including the explicit trailer below. Verification ------------ - npm run typecheck: clean - npm run test: 189 / 189 (snapshots round-trip cleanly through the rename) - `git diff --cached` confirms no content changes on the 8 renamed files beyond the rename itself Refs #1, #12, #19 * test(corpus): include README taxonomy update missed from 9596d2e The previous commit (`9596d2e`) renamed the chromium fixtures and *claimed* in its body to also update `tests/fixtures/pdfs/README.md`, but the README diff didn't make it into the commit due to a stale cwd swallowing the relative path. The README change is what gives the rename its meaning — without it, the new filenames are arbitrary. This commit adds the actual README edit: - `google-docs/` description now explicitly states it accepts Chromium `--print-to-pdf` Skia exports as a deliberate proxy, names the `google-docs-skia-proxy-*` filename prefix, and pre-empts confusion about the `Creator: HeadlessChrome/<v>` Info-dict string. - `unknown/` description no longer lists "headless Chrome" (it's now in `google-docs/` per the redefinition); WeasyPrint added as an example of what `unknown/` genuinely covers. Together with `9596d2e`, this fully resolves the [Blocking] finding from @s-annam's review on PR #26 — taxonomy is now self-consistent and the corpus no longer contradicts its own README. Refs #1 --------- Co-authored-by: Srinivas Annam <annam@annam.org>
…ts (#18) * fix(score): count short bullets in displayed total, don't silently drop them Closes part of #9. Resolves the reported 19→18 / N→N-1 pattern; flags the Deedy 8→1 catastrophic case as a separate root cause needing its own fix. `extractBulletsFromText` was applying `ANON_BULLET_MIN_WORDS = 4` as a hard filter, dropping any marker-prefixed line with fewer than 4 words after the marker. That conflated *bullet detection* with *bullet grading*: short low-quality bullets were hidden from the displayed `bulletCount` even though they were visible in the PDF, and that hid their natural drag on Specificity / Structure ratios. The reporter saw this as "20+ visible · 19 in extracted text · 18 reported" — the 19→18 gap is the under-count caused by exactly one short bullet getting filtered post-extraction. Localised the bug by tracing every marker-prefixed line through the pipeline. The dropped bullet on both Awesome-CV fixtures was the same 3-word line `"• Everything that matters."`. Awesome-CV resume: 31 visible → 30 reported (off by 1). Awesome-CV cv: 59 → 58 (off by 1). Fix: - Lower `ANON_BULLET_MIN_WORDS` from 4 to 1. Empty marker-only lines (`"• "`) are still skipped because `split(/\s+/).filter(Boolean)` returns length 0; any line with at least one word now counts. - Update the doc comments on the constant and `extractBulletsFromText` to explain the split between "is this a bullet?" (count) and "is this a good bullet?" (grade) — the well-formed length window (8-30 words) in `analyzeBullets` and `scoreBulletPool` handles the quality side and naturally penalises the now-counted shorts. Snapshot impact (re-baked): - `awesome-cv-cv.expected.json`: `bulletCount` 58 → 59, `overall` 59 → 58 - `awesome-cv-resume.expected.json`: `bulletCount` 30 → 31, `overall` 64 → 63 - Other 5 fixtures: byte-identical (their visible counts already matched) The -1 overall on each Awesome-CV fixture is the *correct* score movement — the previously-hidden short bullet adds 1 to the denominators of both Specificity ratio and Structure ratio without adding to either numerator (no metric, outside 8-30 word window). The pre-fix score was inflated by silently dropping a bad bullet. Verification: - visible-bullet vs reported-bulletCount delta now 0 on all 5 fixtures that share this root cause (was -1 on the two Awesome-CV files) - npm run test: 175 / 175 (174 baseline + 1 new regression test in score.test.ts pinning the issue-#9 short-bullet behaviour) - npm run typecheck: clean - 5 non-Awesome-CV corpus snapshots: byte-identical, zero regression Deedy still off by 6-7 — different root cause ----------------------------------------------- The two Deedy fixtures both show 8 visible bullets but 1-2 reported. That gap is NOT from this filter — pipeline trace shows their `cascade.rawText` only contains 2 lines starting with a bullet glyph (vs. 8 in pdftotext output), with 10 additional lines where the glyph appears mid-line. The bullets are being mis-grouped at PDF extraction time, most likely by `groupIntoLines` / `assembleTextFromLines` reading the two-column experience section in an order that breaks bullet prefixes. That's an extraction-stage bug, not a counting-stage bug, and the fix lives in `src/lib/heuristics/sections.ts` rather than `src/lib/score/score.ts`. Flagging as a separate follow-up since the blast radius and risk profile are very different from the simple constant change here. Refs #9 * fix(heuristics): split two-column same-y items in groupIntoLines Second half of issue #9. The previous commit (`fbaf8a6`) fixed the score-side off-by-1 for Awesome-CV. This commit fixes the extraction-side catastrophic loss on Deedy-style two-column resumes where bullets in the right column share a y-baseline with prose in the left column. Root cause: `groupIntoLines` clusters items purely by y-proximity (`LINE_Y_EPS = 3.5`), with no concept of column structure. On Deedy's asymmetric 0.33/0.66 layout the left-column education text and right-column experience bullets end up at the same y, so the bullet glyph gets concatenated *after* the education text and never reaches line-start position. `extractBulletsFromText` then correctly skips it (it requires `^\s*[bullet glyph]\s+`), and the cascade reports e.g. `bulletCount: 1` against 8 visible bullets in the PDF. The asymmetric column layout doesn't trigger the existing `isTwoColumn` flag in `pdf-layout.ts` (which requires roughly equal columns and 60% density), so gating on that trigger isn't an option. Instead, fix it inside `groupIntoLines` itself. Fix: when flushing a same-y cluster of items, scan the (already x-sorted) items for any gap >= `COLUMN_GAP_THRESHOLD` (50pt) between consecutive items and emit each side as its own `PdfLine`. 50pt is well above any in-line word/run gap (Awesome-CV's `\hfill` lines produce 0pt gaps because LuaTeX includes trailing whitespace in item widths) and comfortably below the column gaps observed on real two-column resumes (Deedy's experience column starts ~70-130pt past the education column edge). Side effects (all positive) --------------------------- - `openresume-react-pdf.pdf` skillsCount 16 → 20. The skills line `HTML CSS Python TypeScript React C++` had ~160-175pt gaps between each item (visually distinct skill tokens). Splitting these into per-token lines exposed 4 additional skills the parser had been missing. Deedy snapshot impact (re-baked) -------------------------------- The line-grouping fix unblocks every downstream parser on Deedy, not just the bullet counter: | Field | macfonts before → after | openfonts before → after | |-----------------|------------------------|--------------------------| | `bulletCount` | 1 → 8 | 2 → 8 | | `skillsCount` | 0 → 25 | 0 → 24 | | `experienceCount` | 0 → 6 | 0 → 6 | | `metricBullets` | 0 → 1 | 0 → 1 | | `goodBullets` | 1 → 6 | 1 → 6 | | `overall` | 17 → 47 | 17 → 47 | The Deedy PDFs were essentially unparseable before this fix — the parser wasn't dropping individual fields, it was misreading the line structure end-to-end. Score jumping from 17 to 47 reflects that the parser can now see the resume's content rather than scoring it as near-empty. Awesome-CV, header-as-name, and laverne snapshots: byte-identical (no two-column layout, no impact). Visible-vs-reported bullet count delta is now 0 on all 7 corpus fixtures (was 1, 1, 7, 6 pre-fix on Awesome-CV cv / resume / Deedy macfonts / Deedy openfonts). Test added in `pdf-extract.test.ts`: - Two-column same-y items: bullet keeps line-start position - Single-column regression guard: small gaps don't fragment lines Verification ------------ - `npm run test`: 177 / 177 (175 baseline + 2 new in pdf-extract.test) - `npm run typecheck`: clean - `pdftotext` bullet count == reported `bulletCount` on all 7 fixtures Refs #9
* test(corpus): add google-docs/ category with 4 chromium-headless fixtures Closes part of #1 (#12 tracks remaining manual-export categories). Adds 4 synthetic resume PDFs rendered via Chromium headless print-to-pdf, which uses the same Skia/PDF renderer family as Google Docs's "Download as PDF" export. Lands them in the previously-empty `google-docs/` category folder so the corpus now covers 4 source categories (latex, word, unknown, google-docs) — meeting issue #1's "≥4 source categories" acceptance criterion. Persona is synthetic end-to-end (Jane Smith / @example.com / 555-style phone / fictional Acme Corp / Globex / Initech / Springfield State University). Multi-stage PII preflight clean: body text, Info dict (Title only — "Resume", non-identifying), no XMP packet, raw byte grep against the known real-author token list returns 0 on every PDF. The 4 templates each exercise a different mix of parser paths: | Fixture | Score | Notable signal | |---------|-------|----------------| | `chromium-headless-classic` | 98 | Well-formed baseline — full single-column, every bullet has metric + verb + length, all sections detected. Happy-path anchor. | | `chromium-headless-two-column` | 53 | CSS grid sidebar + main column. Exercises the `COLUMN_GAP_THRESHOLD` split in `groupIntoLines` landed in PR #18; 23 bullets correctly counted across both columns. | | `chromium-headless-nonstandard-headers` | 84 | Uses `On Campus Involvement / Volunteer Experience / Internships` — none in `SECTION_KEYWORDS.experience`, so reports `experienceCount: 0` and `missing: ["work experience"]` despite 8 visible bullets in those sections. **Direct regression anchor for issue #19** — when the section-keyword expansion lands, this fixture's snapshot will update and pin the new behaviour. | | `chromium-headless-minimal` | 24 | Short bullets, no metrics, no summary, no LinkedIn. Confirms the post-#9 grading correctly penalises low-quality content rather than hiding it. | Two related findings surfaced during this work, neither blocking but worth flagging for follow-up: 1. **Chromium `--print-to-pdf` renders CSS list-style bullets as graphics, not text** — pdftotext sees no `•` glyph at line start, so the parser counts 0 bullets and grades dimensions as ungradable. All 4 fixtures here use explicit `<p>• text</p>` markup to work around this. Worth filing as a separate finding: Chrome-exported PDFs from apps that emit native `<ul>` markup (most modern web resume tools) may silently lose all bullet structure. Not files yet — leaving it for a focused issue with a dedicated CSS-bullets fixture. 2. **`nonstandard-headers` fixture validates issue #19's scope** — confirms the section-keyword gap affects not just laverne but any student/early-career resume layout with non-canonical experience headings. Already tracked in #19; this fixture adds independent evidence. Corpus state after this commit: | Category | PDFs | Status | |-------------|------|-------------------------------------------------------| | latex/ | 5 | awesome-cv (cv + resume), deedy (mac + open), header-as-name | | word/ | 1 | openresume-laverne-word-quartz | | google-docs/| 4 | chromium-headless × 4 (this commit) | | unknown/ | 1 | openresume-react-pdf | | mac-pages/ | 0 | needs manual export — tracked in #12 | | mac-preview/| 0 | tried cupsfilter, doesn't expose a clean Quartz | | | | re-rendering path on modern macOS — manual export | | | | needed, tracked in #12 | | indesign/ | 0 | needs InDesign access — tracked in #12 | Total: 11 PDFs across 4 categories. Issue #1's "≥4 categories" criterion met; "≥15 PDFs" still gapped at 11/15 — covered by #12's manual-export follow-up which was already filed for exactly this content work. Filename convention follows the README example (`google-docs-skia-m146.pdf`): `<renderer>-<variant>.pdf` inside the category folder. "chromium-headless" is honest about provenance (these are Chromium-rendered, not literal Google Docs exports, though they share the Skia/PDF renderer family — so they live in `google-docs/` for parser-path categorisation purposes). Verification: - `npm run typecheck`: clean - `npm run test`: 185 / 185 (181 baseline + 4 new corpus fixtures) - `npm run bake-fixtures` + `git diff` after: empty (snapshots round-trip) - All 4 PDFs: 0 hits on raw-byte grep for real-author tokens; no XMP packet - CONTRIBUTING.md test count refreshed 167 → 185 Refs #1, #12 * test(corpus): add 4 WeasyPrint Cairo-renderer fixtures to close ≥15 target Brings corpus to 16 PDFs across 4 categories — closes both of issue #1's remaining acceptance criteria. Renders the same 4 HTML templates from the prior commit through WeasyPrint (Cairo backend) instead of Chromium-headless (Skia). Drops them in the existing `unknown/` category per the README's "Generator unknown or one-off" classification — WeasyPrint isn't a category called out in #1's body, and re-using `unknown/` is honest about provenance (these aren't mac-pages / mac-preview / indesign exports, which genuinely require GUI apps I can't drive autonomously and remain tracked in #12). Why the same content twice with different renderers --------------------------------------------------- Each WeasyPrint PDF carries the same source HTML as its Chromium twin in google-docs/, but the rendered byte stream differs (different font subsetting, item layout, line breaks). That's a feature: identical content rendered by two distinct generators is exactly the test the corpus exists to do — surface renderer-dependent parser drift that a single-generator corpus would miss. Initial snapshot comparison shows the parser produces near-identical scores on Chrome vs WeasyPrint for the simpler layouts (98 vs 98, 24 vs 24, 84 vs 84), but diverges on the two-column case (53 vs 43). Worth a focused look later if that gap is a real Cairo-vs-Skia layout difference vs a parser regression — but not blocking this PR; the snapshots pin whatever the current behaviour is. Fixtures -------- - `weasyprint-cairo-classic.pdf` (score 98) - `weasyprint-cairo-two-column.pdf` (score 43) - `weasyprint-cairo-nonstandard-headers.pdf` (score 84 — second regression anchor for #19) - `weasyprint-cairo-minimal.pdf` (score 24) PII preflight (same multi-stage sweep as PR #13) ------------------------------------------------ - Body text grep against known real-author tokens: 0 hits per PDF - Info dict: `Title: Resume` only (HTML `<title>`, non-identifying) - XMP packet: absent - Raw byte grep: 0 hits Corpus state after this commit ------------------------------ | Category | PDFs | Notes | |-------------|------|------------------------------------------------| | latex/ | 5 | awesome-cv × 2, deedy × 2, header-as-name | | word/ | 1 | openresume-laverne (Quartz) | | unknown/ | 6 | openresume-react-pdf, name-set-apart-tagline, | | | | weasyprint-cairo × 4 (this commit) | | google-docs/| 4 | chromium-headless × 4 (prior commit) | | mac-pages/ | 0 | needs Apple Pages — tracked in #12 | | mac-preview/| 0 | needs macOS Preview re-save — tracked in #12 | | indesign/ | 0 | needs InDesign access — tracked in #12 | | **Total** | **16** | **≥15 ✓** (issue #1 acceptance) | Verification ------------ - npm run typecheck: clean - npm run test: 189 / 189 (185 baseline + 4 new corpus tests) - npm run bake-fixtures + git diff after: empty (snapshots round-trip) - All 4 weasyprint PDFs: 0 hits on raw-byte grep, no XMP, generic Title only - CONTRIBUTING.md test count refreshed 185 → 189 Install note for reviewers reproducing locally: WeasyPrint requires native Cairo/Pango/GLib libs. On macOS: pip install --user weasyprint brew install pango cairo glib export DYLD_FALLBACK_LIBRARY_PATH="/opt/homebrew/lib:$DYLD_FALLBACK_LIBRARY_PATH" weasyprint input.html output.pdf The committed `.pdf` files are the artifact — re-rendering isn't required to run tests; only baking new snapshots is, which uses the existing PDFs. Refs #1, #12 * test(corpus): address PR #26 review — rename to google-docs-skia-proxy + doc the taxonomy decision Addresses the [Blocking] + [Suggestion] findings from @s-annam's review on PR #26. The reviewer flagged that the 4 chromium-headless fixtures self-identify as `Creator: HeadlessChrome/149` / `Producer: Skia/PDF m149`, while `tests/fixtures/pdfs/README.md` previously routed "headless Chrome" to `unknown/` and reserved `google-docs/` for actual Google Docs exports. Without resolution, "Closes #1" rests on a mislabel. Choosing reviewer's option (b) — deliberately redefine `google-docs/` as a Skia/PDF category that accepts both real Google Docs exports and Chromium headless prints as a faithful Skia proxy. Google Docs's "Download as PDF" pipeline uses Skia/PDF; a `chrome --headless --print-to-pdf` of the same HTML produces structurally-equivalent output (same renderer family, same item-layout patterns, same font-subsetting behaviour the parser needs to handle). Treating them as the same category for parser-failure-mode purposes is honest about what the corpus is for — distinct renderers, not distinct product provenance. Changes: 1. Renamed 4 PDFs + 4 snapshots: chromium-headless-classic.{pdf,expected.json} → google-docs-skia-proxy-classic.{pdf,expected.json} chromium-headless-two-column.{pdf,expected.json} → google-docs-skia-proxy-two-column.{pdf,expected.json} chromium-headless-nonstandard-headers.{pdf,expected.json} → google-docs-skia-proxy-nonstandard-headers.{pdf,expected.json} chromium-headless-minimal.{pdf,expected.json} → google-docs-skia-proxy-minimal.{pdf,expected.json} New filename encodes the rationale per finding #2 — next contributor reading `google-docs-skia-proxy-*` immediately knows these are Skia prints used as proxies, not real Google Docs exports. 2. Updated `tests/fixtures/pdfs/README.md` taxonomy in two places: - `google-docs/` description now explicitly says it accepts Chromium `--print-to-pdf` Skia exports as a deliberate proxy, names the `google-docs-skia-proxy-*` filename prefix, and pre-empts confusion about the `Creator: HeadlessChrome/<v>` Info-dict string ("that is by design, not a mislabel"). - `unknown/` description no longer claims headless Chrome (it's now in `google-docs/` per the redefinition); WeasyPrint added as an example of what `unknown/` genuinely covers. 3. PDFs themselves are byte-identical to the prior commit — only the filenames changed. Snapshots re-baked to match the new filenames; counts and scores are unchanged. Re finding #3 (metricBullets drift, nit) --------------------------------------- Reviewer noted that on the `classic` variant, `metricBullets` drifts 8 → 6 between the chromium and weasyprint snapshots even though both score 98 — the difference gets absorbed by the 40-pt Specificity cap at the score boundary. Surfacing here so it's discoverable from `git log` rather than buried in the snapshot files: | variant | chromium metricBullets | weasyprint metricBullets | |---------|------------------------|--------------------------| | classic | 8 | 6 | | two-column | varies | varies | | nonstandard-headers | 8 | 8 | | minimal | 0 | 0 | The `classic` 8→6 gap suggests `bulletHasMetric` is sensitive to something in WeasyPrint's text layout that Chrome doesn't reproduce — likely word breaking or numeric-character spacing across line wraps. Worth a focused look as a separate parser investigation; not blocking this PR because the snapshot diff is fully captured either way. Re inline finding on nonstandard-headers ---------------------------------------- Reviewer asked the `Refs #19` link survive into the squash-merge commit body so the regression tripwire is discoverable from `git blame` later. Including the explicit trailer below. Verification ------------ - npm run typecheck: clean - npm run test: 189 / 189 (snapshots round-trip cleanly through the rename) - `git diff --cached` confirms no content changes on the 8 renamed files beyond the rename itself Refs #1, #12, #19 * test(corpus): include README taxonomy update missed from 9596d2e The previous commit (`9596d2e`) renamed the chromium fixtures and *claimed* in its body to also update `tests/fixtures/pdfs/README.md`, but the README diff didn't make it into the commit due to a stale cwd swallowing the relative path. The README change is what gives the rename its meaning — without it, the new filenames are arbitrary. This commit adds the actual README edit: - `google-docs/` description now explicitly states it accepts Chromium `--print-to-pdf` Skia exports as a deliberate proxy, names the `google-docs-skia-proxy-*` filename prefix, and pre-empts confusion about the `Creator: HeadlessChrome/<v>` Info-dict string. - `unknown/` description no longer lists "headless Chrome" (it's now in `google-docs/` per the redefinition); WeasyPrint added as an example of what `unknown/` genuinely covers. Together with `9596d2e`, this fully resolves the [Blocking] finding from @s-annam's review on PR #26 — taxonomy is now self-consistent and the corpus no longer contradicts its own README. Refs #1 --------- Co-authored-by: Srinivas Annam <annam@annam.org>
Summary
Closes #9. Two distinct root causes both manifest as bullet under-reporting; both fixed here, in two cleanly-separated commits.
126ad13ANON_BULLET_MIN_WORDS = 4in score.ts silently dropped legitimate short bullets like"• Everything that matters."src/lib/score/score.ts,score.test.tsaabae8agroupIntoLinesclusters items purely by y-proximity; on two-column layouts the right-column bullet glyph ends up after the left-column text and never reaches line-startsrc/lib/heuristics/sections.ts,pdf-extract.test.tsbulletCount: 1/2vs 8 visible)Root cause #1 (Awesome-CV off-by-1)
extractBulletsFromTextwas applying a 4-word floor before any line could count as a bullet, conflating bullet detection with bullet grading. The reporter's pattern (20+ visible · 19 extracted · 18 reported) is one short bullet getting filtered post-extraction.Localised by tracing every marker-prefixed line through the pipeline. The dropped bullet on both Awesome-CV fixtures was the same 3-word line:
"• Everything that matters.".Fix: lower
ANON_BULLET_MIN_WORDSfrom 4 to 1. Empty marker-only lines ("• ") are still skipped becausesplit(/\s+/).filter(Boolean)returns length 0. Quality grading is now left entirely to the existing well-formed length window (8–30 words) inanalyzeBullets/scoreBulletPool, which naturally penalises short bullets via per-bullet feedback chips rather than hiding them.Root cause #2 (Deedy two-column catastrophic loss)
groupIntoLinesclusters items by y-proximity (LINE_Y_EPS = 3.5) with no concept of column structure. On Deedy's asymmetric 0.33/0.66 layout, the left-column education text and right-column experience bullets land at the same y, so the bullet glyph gets concatenated after the education text.extractBulletsFromTextcorrectly skips it (it requires^\s*[bullet glyph]\s+) and the cascade reportsbulletCount: 1against 8 visible bullets.The asymmetric layout doesn't trigger
isTwoColumninpdf-layout.ts(which requires roughly equal columns + 60% density), so gating on that flag wasn't an option.Fix: in
groupIntoLines'sflush(), scan the (x-sorted) same-y cluster for any gap ≥COLUMN_GAP_THRESHOLD = 50ptbetween consecutive items and emit each side as its ownPdfLine.Why 50pt: measured the in-line gap distributions across all 7 corpus fixtures. Awesome-CV's
\hfilllines produce 0pt gaps because LuaTeX includes trailing whitespace in item widths. Deedy's column jumps in at 70–130pt. 50pt is safely between the two regimes.Empirical impact
visible bullets in pdftotextvsreported bulletCountper fixture, before vs after:awesome-cv-cvawesome-cv-resumedeedy-resume-macfontsdeedy-resume-openfontsheader-as-name-functional-resumeopenresume-react-pdfopenresume-laverne-word-quartzSnapshot deltas (all correct, none regressions)
Awesome-CV — overall score −1 on each. The previously-hidden short bullet has no metric and is outside the 8–30 word window, so it correctly adds 1 to both Specificity and Structure denominators without contributing to either numerator. Pre-fix scores were inflated by silently dropping a bad bullet.
Deedy — line-grouping fix unblocks every downstream parser, not just the bullet counter:
bulletCountskillsCountexperienceCountmetricBulletsgoodBulletsoverallThe Deedy PDFs were essentially unparseable before — the parser wasn't dropping individual fields, it was misreading the line structure end-to-end. Score moving from 17 to 47 reflects the parser actually seeing the resume's content.
openresume-react-pdf —
skillsCount16 → 20. The skills lineHTML CSS Python TypeScript React C++had ~160–175pt gaps; splitting these into per-token lines exposed 4 additional skills the parser had been missing. Bonus.Awesome-CV / header-as-name / laverne — byte-identical, zero negative impact.
Tests added
score.test.ts(1 test) — pins that short bullets count toward the displayed total while still being graded low, guarding against re-introducing the off-by-1 regression.pdf-extract.test.ts(2 tests) — pins the two-column same-y split behaviour AND a single-column regression guard (small gaps must NOT fragment a line).Verification
npm run typecheck— cleannpm run test— 177 / 177 (175 baseline + 3 new)npm run bake-fixtures→git diffafter — empty (snapshots round-trip)pdftotext-vs-reported bulletCount: delta = 0 on every fixture (was −1, −1, −7, −6 pre-fix)Reviewer note on blast radius
The Deedy fix touches
groupIntoLines, which is called by every PDF parse path. Risk is mitigated by:But it's worth a closer read than the Awesome-CV constant change — flagging up-front in case you want to scrutinise the
flush()split logic specifically.Closes #9
🤖 Generated with Claude Code