Skip to content

feat(templates): classify CV presets as ATS-friendly or design-first - #689

Merged
DemchaAV merged 10 commits into
developfrom
feat/cv-ats-friendly-presets
Sep 14, 2026
Merged

DemchaAV merged 10 commits into
developfrom
feat/cv-ats-friendly-presets

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Builds on #688, now merged. The parser results below depend on its letter-spacing fix, without which tracked headings still split into letters in pdf.js and pdfplumber.

Why

A resume parser reads a CV before a person does. Several take the name and the email from the lines stored above the first section heading, and a parser recognises a section by matching its heading against a short list of names. Several CV presets defeated one of those steps with nothing wrong on the page:

  • Five presets print headings of their own that no parser's list contains, such as "EMPLOYMENT HISTORY", "Core Stack" and "EXPERTISE". The showcase samples used titles such as "Education & Certifications" and "CORE COMPETENCIES".
  • Navy Sidebar, Charcoal Gold and Sidebar Portrait drew the sidebar before the name, and Slate Orange drew its monogram tile first. OpenResume and resume-parser-ats took "CONTACT", "DH" or a contact line for the name.
  • Professional Sidebar and Terracotta Rail composed their two columns as one atomic row, so a CV longer than one sheet raised AtomicNodeTooLargeException instead of rendering.

The showcase also had no way to tell a CV that parses from one whose sidebar or columns cost a parser a check.

What changed

Ten commits, in review order:

  1. fix(examples): the sample CV titles its education section "Education". Eight presets print that title, so their previews, pixel baselines and layout snapshots change with it. CvPresetFixtures.canonicalDocument mirrors the sample.
  2. test(qa): re-records the Teal Pulse, Terracotta Rail and Violet Grid pixel baselines. fix(templates): preserve semantic text when applying letter spacing #682 changed how these presets draw letter-spaced lines but did not re-record their baselines. The drift stayed inside the 50 000-pixel budget. Content and layout snapshots are unchanged.
  3. fix(templates): five presets print the headings parsers look for. The presets are ClassicSerif, EditorialBlue, SidebarPortrait, MonogramSidebar and EngineeringResume, and five samples now title their skills "SKILLS". Only the words change; every heading keeps its font, colour, tracking and rules.
  4. fix(templates): four sidebar CVs draw the name first.
    • In Navy Sidebar, Charcoal Gold and Sidebar Portrait, package-private ReadingOrderColumns turns the page grid into a layer stack. The stack is atomic like the row it replaces. It draws the name, then the sidebar, then the rest of the main column under a stand-in as tall as the name.
    • Drawing the whole main column first would cost the email instead, because its first heading would then come before the sidebar's contact lines.
    • Slate Orange's masthead cells become layers, identity strip first.
  5. feat(templates): Professional Sidebar and Terracotta Rail carry a longer CV onto more pages.
    • Package-private ColumnPages measures each block on its own: a role, a project, a sidebar list, and the hairline that leads it. It packs the blocks one row per page.
    • Every row after the first starts behind a page break. A block that opens a page leaves its hairline behind, so without the break a short last row could settle under the row before it.
    • A CV that fits keeps its single row, to within the 0.5pt the paginator allows an atomic block. Its output and one-page layout snapshot do not change.
    • A block taller than a page is refused, and the error names the block.
    • The education rail moves to ProfessionalSidebarEducation and the projects grid to TerracottaRailProjects, which keeps both column classes under 500 lines.
  6. feat(examples): the showcase marks the presets that earned an ATS-friendly badge.
    • ShowcaseMetadata classifies every CV card with an AtsStatus, the parsers used, the date checked and the known limitations. ShowcaseSync writes this as an ats object on CV cards only.
    • web/examples.js draws an "ATS-friendly" chip only when the manifest marks the badge and the status is ATS_CERTIFIED or ATS_COMPATIBLE_WITH_KNOWN_PARSER_LIMITATIONS. The chip links to the new section in using-templates.md.
    • web/examples.json is regenerated by the release, so it is not in this diff.
  7. docs(changelog) records the changes above. It also corrects four earlier v2.4.0 entries that these changes made untrue: Professional Sidebar and Terracotta Rail no longer stop at one page, and Navy Sidebar's columns are a layer stack.
  8. test(examples): each ATS-friendly badge is pinned to the exact PDF its check read.
    • A hash of the extracted text misses a rendering change that keeps the words. A block moved beside another, a line redrawn or a content stream reordered can change what pdf.js or pdfplumber read.
    • For each badged sample, ats-validated-samples.properties records the preset, the status, validatedAt, the SHA-256 of the exact PDF ats-check read, and a text hash beside it. The file is read fail-closed.
    • ShowcaseAtsClassificationTest holds each record to its card's preset and to the date its badge shows. A badged card without a record fails; a design-first card needs none.
  9. test(examples): the certified render lives in the test, and each published sample is held to it.
    • The examples keep buildPdf(), because the runnable-examples guard reads any .render( call in an example as the removed legacy PDF API. Their committed previews keep the files they had.
    • ShowcaseAtsSamples composes each badged sample the way its example does: the preset's default template on A4 at its recommended margin, from the shared sample CV. It renders the sample through PdfFixedLayoutBackend.builder().deterministic(true), which pins the document dates and derives the /ID from the metadata.
    • ShowcaseAtsEvidenceTest writes those renders to examples/target/ats-certification and requires each one's SHA-256 to equal the recorded hash.
    • It also requires the sample each example publishes to draw the same document page by page: size, content stream, fonts and links.
    • Both failures say the ATS check must be run on those renders again.
  10. fix(examples): a badged sample's text hashes the same way on every platform. PDFTextStripper ends every page with the platform's line separator, which setLineSeparator does not change. Both strippers now end pages with \n, and the eight text hashes are recomputed from the certified PDFs. The PDF hashes are unchanged.

Before → after

Every CV showcase sample was read with three parsers:

  • the OpenResume parser (pdf.js 3.7.107)
  • ATS Reader (pdfplumber 0.11.10)
  • resume-parser-ats 1.2.3 (pdf-parse)

Each sample was held to these checks:

  • the text extracts and no parser fails;
  • all three parsers find the name;
  • Experience, Education and Skills are recognised;
  • reading order passes in all three parsers;
  • no words are split;
  • the name and the email are in the text layer.
Classification Presets
ATS-friendly, every check met (7) Blue Banner, Boxed Sections, Centered Headline, Classic Serif, Editorial Blue, Executive, Minimal Underlined
ATS-friendly, known parser limitation (1) Modern Professional: ATS Reader does not recognise "Professional Experience" or "Technical Skills" as headings. The other two parsers do.
Design-first (18, plus the Mint Editorial custom card) every other CV preset
  • Classic Serif and Editorial Blue meet every check because of commit 3.
  • The name as read by OpenResume / ATS Reader / resume-parser-ats (✓ right, ~ partly right, ≠ wrong):
    • Sidebar Portrait: ≠✓≠ → ✓✓✓
    • Slate Orange: ≠✓✓ → ✓✓✓
    • Navy Sidebar: ≠✓≠ → ≠✓✓
    • Charcoal Gold: ≠~≠ → ~~~
  • Professional Sidebar and Terracotta Rail render a full-length CV instead of refusing it.
  • Every design-first preset keeps a miss its layout causes: ATS Reader interleaves the sidebar or the columns, or two headings on one baseline fuse. Removing that miss means flattening the design, so those presets stay design-first.
  • The eight badged samples were read with ats-check again, as the exact files now recorded. Every parser result and verdict matches the earlier check.

Verification

  • ./mvnw -B -ntp clean install -pl :graph-compose-core,:graph-compose-render-pdf,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing,:graph-compose,:graph-compose-bundle,:graph-compose-qa,:graph-compose-coverage -amBUILD SUCCESS. The documentation guards are part of the core and qa counts.

    Module Tests
    core 762 (2 skipped)
    render-pdf 274
    render-docx 94
    testing 5
    render-pptx 138
    templates 127
    qa 1777
  • ./mvnw -B -ntp -f examples/pom.xml clean test → 87 tests, including CommittedAssetDriftTest.

  • Knowledge pack checks all pass:

    • extract-api.mjs --from-reactor --check reports the surfaces current, so no public API changed.
    • check-stability-doc.mjs, check-claims.mjs --check and check-routes.mjs pass, and so do the five fixture suites.
  • CI on this head passes every check, including build-and-test on JDK 17, 21 and 25, the examples job and the performance smoke check.

  • New and extended tests:

    • ColumnPagesTest covers packing, the first-page budget, the page handoff, the one-page allowance, an oversized block and duplicate names.

    • ProfessionalSidebarSmokeTest and TerracottaRailSmokeTest:

      • a long CV paginates;
      • a role taller than the page is refused, and the error names it;
      • Professional Sidebar also pins a CV whose last role only just overflows.

      Each preset also gains a two-page layout snapshot.

    • CvPresetTextLayerTest.theNameIsTheFirstTextInTheFile holds the four presets to drawing the name first. With a sidebar-first preset added to its list, qa fails on that case alone.

    • ReadingOrderColumnsTest holds measure() to putting the caller's roots back.

    • ShowcaseAtsClassificationTest checks that:

      • every CV card is classified, and no other card is;
      • the badged presets and their statuses match the certified samples;
      • each record names its card's preset and the date its badge shows;
      • each classification has a date, at least two parsers and written limitations;
      • the manifest's badge flag and link are right;
      • the guide's table and the README agree with the register.
    • ShowcaseAtsEvidenceTest requires three things: every generated CV document is classified; every badged sample's certification render is byte-identical to the PDF its ATS check read; every published badged sample draws that same document page by page. Each of these turns it red:

      • a changed recorded hash;
      • a 0.5pt sideways shift in a badged example that leaves its text identical, reported as a different content stream and different links;
      • a removed hash.
  • Output:

    • PDFium at 100 dpi shows 0 px changed in all four name-first presets. Commit 4 re-records their layout snapshots for structure only and moves no pixel baseline.
    • Pixel baselines, previews and layout snapshots were re-recorded where a heading's words changed (commits 1 and 3), and for the three baselines in commit 2.
    • The certification renders are byte-identical on Oracle JDK 24 and Corretto 17 on Windows, on Temurin 21 on Linux, and in CI's examples job on Temurin 17. Against the earlier certified files, only the dates and /ID differ; pages, content streams and fonts are identical.

Notes for review

  • A badge speaks for the showcase sample only, read on 2026-09-14. A CV with different content can parse differently, and a badge is not a claim about every ATS product. The guide says both.
  • A changed PDF means a new ATS check. When a badged sample's render stops matching its recorded hash, or its published file stops matching the render, the test says to run ats-check on the renders under examples/target/ats-certification, review the result, and only then record new evidence. It never updates a hash itself.
  • The certification renders use a @Beta option. deterministic(...) is @Beta on PdfFixedLayoutBackend.Builder. If its output changes, the renders change and the guard fails rather than passing.
  • Phone and email rows are not checked. Most presets' phone numbers are found by none of the parsers, and resume-parser-ats returns the whole contact row as the email in several presets. A badge says nothing about either.
  • Two of the three parsers share pdf.js. OpenResume and resume-parser-ats both extract text with pdf.js (3.7.107 and 1.10.100) before applying their own heuristics. ATS Reader uses pdfplumber.
  • Most of the added lines are snapshots. About 16 000 are the two new long-CV layout snapshots. The Charcoal Gold, Navy Sidebar, Sidebar Portrait and Slate Orange snapshots move because their nodes are now layers.
  • No public API changes. ColumnPages and ReadingOrderColumns are package-private, ShowcaseAtsSamples is examples test code, and ShowcaseMetadata and ShowcaseSync live in the unpublished examples module.

Lane: templates — CV presets and their gates, the showcase catalogue and one guide section; no public API change.

Base automatically changed from fix/pdf-letter-spacing-parser-compat to develop September 14, 2026 12:42
Resume parsers recognise a section by its heading, and they compare the
heading with a fixed list of names. Neither ATS Reader (pdfplumber) nor
resume-parser-ats has "Education & Certifications" on that list, and the
sample CV rendered by fifteen CV showcase presets used exactly that title.

ExampleDataFactory.sampleCvDocumentV2, and CvPresetFixtures.canonicalDocument,
the copy the CV preset gates render, now title the section "Education".
Only presets that print the section's own title change, and only in that
heading line: blue-banner, boxed-sections, centered-headline, executive,
minimal-underlined, modern-professional, monogram-sidebar and
timeline-minimal. The other presets draw a heading of their own there.

- The eight committed CV previews are re-rendered.
- Visual baselines are re-recorded for those presets and for mint-editorial,
  which prints the title when it renders the gate copy. The layout snapshots
  of centered-headline, executive, minimal-underlined, mint-editorial,
  modern-professional and timeline-minimal follow the heading's new width.

CvV2VisualParityTest and CvPresetLayoutSnapshotTest pass without approve
mode (32 tests), and the examples suite passes (77 tests), including
CommittedAssetDriftTest.
…aselines

The pixel baselines of these three presets were last recorded on
2026-09-12. #682 then changed how they draw their letter-spaced lines,
in TealPulseWidgets, the Terracotta Rail aside, main, styles and widgets,
and VioletGridWidgets, without re-recording them. Every tracked line has
differed from its baseline since: about 9 700 pixels on Teal Pulse,
6 900 on Terracotta Rail and 2 500 on Violet Grid, all inside the gate's
50 000-pixel budget, so the gates stayed green.

The baselines are re-recorded from the current code with the content
unchanged. The differences sit only on letter-spaced lines (the names,
role lines and section headings); body text is untouched, and the layout
snapshots of the three presets, which compare geometry exactly, do not
move.

TealPulseVisualParityTest, TerracottaRailVisualParityTest and
VioletGridVisualParityTest pass.
…k for

Resume parsers find a section by matching its heading against a short list
of names such as Experience, Education and Skills. Several CV presets print
a heading of their own in place of the caller's title, and several showcase
samples used titles outside those lists, so a parser could miss a section
that was plainly on the page.

- ClassicSerif: Core Skills -> Skills.
- EditorialBlue: EMPLOYMENT HISTORY -> EXPERIENCE, KEY SKILLS -> SKILLS.
- SidebarPortrait: Key Skills -> Skills; MonogramSidebar: EXPERTISE -> SKILLS.
- EngineeringResume: Core Stack -> Skills, Learning -> Education,
  Leadership Experience -> Experience.
- Sample data and the qa fixtures that mirror it: OrangeOps KEY SKILLS,
  TealPulse and SlateOrange CORE COMPETENCIES, VioletGrid DESIGN SKILLS and
  TerracottaRail CORE COMPETENCIES -> SKILLS. Those presets' section lookups
  already accept "skills".

Only the words change: every heading keeps its font, colour, tracking and
rules, and nothing else on these pages moves. The ten committed previews
and the pixel baselines and layout snapshots of those presets are
re-recorded, and SlateOrangeSmokeTest and VioletGridSmokeTest now swap and
expect those sections by their new titles.

The qa suite passes (1759 tests) and the examples suite passes (77 tests),
including CommittedAssetDriftTest.
Resume parsers that read the content stream rather than the page take the
name, the email and the phone from the lines above the first section heading.
Navy Sidebar, Charcoal Gold and Sidebar Portrait drew the sidebar before the
main column, and Slate Orange its monogram tile before the identity strip, so
OpenResume and resume-parser-ats met "CONTACT", "DH" or a contact line first
and took it for the name.

Drawing the whole main column first trades the email for the name: the block
then ends at the main column's first heading, above the sidebar's contact
lines. So the name alone is drawn first, then the sidebar, then the rest of
the main column under a stand-in as tall as the name. Nothing moves on the
page.

- ReadingOrderColumns (package-private): column() lays a column as a
  full-width layer inset to the band a weighted row gave it; measure() lays
  the name out on its own, setting the session's roots aside and putting them
  back, and reads its size from the layout snapshot; holdPlace() is the
  spacer that keeps the name's place.
- NavySidebar, CharcoalGold, SidebarPortrait: the page-grid row becomes a
  layer stack, atomic like the row, of name, sidebar and main column. Sidebar
  Portrait composes its hero strip in two pieces: the fill and the name
  first, the subtitle with the main column.
- SlateOrangeMasthead: the four masthead cells become layers, identity strip
  first. The body row is unchanged.

Name as read from the showcase samples by OpenResume / ATS Reader /
resume-parser-ats: navy-sidebar ≠✓≠ → ≠✓✓, charcoal-gold ≠~≠ → ~~~,
sidebar-portrait ≠✓≠ → ✓✓✓, slate-orange ≠✓✓ → ✓✓✓. Email, phone and
section rows and the reading-order verdicts are unchanged.

PDFium at 100 dpi: 0 px changed in all four. At 600 dpi slate-orange and
charcoal-gold are identical; navy-sidebar differs in 39 antialiased pixels by
1/255, sidebar-portrait in 99,501 by at most 2/255. The stand-in height comes
from the layout snapshot, which rounds to 0.001 pt, and Sidebar Portrait's
name line is not a whole thousandth, so its main column sits 0.0005 pt lower.

Tests: CvPresetTextLayerTest.theNameIsTheFirstTextInTheFile holds the four
presets to drawing the name first; with a sidebar-first preset added to its
list, the full qa suite (1767) fails on that case alone. ReadingOrderColumnsTest
holds measure() to putting the caller's roots back. Layout snapshots
re-recorded for the four, structure only; visual baselines unchanged; the four
previews re-rendered; examples 77 green.
…more pages

Both presets composed their two columns as one row, and a row is atomic,
so a CV longer than one sheet raised AtomicNodeTooLargeException instead
of rendering.

ColumnPages (package-private) measures every block of each column on its
own - a role, a project, a sidebar list, and the hairline that leads it -
and packs the blocks onto pages. A CV whose columns fit the sheet keeps
the single row it always had, to the half point the paginator allows an
atomic block, so its output is unchanged. A longer one becomes one row
per page, each row after the first behind a page break: a block that
opens a page leaves its hairline behind, so without the break a short
last row could settle under the row before it. A heading stays with its
first entry, a page's roles share one rail in Terracotta Rail, and a
block taller than a page is refused by name.

The education rail moves to ProfessionalSidebarEducation and the projects
grid to TerracottaRailProjects, keeping both column classes under 500
lines. using-templates.md lists both presets as paginating, and five
sibling presets no longer call TimelineMinimal the only preset that
splits its columns across pages.

Tests: ColumnPagesTest covers packing, the first-page budget, the page
handoff, the one-page allowance, an oversized block and duplicate names.
Both smoke tests paginate a long CV and refuse a role taller than the
page; the Professional Sidebar one also pins a CV whose last role only
just overflows. Each preset gains a two-page layout snapshot.
A CV preset is either ATS-friendly - its showcase sample passed text
extraction, section recognition and reading order in three PDF resume
parsers - or design-first, where a sidebar, columns or a monogram cost a
parser one of those checks. The showcase had no way to say which.

ShowcaseMetadata now classifies every CV card: an AtsStatus (certified,
compatible with a known parser limitation, needs a template fix, design
first), the parsers the sample was read with, the date, and what the
parsers still get wrong. ShowcaseSync writes that as an `ats` object on CV
cards only, deciding `badge` from the status and linking the guide section
that states the claim. The page shows an "ATS-friendly" chip only when the
manifest marks the badge and the status is one that earns it; the chip
links to that section and carries the claim, the date and the parsers in
its tooltip and in hidden text for screen readers. Seven presets are
certified and Modern Professional is compatible; the other nineteen CV
cards are design-first. The Modern Professional and Editorial Blue cards
no longer describe themselves as two-column.

using-templates.md describes the two categories once and lists the eight
ATS-friendly presets, and the examples README points to it. The site's
examples.json is regenerated by the release, so it is not part of this
change.

Tests: ShowcaseAtsClassificationTest keeps every CV card classified and no
other card, holds the badged presets and their statuses to the validated
samples, requires a date, at least two parsers and written limitations,
checks the manifest's badge flag and link, and holds the guide's table,
its date and the README to the register. ShowcaseAtsEvidenceTest requires
every generated CV document to be classified, and fails when the text a
parser reads from a badged sample no longer matches the fingerprint pinned
when its check ran.
The v2.4.0 section gains three Templates entries and one Documentation
entry: five presets print the section headings resume parsers look for,
four sidebar CVs draw the name first, Professional Sidebar and Terracotta
Rail carry a longer CV onto more pages, and every CV preset is either
ATS-friendly or design-first, with the showcase badge and the test that
pins the text of each badged sample.

Four earlier v2.4.0 entries described what these changes replaced. The
Professional Sidebar and Terracotta Rail entries no longer say a longer
CV is refused, and list their smoke tests as they now stand. Navy
Sidebar's columns are a layer stack rather than a row. Navy Sidebar and
Serif Headline no longer say they hold one page like their siblings,
since Professional Sidebar no longer does.
…k read

A badge rested on a hash of the text PDFBox extracts from the badged sample. The
same words can come out of a different rendering: a block moved beside another,
a line redrawn or a content stream reordered changes what pdf.js or pdfplumber
read while that text hash stays put. The samples could not be pinned byte for
byte either, because every render carried a time-seeded /ID.

The eight badged CV examples now render through
PdfFixedLayoutBackend.builder().deterministic(true), which pins the document
dates and derives the /ID from the metadata; their pages, content streams and
fonts are unchanged. ats-validated-samples.properties records, for each badged
sample, the preset, the status, the day of the check, the SHA-256 of the exact
PDF ats-check read, and the text hash beside it.

ShowcaseAtsEvidenceTest renders every badged sample and fails unless its bytes
equal the recorded hash, naming the sample and saying its ATS check must be run
again, and whether its text changed too. A badged card without a record fails
the same way; a design-first card needs none. The record is read fail-closed: a
missing field, a malformed hash or date, or a status that earns no badge stops
the read. ShowcaseAtsClassificationTest also holds each record's preset to its
card and its date to the date the badge shows.

The recorded files were read with ats-check again, and every parser result and
verdict matches the earlier check. The eight committed previews are re-rendered
and are now those same files. The badged samples render to the same bytes on
JDK 17 and JDK 24 on Windows and on JDK 21 on Linux.
… samples to it

The runnable-examples guard reads any .render( call in an example as the removed
legacy PDF API, so the eight badged CV examples cannot render through
PdfFixedLayoutBackend.builder().deterministic(true) themselves.

The examples go back to buildPdf(), and their committed previews to the files they
had. ShowcaseAtsSamples composes each badged sample the way its example does - the
preset's default template on A4 at the preset's recommended margin, from the shared
sample CV - and renders it deterministically. ShowcaseAtsEvidenceTest writes those
renders to target/ats-certification and holds each one to the SHA-256 recorded for
the PDF its ATS check read. It also holds the sample each example publishes to that
render page by page: page size, content stream, fonts and links, so an example that
stops composing the certified document fails as well. A badged card with no
certification render fails, and both failure messages say the ATS check has to be
run on those renders again.

The renders are byte-identical to the certified files, so the recorded PDF hashes
stand.
…form

PDFTextStripper ends every page with the platform line separator, and
setLineSeparator does not change that, so the text hash kept beside each
certification came out differently on Windows and on Linux for any sample longer
than one page. Both strippers now end pages with \n as well, and the eight recorded
text hashes are recomputed from the certified PDFs; the PDF hashes are unchanged.
@DemchaAV
DemchaAV force-pushed the feat/cv-ats-friendly-presets branch from 78a740c to 5a4a189 Compare September 14, 2026 13:12
@DemchaAV
DemchaAV merged commit a19af77 into develop Sep 14, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the feat/cv-ats-friendly-presets branch September 14, 2026 13:24
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.

1 participant