Skip to content

feat(api): add native letter spacing without changing the text layer - #676

Merged
DemchaAV merged 10 commits into
developfrom
feat/letter-spacing
Sep 11, 2026
Merged

feat(api): add native letter spacing without changing the text layer#676
DemchaAV merged 10 commits into
developfrom
feat/letter-spacing

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Sep 11, 2026

Copy link
Copy Markdown
Owner

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

DocumentTextStyle headline = DocumentTextStyle.builder()
        .size(22)
        .letterSpacing(DocumentLetterSpacing.ofFontSize(0.18))  // 18% of the font size
        .build();

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 the
value rather than in a bare double because 0.18 and 1.2 are both plausible-looking
numbers and a call site passing one has no way to say which it meant — the same defect
lineSpacing(double) already has.

  • Negative tracking tightens, and is supported end to end.
  • DocumentLetterSpacing.NONE is the default and resolves to zero at every font size.
  • The authored value is never rewritten. resolve(fontSize) returns exactly what was asked
    for, whatever the formats below do with it.
  • The existing four-argument DocumentTextStyle constructor is kept and delegates to NONE,
    so code compiled against the 2.0.0 surface keeps linking.

Fixed-layout contract

public value -> resolve against font size -> quantise to 0.01pt
             -> engine TextStyle -> measurement -> PDF Tc / PPTX spc

DocumentNodeAdapters.toTextStyle is the single seam where the public value becomes engine
points, 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.

  • PDF and PPTX use one effective value. DrawingML states spacing in integer hundredths of
    a point, so that is the finest distinction the two can both make. Left unquantised, a
    points(1.0/3.0) style measured at 0.33333… per code point while the deck declared
    spc="33" — the width the layout reserved, wrapped against and sized its frames from was a
    width the deck would never draw. 0.0033pt per code point, 0.133pt over a forty-character
    line, growing with the string.
  • One spacing unit per code point, trailing unit included. Measured, not read off the
    specification: drawing at Tc=5 and reading where the pen landed gives "JANE" +20pt over
    4 code points, "J" +5pt where N-1 would give nothing.
  • An ordinary space is tracked like any other character.
  • Counted in code points of the sanitised string — the one actually handed to the
    backend — never in chars.
  • Out of range is refused, not wrapped. The DrawingML schema 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.

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

  • Native Tc, applied at the TextRenderState seam that already dedupes font and colour.
  • Applied for every run including untracked ones — Tc persists across BT/ET, so
    setting it back to zero is what stops a tracked headline spreading the paragraph after it.
  • Measurement and pen advance are locked together. 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 to 0.01pt.
  • Tracked runs state their own ActualText. This is the part that makes the feature work
    rather than re-create the bug: 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). The mechanism is the one reordered right-to-left runs already use.
  • The author's string stays extractable at every tracking width tested.

PPTX

  • Native DrawingML spc on the run, written at PptxTextFrames.applyStyle — the only
    addNewTextRun in the module, so paragraphs, chips, chrome and table cells all pass
    through it.
  • Hundredths of a point: spc = round(points * 100).
  • Positive and negative both render; verified by exporting a probe deck through PowerPoint
    itself and reading the glyph positions back out of the PDF it wrote.
  • Zero writes no attribute at all.

DOCX

  • Native run-level w:spacing, written at DocxSemanticBackend.applyStyle — the only place
    a run is styled.
  • Twentieths of a point: w:spacing = round(points * 20), a 0.05pt grid.
  • The semantic backend resolves the public value itself, because a semantic export never
    passes through the engine style that would have resolved it already.
  • Zero writes no element at all.

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
SidebarPortrait and TimelineMinimal. Those two copies are gone; TextOrnaments.upper
is the replacement and does only what its name says.

TextOrnaments.spacedUpper is deprecated, not removed. It is still public, still
compiles, 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: grep finds zero call sites across every module's
src/main/java, and exactly one declaration.

It carries @Deprecated(since = "2.4.0", forRemoval = true) with a Javadoc note naming the
replacement, per docs/api-stability.md § 3. That tier is Stable, so it can be removed no
earlier 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. Headline and Subheadline put it
on 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 ordinary
social-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:

ORACLE JAVA CERTIFICAT     ->     ORACLE JAVA
ION                              CERTIFICATION

Compatibility

  • Default tracking is NONE; a style that never mentions it renders exactly as it did.
  • Proven, not argued: a style that never mentions tracking and one that explicitly asks
    for NONE produce the identical file — deterministic PDF compared whole, PPTX and DOCX
    compared on run properties.
  • The four-argument DocumentTextStyle constructor is preserved.
  • No published method is removed. TextOrnaments.spacedUpper is deprecated and kept
    working; every other change to the templates surface is an addition (upper,
    SPACED_CAPS). The full public delta in templates/src/main/java is two additions and
    zero removals.
  • Worth flagging for the next change of this kind: japicmp would not have caught it either
    way. The profile is declared in core/pom.xml alone, so the published, Stable-tier
    graph-compose-templates surface is ungated — filed as Extend binary compatibility checking to graph-compose-templates #677.
  • japicmp is green and additions-only. It is also proven able to fail: removing that
    constructor reports CONSTRUCTOR_REMOVED on exactly that signature.
  • No renderer behaviour changes for styles that do not opt in — no Tc operator, no spc
    attribute, no w:spacing element is emitted anywhere.

Visual changes

The preset migration is a deliberate typographic change, and it moves baselines.

  • 22 baselines intentionally re-recorded: 9 of 16 CV presets, 8 cover letters, and 5
    page-2 images of the multi-page CV presets.
  • Observed delta 0.40%–1.83% of the page (2,014 to 9,162 mismatched pixels of 500,395).
  • All 99 baseline files SHA-audited before and after: zero collateral — nothing
    outside the CV and cover-letter suites moved.
  • No page-count change. The baseline file set is byte-for-byte the same list.
  • Each was checked against the call site that explains it; old and new inspected for
    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 Tc does
and what CSS letter-spacing does.

18 committed README previews re-rendered, plus letter-spacing.pdf added for the new
example — the same CV and cover-letter presets, since assets/readme/examples/*.pdf are the
documents the README links and CommittedAssetDriftTest holds each to what its example
renders today. Worth knowing for the next change of this kind: the reactor gate does not
cover the examples module, and GenerateAllExamples writes to target/ rather than to
assets/, so neither a green local gate nor a run of the generator says anything about these
files.

Also fixed here

  • A tracked table cell no longer calls markReorderedText(). That flag turns on an
    Arabic-only ToUnicode correction which serializes the whole document twice; it was keyed
    on the ActualText that tracking now also sets, so a pure-Latin tracked CV was paying for
    it. ResolvedTextLine carries reordered separately.
  • MarkdownText.withDecoration preserves tracking. It 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.
  • A markdown heading carries its tracking across unchanged rather than scaling it with the
    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.5 is 0.495: the engine would
    measure that and PPTX would have to round it back to 0.50, reopening the gap between
    measurement 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, so
    the two cannot be told apart.
  • MarkDownParser builds five derived styles by copying components; all five carry tracking.
  • LetterSpacingExample is registered in GenerateAllExamples, published as a committed
    preview, and listed in the examples/README.md gallery.
  • The example's javadoc uses <pre>{@code …}</pre> rather than {@snippet}, which is a
    JDK 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.
  • Public documentation: docs/recipes/letter-spacing.md, linked from the recipes index.

Verification

./mvnw -B -ntp clean verify across core, all three backends, templates, testing, qa and
coverage → 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:

suite n asserts
PdfCharacterSpacingContractTest 7 the Tc rule measured off PDFBox — N per code point, trailing included
DocumentLetterSpacingTest 10 units, negatives, -0.0 folding, non-finite refusal
DocumentTextStyleLetterSpacingTest 8 default, null handling, copy methods keep tracking
LetterSpacingPropagationTest 8 the public value crossing the one seam to engine points
PdfFontLetterSpacingMeasurementTest 9 measurement over positive, negative, empty, spaces, supplementary, control chars
PdfLetterSpacingRenderTest 12 text layer at four widths, per-glyph pen steps, no Tc leak, wrapping, CENTER/RIGHT, underline, link rects
PptxLetterSpacingTest 10 spc value and absence, table cells, markdown runs, mixed runs
DocxLetterSpacingTest 7 w:spacing value and absence, adjacent-run independence
TrackingFixedLayoutParityTest 24 one number across engine, PDF and PPTX; range refusal; byte-identity
LetterSpacingAcrossBackendsTest 5 one ofFontSize(0.12) reaching all three as 240 / 48 / 2.4
SpacedCapsTextLayerAcrossBackendsTest 3 a migrated preset read back out of PDF, PPTX and DOCX
CvPresetTextLayerTest +16 across all 16 presets: the name survives, nothing is spelled out letter by letter
TextOrnamentsSpacedUpperCompatibilityTest 9 the deprecated spacedUpper still returns its 2.3.0 strings exactly

Sabotage-verified — each of these turns the suite red when reverted:

  • dropping the PDF Tc emission → 5 red; dropping the measurement term → 11 red. The
    measurement/render coupling is held by the tests, not by the comment above it.
  • restoring the unquantised engine value while PPTX still rounds → 17 of 24 red.
  • removing the four-argument DocumentTextStyle constructor → japicmp
    CONSTRUCTOR_REMOVED.

The restored spacedUpper body is not asserted to be legacy, it is shown to be: extracted
from origin/develop and diffed against the file — 713 bytes, 19 lines, identical.

japicmp green · javadoc green (no new warnings) · knowledge --check green, templates
surface regenerated with the repository tool · all examples regenerate, and
LetterSpacingExample prints its own proof:

PDF text layer  : "JANE O'DOE-SMITH 3RD"
PPTX text       : "JANE O'DOE-SMITH 3RD"
DOCX text       : "JANE O'DOE-SMITH 3RD"

Lane: canonical (document.style) + shared-engine (document.layout, three render
backends) + 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.

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
DemchaAV merged commit 4f1319b into develop Sep 11, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the feat/letter-spacing branch September 11, 2026 11:33
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.
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.

2 participants