feat(templates): classify CV presets as ATS-friendly or design-first - #689
Merged
Merged
Conversation
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
force-pushed
the
feat/cv-ats-friendly-presets
branch
from
September 14, 2026 13:12
78a740c to
5a4a189
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
AtomicNodeTooLargeExceptioninstead 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:
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.canonicalDocumentmirrors the sample.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.fix(templates): five presets print the headings parsers look for. The presets areClassicSerif,EditorialBlue,SidebarPortrait,MonogramSidebarandEngineeringResume, and five samples now title their skills "SKILLS". Only the words change; every heading keeps its font, colour, tracking and rules.fix(templates): four sidebar CVs draw the name first.ReadingOrderColumnsturns 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.feat(templates): Professional Sidebar and Terracotta Rail carry a longer CV onto more pages.ColumnPagesmeasures 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.ProfessionalSidebarEducationand the projects grid toTerracottaRailProjects, which keeps both column classes under 500 lines.feat(examples): the showcase marks the presets that earned an ATS-friendly badge.ShowcaseMetadataclassifies every CV card with anAtsStatus, the parsers used, the date checked and the known limitations.ShowcaseSyncwrites this as anatsobject on CV cards only.web/examples.jsdraws an "ATS-friendly" chip only when the manifest marks the badge and the status isATS_CERTIFIEDorATS_COMPATIBLE_WITH_KNOWN_PARSER_LIMITATIONS. The chip links to the new section inusing-templates.md.web/examples.jsonis regenerated by the release, so it is not in this diff.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.test(examples): each ATS-friendly badge is pinned to the exact PDF its check read.ats-validated-samples.propertiesrecords the preset, the status,validatedAt, the SHA-256 of the exact PDFats-checkread, and a text hash beside it. The file is read fail-closed.ShowcaseAtsClassificationTestholds 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.test(examples): the certified render lives in the test, and each published sample is held to it.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.ShowcaseAtsSamplescomposes 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 throughPdfFixedLayoutBackend.builder().deterministic(true), which pins the document dates and derives the/IDfrom the metadata.ShowcaseAtsEvidenceTestwrites those renders toexamples/target/ats-certificationand requires each one's SHA-256 to equal the recorded hash.fix(examples): a badged sample's text hashes the same way on every platform.PDFTextStripperends every page with the platform's line separator, whichsetLineSeparatordoes 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:
Each sample was held to these checks:
ats-checkagain, 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 -am→ BUILD SUCCESS. The documentation guards are part of the core and qa counts../mvnw -B -ntp -f examples/pom.xml clean test→ 87 tests, includingCommittedAssetDriftTest.Knowledge pack checks all pass:
extract-api.mjs --from-reactor --checkreports the surfaces current, so no public API changed.check-stability-doc.mjs,check-claims.mjs --checkandcheck-routes.mjspass, 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:
ColumnPagesTestcovers packing, the first-page budget, the page handoff, the one-page allowance, an oversized block and duplicate names.ProfessionalSidebarSmokeTestandTerracottaRailSmokeTest:Each preset also gains a two-page layout snapshot.
CvPresetTextLayerTest.theNameIsTheFirstTextInTheFileholds the four presets to drawing the name first. With a sidebar-first preset added to its list, qa fails on that case alone.ReadingOrderColumnsTestholdsmeasure()to putting the caller's roots back.ShowcaseAtsClassificationTestchecks that:ShowcaseAtsEvidenceTestrequires 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:Output:
/IDdiffer; pages, content streams and fonts are identical.Notes for review
ats-checkon the renders underexamples/target/ats-certification, review the result, and only then record new evidence. It never updates a hash itself.@Betaoption.deterministic(...)is@BetaonPdfFixedLayoutBackend.Builder. If its output changes, the renders change and the guard fails rather than passing.ColumnPagesandReadingOrderColumnsare package-private,ShowcaseAtsSamplesis examples test code, andShowcaseMetadataandShowcaseSynclive in the unpublished examples module.Lane: templates — CV presets and their gates, the showcase catalogue and one guide section; no public API change.