feat(api): add native letter spacing without changing the text layer - #676
Merged
Conversation
Spaced caps are drawn today by putting spaces between the letters —
TextOrnaments.spacedUpper("Jane Doe") becomes "J A N E D O E", and 31 call
sites across the CV and cover-letter presets go through it. The picture is
right and the text is wrong: the PDF text layer holds the padded string, so
search, copy/paste, screen readers, text extraction and ATS parsers all read
a name spelled one letter at a time. Real tracking leaves the string alone
and moves the pen instead.
This adds the value and carries it to the engine. Nothing measures or draws
with it yet, so no document changes.
DocumentLetterSpacing keeps the unit: ofFontSize(0.12) is a share of the font
size and scales with the type, points(1.2) is absolute. A bare double could
not tell those apart — 0.12 and 1.2 are both plausible numbers — and the
repository has already paid for that once with lineSpacing. Negative values
tighten. NONE is the default and resolves to zero at every size.
DocumentTextStyle gains the component; the four-argument constructor stays
and delegates to NONE, which is what keeps the binary-compatibility gate
green against the 2.0.0 baseline. withSize and withColor carry the tracking
through rather than dropping it. The engine TextStyle takes the amount
already resolved to points, so nothing below the seam has to remember to
multiply by a font size — DocumentNodeAdapters.toTextStyle is the only place
that knows both the unit and the size, so it is the only place that resolves.
MarkDownParser builds five derived styles by copying four components at a
time; through the new four-argument constructor each would have silently
zeroed the tracking on every markdown run. All five carry it now.
Tests: 26 new — the value type's units, negative tracking, -0.0 folding,
non-finite refusal, and a non-finite font size resolving to zero rather than
poisoning every width downstream; the style's default, its null handling and
its copy methods; and the seam, including resolution against the normalised
size. Core suite 727 green. japicmp green, and proven able to fail: removing
the four-argument constructor reports CONSTRUCTOR_REMOVED on exactly that
signature. Reactor gate green across core, all three backends, templates,
testing, qa and coverage — no layout snapshot and no visual baseline moved.
Javadoc gate green with no new warnings. Knowledge surface regenerated with
the repository tool; --check green.
Tracking now reaches the page. PdfFont adds it to the width it measures and the PDF backend emits it as Tc, so a spaced-caps headline is drawn by moving the pen rather than by pushing spaces into the string. The rule is measured, not assumed: drawing at Tc=5 and reading where the pen landed gives "JANE" +20pt over 4 code points, "JANE DOE" +40 over 8, "J" +5, empty +0. One unit per code point with the trailing unit included — N, not N-1 — and PdfCharacterSpacingContractTest re-measures it so a change in PDFBox says so instead of quietly misplacing every tracked line. Counted in code points of the sanitised string, the one handed to showText. No bundled face can encode a supplementary code point, so sanitizeForRender folds one to '?' before measurement and the char/code-point difference is currently unobservable; counting code points is what stays right when a face that can encode one is added. Measurement and pen have to be the same number, because a line is one BT/ET on an implicit advance: every span after the first is drawn where the pen is, while decorations and link rectangles are placed from the measured width. The tests hold those together to 0.01pt rather than eyeballing a render. Tc is applied at the TextRenderState seam that already dedupes font and colour, and applied for every run rather than only for tracked ones — it persists across BT/ET, so setting it back to zero is what stops a tracked headline spreading the paragraph after it. A zero-tracking document emits no Tc at all and its content stream is unchanged. The table cell sets it once per cell, inside the q..Q that restores it. A tracked run also states its own ActualText. This was not in the plan and is the thing that makes the feature work: PDFTextStripper decides where words are from how far apart glyphs sit, and tracking is precisely moving them apart, so a widely tracked "JANE DOE" extracted as "J A N E D O E" from a file that was already correct — eight glyphs, right ToUnicode. Real tracking was reproducing the exact text-layer defect it exists to remove. The mechanism is the one reordered RTL runs already use; markReorderedText is not called, because nothing was reordered. The width is not clamped. The engine clamps available width, never measured width, and negative tracking really does move the pen backwards — clamping the measurement while being unable to clamp the reader is how the two stop agreeing. Tests: 28 new across three classes — the measured Tc contract; the measurement rule over positive, negative, empty, one-code-point, spaces, supplementary and control-character input, both entry points, and a zero-tracking width that is the identical double it always was; and on a real page the text layer at four tracking widths, per-glyph pen steps, no Tc leak between paragraphs or between runs on one line, and wrapping, CENTER/RIGHT, underline and link rectangles all following the measured width. Both halves proven able to fail: dropping the Tc emission reddens 5, dropping the measurement term reddens 11. Reactor gate green across core, all three backends, templates, testing, qa and coverage. No layout snapshot and no visual baseline moved or was updated. japicmp, javadoc and the knowledge --check are green. PPTX and DOCX do not carry tracking yet and are next.
PPTX and DOCX now carry tracking natively, which closes the gap the PDF phase left: a tracked document was being measured as tracked and drawn untracked in those two formats. Neither unit nor advance rule was taken from the specification. Probe files were built with a tracked run followed by an untracked marker run, exported to PDF by PowerPoint and Word themselves over COM, and the glyph positions read back: spc="500" -> every step of "JANE" +5.0pt => 1/100 pt spc="-150" -> every step -1.5pt => signed, and it renders w:spacing=100 -> every step +5.0pt => 1/20 pt, twips w:spacing=-30 -> every step -1.5pt Both applications apply the trailing unit. The step from the last letter of the tracked run onto the following *untracked* run grew by a full unit (13.367 -> 18.454 in PowerPoint, 13.367 -> 18.431 in Word), and a single-code-point "J" gained a whole unit where N-1 would have given it nothing. An ordinary space is spaced like any other character. That is the same N rule PDF Tc was measured to follow, so the engine's one measurement serves all three backends and no per-backend correction was needed — this was the mismatch the plan was most worried about, and it is not there. PPTX: spc = round(points * 100) DOCX: w:spacing = round(points * 20) One seam each, and each covers everything. PptxTextFrames.applyStyle is the only addNewTextRun in render-pptx, so paragraphs, chips, chrome and table cells all pass through it. DocxSemanticBackend.applyStyle is the only place a run is styled — and the one place a backend resolves the public unit itself, because a semantic export never passes through the engine style that would have resolved it already. Word owns layout there, so the contract is the right native value on the right run, not a matching coordinate. Zero writes nothing: no spc attribute, no w:spacing element. Absence is the default in both formats, so a document without tracking is byte-identical to one produced before this existed. Neither format can leak state the way PDF can: both are run properties, not stream state, so an untracked neighbour simply carries nothing. Asserted rather than assumed. Tests: 22 new. Per backend — points, a font-size share resolved against two different sizes, negative, zero-writes-nothing, sub-unit rounding, the text arriving unpadded, adjacent tracked and untracked runs, and a table cell; plus markdown-derived runs keeping their tracking, which belongs on the PPTX side because MarkDownParser runs in ParagraphWrapping and a semantic export never reaches it. A cross-backend test takes one ofFontSize(0.12) at 20pt and checks all three received that same 2.4pt as 240, 48 and a 2.4 Tc. Reactor gate green across core, all three backends, templates, testing, qa and coverage. No snapshot and no visual baseline moved or was updated. japicmp, javadoc and the knowledge --check are green. TextOrnaments.spacedUpper and its two private copies are untouched; migrating them moves preset baselines and is its own change.
The engine measured the resolved tracking as a raw double while PPTX could only declare integer hundredths of a point. So points(1.0/3.0) was measured at 0.33333... per code point and written as spc="33" — 0.33 — and the width the layout reserved, wrapped against, aligned to and sized its frames from was a width the deck would never draw. 0.0033pt out per code point, 0.133pt over a forty-character line, growing with the string. Fixed at the one seam where the public value becomes engine points, so the measurement and every fixed renderer agree by construction rather than by each rounding the same way and hoping: effectivePoints = Math.round(resolvedPoints * 100.0) / 100.0 DocumentLetterSpacing is untouched. It still returns exactly what the author wrote, because quantisation is a property of fixed layout and not of the value. DOCX does not come through this seam at all: it resolves the authored value itself and rounds to Word's twentieths, a coarser grid again, which is right — Word owns that layout and owes the PDF no coordinate. What this does not claim: that a PDF and a deck rasterise identically. They do not, and never could. Exported through PowerPoint and measured, an *untracked* forty-glyph line already lands 0.77pt apart, because PowerPoint has its own font handling and rounds its own output. That difference is not ours. This one was: arithmetic we performed, knowable, and removable. Range is now refused rather than wrapped. The DrawingML schema was measured, not read — it validates spc="400000" and rejects "400001" — so fixed layout tops out at 4000pt. Past that the int cast silently changes sign: 2.2e7 points becomes -2094967296, turning wide tracking into tight, and 1e9 points becomes -1474836480 in Word's twentieths. Both now throw with the limit named. The value type itself still accepts any finite number; the limits belong to the formats that have to write it. The granularity is now documented where an author will meet it rather than implied to be exact: 0.01pt through fixed layout, 0.05pt through Word, and the authored value preserved. Tests: 23, covering a third of a point, a sub-hundredth, negatives, an already-on-grid value, zero staying the byte-identical legacy path, the authored value surviving unrewritten, DOCX rounding independently to 7 twips where the fixed path takes 0.33, accumulation over a forty-code-point string, both overflow cases, and the half-up asymmetry at exactly -0.005 where the tracking quantises away to none. Proven able to fail: restoring the unquantised engine value while PPTX still rounds reddens 17 of the 23. Reactor gate green across core, all three backends, templates, testing, qa and coverage. No snapshot and no visual baseline moved or was updated. japicmp, javadoc and the knowledge --check are green.
The built-in CV and cover-letter presets drew spaced caps by rewriting the string with a space between every pair of letters. The page looked right and the file did not: an applicant's name was stored as "J A N E D O E", so the one field a CV is searched and parsed by was the one field not in it. All 33 call sites now set the tracking on the style and pass the text through unchanged. The value is ofFontSize(0.18), one token for every preset, picked from a measurement rather than by eye. The old transform's gap was a whole space glyph — 0.232em on IBM Plex Serif up to 0.278em on Helvetica across the faces these presets use, far more than editorial spaced caps normally carry, because a space glyph was what it had to work with. Matching that per-gap would have widened every heading, since real tracking also adds a unit after the last glyph and to the word space. Matching the old *total* width puts the equivalent at 0.174-0.209em, so 0.18 sits inside the band and headings keep close to the width they had. Headings also stop breaking mid-word. Padding every letter made each letter its own word to the line breaker, so a heading wrapped wherever it ran out of room: EDUCATION & CERT / IFICATIONS, ORACLE JAVA CERTIFICAT / ION. Words are whole again, so they wrap between words. Tracking is applied where the spaced-caps intent lives. Headline and Subheadline put it on a copy of whatever style the caller handed in, so every preset calling them migrated without touching its own constants. Elsewhere it went into the style factory when that factory serves only spaced text, and onto a copy at the call site when it does not — MintEditorial.labelStyle() has seven tracked callers and one that renders ordinary social-link labels, and MonogramSidebar.mainEntryDateStyle() is shared the same way. Tracking either of those at the source would have spaced out text nobody asked to space. TextOrnaments.spacedUpper is gone, with the two private copies that had grown in SidebarPortrait and TimelineMinimal. TextOrnaments.upper does only what its name says. Two style-copy defects of the same class came out of review and are fixed here: MarkdownText.withDecoration rebuilt a style from four of its five parts, so a bolded word inside a tracked heading would have carried a different tracking from the words either side of it; and a markdown heading scaled its size while keeping the body's absolute tracking, which is not what a share of the font size means. Also from review: a tracked table cell was calling markReorderedText(), which turns on an Arabic-only ToUnicode correction that serializes the whole document twice — the flag now follows reordering rather than the ActualText it happens to share. Visual: 9 of 16 CV presets and 8 cover letters move, and their baselines are re-recorded here. Each was checked against the call site that explains it; the change is the intended narrower spaced caps, with no collision, no clipping and no pagination change — the page count and the baseline file set are identical. Nothing outside those two suites moved, confirmed by SHA over all 99 baselines. Tests: the preset text-layer guard now also asserts the name survives and that nothing anywhere is spelled out letter by letter, across all 16 presets; a new cross-backend test reads a migrated preset back out of PDF, PPTX and DOCX with a name carrying digits and punctuation; and the "every existing document is byte-for-byte what it was" claim is now asserted rather than argued — deterministic PDF compared whole, OOXML compared on run properties. Reactor gate green. japicmp, javadoc and knowledge --check green.
| * the font size) and the single conversion seam resolves it, so nothing below | ||
| * this type has to remember to multiply by the font size.</p> | ||
| * | ||
| * @param fontName font family name |
| * this type has to remember to multiply by the font size.</p> | ||
| * | ||
| * @param fontName font family name | ||
| * @param size font size in points |
| * | ||
| * @param fontName font family name | ||
| * @param size font size in points | ||
| * @param decoration text decoration |
| * @param fontName font family name | ||
| * @param size font size in points | ||
| * @param decoration text decoration | ||
| * @param color text color |
| * @param size font size in points | ||
| * @param decoration text decoration | ||
| * @param color text color | ||
| * @param letterSpacing tracking in points, already resolved; {@code 0} for none |
| */ | ||
| private static Integer spacingOf(XWPFRun run) { | ||
| Matcher matcher = SPACING.matcher(run.getCTR().xmlText()); | ||
| return matcher.find() ? Integer.valueOf(matcher.group(1)) : null; |
| return null; | ||
| } | ||
| Matcher matcher = SPC.matcher(ctRun.xmlText()); | ||
| return matcher.find() ? Integer.valueOf(matcher.group(1)) : null; |
…c block
{@snippet} is a JDK 18 tag. The project builds and documents against a Java 17
baseline, so the 17 leg of the CI matrix failed the javadoc gate with
"unknown tag: snippet" while 21 and 25 passed — the tag is recognised by the
newer tools and simply ignored the difference.
Rewritten as <pre>{@code ...}</pre>, which is what the other 19 files in core
already use and what renders on every version in the matrix. Same example,
same output.
…ge moved The README links a committed PDF per example, and CommittedAssetDriftTest holds each one to what its example actually renders today. Eighteen of them are CV and cover-letter presets whose spaced caps now come from tracking rather than from padded strings, so the documents moved and the committed copies stopped matching. Re-rendered from the catalogue. The reactor gate does not cover the examples module, and GenerateAllExamples writes to target/ rather than to assets/ — so neither the local gate nor a run of the generator says anything about these files. Only this suite does. letter-spacing.pdf is committed alongside them, which is what the guard was also asking for: every rendered document has to be either a published preview or on the deliberately-unpublished list, and a new example that is neither is one nobody has decided about. Published, so the README row and its section now carry the PDF link the other rows have.
A heading scales the font size, and two commits ago the tracking was scaled
with it. That was wrong twice over. The engine is handed tracking already
quantised to the hundredth of a point DrawingML can state, and 0.33 x 1.5 is
0.495 — a value the engine would measure and PPTX would have to round back to
0.50, which is exactly the gap between measurement and file that the
quantisation seam exists to close. It also scaled an absolute points(1.2) that
was never meant to follow the font size; by this point the unit is gone, so the
two cannot be told apart.
No behaviour changes. The Heading handler is not reachable from the public
paragraph path: rendered through GraphCompose.document().markdown(true) with
.text("# One"), the hash is drawn literally and the size stays at the body's
20pt, on one line and across several. So there is no test here — a guard for
this cannot be made to fail, and one that passes either way is worse than none.
The reason it is fixed anyway is that the arithmetic is wrong wherever that
handler is eventually wired up.
The presets stopped calling it, and it was deleted along with them. But graph-compose-templates is published to Maven Central and docs/api-stability.md declares templates.core.* Stable, which is major-releases-only: deleting a public method there in a 2.3.0 -> 2.4.0 minor hands anyone who calls it a NoSuchMethodError. So spacedUpper comes back, byte-identical to the 2.3.0 body rather than rewritten, carrying @deprecated(since = "2.4.0", forRemoval = true) and a Javadoc note naming the replacement in the format api-stability.md § 3 asks for. Stable tier puts its removal no earlier than 3.0. Nothing inside GraphCompose calls it: all 33 migrated call sites stay migrated, both private copies stay deleted, and there is one declaration and zero callers across every src/main/java. A deprecated method with no callers inside the project is one nobody would notice breaking, so its output is pinned for null, empty, letters, digits, punctuation, every whitespace character, and the trailing character that gets no space after it. japicmp did not catch the deletion and would not have: the profile is declared in core/pom.xml alone, so every other published module is ungated. Filed as #677.
|
|
||
| @Test | ||
| void nullBecomesEmptyRatherThanThrowing() { | ||
| assertThat(TextOrnaments.spacedUpper(null)).isEmpty(); |
|
|
||
| @Test | ||
| void emptyStaysEmpty() { | ||
| assertThat(TextOrnaments.spacedUpper("")).isEmpty(); |
| void lettersAreSeparatedBySingleSpacesAndWordsByThree() { | ||
| // One space between adjacent letters; the real word space keeps | ||
| // itself and gains two more, so words read as separated. | ||
| assertThat(TextOrnaments.spacedUpper("Jane Doe")).isEqualTo("J A N E D O E"); |
| void digitsSpaceLikeLetters() { | ||
| // isLetterOrDigit, so "R2" spaces between R and 2, and a digit | ||
| // adjacent to a letter gets the same treatment either way round. | ||
| assertThat(TextOrnaments.spacedUpper("R2 D2")).isEqualTo("R 2 D 2"); |
| // isLetterOrDigit, so "R2" spaces between R and 2, and a digit | ||
| // adjacent to a letter gets the same treatment either way round. | ||
| assertThat(TextOrnaments.spacedUpper("R2 D2")).isEqualTo("R 2 D 2"); | ||
| assertThat(TextOrnaments.spacedUpper("A1B2")).isEqualTo("A 1 B 2"); |
| assertThat(TextOrnaments.spacedUpper("A B")).isEqualTo("A B"); | ||
| // Any Character.isWhitespace, not just the space glyph. | ||
| assertThat(TextOrnaments.spacedUpper("A\tB")).isEqualTo("A\t B"); | ||
| assertThat(TextOrnaments.spacedUpper("A\nB")).isEqualTo("A\n B"); |
|
|
||
| @Test | ||
| void leadingAndTrailingWhitespaceIsExpandedRatherThanTrimmed() { | ||
| assertThat(TextOrnaments.spacedUpper(" A ")).isEqualTo(" A "); |
| // The trailing unit is exactly what real tracking adds and this | ||
| // transform does not — the reason 0.18em matches the old total | ||
| // width rather than the old per-gap width. | ||
| assertThat(TextOrnaments.spacedUpper("AB")).isEqualTo("A B"); |
| // transform does not — the reason 0.18em matches the old total | ||
| // width rather than the old per-gap width. | ||
| assertThat(TextOrnaments.spacedUpper("AB")).isEqualTo("A B"); | ||
| assertThat(TextOrnaments.spacedUpper("A")).isEqualTo("A"); |
| @Test | ||
| void theReplacementDeliberatelyDoesNotReproduceThis() { | ||
| assertThat(TextOrnaments.upper("Jane Doe")).isEqualTo("JANE DOE"); | ||
| assertThat(TextOrnaments.spacedUpper("Jane Doe")).isEqualTo("J A N E D O E"); |
"Nothing inside GraphCompose calls it" is not quite true: the compatibility test does, and it has to — a deprecated method with no callers at all is one whose behaviour nobody would notice changing. What is true is that no component or preset calls it.
DemchaAV
added a commit
that referenced
this pull request
Sep 12, 2026
Brings the 2.4.0 engine work onto the promotion branch: native letter spacing (#676), opt-in list hanging indent (#674), the resolved timeline rail (#671-#673), the row-child margin fix, the RTL documentation corrections (#679, #680) and the templates japicmp gate (#681). Eight files conflicted. CHANGELOG.md is a union of both v2.4.0 sections, with the branch-local "### Deprecated" folded into the house heading "### Deprecations" and the sections ordered the way released entries are. The other seven are generated and were regenerated from the merged source rather than resolved by side: knowledge/api/templates.json and .md through extract-api --from-reactor, and the five cv preview PDFs by re-rendering their example classes. Five qa baselines moved, all from f75def6, which stops a row child's horizontal margin being taken off twice. Each of the four layout snapshots changes by exactly one node's own horizontal margin - HeadingRule_EXPERIENCE +9.0, EducationHeadingRule +11.285, FooterDueIcon -3.479 (a negative margin) and FooterSite +1.693 (a right margin) - with startPage and endPage unchanged, so no page ownership moved. cobalt_rota keeps its geometry snapshot and moves only in pixels, inside composed table cells, which emit fragments rather than PlacedNodes and so cannot appear in a layout snapshot; the changed region is the day-header and note cells. One of 126 pixel baselines changed, verified by checksum before and after.
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
The built-in CV and cover-letter presets drew spaced caps by rewriting the string with a
space between every pair of letters.
TextOrnaments.spacedUpper("Jane Doe")returned"J A N E D O E", and that is what went into the file.The page looked right, so nothing complained. Everything that reads a document rather than
looking at it did not: search, copy/paste, screen readers, text extraction, and — for a CV,
the one that matters — applicant-tracking parsers. The applicant's name, the single field a
CV is looked up by, was the one field not in the document.
The look is typography. It belongs on the style, not in the text.
Public API
DocumentLetterSpacing(@since 2.4.0) carries the amount and its unit:ofFontSize(fraction)scales with the type,points(pt)is absolute. The unit lives in thevalue rather than in a bare
doublebecause0.18and1.2are both plausible-lookingnumbers and a call site passing one has no way to say which it meant — the same defect
lineSpacing(double)already has.DocumentLetterSpacing.NONEis the default and resolves to zero at every font size.resolve(fontSize)returns exactly what was askedfor, whatever the formats below do with it.
DocumentTextStyleconstructor is kept and delegates toNONE,so code compiled against the 2.0.0 surface keeps linking.
Fixed-layout contract
DocumentNodeAdapters.toTextStyleis the single seam where the public value becomes enginepoints, so it is the only place that can make the measurement and every renderer agree by
construction rather than by each rounding the same way and hoping.
a point, so that is the finest distinction the two can both make. Left unquantised, a
points(1.0/3.0)style measured at0.33333…per code point while the deck declaredspc="33"— the width the layout reserved, wrapped against and sized its frames from was awidth the deck would never draw. 0.0033pt per code point, 0.133pt over a forty-character
line, growing with the string.
specification: drawing at
Tc=5and reading where the pen landed gives"JANE"+20pt over4 code points,
"J"+5pt where N-1 would give nothing.backend — never in
chars.spc="400000"andrejects
400001, so fixed layout tops out at ±4000pt. Past that the int cast silentlychanges sign: 2.2e7 points becomes
-2094967296, turning wide tracking into tight.The semantic DOCX export does not come through this seam and rounds the authored value to
Word's own coarser grid, which is right — Word owns that layout and owes the PDF no
coordinate.
This is not a claim that a PDF and a deck rasterise identically. They do not. Exported
through PowerPoint and measured, an untracked forty-glyph line already lands 0.77pt apart,
because PowerPoint has its own font handling and rounds its own output. That difference is
not ours to remove. The quantisation one was: arithmetic we performed, knowable, removable.
PDF
Tc, applied at theTextRenderStateseam that already dedupes font and colour.Tcpersists acrossBT/ET, sosetting it back to zero is what stops a tracked headline spreading the paragraph after it.
BT/ETon an implicitadvance: every span after the first is drawn where the pen is, while decorations and link
rectangles are placed from the measured width. The tests hold those to 0.01pt.
ActualText. This is the part that makes the feature workrather than re-create the bug:
PDFTextStripperdecides where words are from how far apartglyphs sit, and tracking is precisely moving them apart — so a widely tracked
"JANE DOE"extracted as
"J A N E D O E"from a file that was already correct (eight glyphs, rightToUnicode). The mechanism is the one reordered right-to-left runs already use.PPTX
spcon the run, written atPptxTextFrames.applyStyle— the onlyaddNewTextRunin the module, so paragraphs, chips, chrome and table cells all passthrough it.
spc = round(points * 100).itself and reading the glyph positions back out of the PDF it wrote.
DOCX
w:spacing, written atDocxSemanticBackend.applyStyle— the only placea run is styled.
w:spacing = round(points * 20), a 0.05pt grid.passes through the engine style that would have resolved it already.
Preset migration
33 fake spaced-uppercase usages migrated — every one of them, in the presets, the shared
identity components and the two private copies of the transform that had grown in
SidebarPortraitandTimelineMinimal. Those two copies are gone;TextOrnaments.upperis the replacement and does only what its name says.
TextOrnaments.spacedUpperis deprecated, not removed. It is still public, stillcompiles, and still returns the same strings character for character — the restored body is
byte-identical to 2.3.0's, and a compatibility test pins its output for null, empty, letters,
digits, punctuation, whitespace and the trailing-character case. What changed is that nothing
inside GraphCompose calls it:
grepfinds zero call sites across every module'ssrc/main/java, and exactly one declaration.It carries
@Deprecated(since = "2.4.0", forRemoval = true)with a Javadoc note naming thereplacement, per
docs/api-stability.md§ 3. That tier is Stable, so it can be removed noearlier than 3.0 and not before a full minor has shipped deprecated.
One shared token,
TextOrnaments.SPACED_CAPS = DocumentLetterSpacing.ofFontSize(0.18).0.18 is measured, not chosen. The old transform put a whole space glyph between letters,
which is 0.232em (IBM Plex Serif) to 0.278em (Helvetica) across the faces these presets use —
far more than editorial spaced caps normally carry, because a space glyph was what it had to
work with. Matching that per-gap would have widened every heading, since real tracking also
adds a unit after the last glyph and to the word space. Matching the old total width puts
the equivalent at 0.174–0.209em, so 0.18 sits inside the measured band and headings keep
close to the width they had.
Tracking is applied where the spaced-caps intent lives.
HeadlineandSubheadlineput iton a copy of whatever style the caller hands in, so every preset calling them migrated
without touching its own constants. Elsewhere it went into the style factory when that
factory serves only spaced text, and onto a copy at the call site when it does not —
MintEditorial.labelStyle()has seven tracked callers and one that renders ordinarysocial-link labels, and
MonogramSidebar.mainEntryDateStyle()is shared the same way.Tracking either at the source would have spaced out text nobody asked to space.
Headings also stop breaking mid-word. Padding every letter made each letter its own word
to the line breaker, so a heading wrapped wherever it ran out of room. Words are whole again,
so they wrap between words:
Compatibility
NONE; a style that never mentions it renders exactly as it did.for
NONEproduce the identical file — deterministic PDF compared whole, PPTX and DOCXcompared on run properties.
DocumentTextStyleconstructor is preserved.TextOrnaments.spacedUpperis deprecated and keptworking; every other change to the templates surface is an addition (
upper,SPACED_CAPS). The full public delta intemplates/src/main/javais two additions andzero removals.
way. The profile is declared in
core/pom.xmlalone, so the published, Stable-tiergraph-compose-templatessurface is ungated — filed as Extend binary compatibility checking to graph-compose-templates #677.constructor reports
CONSTRUCTOR_REMOVEDon exactly that signature.Tcoperator, nospcattribute, no
w:spacingelement is emitted anywhere.Visual changes
The preset migration is a deliberate typographic change, and it moves baselines.
page-2 images of the multi-page CV presets.
outside the CV and cover-letter suites moved.
collision, clipping and wrapping.
Note that centred and right-aligned tracked lines align on a width that includes the trailing
unit, so they sit half a unit left of where the old padded string sat. That is what
Tcdoesand what CSS
letter-spacingdoes.18 committed README previews re-rendered, plus
letter-spacing.pdfadded for the newexample — the same CV and cover-letter presets, since
assets/readme/examples/*.pdfare thedocuments the README links and
CommittedAssetDriftTestholds each to what its examplerenders today. Worth knowing for the next change of this kind: the reactor gate does not
cover the examples module, and
GenerateAllExampleswrites totarget/rather than toassets/, so neither a green local gate nor a run of the generator says anything about thesefiles.
Also fixed here
markReorderedText(). That flag turns on anArabic-only
ToUnicodecorrection which serializes the whole document twice; it was keyedon the
ActualTextthat tracking now also sets, so a pure-Latin tracked CV was paying forit.
ResolvedTextLinecarriesreorderedseparately.MarkdownText.withDecorationpreserves tracking. It rebuilt a style from four of its fiveparts, so a bolded word inside a tracked heading would have carried a different tracking
from the words either side of it.
heading. Scaling looks right at first — a share of the font size ought to grow with the
font — but by the time the engine sees it the value is already quantised to the hundredth
of a point a fixed-layout file can state, and
0.33 × 1.5is0.495: the engine wouldmeasure that and PPTX would have to round it back to
0.50, reopening the gap betweenmeasurement and file that the quantisation exists to close. It would also scale an absolute
points(1.2)that was never meant to follow the font size, and the unit is gone by then, sothe two cannot be told apart.
MarkDownParserbuilds five derived styles by copying components; all five carry tracking.LetterSpacingExampleis registered inGenerateAllExamples, published as a committedpreview, and listed in the
examples/README.mdgallery.<pre>{@code …}</pre>rather than{@snippet}, which is aJDK 18 tag: the 17 leg of the matrix documents against a Java 17 baseline and rejects it as
an unknown tag, while 21 and 25 accept it silently.
docs/recipes/letter-spacing.md, linked from the recipes index.Verification
./mvnw -B -ntp clean verifyacross core, all three backends, templates, testing, qa andcoverage → BUILD SUCCESS, 2,486 tests green: core 727, render-pdf 252,
render-pptx 138, templates 113, render-docx 91, testing 5, qa 1,160.
+128 tests. The dedicated suites:
PdfCharacterSpacingContractTestTcrule measured off PDFBox — N per code point, trailing includedDocumentLetterSpacingTest-0.0folding, non-finite refusalDocumentTextStyleLetterSpacingTestLetterSpacingPropagationTestPdfFontLetterSpacingMeasurementTestPdfLetterSpacingRenderTestTcleak, wrapping,CENTER/RIGHT, underline, link rectsPptxLetterSpacingTestspcvalue and absence, table cells, markdown runs, mixed runsDocxLetterSpacingTestw:spacingvalue and absence, adjacent-run independenceTrackingFixedLayoutParityTestLetterSpacingAcrossBackendsTestofFontSize(0.12)reaching all three as 240 / 48 / 2.4SpacedCapsTextLayerAcrossBackendsTestCvPresetTextLayerTestTextOrnamentsSpacedUpperCompatibilityTestspacedUpperstill returns its 2.3.0 strings exactlySabotage-verified — each of these turns the suite red when reverted:
Tcemission → 5 red; dropping the measurement term → 11 red. Themeasurement/render coupling is held by the tests, not by the comment above it.
DocumentTextStyleconstructor → japicmpCONSTRUCTOR_REMOVED.The restored
spacedUpperbody is not asserted to be legacy, it is shown to be: extractedfrom
origin/developand diffed against the file — 713 bytes, 19 lines, identical.japicmp green · javadoc green (no new warnings) · knowledge
--checkgreen,templatessurface regenerated with the repository tool · all examples regenerate, and
LetterSpacingExampleprints its own proof:Lane: canonical (
document.style) + shared-engine (document.layout, three renderbackends) + templates — one feature carried across every layer it needs.
Follow-up, pre-existing and deliberately not fixed here: #675 — the canonical template layout
snapshots have no active assertion, leaving the wide pixel budget as the only gate on preset
geometry.