Skip to content

fix(templates): preserve semantic text when applying letter spacing - #682

Merged
DemchaAV merged 9 commits into
developfrom
fix/letter-spacing-text-layer
Sep 13, 2026
Merged

DemchaAV merged 9 commits into
developfrom
fix/letter-spacing-text-layer

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Why

Ten shipped presets made their letter spacing by writing characters that are not the
text. The page looked right; the file did not say what it drew. Copying a heading out
of a generated CV gave

P R O F I L E

instead of

PROFILE

so a search, a copy-and-paste, and an applicant tracking system all missed it — and on
ProfessionalSidebar the applicant's own name came out as M A R T A L I N D Q V I S T,
which is the one field a CV is read by.

The mechanisms differed. Four presets spliced a space between every character of the
authored String. Five pushed sized spacer runs between the letters. One wrote hair and
thin spaces, which a plain-space check reads straight past. LumaStudioInvoice inserted
an invisible 0.05pt rectangle between each pair, so the glyphs sat far enough apart that
an extractor invented the word breaks itself. All of them predate the native tracking
that shipped in #676, which keeps the run one string, moves the pen with Tc, and states
the run's own ActualText so an extractor cannot mistake the gaps for word breaks.

Note what did not catch this: CvPresetTextLayerTest already asserted exactly this
contract, and passed throughout — it listed the sixteen older CV presets and none of the
promoted ones. That list is why the old presets were fine and the new ones were not.

What changed

  • All ten presets move to DocumentLetterSpacing: ProfessionalSidebar,
    NavySidebar, TealPulse, VioletGrid, CharcoalGold, MidnightNavy,
    TerracottaRail, SerifHeadline, ConsultingInvoice, LumaStudioInvoice. Each gap
    measure is preserved as a named constant — ofFontSize where it was an em fraction,
    points where it was authored in points, and for TerracottaRail the hair and thin
    space characters converted to the tracking measures they always were.
  • SerifHeadline is a node-structure migration, not a refactor. Its trailing rule was
    sized by trackedWidth, a second implementation of text measurement that summed three
    advance classes for capitals and padded the result by 3%. Its error is visible in the old
    snapshot: the gap between the words and the rule came out 10.603, 11.538 and 10.607 where
    one number was intended. The band is now a row — fixed dash, auto words, weight rule that
    fills the rest — so the engine measures and nothing predicts. That gap is now 11.045–11.046.
    trackedWidth, capAdvanceEm, three CAP_ADVANCE constants and TRACKED_WIDTH_SAFETY
    are gone.
  • ConsultingInvoice's masthead qualifier keeps its tracking scope. It is a composite —
    two em-dash rules around the qualifier — and only the word was ever tracked. Moving the
    whole string onto native tracking spread the rules away from the word they point at, so it
    is now three runs with spacing on the middle one only. Measured on one render of all three
    constructions: the old fake set both gaps at 4.870, whole-run tracking inflated both to
    11.710 and shifted the centre; scoped restores the left gap and the centre exactly, leaving
    only +2.280 on the right — one trailing tracking advance.
  • A hidden path is now covered. That qualifier renders only when the brand carries no
    logo, and every fixture supplies one — so no committed preview draws it, and neither the
    layout snapshot nor visual parity could have caught a change there.
    ConsultingInvoiceFixtures.logolessInvoice() reaches it.

Geometry moved only where tracking advances after the final glyph, which spliced spaces did
not. Measured per preset rather than assumed: VioletGrid's heading grows 1.239 and its rule
shrinks by exactly 1.239, conserving the row; CharcoalGold's job title is +1.926, which is
deliberately not one trailing gap, because its helper set three spaces between words and
tracking adds one gap per glyph. MidnightNavy moved no geometry at all.

No pagination regression anywhere — no node changed page ownership, including the two-
and three-page invoice overflow cases.

The exhaustive audit, and the coverage gap it closed

Fixing ten presets did not prove the other forty-eight were clean. That claim rested on the
committed previews, and the previews do not cover the catalogue:

  • 7 of 58 presets render into no committed preview at allMerchantInvoice,
    MeteredInvoice, ObsidianInvoice, PlatformInvoice, SubscriptionInvoice,
    IndigoProposal, CobaltRota. Nothing had ever read their text layer.
  • The invoice pixel gate renders only 2 of 11 presets — the two taking an
    InvoiceDocumentSpec. The other nine carry their own per-preset tests, so no single list
    ever named all eleven.

So the earlier 0 / 115 preview scan, while true, was not sufficient evidence on its own.

The catalogue is 58 presets across 6 families, taken from source rather than from a
count: every class declaring a public ID, cross-checked against every class exposing a
create() returning a DocumentTemplate. Both lists agree exactly.

Family Audited
CV 26 / 26
Cover letter 15 / 15
Invoice 11 / 11
Proposal 4 / 4
Receipt 1 / 1
Rota 1 / 1
Total 58 / 58
  • original semantic-text defects: 10
  • fixed: 10 / 10
  • additional defects found in the exhaustive audit: 0
  • remaining fake-tracking defects: 0
  • unexplained cases: 0

Six source candidates were investigated and cleared: MerchantStyles.spaces,
PlatformStyles.spaces, dotGap, gapRun and two String.join(" ") calls all space two
separate items — fields on a line, dots in a rating row, lines of prose — never the letters
of a word, and their presets extract cleanly.

Regression gates

Each family now renders its presets through the project's own PDFBox and asserts the
extracted text carries no run of three or more single letters separated by any Unicode
space separator
— hair and thin spaces included, because one preset spelled its headings
with those and a plain-space class reads straight past them.

Gate Tests
CvPresetTextLayerTest 52
CoverLetterTextLayerGateTest 16
InvoiceTextLayerGateTest 12
PdfLetterSpacingRenderTest 12
TemplateTextLayerGateTest 10
ProposalTextLayerGateTest 5
CommittedAssetDriftTest 3

Each gate also compares its own list against the preset package on disk, so a shipped
preset cannot be added without semantic-text coverage
. That check is the point: the defect
was not that a gate was missing, it was that the gate that existed listed sixteen CV presets
and no others.

The detector is asserted against fixtures of its own — ordinary spaces caught, hair and thin
spaces caught, a pen-tracked word passed, prose left alone — with ModernReceipt, which has
carried native spaced caps since it shipped, as the control that must stay green.

Verification

./mvnw -B -ntp clean verifyBUILD SUCCESS, 15 / 15 modules, 3 215 tests,
0 failures, 0 errors
.

Extracted with the project's own PDFBox 3.0.8: 86 suspicious runs → 0, and 0 of 115
committed previews contain one. Exactly ten previews were re-rendered — the causally
affected ones.

No public API change — all touched production classes are package-private and the diff adds
and removes zero public/protected declarations; japicmp green in CI.

Lane: templates — preset authoring only; no engine change.

🤖 Generated with Claude Code

DemchaAV and others added 9 commits September 13, 2026 02:15
…th the pen

Both presets faked tracking by writing a heading one character at a time with
a sized space run between each pair. The gaps were real space characters, so
the file said what it drew: PROFILE copied, searched and read back as
"P R O F I L E", and the name as "M A R T A  L I N D Q V I S T".

Native tracking already exists and already solves this — a tracked run keeps
its string whole, moves the pen with Tc, and states its own ActualText so an
extractor cannot mistake the gaps for word breaks. These two presets simply
predated it.

- ProfessionalSidebarStyles.tracked and NavySidebarStyles.tracked now write one
  run carrying DocumentLetterSpacing.ofFontSize(trackingEm).
- Drop SPACE_ADVANCE_EM from both: it existed only to size the spacer runs.

The advance is equivalent by construction — a space glyph at
trackingEm*size/SPACE_ADVANCE_EM advanced by trackingEm*size, which is what
ofFontSize(trackingEm) adds — with one measured difference: Tc also advances
after the final glyph, so each tracked run is one gap wider. Measured on the
ProfessionalSidebar snapshot: placementWidth/contentWidth on 10 headings grow
by +1.390, +1.357 and +1.457 pt (trackingEm x font size); x, y, page
assignment and pagination are unchanged, which is why the two layout
snapshots move and nothing else does.

Verified: extracted text via PDFBox 3.0.8 goes from 13 suspicious runs to 0
for cv-professional-sidebar-v2 and 9 to 0 for cv-navy-sidebar-v2; both visual
parity gates stay green within budget; 25 preset tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TealPulse, VioletGrid, CharcoalGold, MidnightNavy and TerracottaRail each
built their tracking by writing characters that are not in the text: a space
run between every letter, or a StringBuilder splicing spaces into the string
before it was ever authored. Whatever the shape, the gaps were real
characters, so a heading copied as "S K I L L S" and a name as
"O L I V E R  B E N N E T T".

Each now carries DocumentLetterSpacing on the style and hands the engine the
word it means. The gap measures are preserved as named constants rather than
re-derived:

- TealPulse: tracked and knockedOut share one spacedBy(style, em); the spacer
  run count and its style are gone.
- VioletGrid: the gap was authored in points, so it tracks in points.
- CharcoalGold, MidnightNavy: the gap was one space of the face, so 0.25 em.
- TerracottaRail: the hair and thin space characters were already tracking
  measures - a tenth and a fifth of an em - so heading/headingWithDash take
  the measure instead of the character, and the masthead's full space is
  0.25 em.

Geometry, measured rather than assumed:

- TealPulse, TerracottaRail: width only, no x, y or page change.
- VioletGrid: the heading text grows 1.239 and the rule that follows it
  shrinks by exactly 1.239, so placementX moves with the text and the row
  total is conserved - the rule still starts where the words end.
- CharcoalGold: +1.926 on the job title, which is deliberately NOT one
  trailing gap. The old helper set three spaces between words and one between
  letters; tracking adds its gap after every glyph, so the word gaps tighten
  while a trailing gap is added. The look moves slightly and stays inside the
  visual parity budget.
- MidnightNavy: no snapshot movement at all, though its role line is centred.

Verified: the CV text-layer gate now covers all 26 presets and only
serif_headline still fails it, which is the coupled trackedWidth case and
lands next. 129 preset tests green, every visual parity gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The heading band was a layer stack holding three absolutely positioned
children, and the rule that trails a title was sized by subtracting an
estimate of the letters from the band: trackedWidth summed three advance
classes for capitals, added (n-1) gaps, and padded the result by 3% so the
rule would never run under the words. That estimate was a second
implementation of text measurement, and its error is visible in the old
snapshot - the gap between the words and the rule came out 10.603, 11.538
and 10.607 on the three tailed headings, when one number was intended.

The band is now a row: a fixed column for the dash, an auto column for the
words, and a weight column whose line fills what is left. The engine
measures the tracked capitals and the rule takes the remainder, so nothing
here predicts a width. tracked() writes one run carrying
DocumentLetterSpacing, and trackedWidth, capAdvanceEm, the three
CAP_ADVANCE constants and TRACKED_WIDTH_SAFETY are gone.

A row may not nest directly in the body's column row, so the stack stays as
the host - which is also what gives the row a width for its last column to
resolve against. The stack carries the dash's overhang as a negative left
inset, which both hangs the dash into the margin and hands back the width it
borrows.

Structure changed (the snapshot grows 266 -> 290 nodes: a RowNode per
heading plus its cells). Geometry held:

- title x unchanged on all six headings (36.623 / 430.457); title y unchanged.
- dash x, width and thickness unchanged (14.533 / 408.367, 12.789, 1.744).
- band right edge unchanged to the point: 374.658 and 563.593.
- every rule still ends on that edge.
- the words-to-rule gap is now 11.045-11.046 on all three tailed headings,
  where it was 10.603-11.538. The spread was the estimate; it is gone.
- title widths grow 1.174-1.466, the trailing tracking advance plus the old
  spacer approximation's error.
- measured deltas: the dash and rule sit 0.484 higher, because cells align to
  the row top and the row is centred in the 12.595 band rather than each
  child being centred in it. Sub-half-point, inside the visual parity budget.
- one page before and after; no pagination change.

Verified: 65 tests green, including the visual parity gate and the CV
text-layer gate, which now covers all 26 presets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last two presets built tracking by writing something that is not the
text. ConsultingText.tracked spliced a space between every character of the
authored String, so "BILLED" was authored as "B I L L E D" and copied that
way. LumaStudioWidgets.tracked kept the String whole but pushed a 0.05pt
rectangle of the ground colour between each pair of letters, so the glyphs
sat far enough apart that an extractor invented the word breaks itself.
Different mechanisms, same result for a reader.

Consulting: all three call sites migrate to the semantic string plus
ConsultingStyles.HEADING_TRACKING, one space advance of whichever size the
run is set at, and the String helper is deleted. The section rule beneath a
heading is a fixed 30pt, so no width depended on the mutated text.

LumaStudio: the three overloads now build one run carrying
DocumentLetterSpacing.points(tracking) - the gaps were authored in points, so
the tracking is read in points. The six tracking measures and their differing
styles, alignments and vertical alignments are untouched. The ground colour
is gone from the signature and its seven call sites: it existed only to paint
the spacer rectangles, and there are none.

Geometry, measured on all four invoice snapshots: width only. Same node
count, same totalPages - 1, 2, 1 and 3 - and zero nodes changed page
ownership, including both overflow cases. 29 tests green, both visual parity
gates green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… they draw

Ten shipped presets spelled their headings out in the text layer and nothing
caught it, because the one gate that asserts this - CvPresetTextLayerTest -
listed the sixteen older CV presets and none of the promoted ones. That list
is why the old presets were fine and the new ones were not.

- CvPresetTextLayerTest now covers all 26 CV presets. OrangeOps names Oswald
  and leaves registering it to the caller, so the session carries it, as its
  own snapshot test does.
- TemplateTextLayerGateTest covers the families the CV gate cannot reach. It
  pins BILLED, INVOICE and PROFILE by name, and keeps ModernReceipt as the
  control: that preset has carried native spaced caps since it shipped, so if
  a change to the detector ever reddens it, the detector is wrong.

The detector is asserted against fixtures of its own, which is the part worth
keeping. It matches a run of three or more single letters separated by any
Unicode space separator, not just U+0020 - one preset spelled its headings
with hair and thin spaces, and a plain-space class reads straight past that.
Four cases pin it: ordinary spaces caught, hair and thin spaces caught, a
word tracked with the pen passed, and prose left alone.

That last pair is the lesson from the first sweep of this defect, which ran on
a PDFBox that ignores ActualText and called twenty-two correct templates
broken. A detector nobody has tried to fool is a detector nobody should trust,
so this one is run against both classes before it is believed.

Three fixtures widen to public to be reachable from one gate rather than
three; ProfessionalSidebarFixtures already was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The spacer runs are gone, so the ground colours they were painted in and the
builders that assembled them are unreferenced: PAPER and INK_SURFACE across
the LumaStudio sheet, DocumentColor and ShapeOutline in its widgets, and
ParagraphBuilder in SerifHeadlineWidgets now that the heading is a row.

Two imports in TerracottaRailWidgets are also unused - EllipseBuilder and
ENTRY_INDENT - but both were already unused before this branch, so they are
left where they are rather than folded into an unrelated change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every preview whose template stopped writing its tracking as characters is
regenerated from the migrated engine: eight CV sheets and both invoices.
CommittedAssetDriftTest is green again.

Only those ten moved. A content hash over the page streams also flagged six
unrelated previews - arabic-article, both engine decks, hebrew-invoice,
linkedin-carousel and maven-banner - but the drift gate, which drops what the
machine writes rather than comparing bytes, reads them as unchanged. They are
left alone rather than committed as churn.

Also drops a bare block left around the row construction in
SerifHeadlineWidgets while it was being reshaped; it scoped nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The masthead qualifier is a composite - two em-dash rules with the brand
qualifier between them - and the old implementation tracked only the word:
the rules and their separating spaces were written plainly. Moving the whole
string onto native tracking moved that boundary, spreading the rules away
from the word they point at.

It is now three runs, and only the middle one carries letter spacing.

Measured on one render of all three constructions, so the only variable is
where the tracking is applied:

                    line width   dash-to-word   word-to-dash   centre
  old fake             104.552          4.870          4.870  297.638
  whole-run native     116.836         11.710         11.710  296.498
  scoped                105.436          4.870          7.150  297.638

Scoped returns the left gap to the old value exactly and the centre to the
old value exactly. The right gap keeps +2.280, which is one trailing tracking
advance - 0.25 em of the qualifier's size - because tracking advances after
the final glyph and spliced spaces did not. The word itself is 1.395 narrower
than the fake, the difference between nine space glyphs and nine tracked
advances.

No baseline moves. Every committed preview gives this brand a logo, and the
text lockup is drawn only when there is none, so nothing that ships renders
this path - which is also why the layout snapshot never saw the change and
why visual parity could not have caught it either.

That gap is now covered: ConsultingInvoiceFixtures.logolessInvoice reaches the
lockup, and the text-layer gate asserts the brand and its qualifier both read
back as words.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first sweep fixed ten presets and concluded the rest were clean. That
conclusion rested on the committed previews, and the previews do not cover
the catalogue: seven shipped presets render into no preview at all -
MerchantInvoice, MeteredInvoice, ObsidianInvoice, PlatformInvoice,
SubscriptionInvoice, IndigoProposal and CobaltRota - so nothing had ever read
their text layer.

The catalogue is 58 presets across six families, taken from the source rather
than from a count: every class declaring a public ID, cross-checked against
every class exposing a create() returning a DocumentTemplate. Both lists agree
exactly.

Each family now has a gate that renders its presets through the project's own
PDFBox and asserts the extracted text carries no run of three or more single
letters separated by any Unicode space separator - hair and thin spaces
included, because one preset spelled its headings with those and a plain-space
class reads straight past them.

- InvoiceTextLayerGateTest - 11 presets, across all three spec types the
  family uses. Its pixel gate renders only the two taking an
  InvoiceDocumentSpec, which is why nine went unlisted.
- CoverLetterTextLayerGateTest - 15 presets, each asserting the sender's name
  survives extraction.
- ProposalTextLayerGateTest - 4 presets.
- TemplateTextLayerGateTest gains CobaltRota.

Each gate also compares its own list against the preset package on disk, so a
preset cannot be added without one. That check is the point: the defect was
not that a gate was missing, it was that the gate that existed listed sixteen
CV presets and no others.

Result: no further defects. All 48 presets outside the original ten extract
cleanly. Two helpers that looked like candidates - MerchantStyles.spaces and
PlatformStyles.spaces - space two separate fields on a line rather than the
letters of a word, and their presets extract clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@DemchaAV
DemchaAV merged commit ad5513a into develop Sep 13, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the fix/letter-spacing-text-layer branch September 13, 2026 10:03
DemchaAV added a commit that referenced this pull request Sep 14, 2026
…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.
DemchaAV added a commit that referenced this pull request Sep 14, 2026
…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.
DemchaAV added a commit that referenced this pull request Sep 14, 2026
…689)

* fix(examples): title the sample CV's education section Education

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.

* test(qa): re-record the Teal Pulse, Terracotta Rail and Violet Grid baselines

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.

* fix(templates): name CV sections with the headings resume parsers look 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.

* fix(templates): draw the name first in four sidebar CVs

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.

* feat(templates): carry Professional Sidebar and Terracotta Rail onto 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.

* feat(examples): mark the CV presets that earned an ATS-friendly badge

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.

* docs(changelog): record the ATS-friendly CV preset changes

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.

* test(examples): pin each ATS-friendly badge to the exact PDF its check 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.

* test(examples): certify a deterministic render and hold the published 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.

* fix(examples): hash a badged sample's text the same way on every platform

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