fix(templates): preserve semantic text when applying letter spacing - #682
Merged
Merged
Conversation
…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
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.
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.
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
instead of
so a search, a copy-and-paste, and an applicant tracking system all missed it — and on
ProfessionalSidebarthe applicant's own name came out asM 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 andthin spaces, which a plain-space check reads straight past.
LumaStudioInvoiceinsertedan 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 statesthe run's own
ActualTextso an extractor cannot mistake the gaps for word breaks.Note what did not catch this:
CvPresetTextLayerTestalready asserted exactly thiscontract, 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
DocumentLetterSpacing:ProfessionalSidebar,NavySidebar,TealPulse,VioletGrid,CharcoalGold,MidnightNavy,TerracottaRail,SerifHeadline,ConsultingInvoice,LumaStudioInvoice. Each gapmeasure is preserved as a named constant —
ofFontSizewhere it was an em fraction,pointswhere it was authored in points, and forTerracottaRailthe hair and thinspace characters converted to the tracking measures they always were.
SerifHeadlineis a node-structure migration, not a refactor. Its trailing rule wassized by
trackedWidth, a second implementation of text measurement that summed threeadvance 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, threeCAP_ADVANCEconstants andTRACKED_WIDTH_SAFETYare 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.
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 ruleshrinks by exactly 1.239, conserving the row;
CharcoalGold's job title is +1.926, which isdeliberately not one trailing gap, because its helper set three spaces between words and
tracking adds one gap per glyph.
MidnightNavymoved 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:
MerchantInvoice,MeteredInvoice,ObsidianInvoice,PlatformInvoice,SubscriptionInvoice,IndigoProposal,CobaltRota. Nothing had ever read their text layer.InvoiceDocumentSpec. The other nine carry their own per-preset tests, so no single listever named all eleven.
So the earlier
0 / 115preview 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 acreate()returning aDocumentTemplate. Both lists agree exactly.Six source candidates were investigated and cleared:
MerchantStyles.spaces,PlatformStyles.spaces,dotGap,gapRunand twoString.join(" ")calls all space twoseparate 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.
CvPresetTextLayerTestCoverLetterTextLayerGateTestInvoiceTextLayerGateTestPdfLetterSpacingRenderTestTemplateTextLayerGateTestProposalTextLayerGateTestCommittedAssetDriftTestEach 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 hascarried native spaced caps since it shipped, as the control that must stay green.
Verification
./mvnw -B -ntp clean verify→ BUILD 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/protecteddeclarations; japicmp green in CI.Lane: templates — preset authoring only; no engine change.
🤖 Generated with Claude Code