Skip to content

fix: bullet count under-reports on short bullets and two-column layouts - #18

Merged
Vaishnavi1709 merged 2 commits into
mainfrom
fix/bullet-count-issue-9
Jun 8, 2026
Merged

fix: bullet count under-reports on short bullets and two-column layouts#18
Vaishnavi1709 merged 2 commits into
mainfrom
fix/bullet-count-issue-9

Conversation

@Vaishnavi1709

Copy link
Copy Markdown
Collaborator

Summary

Closes #9. Two distinct root causes both manifest as bullet under-reporting; both fixed here, in two cleanly-separated commits.

Commit Root cause Files Affected fixtures
126ad13 ANON_BULLET_MIN_WORDS = 4 in score.ts silently dropped legitimate short bullets like "• Everything that matters." src/lib/score/score.ts, score.test.ts Awesome-CV cv / resume (off-by-1, the issue's reported pattern)
aabae8a groupIntoLines clusters 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-start src/lib/heuristics/sections.ts, pdf-extract.test.ts Deedy macfonts / openfonts (catastrophic — bulletCount: 1/2 vs 8 visible)

Root cause #1 (Awesome-CV off-by-1)

extractBulletsFromText was 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_WORDS from 4 to 1. Empty marker-only lines ("• ") are still skipped because split(/\s+/).filter(Boolean) returns length 0. Quality grading is now left entirely to the existing well-formed length window (8–30 words) in analyzeBullets / scoreBulletPool, which naturally penalises short bullets via per-bullet feedback chips rather than hiding them.

Root cause #2 (Deedy two-column catastrophic loss)

groupIntoLines clusters 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. extractBulletsFromText correctly skips it (it requires ^\s*[bullet glyph]\s+) and the cascade reports bulletCount: 1 against 8 visible bullets.

The asymmetric layout doesn't trigger isTwoColumn in pdf-layout.ts (which requires roughly equal columns + 60% density), so gating on that flag wasn't an option.

Fix: in groupIntoLines's flush(), scan the (x-sorted) same-y cluster for any gap ≥ COLUMN_GAP_THRESHOLD = 50pt between consecutive items and emit each side as its own PdfLine.

Why 50pt: measured the in-line gap distributions across all 7 corpus fixtures. Awesome-CV's \hfill lines 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 pdftotext vs reported bulletCount per fixture, before vs after:

Fixture Pre-fix delta Post-fix delta
awesome-cv-cv −1 0 ✅
awesome-cv-resume −1 0 ✅
deedy-resume-macfonts −7 0 ✅
deedy-resume-openfonts −6 0 ✅
header-as-name-functional-resume 0 0
openresume-react-pdf 0 0
openresume-laverne-word-quartz 0 0

Snapshot 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:

macfonts openfonts
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 — 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-pdfskillsCount 16 → 20. The skills line HTML 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 — clean
  • npm run test177 / 177 (175 baseline + 3 new)
  • npm run bake-fixturesgit diff after — 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:

  • the single-column regression test (small gaps stay merged)
  • 7-fixture corpus all green with no negative metric changes
  • the 50pt threshold being measured empirically against the actual gap distributions in the corpus

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

…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
@Vaishnavi1709
Vaishnavi1709 requested a review from s-annam June 8, 2026 17:54

@s-annam s-annam left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
  • buildLine extraction from flush() is a clean refactor — makes the new split loop readable.
  • assembleTextFromLinesgroupIntoLines chain 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

  1. [Suggestion] ANON_BULLET_MIN_WORDS = 1 — single-word section header edge case — see inline comment on score.ts.

  2. [Nit] openresume-react-pdf skillsCount 16 → 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

Comment thread src/lib/score/score.ts
* 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@Vaishnavi1709
Vaishnavi1709 merged commit 29660cb into main Jun 8, 2026
1 check passed
Vaishnavi1709 added a commit that referenced this pull request Jun 10, 2026
* 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>
@s-annam
s-annam deleted the fix/bullet-count-issue-9 branch June 11, 2026 16:59
s-annam pushed a commit that referenced this pull request Jun 15, 2026
…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
s-annam added a commit that referenced this pull request Jun 15, 2026
* 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>
s-annam pushed a commit that referenced this pull request Jun 25, 2026
…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
s-annam added a commit that referenced this pull request Jun 25, 2026
* 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>
s-annam pushed a commit that referenced this pull request Jun 28, 2026
…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
s-annam added a commit that referenced this pull request Jun 28, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bullet count under-reports — parser shows 18 vs. 19 extracted lines vs. 20+ in source PDF

2 participants