From 48d20fc72a32251b996832821ff95859adc85fca Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 11 Sep 2026 00:59:19 +0100
Subject: [PATCH 01/10] feat(api): let a text style carry tracking, in a unit
it can name
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
CHANGELOG.md | 14 +
.../document/layout/DocumentNodeAdapters.java | 7 +-
.../document/style/DocumentLetterSpacing.java | 126 +++++++++
.../document/style/DocumentTextStyle.java | 66 ++++-
.../components/content/text/TextStyle.java | 34 ++-
.../engine/text/markdown/MarkDownParser.java | 13 +-
.../layout/LetterSpacingPropagationTest.java | 103 +++++++
.../style/DocumentLetterSpacingTest.java | 99 +++++++
.../DocumentTextStyleLetterSpacingTest.java | 101 +++++++
knowledge/api/authoring.json | 258 +++++++++++++++++-
knowledge/api/authoring.md | 26 +-
11 files changed, 829 insertions(+), 18 deletions(-)
create mode 100644 core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java
create mode 100644 core/src/test/java/com/demcha/compose/document/layout/LetterSpacingPropagationTest.java
create mode 100644 core/src/test/java/com/demcha/compose/document/style/DocumentLetterSpacingTest.java
create mode 100644 core/src/test/java/com/demcha/compose/document/style/DocumentTextStyleLetterSpacingTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 91083c3ff..39c951cbf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,20 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **Text style carries typographic tracking.** `DocumentTextStyle.builder().letterSpacing(...)`
+ takes a `DocumentLetterSpacing` — either `ofFontSize(0.12)`, a share of the font size, or
+ `points(1.2)`, an absolute amount. Negative values tighten. The unit lives in the value
+ rather than in a bare `double`, because `0.12` and `1.2` are both plausible-looking
+ numbers and a call site passing one has no way to say which it meant.
+
+ The default is `DocumentLetterSpacing.NONE`, which resolves to zero at every font size, so
+ a document that never asks for tracking renders exactly as it did.
+
+ This step adds the value and carries it to the engine; nothing measures or draws with it
+ yet. When it does, it will be real tracking rather than spaces inserted between letters:
+ the string handed to the backend stays the author's string, so a spaced-caps headline
+ still reads as `JANE DOE` to search, copy/paste, text extraction and ATS parsers.
+
- **A list can hang its wrapped lines under its own text instead of under its marker.**
`ListBuilder.hangingIndent(true)` gives an item a marker column and a content column, so
every visual line of it starts at one horizontal position — the first line, the lines it
diff --git a/core/src/main/java/com/demcha/compose/document/layout/DocumentNodeAdapters.java b/core/src/main/java/com/demcha/compose/document/layout/DocumentNodeAdapters.java
index a6925e1ad..349787004 100644
--- a/core/src/main/java/com/demcha/compose/document/layout/DocumentNodeAdapters.java
+++ b/core/src/main/java/com/demcha/compose/document/layout/DocumentNodeAdapters.java
@@ -58,7 +58,12 @@ static TextStyle toTextStyle(DocumentTextStyle textStyle) {
textStyle.fontName(),
textStyle.size(),
toDecoration(textStyle.decoration()),
- textStyle.color().color());
+ textStyle.color().color(),
+ // The public value keeps its unit; the engine gets points. This
+ // is the only place that knows the font size and the unit at
+ // the same time, so it is the only place that can resolve one
+ // against the other.
+ textStyle.letterSpacing().resolve(textStyle.size()));
}
static TextIndentStrategy toIndentStrategy(DocumentTextIndent indent) {
diff --git a/core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java b/core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java
new file mode 100644
index 000000000..2530259d7
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java
@@ -0,0 +1,126 @@
+package com.demcha.compose.document.style;
+
+/**
+ * Letter spacing (typographic tracking) for a
+ * {@link DocumentTextStyle} — extra advance inserted after every rendered
+ * code point, expressed either in absolute points or as a fraction of the font
+ * size.
+ *
+ * This is real tracking, not spaces: the string handed to the backend is the
+ * author's string, so the PDF text layer, search, copy/paste, text extraction
+ * and ATS parsing all still see {@code "JANE DOE"} for a headline that renders
+ * as widely spaced caps. Padding the string with literal spaces — the
+ * shape {@code "J A N E D O E"} — achieves the same picture and breaks
+ * every one of those.
+ *
+ * Prefer {@link #ofFontSize(double)}: expressed as a fraction, the tracking
+ * scales with the type, so one style value reads the same at 9pt as at 24pt and
+ * keeps its proportions under auto-size. {@link #points(double)} is there for
+ * the cases that are specified in absolute points. The unit lives in the value,
+ * so a call site says which one it meant — {@code ofFontSize(0.12)} and
+ * {@code points(1.2)} are both plausible-looking numbers and a bare
+ * {@code double} could not tell them apart.
+ *
+ * Negative tracking (tighter than normal) is allowed. {@link #NONE} is the
+ * neutral value carried by every style that has not opted in; it resolves to
+ * {@code 0} and leaves measurement and rendering exactly as they were.
+ * Instances are immutable and thread-safe.
+ *
+ * {@snippet :
+ * DocumentTextStyle headline = DocumentTextStyle.builder()
+ * .size(24)
+ * .letterSpacing(DocumentLetterSpacing.ofFontSize(0.12)) // 12% of 24pt = 2.88pt
+ * .build();
+ * }
+ *
+ * @param type whether {@code value} is read as points or as a fraction of the
+ * font size
+ * @param value the tracking amount, in the unit named by {@code type}
+ * @author Artem Demchyshyn
+ * @see DocumentTextStyle#letterSpacing()
+ * @since 2.4.0
+ */
+public record DocumentLetterSpacing(Type type, double value) {
+
+ /** The unit a tracking amount is expressed in. */
+ public enum Type {
+ /** {@code value} is an absolute amount in points. */
+ POINTS,
+ /** {@code value} is a fraction of the font size (an em share). */
+ FONT_SIZE
+ }
+
+ /**
+ * No tracking — the neutral value, and the default of every
+ * {@link DocumentTextStyle}. Resolves to {@code 0} at any font size.
+ */
+ public static final DocumentLetterSpacing NONE = new DocumentLetterSpacing(Type.POINTS, 0.0);
+
+ /**
+ * Validates the unit and the amount.
+ *
+ * @param type the unit; must not be {@code null}
+ * @param value the amount; must be finite, may be negative
+ */
+ public DocumentLetterSpacing {
+ if (type == null) {
+ throw new IllegalArgumentException("Letter-spacing type cannot be null.");
+ }
+ if (!Double.isFinite(value)) {
+ throw new IllegalArgumentException("Letter spacing must be a finite number, got: " + value);
+ }
+ // -0.0 renders identically to +0.0 but would compare unequal to NONE and
+ // hash differently. Fold it so one behaviour has one value.
+ value = value == 0.0 ? 0.0 : value;
+ }
+
+ /**
+ * Tracking of an absolute size, in points.
+ *
+ * @param points extra advance after each code point, in points; negative
+ * tightens, {@code 0} is {@link #NONE}
+ * @return a points-valued tracking
+ */
+ public static DocumentLetterSpacing points(double points) {
+ return points == 0.0 ? NONE : new DocumentLetterSpacing(Type.POINTS, points);
+ }
+
+ /**
+ * Tracking as a fraction of the font size, so it scales with the type.
+ *
+ * @param fraction share of the font size, e.g. {@code 0.12} for 12%;
+ * negative tightens, {@code 0} is {@link #NONE}
+ * @return a font-size-relative tracking
+ */
+ public static DocumentLetterSpacing ofFontSize(double fraction) {
+ return fraction == 0.0 ? NONE : new DocumentLetterSpacing(Type.FONT_SIZE, fraction);
+ }
+
+ /**
+ * Resolves this tracking to points against a concrete font size.
+ *
+ * A non-finite {@code fontSize} resolves to {@code 0} rather than
+ * propagating {@code NaN}: every text width in the engine is measured
+ * through this value, and a {@code NaN} advance would poison wrapping,
+ * alignment and pagination far from where it originated.
+ *
+ * @param fontSize the font size the text is set at, in points
+ * @return the extra advance per code point, in points
+ */
+ public double resolve(double fontSize) {
+ return switch (type) {
+ case POINTS -> value;
+ case FONT_SIZE -> Double.isFinite(fontSize) ? value * fontSize : 0.0;
+ };
+ }
+
+ /**
+ * Whether this is the neutral value, i.e. it resolves to {@code 0} at every
+ * font size.
+ *
+ * @return {@code true} if no tracking is applied
+ */
+ public boolean isNone() {
+ return value == 0.0;
+ }
+}
diff --git a/core/src/main/java/com/demcha/compose/document/style/DocumentTextStyle.java b/core/src/main/java/com/demcha/compose/document/style/DocumentTextStyle.java
index 30ba09bcc..d517ffff0 100644
--- a/core/src/main/java/com/demcha/compose/document/style/DocumentTextStyle.java
+++ b/core/src/main/java/com/demcha/compose/document/style/DocumentTextStyle.java
@@ -18,13 +18,17 @@
* @param size font size in points
* @param decoration text decoration
* @param color text color
+ * @param letterSpacing typographic tracking; {@link DocumentLetterSpacing#NONE}
+ * (the default) leaves glyph advances exactly as they were
* @author Artem Demchyshyn
+ * @see DocumentLetterSpacing
*/
public record DocumentTextStyle(
FontName fontName,
double size,
DocumentTextDecoration decoration,
- DocumentColor color
+ DocumentColor color,
+ DocumentLetterSpacing letterSpacing
) {
public static final DocumentTextStyle DEFAULT = builder().build();
@@ -36,6 +40,27 @@ public record DocumentTextStyle(
size = size <= 0 ? 14 : size;
decoration = decoration == null ? DocumentTextDecoration.DEFAULT : decoration;
color = color == null ? DocumentColor.BLACK : color;
+ letterSpacing = letterSpacing == null ? DocumentLetterSpacing.NONE : letterSpacing;
+ }
+
+ /**
+ * Creates a normalized text style without tracking.
+ *
+ * The signature this type carried before {@code letterSpacing} was
+ * added. It stays so code compiled against the 2.0.0 surface keeps
+ * linking, and so the binary-compatibility gate still finds the
+ * constructor it has always found.
+ *
+ * @param fontName font family name
+ * @param size font size in points
+ * @param decoration text decoration
+ * @param color text color
+ */
+ public DocumentTextStyle(FontName fontName,
+ double size,
+ DocumentTextDecoration decoration,
+ DocumentColor color) {
+ this(fontName, size, decoration, color, DocumentLetterSpacing.NONE);
}
/**
@@ -54,7 +79,7 @@ public static Builder builder() {
* @return updated text style
*/
public DocumentTextStyle withSize(double size) {
- return new DocumentTextStyle(fontName, size, decoration, color);
+ return new DocumentTextStyle(fontName, size, decoration, color, letterSpacing);
}
/**
@@ -64,7 +89,19 @@ public DocumentTextStyle withSize(double size) {
* @return updated text style
*/
public DocumentTextStyle withColor(DocumentColor color) {
- return new DocumentTextStyle(fontName, size, decoration, color);
+ return new DocumentTextStyle(fontName, size, decoration, color, letterSpacing);
+ }
+
+ /**
+ * Creates a copy with different tracking.
+ *
+ * @param letterSpacing tracking to apply; {@code null} means
+ * {@link DocumentLetterSpacing#NONE}
+ * @return updated text style
+ * @since 2.4.0
+ */
+ public DocumentTextStyle withLetterSpacing(DocumentLetterSpacing letterSpacing) {
+ return new DocumentTextStyle(fontName, size, decoration, color, letterSpacing);
}
/**
@@ -75,6 +112,7 @@ public static final class Builder {
private double size = 14;
private DocumentTextDecoration decoration = DocumentTextDecoration.DEFAULT;
private DocumentColor color = DocumentColor.BLACK;
+ private DocumentLetterSpacing letterSpacing = DocumentLetterSpacing.NONE;
private Builder() {
}
@@ -123,13 +161,33 @@ public Builder color(DocumentColor color) {
return this;
}
+ /**
+ * Sets the typographic tracking — extra advance after every
+ * rendered code point.
+ *
+ * Real tracking, not inserted spaces: the text handed to the
+ * backend stays the author's string, so search, copy/paste and text
+ * extraction still read it as written.
+ *
+ * @param letterSpacing tracking to apply, e.g.
+ * {@code DocumentLetterSpacing.ofFontSize(0.12)};
+ * {@code null} means
+ * {@link DocumentLetterSpacing#NONE}
+ * @return this builder
+ * @since 2.4.0
+ */
+ public Builder letterSpacing(DocumentLetterSpacing letterSpacing) {
+ this.letterSpacing = Objects.requireNonNullElse(letterSpacing, DocumentLetterSpacing.NONE);
+ return this;
+ }
+
/**
* Builds an immutable style value.
*
* @return text style
*/
public DocumentTextStyle build() {
- return new DocumentTextStyle(fontName, size, decoration, color);
+ return new DocumentTextStyle(fontName, size, decoration, color, letterSpacing);
}
}
}
diff --git a/core/src/main/java/com/demcha/compose/engine/components/content/text/TextStyle.java b/core/src/main/java/com/demcha/compose/engine/components/content/text/TextStyle.java
index b422f95d0..1b673a22e 100644
--- a/core/src/main/java/com/demcha/compose/engine/components/content/text/TextStyle.java
+++ b/core/src/main/java/com/demcha/compose/engine/components/content/text/TextStyle.java
@@ -5,8 +5,40 @@
import java.awt.*;
+/**
+ * Engine-side text style.
+ *
+ * {@code letterSpacing} is the tracking already resolved to points:
+ * the public {@code DocumentLetterSpacing} keeps its unit (points or a share of
+ * the font size) and the single conversion seam resolves it, so nothing below
+ * this type has to remember to multiply by the font size.
+ *
+ * @param fontName font family name
+ * @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
+ */
@Builder
-public record TextStyle(FontName fontName, double size, TextDecoration decoration, Color color) {
+public record TextStyle(FontName fontName,
+ double size,
+ TextDecoration decoration,
+ Color color,
+ double letterSpacing) {
public static TextStyle DEFAULT_STYLE = new TextStyle(FontName.HELVETICA, 14, TextDecoration.DEFAULT, Color.BLACK);
+
+ /**
+ * Creates a style without tracking — the shape this record had before
+ * {@code letterSpacing} was added, kept so the existing engine call sites
+ * that build a style from four values stay as they are.
+ *
+ * @param fontName font family name
+ * @param size font size in points
+ * @param decoration text decoration
+ * @param color text color
+ */
+ public TextStyle(FontName fontName, double size, TextDecoration decoration, Color color) {
+ this(fontName, size, decoration, color, 0.0);
+ }
}
diff --git a/core/src/main/java/com/demcha/compose/engine/text/markdown/MarkDownParser.java b/core/src/main/java/com/demcha/compose/engine/text/markdown/MarkDownParser.java
index 7996d98a1..c207a5562 100644
--- a/core/src/main/java/com/demcha/compose/engine/text/markdown/MarkDownParser.java
+++ b/core/src/main/java/com/demcha/compose/engine/text/markdown/MarkDownParser.java
@@ -27,7 +27,7 @@ public List getBody(String markdown, TextStyle style) {
// 1) List items: add your own prefix (since '-' is not Text)
new VisitHandler<>(ListItem.class, node -> {
TextStyle prefixStyle = new TextStyle(style.fontName(), style.size(), TextDecoration.DEFAULT,
- style.color());
+ style.color(), style.letterSpacing());
// New line before each list item (optional; helps readability)
// resultList.add(new TextDataBody("\n", prefixStyle));
@@ -43,10 +43,12 @@ public List getBody(String markdown, TextStyle style) {
// 2) Preserve line breaks
new VisitHandler<>(SoftLineBreak.class,
br -> resultList.add(new TextDataBody(" ",
- new TextStyle(style.fontName(), style.size(), TextDecoration.DEFAULT, style.color())))),
+ new TextStyle(style.fontName(), style.size(), TextDecoration.DEFAULT, style.color(),
+ style.letterSpacing())))),
new VisitHandler<>(HardLineBreak.class,
br -> resultList.add(new TextDataBody(" ",
- new TextStyle(style.fontName(), style.size(), TextDecoration.DEFAULT, style.color())))),
+ new TextStyle(style.fontName(), style.size(), TextDecoration.DEFAULT, style.color(),
+ style.letterSpacing())))),
// 3) Headers
new VisitHandler<>(Heading.class, node -> {
@@ -59,7 +61,7 @@ public List getBody(String markdown, TextStyle style) {
};
double newSize = style.size() * scale;
TextStyle headerStyle = new TextStyle(style.fontName(), newSize, TextDecoration.BOLD,
- style.color());
+ style.color(), style.letterSpacing());
// Add newline before header for better separation
// resultList.add(new TextDataBody("\n",
@@ -102,7 +104,8 @@ public List getBody(String markdown, TextStyle style) {
// 4) Text nodes (your current logic)
new VisitHandler<>(Text.class, textNode -> {
TextDecoration decoration = determineStyle(textNode);
- TextStyle newTextStyle = new TextStyle(style.fontName(), style.size(), decoration, style.color());
+ TextStyle newTextStyle = new TextStyle(style.fontName(), style.size(), decoration, style.color(),
+ style.letterSpacing());
String rawText = textNode.getChars().toString();
splitKeepingWhitespace(rawText).stream()
diff --git a/core/src/test/java/com/demcha/compose/document/layout/LetterSpacingPropagationTest.java b/core/src/test/java/com/demcha/compose/document/layout/LetterSpacingPropagationTest.java
new file mode 100644
index 000000000..c75403a02
--- /dev/null
+++ b/core/src/test/java/com/demcha/compose/document/layout/LetterSpacingPropagationTest.java
@@ -0,0 +1,103 @@
+package com.demcha.compose.document.layout;
+
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentLetterSpacing;
+import com.demcha.compose.document.style.DocumentTextDecoration;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.engine.components.content.text.TextStyle;
+import com.demcha.compose.font.FontName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tracking crossing the one seam between the public style and the engine style.
+ *
+ * The public value keeps its unit; the engine is handed points. This is the
+ * only place that knows the unit and the font size at the same time, so it is
+ * the only place that can resolve one against the other — and the only
+ * place that has to be right about it.
+ */
+class LetterSpacingPropagationTest {
+
+ @Test
+ void aStyleWithoutTrackingReachesTheEngineAsZero() {
+ TextStyle engineStyle = DocumentNodeAdapters.toTextStyle(DocumentTextStyle.DEFAULT);
+
+ assertThat(engineStyle.letterSpacing()).isZero();
+ }
+
+ @Test
+ void aNullStyleFallsBackToTheEngineDefaultWhichHasNoTracking() {
+ assertThat(DocumentNodeAdapters.toTextStyle(null).letterSpacing()).isZero();
+ assertThat(TextStyle.DEFAULT_STYLE.letterSpacing()).isZero();
+ }
+
+ @Test
+ void pointsCrossTheSeamUnchanged() {
+ DocumentTextStyle style = DocumentTextStyle.builder()
+ .size(24)
+ .letterSpacing(DocumentLetterSpacing.points(1.2))
+ .build();
+
+ assertThat(DocumentNodeAdapters.toTextStyle(style).letterSpacing()).isEqualTo(1.2);
+ }
+
+ @Test
+ void aFontSizeShareIsResolvedAgainstTheStylesOwnSizeBeforeTheEngineSeesIt() {
+ DocumentTextStyle style = DocumentTextStyle.builder()
+ .size(24)
+ .letterSpacing(DocumentLetterSpacing.ofFontSize(0.12))
+ .build();
+
+ // 12% of 24pt. The engine never learns the share existed.
+ assertThat(DocumentNodeAdapters.toTextStyle(style).letterSpacing()).isEqualTo(2.88);
+ }
+
+ @Test
+ void theShareIsResolvedAgainstTheNormalizedSizeNotTheRequestedOne() {
+ // DocumentTextStyle folds a non-positive size onto 14pt; the tracking
+ // has to follow the size the text is actually set at.
+ DocumentTextStyle style = DocumentTextStyle.builder()
+ .size(0)
+ .letterSpacing(DocumentLetterSpacing.ofFontSize(0.5))
+ .build();
+
+ assertThat(style.size()).isEqualTo(14.0);
+ assertThat(DocumentNodeAdapters.toTextStyle(style).letterSpacing()).isEqualTo(7.0);
+ }
+
+ @Test
+ void negativeTrackingSurvivesTheCrossing() {
+ DocumentTextStyle style = DocumentTextStyle.builder()
+ .size(20)
+ .letterSpacing(DocumentLetterSpacing.ofFontSize(-0.05))
+ .build();
+
+ assertThat(DocumentNodeAdapters.toTextStyle(style).letterSpacing()).isEqualTo(-1.0);
+ }
+
+ @Test
+ void everyOtherComponentStillCrossesAsItDid() {
+ DocumentTextStyle style = new DocumentTextStyle(
+ FontName.TIMES_ROMAN, 18, DocumentTextDecoration.BOLD, DocumentColor.rgb(17, 34, 51),
+ DocumentLetterSpacing.points(0.75));
+
+ TextStyle engineStyle = DocumentNodeAdapters.toTextStyle(style);
+
+ assertThat(engineStyle.fontName()).isEqualTo(FontName.TIMES_ROMAN);
+ assertThat(engineStyle.size()).isEqualTo(18.0);
+ assertThat(engineStyle.color()).isEqualTo(DocumentColor.rgb(17, 34, 51).color());
+ assertThat(engineStyle.letterSpacing()).isEqualTo(0.75);
+ }
+
+ @Test
+ void theEngineStylesFourArgumentShapeStillMeansNoTracking() {
+ TextStyle style = new TextStyle(
+ FontName.HELVETICA, 12,
+ com.demcha.compose.engine.components.content.text.TextDecoration.DEFAULT,
+ java.awt.Color.BLACK);
+
+ assertThat(style.letterSpacing()).isZero();
+ }
+}
diff --git a/core/src/test/java/com/demcha/compose/document/style/DocumentLetterSpacingTest.java b/core/src/test/java/com/demcha/compose/document/style/DocumentLetterSpacingTest.java
new file mode 100644
index 000000000..b22cdc536
--- /dev/null
+++ b/core/src/test/java/com/demcha/compose/document/style/DocumentLetterSpacingTest.java
@@ -0,0 +1,99 @@
+package com.demcha.compose.document.style;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * The tracking value itself: what it accepts, what it refuses, and how it
+ * resolves against the size the text is actually set at.
+ */
+class DocumentLetterSpacingTest {
+
+ @Test
+ void noneResolvesToZeroAtEverySize() {
+ assertThat(DocumentLetterSpacing.NONE.isNone()).isTrue();
+ assertThat(DocumentLetterSpacing.NONE.resolve(9)).isZero();
+ assertThat(DocumentLetterSpacing.NONE.resolve(24)).isZero();
+ assertThat(DocumentLetterSpacing.NONE.resolve(0)).isZero();
+ }
+
+ @Test
+ void pointsResolveToThemselvesRegardlessOfFontSize() {
+ DocumentLetterSpacing spacing = DocumentLetterSpacing.points(1.2);
+
+ assertThat(spacing.type()).isEqualTo(DocumentLetterSpacing.Type.POINTS);
+ assertThat(spacing.resolve(9)).isEqualTo(1.2);
+ assertThat(spacing.resolve(24)).isEqualTo(1.2);
+ }
+
+ @Test
+ void aFontSizeShareScalesWithTheType() {
+ DocumentLetterSpacing spacing = DocumentLetterSpacing.ofFontSize(0.12);
+
+ assertThat(spacing.type()).isEqualTo(DocumentLetterSpacing.Type.FONT_SIZE);
+ assertThat(spacing.resolve(24)).isEqualTo(2.88);
+ assertThat(spacing.resolve(10)).isEqualTo(1.2);
+ }
+
+ @Test
+ void theTwoUnitsAreDistinguishableAtTheSameNumber() {
+ // The whole reason this is a value type and not a bare double: 1.2 as
+ // points and 1.2 as a share of the font size are wildly different, and
+ // a double could not say which was meant.
+ assertThat(DocumentLetterSpacing.points(1.2)).isNotEqualTo(DocumentLetterSpacing.ofFontSize(1.2));
+ // Points pass straight through, so this one is exact. The share is a
+ // product of two doubles (1.2 * 24 lands on 28.799999999999997), so it
+ // is asserted the way a float product has to be.
+ assertThat(DocumentLetterSpacing.points(1.2).resolve(24)).isEqualTo(1.2);
+ assertThat(DocumentLetterSpacing.ofFontSize(1.2).resolve(24)).isCloseTo(28.8, within(1e-9));
+ }
+
+ @Test
+ void negativeTrackingIsAllowedAndTightens() {
+ assertThat(DocumentLetterSpacing.points(-0.5).resolve(12)).isEqualTo(-0.5);
+ assertThat(DocumentLetterSpacing.ofFontSize(-0.05).resolve(20)).isEqualTo(-1.0);
+ }
+
+ @Test
+ void zeroInEitherUnitFoldsOntoTheNeutralValue() {
+ assertThat(DocumentLetterSpacing.points(0)).isSameAs(DocumentLetterSpacing.NONE);
+ assertThat(DocumentLetterSpacing.ofFontSize(0)).isSameAs(DocumentLetterSpacing.NONE);
+ assertThat(DocumentLetterSpacing.points(-0.0)).isSameAs(DocumentLetterSpacing.NONE);
+ }
+
+ @Test
+ void negativeZeroFoldsOntoPositiveZeroSoOneBehaviourHasOneValue() {
+ DocumentLetterSpacing minusZero = new DocumentLetterSpacing(DocumentLetterSpacing.Type.FONT_SIZE, -0.0);
+
+ assertThat(minusZero.value()).isEqualTo(0.0);
+ assertThat(minusZero.isNone()).isTrue();
+ assertThat(minusZero).isEqualTo(new DocumentLetterSpacing(DocumentLetterSpacing.Type.FONT_SIZE, 0.0));
+ }
+
+ @Test
+ void aNonFiniteAmountIsRefused() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> DocumentLetterSpacing.points(Double.NaN));
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> DocumentLetterSpacing.ofFontSize(Double.POSITIVE_INFINITY));
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> DocumentLetterSpacing.points(Double.NEGATIVE_INFINITY));
+ }
+
+ @Test
+ void aNullUnitIsRefused() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> new DocumentLetterSpacing(null, 1.0));
+ }
+
+ @Test
+ void aNonFiniteFontSizeResolvesToZeroRatherThanPoisoningEveryWidth() {
+ // A NaN advance would spread through wrapping, alignment and pagination
+ // and surface far from here, so it is stopped at the seam.
+ assertThat(DocumentLetterSpacing.ofFontSize(0.12).resolve(Double.NaN)).isZero();
+ assertThat(DocumentLetterSpacing.ofFontSize(0.12).resolve(Double.POSITIVE_INFINITY)).isZero();
+ }
+}
diff --git a/core/src/test/java/com/demcha/compose/document/style/DocumentTextStyleLetterSpacingTest.java b/core/src/test/java/com/demcha/compose/document/style/DocumentTextStyleLetterSpacingTest.java
new file mode 100644
index 000000000..e2f260477
--- /dev/null
+++ b/core/src/test/java/com/demcha/compose/document/style/DocumentTextStyleLetterSpacingTest.java
@@ -0,0 +1,101 @@
+package com.demcha.compose.document.style;
+
+import com.demcha.compose.font.FontName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * How {@link DocumentTextStyle} carries tracking: the default costs nothing,
+ * the pre-existing constructor still means "no tracking", and the copy methods
+ * do not quietly drop it.
+ */
+class DocumentTextStyleLetterSpacingTest {
+
+ @Test
+ void aStyleThatNeverAskedForTrackingHasNone() {
+ assertThat(DocumentTextStyle.DEFAULT.letterSpacing()).isEqualTo(DocumentLetterSpacing.NONE);
+ assertThat(DocumentTextStyle.builder().build().letterSpacing()).isEqualTo(DocumentLetterSpacing.NONE);
+ assertThat(DocumentTextStyle.DEFAULT.letterSpacing().resolve(DocumentTextStyle.DEFAULT.size())).isZero();
+ }
+
+ @Test
+ void theFourArgumentConstructorStillMeansNoTracking() {
+ DocumentTextStyle style =
+ new DocumentTextStyle(FontName.HELVETICA, 12, DocumentTextDecoration.BOLD, DocumentColor.BLACK);
+
+ assertThat(style.letterSpacing()).isEqualTo(DocumentLetterSpacing.NONE);
+ }
+
+ @Test
+ void aNullTrackingNormalizesToNone() {
+ DocumentTextStyle viaConstructor =
+ new DocumentTextStyle(FontName.HELVETICA, 12, DocumentTextDecoration.DEFAULT, DocumentColor.BLACK, null);
+ DocumentTextStyle viaBuilder = DocumentTextStyle.builder().letterSpacing(null).build();
+
+ assertThat(viaConstructor.letterSpacing()).isEqualTo(DocumentLetterSpacing.NONE);
+ assertThat(viaBuilder.letterSpacing()).isEqualTo(DocumentLetterSpacing.NONE);
+ }
+
+ @Test
+ void theBuilderCarriesTrackingThrough() {
+ DocumentTextStyle style = DocumentTextStyle.builder()
+ .size(24)
+ .letterSpacing(DocumentLetterSpacing.ofFontSize(0.12))
+ .build();
+
+ assertThat(style.letterSpacing()).isEqualTo(DocumentLetterSpacing.ofFontSize(0.12));
+ assertThat(style.letterSpacing().resolve(style.size())).isEqualTo(2.88);
+ }
+
+ @Test
+ void withSizeKeepsTheTrackingAndRescalesAFontSizeShare() {
+ DocumentTextStyle style = DocumentTextStyle.builder()
+ .size(10)
+ .letterSpacing(DocumentLetterSpacing.ofFontSize(0.1))
+ .build();
+
+ DocumentTextStyle bigger = style.withSize(30);
+
+ assertThat(bigger.letterSpacing()).isEqualTo(DocumentLetterSpacing.ofFontSize(0.1));
+ // The share is kept, so the resolved amount follows the new size.
+ assertThat(bigger.letterSpacing().resolve(bigger.size())).isEqualTo(3.0);
+ }
+
+ @Test
+ void withColorKeepsTheTracking() {
+ DocumentTextStyle style = DocumentTextStyle.builder()
+ .letterSpacing(DocumentLetterSpacing.points(1.5))
+ .build();
+
+ assertThat(style.withColor(DocumentColor.rgb(255, 0, 0)).letterSpacing())
+ .isEqualTo(DocumentLetterSpacing.points(1.5));
+ }
+
+ @Test
+ void withLetterSpacingReplacesOnlyTheTracking() {
+ DocumentTextStyle style = DocumentTextStyle.builder()
+ .fontName(FontName.TIMES_ROMAN)
+ .size(18)
+ .decoration(DocumentTextDecoration.ITALIC)
+ .color(DocumentColor.rgb(18, 52, 86))
+ .build();
+
+ DocumentTextStyle tracked = style.withLetterSpacing(DocumentLetterSpacing.points(2));
+
+ assertThat(tracked.letterSpacing()).isEqualTo(DocumentLetterSpacing.points(2));
+ assertThat(tracked.fontName()).isEqualTo(style.fontName());
+ assertThat(tracked.size()).isEqualTo(style.size());
+ assertThat(tracked.decoration()).isEqualTo(style.decoration());
+ assertThat(tracked.color()).isEqualTo(style.color());
+ }
+
+ @Test
+ void trackingTakesPartInEquality() {
+ DocumentTextStyle plain = DocumentTextStyle.builder().size(12).build();
+ DocumentTextStyle tracked = plain.withLetterSpacing(DocumentLetterSpacing.points(1));
+
+ assertThat(tracked).isNotEqualTo(plain);
+ assertThat(tracked.withLetterSpacing(DocumentLetterSpacing.NONE)).isEqualTo(plain);
+ }
+}
diff --git a/knowledge/api/authoring.json b/knowledge/api/authoring.json
index ebfdc6605..10a85a758 100644
--- a/knowledge/api/authoring.json
+++ b/knowledge/api/authoring.json
@@ -22,10 +22,10 @@
"graph-compose-testing:sources"
],
"counts": {
- "types": 235,
- "methods": 2093,
- "constants": 234,
- "generated": 1117
+ "types": 237,
+ "methods": 2107,
+ "constants": 237,
+ "generated": 1126
},
"packages": [
{
@@ -29241,6 +29241,136 @@
}
]
},
+ {
+ "name": "DocumentLetterSpacing",
+ "binaryName": "com.demcha.compose.document.style.DocumentLetterSpacing",
+ "kind": "record",
+ "modifiers": [
+ "final"
+ ],
+ "artifact": "graph-compose-core",
+ "members": [
+ {
+ "kind": "constant",
+ "name": "NONE",
+ "static": true,
+ "origin": "generated",
+ "type": "DocumentLetterSpacing"
+ },
+ {
+ "kind": "constructor",
+ "name": "DocumentLetterSpacing",
+ "static": false,
+ "origin": "generated",
+ "typeParameters": null,
+ "returns": null,
+ "params": [
+ {
+ "type": "DocumentLetterSpacing.Type",
+ "name": null
+ },
+ {
+ "type": "double",
+ "name": null
+ }
+ ]
+ },
+ {
+ "kind": "method",
+ "name": "points",
+ "static": true,
+ "origin": "source",
+ "typeParameters": null,
+ "returns": "DocumentLetterSpacing",
+ "params": [
+ {
+ "type": "double",
+ "name": "points"
+ }
+ ]
+ },
+ {
+ "kind": "method",
+ "name": "ofFontSize",
+ "static": true,
+ "origin": "source",
+ "typeParameters": null,
+ "returns": "DocumentLetterSpacing",
+ "params": [
+ {
+ "type": "double",
+ "name": "fraction"
+ }
+ ]
+ },
+ {
+ "kind": "method",
+ "name": "resolve",
+ "static": false,
+ "origin": "source",
+ "typeParameters": null,
+ "returns": "double",
+ "params": [
+ {
+ "type": "double",
+ "name": "fontSize"
+ }
+ ]
+ },
+ {
+ "kind": "method",
+ "name": "isNone",
+ "static": false,
+ "origin": "source",
+ "typeParameters": null,
+ "returns": "boolean",
+ "params": []
+ },
+ {
+ "kind": "method",
+ "name": "type",
+ "static": false,
+ "origin": "generated",
+ "typeParameters": null,
+ "returns": "DocumentLetterSpacing.Type",
+ "params": []
+ },
+ {
+ "kind": "method",
+ "name": "value",
+ "static": false,
+ "origin": "generated",
+ "typeParameters": null,
+ "returns": "double",
+ "params": []
+ }
+ ]
+ },
+ {
+ "name": "DocumentLetterSpacing.Type",
+ "binaryName": "com.demcha.compose.document.style.DocumentLetterSpacing$Type",
+ "kind": "enum",
+ "modifiers": [
+ "final"
+ ],
+ "artifact": "graph-compose-core",
+ "members": [
+ {
+ "kind": "constant",
+ "name": "POINTS",
+ "static": true,
+ "origin": "generated",
+ "type": "DocumentLetterSpacing.Type"
+ },
+ {
+ "kind": "constant",
+ "name": "FONT_SIZE",
+ "static": true,
+ "origin": "generated",
+ "type": "DocumentLetterSpacing.Type"
+ }
+ ]
+ },
{
"name": "DocumentLineCap",
"binaryName": "com.demcha.compose.document.style.DocumentLineCap",
@@ -30269,6 +30399,36 @@
{
"type": "DocumentColor",
"name": null
+ },
+ {
+ "type": "DocumentLetterSpacing",
+ "name": null
+ }
+ ]
+ },
+ {
+ "kind": "constructor",
+ "name": "DocumentTextStyle",
+ "static": false,
+ "origin": "source",
+ "typeParameters": null,
+ "returns": null,
+ "params": [
+ {
+ "type": "FontName",
+ "name": "fontName"
+ },
+ {
+ "type": "double",
+ "name": "size"
+ },
+ {
+ "type": "DocumentTextDecoration",
+ "name": "decoration"
+ },
+ {
+ "type": "DocumentColor",
+ "name": "color"
}
]
},
@@ -30309,6 +30469,20 @@
}
]
},
+ {
+ "kind": "method",
+ "name": "withLetterSpacing",
+ "static": false,
+ "origin": "source",
+ "typeParameters": null,
+ "returns": "DocumentTextStyle",
+ "params": [
+ {
+ "type": "DocumentLetterSpacing",
+ "name": "letterSpacing"
+ }
+ ]
+ },
{
"kind": "method",
"name": "fontName",
@@ -30344,6 +30518,15 @@
"typeParameters": null,
"returns": "DocumentColor",
"params": []
+ },
+ {
+ "kind": "method",
+ "name": "letterSpacing",
+ "static": false,
+ "origin": "generated",
+ "typeParameters": null,
+ "returns": "DocumentLetterSpacing",
+ "params": []
}
]
},
@@ -30412,6 +30595,20 @@
}
]
},
+ {
+ "kind": "method",
+ "name": "letterSpacing",
+ "static": false,
+ "origin": "source",
+ "typeParameters": null,
+ "returns": "DocumentTextStyle.Builder",
+ "params": [
+ {
+ "type": "DocumentLetterSpacing",
+ "name": "letterSpacing"
+ }
+ ]
+ },
{
"kind": "method",
"name": "build",
@@ -32841,6 +33038,32 @@
"artifact": "graph-compose-core",
"reachedVia": "referenced by admitted API",
"members": [
+ {
+ "kind": "constructor",
+ "name": "TextStyle",
+ "static": false,
+ "origin": "source",
+ "typeParameters": null,
+ "returns": null,
+ "params": [
+ {
+ "type": "FontName",
+ "name": "fontName"
+ },
+ {
+ "type": "double",
+ "name": "size"
+ },
+ {
+ "type": "TextDecoration",
+ "name": "decoration"
+ },
+ {
+ "type": "Color",
+ "name": "color"
+ }
+ ]
+ },
{
"kind": "constructor",
"name": "TextStyle",
@@ -32864,6 +33087,10 @@
{
"type": "Color",
"name": null
+ },
+ {
+ "type": "double",
+ "name": null
}
]
},
@@ -32911,6 +33138,15 @@
"typeParameters": null,
"returns": "Color",
"params": []
+ },
+ {
+ "kind": "method",
+ "name": "letterSpacing",
+ "static": false,
+ "origin": "generated",
+ "typeParameters": null,
+ "returns": "double",
+ "params": []
}
]
},
@@ -32978,6 +33214,20 @@
}
]
},
+ {
+ "kind": "method",
+ "name": "letterSpacing",
+ "static": false,
+ "origin": "generated",
+ "typeParameters": null,
+ "returns": "TextStyle.TextStyleBuilder",
+ "params": [
+ {
+ "type": "double",
+ "name": null
+ }
+ ]
+ },
{
"kind": "method",
"name": "build",
diff --git a/knowledge/api/authoring.md b/knowledge/api/authoring.md
index 4039d7bdb..e531a52b1 100644
--- a/knowledge/api/authoring.md
+++ b/knowledge/api/authoring.md
@@ -28,7 +28,7 @@ note: "Generated from the pinned artifact's class files. Authoritative closed se
**GraphCompose version:** 2.4.0-SNAPSHOT
-Types: 235 · methods: 2093 · constants: 234 · compiler-generated members: 1117
+Types: 237 · methods: 2107 · constants: 237 · compiler-generated members: 1126
## com.demcha.compose
@@ -2185,6 +2185,19 @@ Types: 235 · methods: 2093 · constants: 234 · compiler-generated members: 111
### DocumentLeader (enum)
- constants: `NONE`, `DOTS`, `DASHES`
+### DocumentLetterSpacing (record)
+- `new DocumentLetterSpacing(DocumentLetterSpacing.Type, double)`
+- `DocumentLetterSpacing points(double points)`
+- `DocumentLetterSpacing ofFontSize(double fraction)`
+- `double resolve(double fontSize)`
+- `boolean isNone()`
+- `DocumentLetterSpacing.Type type()`
+- `double value()`
+- constants: `NONE`
+
+### DocumentLetterSpacing.Type (enum)
+- constants: `POINTS`, `FONT_SIZE`
+
### DocumentLineCap (enum)
- `int pdfCode()`
- constants: `BUTT`, `ROUND`, `SQUARE`
@@ -2273,14 +2286,17 @@ Types: 235 · methods: 2093 · constants: 234 · compiler-generated members: 111
- constants: `NONE`, `FIRST_LINE`, `FROM_SECOND_LINE`, `ALL_LINES`
### DocumentTextStyle (record)
-- `new DocumentTextStyle(FontName, double, DocumentTextDecoration, DocumentColor)`
+- `new DocumentTextStyle(FontName, double, DocumentTextDecoration, DocumentColor, DocumentLetterSpacing)`
+- `new DocumentTextStyle(FontName fontName, double size, DocumentTextDecoration decoration, DocumentColor color)`
- `DocumentTextStyle.Builder builder()`
- `DocumentTextStyle withSize(double size)`
- `DocumentTextStyle withColor(DocumentColor color)`
+- `DocumentTextStyle withLetterSpacing(DocumentLetterSpacing letterSpacing)`
- `FontName fontName()`
- `double size()`
- `DocumentTextDecoration decoration()`
- `DocumentColor color()`
+- `DocumentLetterSpacing letterSpacing()`
- constants: `DEFAULT`
### DocumentTextStyle.Builder (class)
@@ -2288,6 +2304,7 @@ Types: 235 · methods: 2093 · constants: 234 · compiler-generated members: 111
- `DocumentTextStyle.Builder size(double size)`
- `DocumentTextStyle.Builder decoration(DocumentTextDecoration decoration)`
- `DocumentTextStyle.Builder color(DocumentColor color)`
+- `DocumentTextStyle.Builder letterSpacing(DocumentLetterSpacing letterSpacing)`
- `DocumentTextStyle build()`
### DocumentTransform (record)
@@ -2501,18 +2518,21 @@ Types: 235 · methods: 2093 · constants: 234 · compiler-generated members: 111
- constants: `DEFAULT`, `BOLD`, `ITALIC`, `BOLD_ITALIC`, `UNDERLINE`, `STRIKETHROUGH`
### TextStyle (record)
-- `new TextStyle(FontName, double, TextDecoration, Color)`
+- `new TextStyle(FontName fontName, double size, TextDecoration decoration, Color color)`
+- `new TextStyle(FontName, double, TextDecoration, Color, double)`
- `TextStyle.TextStyleBuilder builder()`
- `FontName fontName()`
- `double size()`
- `TextDecoration decoration()`
- `Color color()`
+- `double letterSpacing()`
### TextStyle.TextStyleBuilder (class)
- `TextStyle.TextStyleBuilder fontName(FontName)`
- `TextStyle.TextStyleBuilder size(double)`
- `TextStyle.TextStyleBuilder decoration(TextDecoration)`
- `TextStyle.TextStyleBuilder color(Color)`
+- `TextStyle.TextStyleBuilder letterSpacing(double)`
- `TextStyle build()`
## com.demcha.compose.engine.components.geometry
From 650b7aa18378017b86f72caed03792983b924054 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 11 Sep 2026 01:32:54 +0100
Subject: [PATCH 02/10] feat(pdf): move the pen for tracking, and leave the
text alone
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
CHANGELOG.md | 22 +-
.../document/style/DocumentLetterSpacing.java | 9 +-
.../style/DocumentLetterSpacingTest.java | 7 +-
.../PdfParagraphFragmentRenderHandler.java | 48 +++
.../PdfTableRowFragmentRenderHandler.java | 17 +-
.../compose/engine/render/pdf/PdfFont.java | 65 +++-
.../document/backend/fixed/pdf/DrawnPen.java | 113 ++++++
.../pdf/PdfCharacterSpacingContractTest.java | 161 +++++++++
.../PdfFontLetterSpacingMeasurementTest.java | 148 ++++++++
.../fixed/pdf/PdfLetterSpacingRenderTest.java | 328 ++++++++++++++++++
10 files changed, 904 insertions(+), 14 deletions(-)
create mode 100644 render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/DrawnPen.java
create mode 100644 render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfCharacterSpacingContractTest.java
create mode 100644 render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfFontLetterSpacingMeasurementTest.java
create mode 100644 render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfLetterSpacingRenderTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 39c951cbf..2006a5907 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,10 +16,24 @@ follow semantic versioning; release dates are ISO 8601.
The default is `DocumentLetterSpacing.NONE`, which resolves to zero at every font size, so
a document that never asks for tracking renders exactly as it did.
- This step adds the value and carries it to the engine; nothing measures or draws with it
- yet. When it does, it will be real tracking rather than spaces inserted between letters:
- the string handed to the backend stays the author's string, so a spaced-caps headline
- still reads as `JANE DOE` to search, copy/paste, text extraction and ATS parsers.
+ **PDF honours it natively.** The advance comes from the PDF `Tc` operator, not from spaces
+ pushed into the string, so a spaced-caps headline still reads as `JANE DOE` to search,
+ copy/paste, text extraction and ATS parsers — one glyph per character, the original text.
+ Tracked runs also state their own text via `ActualText`, because an extractor decides
+ where words are by how far apart glyphs sit and tracking is the act of moving them apart;
+ without that statement a widely tracked line comes back as `J A N E D O E` from a file
+ that is otherwise perfectly correct.
+
+ Measurement and drawing use one rule, measured off PDFBox rather than assumed: one spacing
+ unit per Unicode **code point** of the string actually drawn, the trailing unit included.
+ Wrapping, `CENTER`/`RIGHT` alignment, underline and strike rules, link rectangles and
+ table cells all consume that one measured width, so they follow without special cases.
+ Negative tracking tightens, and the measured width is not clamped — the pen really does
+ move backwards, and a measurement that refused to would simply stop matching the page.
+
+ **PPTX and DOCX do not carry tracking yet.** Both are next; until then a document that
+ asks for tracking and renders to those formats lays out to the tracked width but draws
+ untracked text. Untracked documents — every existing one — are unaffected.
- **A list can hang its wrapped lines under its own text instead of under its marker.**
`ListBuilder.hangingIndent(true)` gives an item a marker column and a content column, so
diff --git a/core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java b/core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java
index 2530259d7..ebd810d7b 100644
--- a/core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java
+++ b/core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java
@@ -99,10 +99,11 @@ public static DocumentLetterSpacing ofFontSize(double fraction) {
/**
* Resolves this tracking to points against a concrete font size.
*
- * A non-finite {@code fontSize} resolves to {@code 0} rather than
- * propagating {@code NaN}: every text width in the engine is measured
- * through this value, and a {@code NaN} advance would poison wrapping,
- * alignment and pagination far from where it originated.
+ * A non-finite {@code fontSize} makes the tracking contribution
+ * {@code 0} instead of {@code NaN}. That bounds this term only — it says
+ * nothing about the rest of the measurement, which still multiplies glyph
+ * widths by that same font size. A non-finite font size remains a bad font
+ * size, and it is not this type's job to make it finite.
*
* @param fontSize the font size the text is set at, in points
* @return the extra advance per code point, in points
diff --git a/core/src/test/java/com/demcha/compose/document/style/DocumentLetterSpacingTest.java b/core/src/test/java/com/demcha/compose/document/style/DocumentLetterSpacingTest.java
index b22cdc536..7fa07bccf 100644
--- a/core/src/test/java/com/demcha/compose/document/style/DocumentLetterSpacingTest.java
+++ b/core/src/test/java/com/demcha/compose/document/style/DocumentLetterSpacingTest.java
@@ -90,9 +90,10 @@ void aNullUnitIsRefused() {
}
@Test
- void aNonFiniteFontSizeResolvesToZeroRatherThanPoisoningEveryWidth() {
- // A NaN advance would spread through wrapping, alignment and pagination
- // and surface far from here, so it is stopped at the seam.
+ void aNonFiniteFontSizeMakesTheTrackingTermZeroNotNaN() {
+ // Bounds this term only. The rest of the measurement still multiplies
+ // glyph widths by the same bad font size, so this does not claim to
+ // make the resulting text width finite.
assertThat(DocumentLetterSpacing.ofFontSize(0.12).resolve(Double.NaN)).isZero();
assertThat(DocumentLetterSpacing.ofFontSize(0.12).resolve(Double.POSITIVE_INFINITY)).isZero();
}
diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java
index 9e4911d3a..d11f7f1af 100644
--- a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java
+++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java
@@ -160,6 +160,11 @@ private static boolean renderChip(PDPageContentStream stream,
text = BidiVisualOrder.visualize(sanitizedLogical, span.rightToLeft());
written = PdfActualText.writtenTextOf(span);
environment.markReorderedText();
+ } else if (span.textStyle().letterSpacing() != 0.0) {
+ // As in renderLine: tracked glyphs are far enough apart that an
+ // extractor invents word breaks between them, so the chip states
+ // its own text rather than leaving a reader to guess it from gaps.
+ written = PdfActualText.writtenTextOf(span);
}
if (text.isEmpty()) {
return false; // nothing to paint — no glyph-less fill or mark
@@ -181,6 +186,7 @@ private static boolean renderChip(PDPageContentStream stream,
textState.invalidate();
textState.applyFont(stream, font.fontType(span.textStyle().decoration()), (float) span.textStyle().size());
textState.applyColor(stream, span.textStyle().color());
+ textState.applyCharacterSpacing(stream, (float) span.textStyle().letterSpacing());
if (written != null) {
stream.beginMarkedContent(PdfActualText.tag(), PdfActualText.properties(written));
}
@@ -429,6 +435,19 @@ private void renderLine(PDPageContentStream stream,
// run stays ordinary glyphs a reader keeps whole.
written = PdfActualText.writtenTextOf(textSpan);
environment.markReorderedText();
+ } else if (textSpan.textStyle().letterSpacing() != 0.0) {
+ // Tracked glyphs are the other case where the marks and
+ // the meaning come apart, for a different reason. The
+ // file is correct — one glyph per character, the right
+ // ToUnicode — but an extractor decides where the words
+ // are by how far apart the glyphs sit, and tracking is
+ // precisely the act of moving them apart. Left alone,
+ // PDFBox reads a spaced headline back as "J A N E D O E",
+ // which is the exact defect this feature exists to end.
+ // ActualText states the run's own text, and a reader that
+ // honours it takes that instead of guessing from gaps.
+ // Not markReorderedText(): nothing was reordered.
+ written = PdfActualText.writtenTextOf(textSpan);
}
if (text.isEmpty()) {
cursorX += textSpan.width();
@@ -443,6 +462,7 @@ private void renderLine(PDPageContentStream stream,
font.fontType(textSpan.textStyle().decoration()),
(float) textSpan.textStyle().size());
textState.applyColor(stream, textSpan.textStyle().color());
+ textState.applyCharacterSpacing(stream, (float) textSpan.textStyle().letterSpacing());
if (written != null) {
stream.beginMarkedContent(PdfActualText.tag(),
PdfActualText.properties(written));
@@ -543,6 +563,10 @@ private static final class TextRenderState {
// own q..Q, so the alpha WE set is what survives — invalidate() must
// not reset it.
private float alpha = 1f;
+ // Tc. Zero is the page default, so a document with no tracking anywhere
+ // never emits the operator and its content stream is byte-identical to
+ // what it was before tracking existed.
+ private float characterSpacing = 0f;
TextRenderState(PdfRenderEnvironment environment) {
this.environment = environment;
@@ -556,6 +580,24 @@ void applyFont(PDPageContentStream stream, PDFont newFont, float newSize) throws
}
}
+ /**
+ * Sets the tracking for the run about to be drawn.
+ *
+ * Deduplicated like the font and the colour, and — more importantly —
+ * always applied before a run rather than only when the run
+ * wants tracking. {@code Tc} persists across {@code BT}/{@code ET} and
+ * over the whole {@code q..Q} block, so a span that says nothing about
+ * tracking keeps whatever the span before it set. Setting it back to
+ * zero for an untracked run is what stops a tracked headline from
+ * spreading the ordinary paragraph that follows it.
+ */
+ void applyCharacterSpacing(PDPageContentStream stream, float newSpacing) throws IOException {
+ if (newSpacing != characterSpacing) {
+ stream.setCharacterSpacing(newSpacing);
+ characterSpacing = newSpacing;
+ }
+ }
+
void applyColor(PDPageContentStream stream, Color newColor) throws IOException {
if (!newColor.equals(color)) {
float newAlpha = newColor.getAlpha() / 255f;
@@ -592,6 +634,12 @@ void invalidate() {
font = null;
size = Float.NaN;
color = null;
+ // characterSpacing is deliberately NOT reset. Every nested draw runs
+ // in its own balanced q..Q, which restores Tc along with the rest of
+ // the graphics state, so the tracked value is still what the stream
+ // holds. Resetting it would force a re-emit, and in a document with
+ // no tracking at all that means writing a "0 Tc" that was never
+ // there before — a byte change for no behaviour change.
}
}
diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfTableRowFragmentRenderHandler.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfTableRowFragmentRenderHandler.java
index 428ca02cb..e4f92b2bc 100644
--- a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfTableRowFragmentRenderHandler.java
+++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfTableRowFragmentRenderHandler.java
@@ -196,6 +196,16 @@ private void renderCellText(PDPageContentStream stream,
PdfAlphaSupport.applyFillAlpha(environment, stream, cell.style().textStyle().color());
stream.setFont(font.fontType(cell.style().textStyle().decoration()), (float) cell.style().textStyle().size());
stream.setNonStrokingColor(cell.style().textStyle().color());
+ double letterSpacing = cell.style().textStyle().letterSpacing();
+ if (letterSpacing != 0.0) {
+ // One style for the whole cell, so Tc is set once here. Emitted
+ // only when there is tracking to apply: this q..Q block starts at
+ // the page default of zero, so writing "0 Tc" would add a byte to
+ // every table ever rendered and change nothing about any of them.
+ // The enclosing restoreGraphicsState puts Tc back, so a tracked
+ // cell cannot spread the next one.
+ stream.setCharacterSpacing((float) letterSpacing);
+ }
List decorations = null;
for (ResolvedTextLine line : lines) {
if (line.text().isEmpty()) {
@@ -281,8 +291,13 @@ private List resolveTextLines(PdfFont font,
};
double lineBoxY = blockY + lineHeight * (safeLines.size() - lineIndex - 1);
double baselineY = lineBoxY + metrics.baselineOffsetFromBottom();
+ // Tracked glyphs need the same statement of intent a reordered line
+ // needs, for a different reason: an extractor puts word breaks where
+ // it sees wide gaps, and tracking is the act of widening them. The
+ // cell states its own text so a reader takes that instead.
+ boolean states = reordered || cell.style().textStyle().letterSpacing() != 0.0;
resolved.add(new ResolvedTextLine(drawn,
- reordered ? PdfActualText.writtenTextOf(logical) : null,
+ states ? PdfActualText.writtenTextOf(logical) : null,
lineX, baselineY));
}
diff --git a/render-pdf/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java b/render-pdf/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java
index 9b50dfdc1..5c1054581 100644
--- a/render-pdf/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java
+++ b/render-pdf/src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java
@@ -106,6 +106,12 @@ public double getTextHeight(TextStyle style) {
* drifting when input contains characters outside the font's coverage
* (arrows, dots, emoji, custom unicode).
*
+ * The style's tracking is included, on the same string, by the rule the
+ * PDF {@code Tc} operator was measured to follow — see
+ * {@link #trackingAdvance(TextStyle, String)}. Width and pen advance are the
+ * same number or every span after the first on a line is drawn somewhere
+ * other than where the layout thinks it is.
+ *
* @param style style selecting the concrete font variant
* @param text raw text from the document model
* @return rendered width in points
@@ -124,13 +130,54 @@ public double getTextWidth(TextStyle style, String text) {
String measured = whitespaceOnly ? text : sanitizeForRender(style, text);
double width = fontType(style.decoration()).getStringWidth(measured) / 1000d * size;
- return width;
+ return width + trackingAdvance(style, measured);
} catch (Exception e) {
log.error("Error while getting text width {}", e.getMessage(), e);
return 0;
}
}
+ /**
+ * The advance a style's tracking adds to {@code measured}, in points.
+ *
+ * One unit per code point, trailing unit included —
+ * measured against PDFBox 3.0.8 rather than read off the specification or
+ * borrowed from CSS. Drawing a string at {@code Tc = 5} and reading where
+ * the pen actually landed: {@code "JANE"} advances 20pt further (4 code
+ * points), {@code "JANE DOE"} 40pt (8 — the space is a code point like any
+ * other), a single {@code "J"} 5pt, and the empty string not at all. The
+ * trailing unit is real: the pen sits one full unit past the last glyph's
+ * ink, which is why this counts N and not N-1.
+ * {@code PdfCharacterSpacingContractTest} re-measures this and fails if
+ * PDFBox ever changes it.
+ *
+ * Counted in code points of the string that is actually drawn,
+ * never in {@code char}s: {@code Tc} is applied once per glyph, and a
+ * supplementary code point is one glyph out of two {@code char}s. (With the
+ * bundled faces the distinction is currently unobservable — none of them can
+ * encode a supplementary code point, so {@code sanitizeForRender} folds one
+ * to {@code '?'} before it ever reaches here. Counting code points is what
+ * stays correct on the day a face that can encode one is added.)
+ *
+ * The result is not clamped. Negative tracking is allowed, and the pen in
+ * a reader genuinely moves backwards by it; clamping the measurement while
+ * being unable to clamp the reader is how the two stop agreeing.
+ *
+ * @param style the style whose tracking to apply, already resolved to points
+ * @param measured the exact string handed to {@code showText}
+ * @return the extra advance in points, {@code 0} when there is no tracking
+ */
+ private static double trackingAdvance(TextStyle style, String measured) {
+ double spacing = style.letterSpacing();
+ if (spacing == 0.0 || measured == null || measured.isEmpty()) {
+ // Short-circuited rather than added as a zero, so an untracked
+ // style returns the identical double it returned before tracking
+ // existed.
+ return 0.0;
+ }
+ return measured.codePointCount(0, measured.length()) * spacing;
+ }
+
/**
* Sanitises {@code text} for safe rendering with the font selected by
* {@code style}. Applies the standard control-character cleanup that
@@ -224,12 +271,26 @@ public String sanitizeForLogicalTextExport(TextStyle style, String text) {
fontType(style.decoration()), textSanitizer(text));
}
+ /**
+ * Measures {@code text} exactly as given, for a caller that has already
+ * sanitised it.
+ *
+ * Tracking is applied here too, by the same rule and on the same string,
+ * so the two entry points cannot disagree about the width of one run. There
+ * is no double application: this does not delegate to
+ * {@link #getTextWidth(TextStyle, String)}, and that one does not delegate
+ * here — each adds the tracking once, to the string it measured.
+ *
+ * @param style style selecting the concrete font variant
+ * @param text already-sanitised text
+ * @return rendered width in points
+ */
public double getTextWidthNoSanitize(TextStyle style, String text) {
double size = style.size();
try {
float width = fontType(style.decoration()).getStringWidth(text) / 1000 * (float) size;
log.debug("Getting text width: " + width);
- return width;
+ return width + trackingAdvance(style, text);
} catch (Exception e) {
e.printStackTrace();
log.error("Error while getting text width {}", e.getMessage(), e);
diff --git a/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/DrawnPen.java b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/DrawnPen.java
new file mode 100644
index 000000000..0abc05f8c
--- /dev/null
+++ b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/DrawnPen.java
@@ -0,0 +1,113 @@
+package com.demcha.compose.document.backend.fixed.pdf;
+
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.apache.pdfbox.pdmodel.PDDocument;
+
+import org.apache.pdfbox.pdmodel.font.PDFont;
+import org.apache.pdfbox.util.Matrix;
+import org.apache.pdfbox.util.Vector;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Where the pen actually put each glyph.
+ *
+ * {@code PDFTextStripper} answers a different question — what a reader
+ * makes of the page — and two things this codebase does deliberately get in the
+ * way of using it for geometry. It invents a word break wherever glyphs sit far
+ * apart, which is what tracking is; and where a run states its own
+ * {@code ActualText} it reports that string instead of the glyphs, so the
+ * positions stop lining up one to one.
+ *
+ * This reads the drawing operators instead. {@code showGlyph} is the callback
+ * PDFBox makes for each glyph it paints, with the text-rendering matrix at that
+ * moment, so the x it reports is the pen — {@code Tc} and all — and no
+ * extraction heuristic or marked-content section stands in between.
+ */
+final class DrawnPen {
+
+ private DrawnPen() {
+ }
+
+ /** One painted glyph: the pen position it was placed at, and how far it advanced. */
+ record Placement(double x, double y, double advance) {
+
+ @Override
+ public String toString() {
+ return String.format("@%.2f(+%.2f)", x, advance);
+ }
+ }
+
+ /**
+ * Every glyph the first page paints, in painting order.
+ *
+ * @param pdf a rendered document
+ * @return the pen positions
+ * @throws IOException if the document cannot be read
+ */
+ static List placements(byte[] pdf) throws IOException {
+ return placements(pdf, 0);
+ }
+
+ /**
+ * Every glyph a page paints, in painting order.
+ *
+ * @param pdf a rendered document
+ * @param index zero-based page index
+ * @return the pen positions
+ * @throws IOException if the document cannot be read
+ */
+ static List placements(byte[] pdf, int index) throws IOException {
+ List placements = new ArrayList<>();
+ try (PDDocument document = Loader.loadPDF(pdf)) {
+ // Driven through PDFTextStripper because a bare PDFStreamEngine has
+ // no operators registered and would process nothing. Only showGlyph
+ // is taken from it — that callback sits below the ActualText
+ // substitution the stripper's own text output goes through.
+ PDFTextStripper engine = new PDFTextStripper() {
+ @Override
+ protected void showGlyph(Matrix textRenderingMatrix, PDFont font, int code,
+ Vector displacement) throws IOException {
+ placements.add(new Placement(
+ textRenderingMatrix.getTranslateX(),
+ textRenderingMatrix.getTranslateY(),
+ displacement.getX() * textRenderingMatrix.getScalingFactorX()));
+ super.showGlyph(textRenderingMatrix, font, code, displacement);
+ }
+ };
+ engine.setStartPage(index + 1);
+ engine.setEndPage(index + 1);
+ engine.getText(document);
+ }
+ return placements;
+ }
+
+ /** The x of the first glyph painted on the page. */
+ static double firstX(byte[] pdf) throws IOException {
+ return placements(pdf).get(0).x();
+ }
+
+ /** The x of the last glyph painted on the page. */
+ static double lastX(byte[] pdf) throws IOException {
+ List placements = placements(pdf);
+ return placements.get(placements.size() - 1).x();
+ }
+
+ /**
+ * Pen distance from the first glyph to the last.
+ *
+ * Note this is N-1 gaps, not the run's full advance: the pen's final
+ * trailing step lands past the last glyph and no glyph records it.
+ */
+ static double firstToLastX(byte[] pdf) throws IOException {
+ return lastX(pdf) - firstX(pdf);
+ }
+
+ /** Distance from glyph {@code i-1}'s pen position to glyph {@code i}'s. */
+ static double step(List placements, int i) {
+ return placements.get(i).x() - placements.get(i - 1).x();
+ }
+}
diff --git a/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfCharacterSpacingContractTest.java b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfCharacterSpacingContractTest.java
new file mode 100644
index 000000000..062aae2bf
--- /dev/null
+++ b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfCharacterSpacingContractTest.java
@@ -0,0 +1,161 @@
+package com.demcha.compose.document.backend.fixed.pdf;
+
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+import org.apache.pdfbox.pdmodel.font.PDFont;
+import org.apache.pdfbox.pdmodel.font.PDType0Font;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.apache.pdfbox.text.TextPosition;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * What PDFBox's {@code Tc} operator actually does to the pen — measured, and
+ * pinned.
+ *
+ * The whole feature rests on one number: how many spacing units a string of
+ * N code points adds to the pen advance. The specification can be read either
+ * way by a careful person and CSS {@code letter-spacing} is a different
+ * product, so this measures it rather than citing it. {@code PdfFont} adds the
+ * same number to its measurement; if PDFBox ever changes the rule, this test
+ * says so instead of leaving every tracked line silently misplaced.
+ *
+ * The measurement trick is a marker glyph drawn immediately after the
+ * subject inside the same {@code BT}/{@code ET} with no repositioning: the
+ * marker's left edge is the pen position the subject left behind,
+ * trailing spacing included. Reading the subject's own ink extent instead would
+ * report N-1 by construction and prove nothing.
+ */
+class PdfCharacterSpacingContractTest {
+
+ private static final String FONT = "fonts/google/lato/Lato-Regular.ttf";
+ private static final float SIZE = 12f;
+ private static final float START_X = 50f;
+ /** Large enough that N and N-1 cannot be confused with rounding. */
+ private static final float SPACING = 5f;
+ private static final String MARKER = "|";
+
+ /**
+ * No bundled family encodes a supplementary code point, so one can never
+ * reach the backend — {@code sanitizeForRender} folds it to {@code '?'}
+ * first. That case is covered through the production path instead; putting
+ * it here would only prove that PDFBox throws on a glyph it does not have.
+ */
+ @ParameterizedTest(name = "[{index}] \"{0}\" -> {1} unit(s)")
+ @CsvSource({
+ "JANE, 4", // plain ASCII
+ "'JANE DOE',8", // a normal space is a code point like any other
+ "J, 1", // one code point
+ "JOSÉ, 4", // a covered non-ASCII code point is still one unit
+ })
+ void trackingAddsOneUnitPerCodePointIncludingTheTrailingOne(String subject, int expectedUnits)
+ throws Exception {
+ double untracked = advanceOf(subject, 0f);
+ double tracked = advanceOf(subject, SPACING);
+
+ assertThat(tracked - untracked)
+ .as("%s: %d code points at %s pt of tracking", subject,
+ subject.codePointCount(0, subject.length()), SPACING)
+ .isCloseTo(expectedUnits * SPACING, within(0.01));
+ }
+
+ @Test
+ void theRuleIsNotOffByOneAgainstTheCodePointCount() throws Exception {
+ // Stated separately so a change from N to N-1 reads as what it is
+ // rather than as four arithmetic failures.
+ String subject = "JANE";
+ int codePoints = subject.codePointCount(0, subject.length());
+
+ assertThat(codePoints).isEqualTo(4);
+ assertThat(advanceOf(subject, SPACING) - advanceOf(subject, 0f))
+ .as("N (trailing unit included), not N-1")
+ .isCloseTo(codePoints * SPACING, within(0.01))
+ .isNotCloseTo((codePoints - 1) * SPACING, within(0.01));
+ }
+
+ @Test
+ void theEmptyStringTakesNoTrackingAtAll() throws Exception {
+ assertThat(advanceOf("", SPACING)).isCloseTo(advanceOf("", 0f), within(0.001));
+ assertThat(advanceOf("", SPACING)).isCloseTo(0.0, within(0.001));
+ }
+
+ @Test
+ void trackingDoesNotTouchTheTextLayer() throws Exception {
+ assertThat(extractionOf("JANE DOE", SPACING)).isEqualTo("JANE DOE|");
+ assertThat(extractionOf("JANE DOE", 0f)).isEqualTo("JANE DOE|");
+ }
+
+ private static double advanceOf(String subject, float spacing) throws IOException {
+ List positions = positions(render(subject, spacing));
+ TextPosition marker = positions.get(positions.size() - 1);
+ return marker.getXDirAdj() - START_X;
+ }
+
+ private static String extractionOf(String subject, float spacing) throws IOException {
+ StringBuilder extracted = new StringBuilder();
+ for (TextPosition position : positions(render(subject, spacing))) {
+ extracted.append(position.getUnicode());
+ }
+ return extracted.toString();
+ }
+
+ /** Draws {@code subject} at {@code spacing}, then a marker with no repositioning. */
+ private static byte[] render(String subject, float spacing) throws IOException {
+ try (PDDocument document = new PDDocument()) {
+ PDPage page = new PDPage(PDRectangle.A4);
+ document.addPage(page);
+ PDFont font = loadFont(document);
+
+ try (PDPageContentStream stream = new PDPageContentStream(document, page)) {
+ stream.beginText();
+ stream.setFont(font, SIZE);
+ stream.newLineAtOffset(START_X, 700);
+ stream.setCharacterSpacing(spacing);
+ if (!subject.isEmpty()) {
+ stream.showText(subject);
+ }
+ stream.showText(MARKER);
+ stream.endText();
+ }
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ document.save(bytes);
+ return bytes.toByteArray();
+ }
+ }
+
+ private static PDFont loadFont(PDDocument document) throws IOException {
+ try (InputStream ttf = PdfCharacterSpacingContractTest.class
+ .getClassLoader().getResourceAsStream(FONT)) {
+ if (ttf == null) {
+ throw new IllegalStateException("font resource missing: " + FONT);
+ }
+ return PDType0Font.load(document, ttf, true);
+ }
+ }
+
+ private static List positions(byte[] pdf) throws IOException {
+ List all = new ArrayList<>();
+ try (PDDocument document = Loader.loadPDF(pdf)) {
+ new PDFTextStripper() {
+ @Override
+ protected void writeString(String text, List positions) {
+ all.addAll(positions);
+ }
+ }.getText(document);
+ }
+ return all;
+ }
+}
diff --git a/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfFontLetterSpacingMeasurementTest.java b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfFontLetterSpacingMeasurementTest.java
new file mode 100644
index 000000000..150c5b871
--- /dev/null
+++ b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfFontLetterSpacingMeasurementTest.java
@@ -0,0 +1,148 @@
+package com.demcha.compose.document.backend.fixed.pdf;
+
+import com.demcha.compose.engine.components.content.text.TextDecoration;
+import com.demcha.compose.engine.components.content.text.TextStyle;
+import com.demcha.compose.engine.render.pdf.PdfFont;
+import com.demcha.compose.font.FontLibrary;
+import com.demcha.compose.font.FontName;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.awt.Color;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * What tracking does to a measured width.
+ *
+ * Held to the rule {@link PdfCharacterSpacingContractTest} measured off
+ * PDFBox — one unit per code point, trailing unit included — because the
+ * measurement and the pen have to produce the same number. A test that invented
+ * its own expectation here would pass while the page drifted.
+ */
+class PdfFontLetterSpacingMeasurementTest {
+
+ private static final FontName FAMILY = FontName.LATO;
+ private static final double SIZE = 12.0;
+
+ private static PDDocument document;
+ private static PdfFont font;
+
+ @BeforeAll
+ static void loadFont() {
+ document = new PDDocument();
+ FontLibrary library = PdfFontLibraryFactory.library(document, List.of());
+ font = library.getFont(FAMILY, PdfFont.class).orElseThrow();
+ }
+
+ @AfterAll
+ static void closeDocument() throws Exception {
+ document.close();
+ }
+
+ private static TextStyle style(double letterSpacing) {
+ return new TextStyle(FAMILY, SIZE, TextDecoration.DEFAULT, Color.BLACK, letterSpacing);
+ }
+
+ private static double units(String text, double spacing) {
+ return font.getTextWidth(style(spacing), text) - font.getTextWidth(style(0), text);
+ }
+
+ @Test
+ void withoutTrackingTheWidthIsTheIdenticalDoubleItAlwaysWas() {
+ // Not "close to" — the same bits. The zero path short-circuits rather
+ // than adding a zero, so every existing document measures as before.
+ String text = "The quick brown fox jumps over the lazy dog";
+
+ TextStyle legacy = new TextStyle(FAMILY, SIZE, TextDecoration.DEFAULT, Color.BLACK);
+ TextStyle explicitZero = style(0.0);
+
+ assertThat(font.getTextWidth(explicitZero, text))
+ .isEqualTo(font.getTextWidth(legacy, text));
+ }
+
+ @Test
+ void positiveTrackingAddsOneUnitPerCodePoint() {
+ assertThat(units("JANE", 2.0)).isCloseTo(4 * 2.0, within(1e-9));
+ assertThat(units("J", 2.0)).isCloseTo(2.0, within(1e-9));
+ }
+
+ @Test
+ void aNormalSpaceCountsAsACodePointLikeAnyOther() {
+ // "JANE DOE" is eight code points, not seven: the space is tracked too,
+ // which is exactly what Tc does to the pen.
+ assertThat(units("JANE DOE", 2.0)).isCloseTo(8 * 2.0, within(1e-9));
+ }
+
+ @Test
+ void negativeTrackingTightensByTheSameRule() {
+ assertThat(units("JANE", -0.5)).isCloseTo(4 * -0.5, within(1e-9));
+ assertThat(font.getTextWidth(style(-0.5), "JANE"))
+ .isLessThan(font.getTextWidth(style(0), "JANE"));
+ }
+
+ @Test
+ void anEmptyStringIsZeroWideWhateverTheTracking() {
+ assertThat(font.getTextWidth(style(5.0), "")).isZero();
+ assertThat(font.getTextWidth(style(0), "")).isZero();
+ }
+
+ @Test
+ void aSupplementaryCodePointCostsOneUnitNotTwo() {
+ // No bundled face can encode one, so sanitizeForRender folds it to '?'
+ // before measurement — one char, one code point, one unit. Counting
+ // Java chars on the raw string would bill it twice.
+ String astral = new String(Character.toChars(0x1D400));
+ assertThat(astral.length()).isEqualTo(2);
+ assertThat(astral.codePointCount(0, astral.length())).isEqualTo(1);
+
+ assertThat(units(astral, 3.0)).isCloseTo(3.0, within(1e-9));
+ assertThat(units("A" + astral + "B", 3.0)).isCloseTo(3 * 3.0, within(1e-9));
+ }
+
+ @Test
+ void trackingIsBilledOnTheSanitizedStringNotTheAuthorsOne() {
+ // A control character collapses to a single space before drawing, so it
+ // is billed once — as the thing that is actually drawn, not as what was
+ // typed. Measuring the raw string would bill a glyph that never appears.
+ String withControl = "AB\u0000CD";
+ String sanitized = font.sanitizeForRender(style(0), withControl);
+
+ assertThat(sanitized).hasSize(5);
+ assertThat(units(withControl, 2.0))
+ .isCloseTo(sanitized.codePointCount(0, sanitized.length()) * 2.0, within(1e-9));
+ }
+
+ @Test
+ void theTwoEntryPointsAgreeAndNeitherBillsTrackingTwice() {
+ String alreadySanitized = "JANE";
+
+ double trackedSanitizing = font.getTextWidth(style(2.0), alreadySanitized);
+ double trackedNoSanitize = font.getTextWidthNoSanitize(style(2.0), alreadySanitized);
+
+ // The tracking each one adds is the same number, exactly: one rule, one
+ // string, applied once by each entry point and never twice. Four units,
+ // not eight.
+ assertThat(trackedNoSanitize - font.getTextWidthNoSanitize(style(0), alreadySanitized))
+ .isEqualTo(4 * 2.0);
+ assertThat(trackedSanitizing - font.getTextWidth(style(0), alreadySanitized))
+ .isEqualTo(4 * 2.0);
+
+ // The absolute widths agree only to float precision, and did so before
+ // tracking existed: getTextWidthNoSanitize computes in float while
+ // getTextWidth computes in double. That is pre-existing and untouched
+ // here — it is asserted so the gap is documented rather than discovered.
+ assertThat(trackedNoSanitize).isCloseTo(trackedSanitizing, within(1e-4));
+ }
+
+ @Test
+ void trackingScalesTheWholeRunNotJustItsEnds() {
+ // Guards the N vs N-1 boundary at the measurement layer: a one-code-point
+ // string gets a full unit, which N-1 would make zero.
+ assertThat(units("J", 4.0)).isCloseTo(4.0, within(1e-9)).isNotCloseTo(0.0, within(1e-6));
+ }
+}
diff --git a/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfLetterSpacingRenderTest.java b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfLetterSpacingRenderTest.java
new file mode 100644
index 000000000..40892b039
--- /dev/null
+++ b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfLetterSpacingRenderTest.java
@@ -0,0 +1,328 @@
+package com.demcha.compose.document.backend.fixed.pdf;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.PageFlowBuilder;
+import com.demcha.compose.document.node.DocumentLinkOptions;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentLetterSpacing;
+import com.demcha.compose.document.style.DocumentTextDecoration;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.font.FontName;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.contentstream.operator.Operator;
+import org.apache.pdfbox.cos.COSBase;
+import org.apache.pdfbox.cos.COSNumber;
+import org.apache.pdfbox.pdfparser.PDFStreamParser;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * Tracking on a real page: what it moves, and what it must not touch.
+ *
+ * The point of the feature is that the picture changes and the text does
+ * not. A headline set in spaced caps has to still be "JANE DOE" to
+ * anything that reads the file — search, copy/paste, a screen reader, an
+ * applicant-tracking parser — which is exactly what padding the string with
+ * spaces destroys.
+ *
+ * The geometry cases are not four more implementations being checked.
+ * Alignment, wrapping, decoration rules and link rectangles all consume one
+ * measured span width, so they are evidence that the width carries tracking,
+ * not evidence that four code paths each learned about it.
+ *
+ * Geometry is read through {@link DrawnPen} rather than the text stripper:
+ * tracked runs state their own {@code ActualText}, and a stripper honouring
+ * that reports the stated string instead of the glyphs it covers.
+ */
+class PdfLetterSpacingRenderTest {
+
+ private static final String NAME = "JANE DOE";
+ private static final FontName FAMILY = FontName.LATO;
+
+ private static DocumentTextStyle style(DocumentLetterSpacing spacing) {
+ return DocumentTextStyle.builder()
+ .fontName(FAMILY).size(20).letterSpacing(spacing).build();
+ }
+
+ private static DocumentTextStyle underlined(DocumentLetterSpacing spacing) {
+ return DocumentTextStyle.builder()
+ .fontName(FAMILY).size(20)
+ .decoration(DocumentTextDecoration.UNDERLINE)
+ .letterSpacing(spacing).build();
+ }
+
+ // --- the text layer -------------------------------------------------
+
+ @Test
+ void aTrackedHeadlineStillReadsAsTheWordThatWasWritten() throws Exception {
+ byte[] pdf = render(page -> page.addParagraph(p -> p
+ .text(NAME).textStyle(style(DocumentLetterSpacing.ofFontSize(0.3)))));
+
+ // Not "J A N E D O E". The string was never touched; only the pen was.
+ assertThat(extractedText(pdf)).isEqualTo(NAME);
+ }
+
+ @Test
+ void theTextLayerSurvivesTrackingWideEnoughToFoolAnExtractor() throws Exception {
+ // Without the run stating its own text this comes back "J A N E D O E":
+ // an extractor decides where words are by how far apart glyphs sit, and
+ // tracking is the act of moving them apart. The file was always right —
+ // eight glyphs, correct ToUnicode — and a reader still got it wrong.
+ for (double points : new double[] {2, 4, 8, 16}) {
+ byte[] pdf = render(page -> page.addParagraph(p -> p
+ .text(NAME).textStyle(style(DocumentLetterSpacing.points(points)))));
+
+ assertThat(extractedText(pdf))
+ .as("extraction at %s pt of tracking", points)
+ .isEqualTo(NAME);
+ }
+ }
+
+ @Test
+ void aTrackedRunDrawsTheGlyphsItWasGivenAndNoExtraSpaces() throws Exception {
+ byte[] tracked = render(page -> page.addParagraph(p -> p
+ .text(NAME).textStyle(style(DocumentLetterSpacing.points(4)))));
+
+ // One glyph per character of the original string — the spaced-out
+ // imitation this replaces would have painted seven extra space glyphs.
+ assertThat(DrawnPen.placements(tracked)).hasSize(NAME.length());
+ }
+
+ @Test
+ void trackingActuallySpreadsTheGlyphs() throws Exception {
+ byte[] plain = render(page -> page.addParagraph(p -> p.text(NAME)
+ .textStyle(style(DocumentLetterSpacing.NONE))));
+ byte[] tracked = render(page -> page.addParagraph(p -> p.text(NAME)
+ .textStyle(style(DocumentLetterSpacing.points(4)))));
+
+ // Seven steps between eight glyphs, each four points longer. The eighth
+ // unit is past the last glyph, so no glyph records it — which is exactly
+ // why measurement counts N and this counts N-1.
+ assertThat(DrawnPen.firstToLastX(tracked) - DrawnPen.firstToLastX(plain))
+ .isCloseTo(7 * 4.0, within(0.01));
+ }
+
+ @Test
+ void everyStepIsWidenedByExactlyTheTracking() throws Exception {
+ List plain = DrawnPen.placements(
+ render(page -> page.addParagraph(p -> p.text(NAME)
+ .textStyle(style(DocumentLetterSpacing.NONE)))));
+ List tracked = DrawnPen.placements(
+ render(page -> page.addParagraph(p -> p.text(NAME)
+ .textStyle(style(DocumentLetterSpacing.points(4))))));
+
+ assertThat(tracked).hasSameSizeAs(plain);
+ for (int i = 1; i < tracked.size(); i++) {
+ assertThat(DrawnPen.step(tracked, i) - DrawnPen.step(plain, i))
+ .as("step %d", i)
+ .isCloseTo(4.0, within(0.01));
+ }
+ }
+
+ // --- text-state management ------------------------------------------
+
+ @Test
+ void trackingDoesNotLeakIntoTheParagraphAfterIt() throws Exception {
+ byte[] mixed = render(page -> {
+ page.addParagraph(p -> p.text(NAME).textStyle(style(DocumentLetterSpacing.points(6))));
+ page.addParagraph(p -> p.text(NAME).textStyle(style(DocumentLetterSpacing.NONE)));
+ });
+ byte[] reference = render(page -> {
+ page.addParagraph(p -> p.text(NAME).textStyle(style(DocumentLetterSpacing.NONE)));
+ page.addParagraph(p -> p.text(NAME).textStyle(style(DocumentLetterSpacing.NONE)));
+ });
+
+ List mixedGlyphs = DrawnPen.placements(mixed);
+ List plainGlyphs = DrawnPen.placements(reference);
+ assertThat(mixedGlyphs).hasSize(16);
+ assertThat(plainGlyphs).hasSize(16);
+
+ // First paragraph's steps are six points longer...
+ assertThat(DrawnPen.step(mixedGlyphs, 1) - DrawnPen.step(plainGlyphs, 1))
+ .isCloseTo(6.0, within(0.01));
+ // ...and the untracked paragraph after it is drawn exactly as if the
+ // tracked one were not there. Tc is restored with the graphics state.
+ for (int i = 9; i < 16; i++) {
+ assertThat(DrawnPen.step(mixedGlyphs, i))
+ .as("second paragraph, step %d", i)
+ .isCloseTo(DrawnPen.step(plainGlyphs, i), within(0.01));
+ }
+ }
+
+ @Test
+ void trackedAndUntrackedRunsOnOneLineEachKeepTheirOwn() throws Exception {
+ // Two runs inside one paragraph are drawn inside a single BT/ET on the
+ // implicit pen, so this is where a leaked Tc would show: the second run
+ // would spread and the line would end somewhere else entirely.
+ List mixed = DrawnPen.placements(
+ render(page -> page.addParagraph(p -> p
+ .inlineText("AAAA", style(DocumentLetterSpacing.points(5)))
+ .inlineText("BBBB", style(DocumentLetterSpacing.NONE)))));
+ List neither = DrawnPen.placements(
+ render(page -> page.addParagraph(p -> p
+ .inlineText("AAAA", style(DocumentLetterSpacing.NONE))
+ .inlineText("BBBB", style(DocumentLetterSpacing.NONE)))));
+
+ assertThat(mixed).hasSize(8);
+ assertThat(neither).hasSize(8);
+
+ // Steps inside "AAAA" are five points longer...
+ for (int i = 1; i < 4; i++) {
+ assertThat(DrawnPen.step(mixed, i) - DrawnPen.step(neither, i))
+ .as("tracked run, step %d", i).isCloseTo(5.0, within(0.01));
+ }
+ // ...while steps inside "BBBB" are the untracked ones, however far the
+ // A's pushed them to the right.
+ for (int i = 5; i < 8; i++) {
+ assertThat(DrawnPen.step(mixed, i))
+ .as("untracked run, step %d", i)
+ .isCloseTo(DrawnPen.step(neither, i), within(0.01));
+ }
+ }
+
+ // --- consumers of the measured width --------------------------------
+
+ @Test
+ void rightAlignmentUsesTheTrackedWidth() throws Exception {
+ double plainLast = DrawnPen.lastX(render(page -> page.addParagraph(p -> p
+ .text(NAME).align(TextAlign.RIGHT).textStyle(style(DocumentLetterSpacing.NONE)))));
+ double trackedLast = DrawnPen.lastX(render(page -> page.addParagraph(p -> p
+ .text(NAME).align(TextAlign.RIGHT).textStyle(style(DocumentLetterSpacing.points(3))))));
+
+ // A right-aligned line ends at the margin whatever its width, which only
+ // holds if the aligner used the tracked width. The trailing unit is part
+ // of that width, so the last glyph is placed one unit short of where the
+ // untracked line's last glyph sat.
+ assertThat(trackedLast).isCloseTo(plainLast - 3.0, within(0.05));
+ }
+
+ @Test
+ void centreAlignmentUsesTheTrackedWidth() throws Exception {
+ double plainFirst = DrawnPen.firstX(render(page -> page.addParagraph(p -> p
+ .text(NAME).align(TextAlign.CENTER).textStyle(style(DocumentLetterSpacing.NONE)))));
+ double trackedFirst = DrawnPen.firstX(render(page -> page.addParagraph(p -> p
+ .text(NAME).align(TextAlign.CENTER).textStyle(style(DocumentLetterSpacing.points(3))))));
+
+ // Centring a wider line starts it further left, by half the extra width
+ // — half of all eight units, trailing one included.
+ assertThat(plainFirst - trackedFirst).isCloseTo(8 * 3.0 / 2.0, within(0.05));
+ }
+
+ @Test
+ void trackingMovesTheWrappingBoundary() throws Exception {
+ // A phrase that fits on one line untracked and cannot once tracked. If
+ // wrapping measured the untracked width, both would be one line.
+ String phrase = "SENIOR ENGINEER";
+
+ assertThat(lineCount(renderNarrow(phrase, DocumentLetterSpacing.NONE))).isEqualTo(1);
+ assertThat(lineCount(renderNarrow(phrase, DocumentLetterSpacing.points(6))))
+ .isGreaterThan(1);
+ }
+
+ @Test
+ void anUnderlineCoversTheWholeTrackedRun() throws Exception {
+ double plainRule = widestFilledRectangle(render(page -> page.addParagraph(p -> p
+ .text(NAME).textStyle(underlined(DocumentLetterSpacing.NONE)))));
+ double trackedRule = widestFilledRectangle(render(page -> page.addParagraph(p -> p
+ .text(NAME).textStyle(underlined(DocumentLetterSpacing.points(4))))));
+
+ // The decoration segment is built from the span's measured width, so a
+ // wider run draws a wider rule rather than one that stops short of it.
+ assertThat(trackedRule - plainRule).isCloseTo(8 * 4.0, within(0.5));
+ }
+
+ @Test
+ void aLinkRectangleCoversTheWholeTrackedRun() throws Exception {
+ double plainWidth = linkWidth(render(page -> page.addParagraph(p -> p
+ .text(NAME).link(new DocumentLinkOptions("https://example.com"))
+ .textStyle(style(DocumentLetterSpacing.NONE)))));
+ double trackedWidth = linkWidth(render(page -> page.addParagraph(p -> p
+ .text(NAME).link(new DocumentLinkOptions("https://example.com"))
+ .textStyle(style(DocumentLetterSpacing.points(4))))));
+
+ assertThat(trackedWidth - plainWidth).isCloseTo(8 * 4.0, within(0.5));
+ }
+
+ // --- helpers ---------------------------------------------------------
+
+ private static int lineCount(byte[] pdf) throws IOException {
+ return DrawnGlyphs.byLine(pdf).size();
+ }
+
+ private static String extractedText(byte[] pdf) throws IOException {
+ try (PDDocument document = Loader.loadPDF(pdf)) {
+ return new PDFTextStripper().getText(document).trim();
+ }
+ }
+
+ private static double linkWidth(byte[] pdf) throws IOException {
+ try (PDDocument document = Loader.loadPDF(pdf)) {
+ List annotations = document.getPage(0).getAnnotations();
+ assertThat(annotations).isNotEmpty();
+ return annotations.get(0).getRectangle().getWidth();
+ }
+ }
+
+ /**
+ * The widest rectangle the page fills — the underline rule, read off the
+ * content stream rather than guessed from the text.
+ */
+ private static double widestFilledRectangle(byte[] pdf) throws IOException {
+ double widest = 0.0;
+ try (PDDocument document = Loader.loadPDF(pdf)) {
+ PDFStreamParser parser = new PDFStreamParser(document.getPage(0));
+ List operands = new ArrayList<>();
+ Object token;
+ double pendingWidth = 0.0;
+ while ((token = parser.parseNextToken()) != null) {
+ if (token instanceof COSBase operand) {
+ operands.add(operand);
+ continue;
+ }
+ if (token instanceof Operator operator) {
+ if ("re".equals(operator.getName()) && operands.size() >= 4) {
+ pendingWidth = ((COSNumber) operands.get(operands.size() - 2)).floatValue();
+ } else if ("f".equals(operator.getName()) || "f*".equals(operator.getName())) {
+ widest = Math.max(widest, pendingWidth);
+ }
+ operands.clear();
+ }
+ }
+ }
+ return widest;
+ }
+
+ private static byte[] render(Consumer body) {
+ try (DocumentSession document = GraphCompose.document()
+ .pageSize(400, 200)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ document.pageFlow(body);
+ return document.toPdfBytes();
+ }
+ }
+
+ private static byte[] renderNarrow(String text, DocumentLetterSpacing spacing) {
+ try (DocumentSession document = GraphCompose.document()
+ .pageSize(180, 200)
+ .margin(DocumentInsets.of(10))
+ .create()) {
+ document.pageFlow(page -> page.addParagraph(p -> p.text(text).textStyle(
+ DocumentTextStyle.builder().fontName(FAMILY).size(14)
+ .letterSpacing(spacing).build())));
+ return document.toPdfBytes();
+ }
+ }
+}
From 6e8dbfc3d7ec7331cd3c2b1df829fcaff970e22a Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 11 Sep 2026 08:59:24 +0100
Subject: [PATCH 03/10] feat(ooxml): declare tracking to PowerPoint and Word in
their own units
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
CHANGELOG.md | 14 +-
.../api/LetterSpacingAcrossBackendsTest.java | 202 +++++++++++++++++
.../semantic/docx/DocxSemanticBackend.java | 33 +++
.../semantic/docx/DocxLetterSpacingTest.java | 185 +++++++++++++++
.../fixed/pptx/handlers/PptxTextFrames.java | 43 +++-
.../fixed/pptx/PptxLetterSpacingTest.java | 214 ++++++++++++++++++
6 files changed, 685 insertions(+), 6 deletions(-)
create mode 100644 qa/src/test/java/com/demcha/compose/document/api/LetterSpacingAcrossBackendsTest.java
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLetterSpacingTest.java
create mode 100644 render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxLetterSpacingTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2006a5907..fd5eb2945 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,9 +31,17 @@ follow semantic versioning; release dates are ISO 8601.
Negative tracking tightens, and the measured width is not clamped — the pen really does
move backwards, and a measurement that refused to would simply stop matching the page.
- **PPTX and DOCX do not carry tracking yet.** Both are next; until then a document that
- asks for tracking and renders to those formats lays out to the tracked width but draws
- untracked text. Untracked documents — every existing one — are unaffected.
+ **All three backends carry it natively.** PPTX writes DrawingML's `spc` in hundredths of
+ a point, DOCX writes Word's run-level `w:spacing` in twentieths, and neither pads the
+ text. The units and the advance rule were measured rather than read off the
+ specification: probe files were exported to PDF by PowerPoint and Word themselves and the
+ glyph positions read back. Both applications spend the spacing exactly the way PDF's `Tc`
+ does — one unit per code point, the trailing one included, an ordinary space counted like
+ any other character — so a line laid out against the engine's measurement arrives at the
+ width it was given in every format.
+
+ Asking for no tracking writes nothing at all: no `spc` attribute, no `w:spacing` element,
+ no `Tc` operator. Every existing document is byte-for-byte what it was.
- **A list can hang its wrapped lines under its own text instead of under its marker.**
`ListBuilder.hangingIndent(true)` gives an item a marker column and a content column, so
diff --git a/qa/src/test/java/com/demcha/compose/document/api/LetterSpacingAcrossBackendsTest.java b/qa/src/test/java/com/demcha/compose/document/api/LetterSpacingAcrossBackendsTest.java
new file mode 100644
index 000000000..19a29b390
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/api/LetterSpacingAcrossBackendsTest.java
@@ -0,0 +1,202 @@
+package com.demcha.compose.document.api;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.backend.fixed.pdf.PdfFixedLayoutBackend;
+import com.demcha.compose.document.backend.fixed.pptx.PptxFixedLayoutBackend;
+import com.demcha.compose.document.backend.semantic.docx.DocxSemanticBackend;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentLetterSpacing;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.font.FontName;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.apache.poi.xslf.usermodel.XMLSlideShow;
+import org.apache.poi.xslf.usermodel.XSLFShape;
+import org.apache.poi.xslf.usermodel.XSLFTextParagraph;
+import org.apache.poi.xslf.usermodel.XSLFTextRun;
+import org.apache.poi.xslf.usermodel.XSLFTextShape;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFRun;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * One authored style, all three backends.
+ *
+ * The point of {@code DocumentLetterSpacing} keeping its unit is that one
+ * declaration means the same thing everywhere. This takes a single
+ * {@code ofFontSize(0.12)} at 20pt — 2.4 points of tracking — and checks that
+ * each backend received that amount expressed in its own native unit,
+ * and that none of them touched the author's text to get it.
+ *
+ * The three units are genuinely different numbers for the same distance:
+ * 2.4pt is {@code spc="240"} in DrawingML's hundredths, {@code w:val="48"} in
+ * Word's twentieths, and a {@code Tc} of 2.4 in the PDF's points. A test that
+ * asserted one number across all three would be asserting a bug.
+ */
+class LetterSpacingAcrossBackendsTest {
+
+ private static final String NAME = "JANE DOE";
+ /** 12% of 20pt. */
+ private static final double EXPECTED_POINTS = 2.4;
+
+ private static final Pattern SPC = Pattern.compile("spc=\"(-?[0-9]+)\"");
+ private static final Pattern W_SPACING = Pattern.compile("spacing[^/>]*val=\"(-?[0-9]+)\"");
+
+ private static final DocumentTextStyle TRACKED = DocumentTextStyle.builder()
+ .fontName(FontName.LATO)
+ .size(20)
+ .letterSpacing(DocumentLetterSpacing.ofFontSize(0.12))
+ .build();
+
+ @Test
+ void theStyleResolvesToTheSameDistanceBeforeAnyBackendSeesIt() {
+ assertThat(TRACKED.letterSpacing().resolve(TRACKED.size())).isEqualTo(EXPECTED_POINTS);
+ }
+
+ @Test
+ void pptxReceivesItInHundredthsOfAPoint() throws Exception {
+ byte[] pptx = render(session -> session.render(new PptxFixedLayoutBackend()));
+
+ assertThat(values(pptxRunXml(pptx), SPC))
+ .isNotEmpty()
+ .allMatch(value -> value == (int) Math.round(EXPECTED_POINTS * 100));
+ assertThat(pptxText(pptx)).isEqualTo(NAME);
+ }
+
+ @Test
+ void docxReceivesItInTwentiethsOfAPoint() throws Exception {
+ byte[] docx = render(session -> session.export(new DocxSemanticBackend()));
+
+ assertThat(values(docxRunXml(docx), W_SPACING))
+ .isNotEmpty()
+ .allMatch(value -> value == (int) Math.round(EXPECTED_POINTS * 20));
+ assertThat(docxText(docx)).isEqualTo(NAME);
+ }
+
+ @Test
+ void pdfReceivesItAsPointsOfCharacterSpacing() throws Exception {
+ byte[] pdf = render(session -> session.render(new PdfFixedLayoutBackend()));
+
+ // Tc is written in points, so the operator carries the resolved value
+ // itself rather than a converted one.
+ assertThat(contentStream(pdf)).containsPattern("2\\.4\\d*\\s+Tc");
+ assertThat(pdfText(pdf)).isEqualTo(NAME);
+ }
+
+ @Test
+ void noBackendPadsTheTextToFakeIt() throws Exception {
+ // The whole reason the feature exists: the picture is spaced out and the
+ // text is not.
+ assertThat(pptxText(render(s -> s.render(new PptxFixedLayoutBackend())))).isEqualTo(NAME);
+ assertThat(docxText(render(s -> s.export(new DocxSemanticBackend())))).isEqualTo(NAME);
+ assertThat(pdfText(render(s -> s.render(new PdfFixedLayoutBackend())))).isEqualTo(NAME);
+ }
+
+ // --- helpers ---------------------------------------------------------
+
+ /** An export that is allowed to fail, which every backend's is. */
+ @FunctionalInterface
+ private interface Export {
+ byte[] from(DocumentSession session) throws Exception;
+ }
+
+ private static byte[] render(Export export) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(500, 200)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageFlow(page -> page.addParagraph(p -> p.text(NAME).textStyle(TRACKED)));
+ return export.from(session);
+ }
+ }
+
+ private static List values(List xml, Pattern pattern) {
+ List values = new ArrayList<>();
+ for (String fragment : xml) {
+ Matcher matcher = pattern.matcher(fragment);
+ if (matcher.find()) {
+ values.add(Integer.valueOf(matcher.group(1)));
+ }
+ }
+ return values;
+ }
+
+ private static List pptxRunXml(byte[] pptx) throws Exception {
+ List xml = new ArrayList<>();
+ for (XSLFTextRun run : pptxRuns(pptx)) {
+ xml.add(run.getXmlObject().xmlText());
+ }
+ return xml;
+ }
+
+ private static List pptxRuns(byte[] pptx) throws Exception {
+ List runs = new ArrayList<>();
+ try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) {
+ for (XSLFShape shape : show.getSlides().get(0).getShapes()) {
+ if (shape instanceof XSLFTextShape textShape) {
+ for (XSLFTextParagraph paragraph : textShape.getTextParagraphs()) {
+ runs.addAll(paragraph.getTextRuns());
+ }
+ }
+ }
+ }
+ return runs;
+ }
+
+ private static String pptxText(byte[] pptx) throws Exception {
+ StringBuilder text = new StringBuilder();
+ for (XSLFTextRun run : pptxRuns(pptx)) {
+ text.append(run.getRawText());
+ }
+ return text.toString();
+ }
+
+ private static List docxRunXml(byte[] docx) throws Exception {
+ List xml = new ArrayList<>();
+ for (XWPFRun run : docxRuns(docx)) {
+ xml.add(run.getCTR().xmlText());
+ }
+ return xml;
+ }
+
+ private static List docxRuns(byte[] docx) throws Exception {
+ List runs = new ArrayList<>();
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ for (XWPFParagraph paragraph : document.getParagraphs()) {
+ runs.addAll(paragraph.getRuns());
+ }
+ }
+ return runs;
+ }
+
+ private static String docxText(byte[] docx) throws Exception {
+ StringBuilder text = new StringBuilder();
+ for (XWPFRun run : docxRuns(docx)) {
+ text.append(run.text());
+ }
+ return text.toString();
+ }
+
+ private static String pdfText(byte[] pdf) throws Exception {
+ try (PDDocument document = Loader.loadPDF(pdf)) {
+ return new PDFTextStripper().getText(document).trim();
+ }
+ }
+
+ private static String contentStream(byte[] pdf) throws Exception {
+ try (PDDocument document = Loader.loadPDF(pdf)) {
+ return new String(document.getPage(0).getContents().readAllBytes(),
+ java.nio.charset.StandardCharsets.ISO_8859_1);
+ }
+ }
+}
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index 5078cd547..9c7e7048b 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -1058,6 +1058,38 @@ private static void applyRunDirection(XWPFRun run, boolean rightToLeft) {
}
}
+ /**
+ * Writes tracking as Word's own run-level {@code w:spacing}, never as spaces
+ * pushed into the text.
+ *
+ * The unit is twentieths of a point. Measured rather than assumed:
+ * exporting a probe document through Word itself and reading the glyph
+ * positions out of the PDF it wrote, {@code w:spacing w:val="100"} widened
+ * every step of {@code "JANE"} by 5.0pt — including the step onto a
+ * following untracked run, which is the trailing unit — and {@code "-30"}
+ * narrowed each by 1.5pt, with an ordinary space spaced like any other
+ * character. Word spends the value the same way the PDF {@code Tc} operator
+ * does.
+ *
+ * This is the one place the backend resolves the public unit itself: a
+ * semantic export never passes through the engine's text style, which is
+ * where a fixed-layout backend would have had it resolved already. Word owns
+ * the layout here, so the contract is that the asked-for tracking arrives as
+ * the right native value — not that any x coordinate matches the PDF.
+ * Twentieths of a point quantise to 0.05pt, which is the format's own
+ * granularity and not something to work around.
+ *
+ * No tracking writes no element, so a document that never asks for it
+ * carries exactly the run properties it carried before.
+ */
+ private static void applyLetterSpacing(XWPFRun run, DocumentTextStyle style) {
+ double points = style.letterSpacing().resolve(style.size());
+ if (points == 0.0) {
+ return;
+ }
+ run.setCharacterSpacing((int) Math.round(points * 20.0));
+ }
+
private void applyStyle(XWPFRun run, DocumentTextStyle style) {
if (style == null) {
return;
@@ -1075,6 +1107,7 @@ private void applyStyle(XWPFRun run, DocumentTextStyle style) {
// and everything else at Word's own default until this is written too.
run.setComplexScriptFontSize(style.size());
}
+ applyLetterSpacing(run, style);
if (style.color() != null) {
run.setColor(toHexColor(style.color().color()));
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLetterSpacingTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLetterSpacingTest.java
new file mode 100644
index 000000000..7fae512c9
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLetterSpacingTest.java
@@ -0,0 +1,185 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentLetterSpacing;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.font.FontName;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFRun;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tracking through the DOCX backend, as Word's own run-level {@code w:spacing}.
+ *
+ * The unit was measured by exporting a probe document through real Word and
+ * reading the glyph positions out of the PDF it produced: {@code w:spacing} is
+ * twentieths of a point, spent one unit per code point with the trailing one
+ * included — the same rule PowerPoint and the PDF {@code Tc} operator follow.
+ *
+ * What is not asserted is any x coordinate. Word owns semantic
+ * layout, and a DOCX export deliberately carries no measurement of its own; the
+ * contract is that the tracking an author asked for arrives as the right native
+ * value on the right run, and that the text is untouched.
+ */
+class DocxLetterSpacingTest {
+
+ private static final String NAME = "JANE DOE";
+ private static final FontName FAMILY = FontName.LATO;
+ /** {@code }, whatever prefix the serializer chose. */
+ private static final Pattern SPACING =
+ Pattern.compile("spacing[^/>]*val=\"(-?[0-9]+)\"");
+
+ private static DocumentTextStyle style(DocumentLetterSpacing spacing, double size) {
+ return DocumentTextStyle.builder()
+ .fontName(FAMILY).size(size).letterSpacing(spacing).build();
+ }
+
+ @Test
+ void pointsBecomeTwentiethsOfAPoint() throws Exception {
+ // 1.25pt -> 25 twips.
+ assertThat(spacingOf(render(NAME, style(DocumentLetterSpacing.points(1.25), 20))))
+ .containsExactly(25);
+ }
+
+ @Test
+ void aFontSizeShareArrivesResolvedAgainstTheStylesSize() throws Exception {
+ // 12% of 20pt = 2.4pt -> 48 twips. The public value keeps the unit; the
+ // backend resolves it here, because a semantic export never passes
+ // through the engine style that would have resolved it already.
+ assertThat(spacingOf(render(NAME, style(DocumentLetterSpacing.ofFontSize(0.12), 20))))
+ .containsExactly(48);
+ }
+
+ @Test
+ void theSameShareAtADifferentSizeResolvesDifferently() throws Exception {
+ // 12% of 24pt = 2.88pt -> 57.6 -> 58 twips. Twentieths quantise to
+ // 0.05pt; that is the format's granularity, not a rounding bug.
+ assertThat(spacingOf(render(NAME, style(DocumentLetterSpacing.ofFontSize(0.12), 24))))
+ .containsExactly(58);
+ }
+
+ @Test
+ void negativeTrackingIsWrittenAsANegativeValue() throws Exception {
+ assertThat(spacingOf(render(NAME, style(DocumentLetterSpacing.points(-0.75), 20))))
+ .containsExactly(-15);
+ }
+
+ @Test
+ void noTrackingWritesNoElementAtAll() throws Exception {
+ // A document that never asks for tracking carries exactly the run
+ // properties it carried before this existed.
+ byte[] docx = render(NAME, style(DocumentLetterSpacing.NONE, 20));
+
+ assertThat(spacingOf(docx)).isEmpty();
+ assertThat(xmlOf(docx)).doesNotContain(" page.addParagraph(p -> p
+ .inlineText("AAAA", style(DocumentLetterSpacing.points(5), 20))
+ .inlineText("BBBB", style(DocumentLetterSpacing.NONE, 20))));
+
+ List spacings = new ArrayList<>();
+ for (XWPFRun run : runsOf(docx)) {
+ spacings.add(spacingOf(run));
+ }
+ // w:spacing is a run property, so the untracked neighbour simply does
+ // not carry one. There is no state to leak.
+ assertThat(spacings).containsExactly(100, null);
+ assertThat(textOf(docx)).isEqualTo("AAAABBBB");
+ }
+
+ // Markdown is deliberately not covered here: MarkDownParser runs inside
+ // ParagraphWrapping, which is the fixed-layout path. A semantic export never
+ // reaches it, so the markdown-keeps-its-tracking case belongs where markdown
+ // is actually parsed — see PptxLetterSpacingTest.
+
+ // --- helpers ---------------------------------------------------------
+
+ private static byte[] render(String text, DocumentTextStyle style) throws Exception {
+ return renderDocument(page -> page.addParagraph(p -> p.text(text).textStyle(style)));
+ }
+
+ private static byte[] renderDocument(
+ Consumer body) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(500, 300)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageFlow(body);
+ return session.export(new DocxSemanticBackend());
+ }
+ }
+
+ private static List spacingOf(byte[] docx) throws Exception {
+ List values = new ArrayList<>();
+ for (XWPFRun run : runsOf(docx)) {
+ Integer spacing = spacingOf(run);
+ if (spacing != null) {
+ values.add(spacing);
+ }
+ }
+ return values;
+ }
+
+ /**
+ * The {@code w:spacing} value as the file spells it.
+ *
+ * Read out of the serialized XML rather than through the schema getter,
+ * which returns the union type {@code Object}: the question this asks is
+ * what the document says, and the element is the answer.
+ */
+ private static Integer spacingOf(XWPFRun run) {
+ Matcher matcher = SPACING.matcher(run.getCTR().xmlText());
+ return matcher.find() ? Integer.valueOf(matcher.group(1)) : null;
+ }
+
+ private static List runsOf(byte[] docx) throws Exception {
+ List runs = new ArrayList<>();
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ for (XWPFParagraph paragraph : document.getParagraphs()) {
+ runs.addAll(paragraph.getRuns());
+ }
+ }
+ return runs;
+ }
+
+ private static String textOf(byte[] docx) throws Exception {
+ StringBuilder text = new StringBuilder();
+ for (XWPFRun run : runsOf(docx)) {
+ text.append(run.text());
+ }
+ return text.toString();
+ }
+
+ /** The body part as the file spells it. */
+ private static String xmlOf(byte[] docx) throws Exception {
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ return document.getDocument().xmlText();
+ }
+ }
+}
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java
index 9640d69ef..9d8037452 100644
--- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java
@@ -18,6 +18,7 @@
import org.openxmlformats.schemas.presentationml.x2006.main.CTShape;
import java.awt.Color;
+import java.util.Optional;
import java.awt.geom.Rectangle2D;
import java.util.List;
@@ -157,6 +158,7 @@ static void applyStyle(XSLFTextRun run,
run.setUnderlined(PptxFontMapping.isUnderline(style));
run.setStrikethrough(PptxFontMapping.isStrikethrough(style));
disableKerning(run);
+ applyLetterSpacing(run, style.letterSpacing());
}
/** Stamps the shape's non-visual name so tests and users can identify frames. */
@@ -175,10 +177,45 @@ static void setShapeName(XSLFSimpleShape shape, String name) {
* kerning entirely.
*/
private static void disableKerning(XSLFTextRun run) {
+ characterProperties(run).ifPresent(properties -> properties.setKern(0));
+ }
+
+ /**
+ * Writes tracking as DrawingML's own {@code spc}, never as spaces pushed
+ * into the text.
+ *
+ * The unit is hundredths of a point, and PowerPoint spends it the same
+ * way the PDF {@code Tc} operator does. Measured rather than assumed:
+ * exporting a probe deck through PowerPoint itself and reading the glyph
+ * positions out of the PDF it wrote, {@code spc="500"} widened every step of
+ * {@code "JANE"} by 5.0pt — including the step onto a following
+ * untracked run, which is the trailing unit — and {@code spc="-150"}
+ * narrowed each by 1.5pt. A single {@code "J"} gained a full unit, and an
+ * ordinary space was spaced like any other character. One unit per code
+ * point, trailing included: the same N rule the engine measures with, so a
+ * line laid out against the PDF measurement arrives on a slide at the width
+ * it was given.
+ *
+ * Zero writes nothing at all. The attribute's absence is the default, so
+ * a deck with no tracking in it is byte-identical to one produced before
+ * this existed.
+ *
+ * @param run the run being styled
+ * @param letterSpacing tracking in points, already resolved by the engine
+ */
+ private static void applyLetterSpacing(XSLFTextRun run, double letterSpacing) {
+ if (letterSpacing == 0.0) {
+ return;
+ }
+ characterProperties(run).ifPresent(properties ->
+ properties.setSpc((int) Math.round(letterSpacing * 100.0)));
+ }
+
+ /** The run's character properties, created if the run has none yet. */
+ private static Optional characterProperties(XSLFTextRun run) {
if (run.getXmlObject() instanceof CTRegularTextRun ctRun) {
- CTTextCharacterProperties properties =
- ctRun.isSetRPr() ? ctRun.getRPr() : ctRun.addNewRPr();
- properties.setKern(0);
+ return Optional.of(ctRun.isSetRPr() ? ctRun.getRPr() : ctRun.addNewRPr());
}
+ return Optional.empty();
}
}
diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxLetterSpacingTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxLetterSpacingTest.java
new file mode 100644
index 000000000..ee8558ff1
--- /dev/null
+++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxLetterSpacingTest.java
@@ -0,0 +1,214 @@
+package com.demcha.compose.document.backend.fixed.pptx;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentLetterSpacing;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.table.DocumentTableColumn;
+import com.demcha.compose.font.FontName;
+import org.apache.poi.xslf.usermodel.XMLSlideShow;
+import org.apache.poi.xslf.usermodel.XSLFShape;
+import org.apache.poi.xslf.usermodel.XSLFTextParagraph;
+import org.apache.poi.xslf.usermodel.XSLFTextRun;
+import org.apache.poi.xslf.usermodel.XSLFTextShape;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.drawingml.x2006.main.CTRegularTextRun;
+
+import java.io.ByteArrayInputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tracking through the PPTX backend, as DrawingML's own {@code spc}.
+ *
+ * The unit and the advance rule were measured by exporting a probe deck
+ * through real PowerPoint and reading the glyph positions out of the PDF it
+ * produced: {@code spc} is hundredths of a point, and PowerPoint spends one unit
+ * per code point with the trailing one included — the same N rule the engine
+ * measures with, so a line laid out against that measurement arrives at the
+ * width it was given. What is asserted here is that the backend writes the
+ * value that measurement calls for, and leaves the text alone.
+ */
+class PptxLetterSpacingTest {
+
+ private static final String NAME = "JANE DOE";
+ private static final FontName FAMILY = FontName.LATO;
+ private static final Pattern SPC = Pattern.compile("spc=\"(-?[0-9]+)\"");
+
+ private static DocumentTextStyle style(DocumentLetterSpacing spacing, double size) {
+ return DocumentTextStyle.builder()
+ .fontName(FAMILY).size(size).letterSpacing(spacing).build();
+ }
+
+ @Test
+ void pointsBecomeHundredthsOfAPoint() throws Exception {
+ // 1.25pt -> 125. The unit is the format's, not the engine's.
+ assertThat(spcOf(render(NAME, style(DocumentLetterSpacing.points(1.25), 20))))
+ .containsExactly(125);
+ }
+
+ @Test
+ void aFontSizeShareArrivesResolvedAgainstTheStylesSize() throws Exception {
+ // 12% of 20pt = 2.4pt -> 240. The share is resolved before the backend
+ // ever sees it; PPTX is handed points.
+ assertThat(spcOf(render(NAME, style(DocumentLetterSpacing.ofFontSize(0.12), 20))))
+ .containsExactly(240);
+ }
+
+ @Test
+ void theSameShareAtADifferentSizeResolvesDifferently() throws Exception {
+ // 12% of 24pt = 2.88pt -> 288, so the value really does follow the size
+ // rather than being a constant the backend made up.
+ assertThat(spcOf(render(NAME, style(DocumentLetterSpacing.ofFontSize(0.12), 24))))
+ .containsExactly(288);
+ }
+
+ @Test
+ void negativeTrackingIsWrittenAsANegativeValue() throws Exception {
+ assertThat(spcOf(render(NAME, style(DocumentLetterSpacing.points(-0.75), 20))))
+ .containsExactly(-75);
+ }
+
+ @Test
+ void noTrackingWritesNoAttributeAtAll() throws Exception {
+ // Absence is the default, so a deck without tracking carries exactly the
+ // run properties it carried before this existed.
+ byte[] pptx = render(NAME, style(DocumentLetterSpacing.NONE, 20));
+
+ assertThat(spcOf(pptx)).isEmpty();
+ assertThat(runsOf(pptx)).isNotEmpty();
+ }
+
+ @Test
+ void aValueBetweenUnitsRoundsToTheNearestHundredth() throws Exception {
+ // 1/3 pt = 0.3333... -> 33 hundredths. Rounded, not truncated toward
+ // zero, and the residue is a hundredth of a point.
+ assertThat(spcOf(render(NAME, style(DocumentLetterSpacing.points(1.0 / 3.0), 20))))
+ .containsExactly(33);
+ }
+
+ @Test
+ void theTextIsTheAuthorsTextAndNothingElse() throws Exception {
+ byte[] tracked = render(NAME, style(DocumentLetterSpacing.points(4), 20));
+
+ // Not "J A N E D O E", and not eight runs of one letter either.
+ assertThat(textOf(tracked)).isEqualTo(NAME);
+ }
+
+ @Test
+ void aTrackedRunAndAnUntrackedOneDoNotAffectEachOther() throws Exception {
+ // Each PPTX run carries its own rPr, so there is no shared state to
+ // leak — but that is a claim about the format, and this holds it.
+ byte[] pptx = renderDocument(page -> page.addParagraph(p -> p
+ .inlineText("AAAA", style(DocumentLetterSpacing.points(5), 20))
+ .inlineText("BBBB", style(DocumentLetterSpacing.NONE, 20))));
+
+ List runs = runsOf(pptx);
+ List spacings = new ArrayList<>();
+ for (XSLFTextRun run : runs) {
+ spacings.add(spcOf(run));
+ }
+ assertThat(spacings).containsExactly(500, null);
+ assertThat(textOf(pptx)).isEqualTo("AAAABBBB");
+ }
+
+ @Test
+ void aMarkdownStyledRunKeepsTheTrackingOfTheParagraphItCameFrom() throws Exception {
+ // Markdown is parsed in ParagraphWrapping, on the fixed-layout path, and
+ // it builds each emphasised run by copying components off the paragraph's
+ // style. Copying four of five would drop the tracking on exactly the
+ // words an author bothered to emphasise.
+ byte[] pptx = renderDocument(page -> page.addParagraph(p -> p
+ .text("PLAIN **BOLD**")
+ .textStyle(style(DocumentLetterSpacing.points(3), 20))));
+
+ assertThat(runsOf(pptx)).hasSizeGreaterThan(1);
+ assertThat(spcOf(pptx))
+ .isNotEmpty()
+ .allMatch(value -> value == 300);
+ }
+
+ @Test
+ void aTableCellCarriesTrackingThroughTheSameSeam() throws Exception {
+ byte[] pptx = renderDocument(page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(300))
+ .defaultCellStyle(com.demcha.compose.document.table.DocumentTableStyle.builder()
+ .textStyle(style(DocumentLetterSpacing.points(2), 20)).build())
+ .row(NAME)));
+
+ assertThat(spcOf(pptx)).contains(200);
+ assertThat(textOf(pptx)).contains(NAME);
+ }
+
+ // --- helpers ---------------------------------------------------------
+
+ private static byte[] render(String text, DocumentTextStyle style) throws Exception {
+ return renderDocument(page -> page.addParagraph(p -> p.text(text).textStyle(style)));
+ }
+
+ private static byte[] renderDocument(
+ Consumer body) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(500, 200)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageFlow(body);
+ return session.render(new PptxFixedLayoutBackend());
+ }
+ }
+
+ /** Every {@code spc} actually written, in run order. */
+ private static List spcOf(byte[] pptx) throws Exception {
+ List values = new ArrayList<>();
+ for (XSLFTextRun run : runsOf(pptx)) {
+ Integer spc = spcOf(run);
+ if (spc != null) {
+ values.add(spc);
+ }
+ }
+ return values;
+ }
+
+ /**
+ * The {@code spc} attribute as the file spells it.
+ *
+ * Read out of the serialized XML rather than through the schema getter,
+ * which returns the union type {@code Object}: the question this asks is
+ * what the document says, and the attribute is the answer.
+ */
+ private static Integer spcOf(XSLFTextRun run) {
+ if (!(run.getXmlObject() instanceof CTRegularTextRun ctRun)) {
+ return null;
+ }
+ Matcher matcher = SPC.matcher(ctRun.xmlText());
+ return matcher.find() ? Integer.valueOf(matcher.group(1)) : null;
+ }
+
+ private static List runsOf(byte[] pptx) throws Exception {
+ List runs = new ArrayList<>();
+ try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) {
+ for (XSLFShape shape : show.getSlides().get(0).getShapes()) {
+ if (shape instanceof XSLFTextShape textShape) {
+ for (XSLFTextParagraph paragraph : textShape.getTextParagraphs()) {
+ runs.addAll(paragraph.getTextRuns());
+ }
+ }
+ }
+ }
+ return runs;
+ }
+
+ private static String textOf(byte[] pptx) throws Exception {
+ StringBuilder text = new StringBuilder();
+ for (XSLFTextRun run : runsOf(pptx)) {
+ text.append(run.getRawText());
+ }
+ return text.toString();
+ }
+}
From 8297c0a83c5a2210f8f4f3d99b83a9147ad5d434 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 11 Sep 2026 09:37:26 +0100
Subject: [PATCH 04/10] fix(layout): measure tracking on the grid a
fixed-layout file can state
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
CHANGELOG.md | 20 +-
.../document/layout/DocumentNodeAdapters.java | 68 +++-
.../document/style/DocumentLetterSpacing.java | 29 ++
.../api/TrackingFixedLayoutParityTest.java | 328 ++++++++++++++++++
.../semantic/docx/DocxSemanticBackend.java | 18 +
.../fixed/pptx/handlers/PptxTextFrames.java | 11 +-
6 files changed, 468 insertions(+), 6 deletions(-)
create mode 100644 qa/src/test/java/com/demcha/compose/document/api/TrackingFixedLayoutParityTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fd5eb2945..36d23985f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,10 +35,22 @@ follow semantic versioning; release dates are ISO 8601.
a point, DOCX writes Word's run-level `w:spacing` in twentieths, and neither pads the
text. The units and the advance rule were measured rather than read off the
specification: probe files were exported to PDF by PowerPoint and Word themselves and the
- glyph positions read back. Both applications spend the spacing exactly the way PDF's `Tc`
- does — one unit per code point, the trailing one included, an ordinary space counted like
- any other character — so a line laid out against the engine's measurement arrives at the
- width it was given in every format.
+ glyph positions read back. Both applications spend the spacing the way PDF's `Tc` does —
+ one unit per code point, the trailing one included, an ordinary space counted like any
+ other character.
+
+ **Tracking has a granularity, and it is 0.01pt in a fixed-layout document.** DrawingML can
+ only state hundredths of a point, so that is the finest distinction a PDF and a deck can
+ both make. The engine measures on that grid rather than on the raw value, which is what
+ keeps the width it reserves, wraps against and aligns to the width the file will actually
+ draw: ask for a third of a point and every fixed backend, and the measurement behind them,
+ uses 0.33. Word's own grid is coarser still at 0.05pt, and the DOCX export rounds the
+ authored value to it independently — a semantic document owes the fixed backends no
+ coordinate. The authored `DocumentLetterSpacing` is never rewritten; it keeps the value
+ and the unit it was given, and reports them back unchanged.
+
+ A tracking too large for a format to state is refused rather than silently wrapped —
+ beyond ±4000pt for fixed layout, which is where DrawingML's own bound sits.
Asking for no tracking writes nothing at all: no `spc` attribute, no `w:spacing` element,
no `Tc` operator. Every existing document is byte-for-byte what it was.
diff --git a/core/src/main/java/com/demcha/compose/document/layout/DocumentNodeAdapters.java b/core/src/main/java/com/demcha/compose/document/layout/DocumentNodeAdapters.java
index 349787004..2bb36301d 100644
--- a/core/src/main/java/com/demcha/compose/document/layout/DocumentNodeAdapters.java
+++ b/core/src/main/java/com/demcha/compose/document/layout/DocumentNodeAdapters.java
@@ -63,7 +63,73 @@ static TextStyle toTextStyle(DocumentTextStyle textStyle) {
// is the only place that knows the font size and the unit at
// the same time, so it is the only place that can resolve one
// against the other.
- textStyle.letterSpacing().resolve(textStyle.size()));
+ toFixedLayoutTracking(textStyle.letterSpacing().resolve(textStyle.size())));
+ }
+
+ /**
+ * The largest tracking a fixed-layout document can carry, in points.
+ *
+ * Set by DrawingML, the least capacious of the fixed backends: {@code spc}
+ * is {@code ST_TextPoint}, whose numeric member is bounded at
+ * ±400000 hundredths. Measured, not read — the schema validates
+ * {@code 400000} and rejects {@code 400001}.
+ */
+ static final double MAX_FIXED_LAYOUT_TRACKING_POINTS = 4000.0;
+
+ /**
+ * Tracking as fixed layout can actually express it: quantised to hundredths
+ * of a point.
+ *
+ * This exists because the engine's measurement and the file's declared
+ * spacing have to be the same number, and PPTX can only declare
+ * hundredths. Left unquantised, a {@code points(1.0/3.0)} style measured at
+ * {@code 0.33333…} per code point while the deck said {@code spc="33"} —
+ * {@code 0.33} — so the width the layout reserved, wrapped against, aligned
+ * to and sized its frames from was a width the deck would never draw. The
+ * residue is small per code point and accumulates with the string: a third
+ * of a point is {@code 0.0033} out per code point, {@code 0.13pt} over a
+ * forty-character line. Quantising here makes the engine measure the value
+ * every fixed backend will actually use, so PDF's {@code Tc} and PPTX's
+ * {@code spc} are two spellings of one number.
+ *
+ * It is done once, here, rather than in each backend: this 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.
+ * The public {@link DocumentTextStyle} is untouched — it still carries
+ * exactly what the author wrote, and {@code DocumentLetterSpacing} still
+ * resolves to exactly what the author asked for. The quantisation is a
+ * property of fixed layout, not of the value.
+ *
+ * The semantic DOCX export does not come through here. It resolves the
+ * public value itself and rounds to Word's twentieths, which is a coarser
+ * grid again — and correctly so, because Word owns that layout and
+ * owes the PDF no coordinate.
+ *
+ * Out of range is refused rather than clamped or wrapped. {@code spc} is
+ * written as an {@code int} of hundredths, and a large enough value silently
+ * changes sign on the cast — {@code 2.2e7} points becomes
+ * {@code -2094967296}, turning wide tracking into tight. A document asking
+ * for more than the format can hold is a mistake worth hearing about.
+ *
+ * @param points resolved tracking in points
+ * @return the same tracking on the grid fixed layout can express
+ * @throws IllegalArgumentException if the tracking exceeds
+ * {@link #MAX_FIXED_LAYOUT_TRACKING_POINTS}
+ */
+ private static double toFixedLayoutTracking(double points) {
+ if (points == 0.0) {
+ // Short-circuited so an untracked style keeps the identical double,
+ // and never depends on the rounding below behaving at zero.
+ return 0.0;
+ }
+ if (Math.abs(points) > MAX_FIXED_LAYOUT_TRACKING_POINTS) {
+ throw new IllegalArgumentException(
+ "Letter spacing resolves to " + points + "pt, beyond the "
+ + MAX_FIXED_LAYOUT_TRACKING_POINTS
+ + "pt a fixed-layout document can express (DrawingML spc is "
+ + "hundredths of a point, bounded at +/-400000).");
+ }
+ return Math.round(points * 100.0) / 100.0;
}
static TextIndentStrategy toIndentStrategy(DocumentTextIndent indent) {
diff --git a/core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java b/core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java
index ebd810d7b..0b7ee9ad8 100644
--- a/core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java
+++ b/core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java
@@ -26,6 +26,35 @@
* {@code 0} and leaves measurement and rendering exactly as they were.
* Instances are immutable and thread-safe.
*
+ * What survives into a file
+ *
+ * This value keeps exactly what it was given: {@link #resolve(double)}
+ * returns the amount asked for, to the last bit, and nothing rewrites it. The
+ * file formats are what quantise, and they do it differently:
+ *
+ *
+ * - PDF and PPTX — 0.01pt. DrawingML states spacing
+ * in hundredths of a point, so that is the finest distinction the two
+ * fixed-layout formats can both make. The engine measures on that grid,
+ * which is what keeps the width it reserves and wraps against equal to
+ * the width the file draws. Ask for a third of a point and both get
+ * {@code 0.33}.
+ * - DOCX — 0.05pt. Word states spacing in twentieths
+ * of a point, and the export rounds this value to that grid on its own.
+ * Word owns its layout, so it is not held to the hundredth the fixed
+ * backends settled on.
+ *
+ *
+ * So an arbitrary {@code double} does not survive all three formats exactly,
+ * and no amount of care here would make it. What is guaranteed is that within
+ * fixed layout there is one number: what was measured, what the PDF states and
+ * what the deck states are the same value.
+ *
+ * Tracking larger than a format can state is refused when the document is
+ * rendered, rather than wrapped into a negative — fixed layout tops out at
+ * ±4000pt, DrawingML's own bound. The limits belong to the formats; this
+ * value accepts any finite number.
+ *
* {@snippet :
* DocumentTextStyle headline = DocumentTextStyle.builder()
* .size(24)
diff --git a/qa/src/test/java/com/demcha/compose/document/api/TrackingFixedLayoutParityTest.java b/qa/src/test/java/com/demcha/compose/document/api/TrackingFixedLayoutParityTest.java
new file mode 100644
index 000000000..49dfddbbd
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/api/TrackingFixedLayoutParityTest.java
@@ -0,0 +1,328 @@
+package com.demcha.compose.document.api;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.backend.fixed.pdf.PdfFixedLayoutBackend;
+import com.demcha.compose.document.backend.fixed.pptx.PptxFixedLayoutBackend;
+import com.demcha.compose.document.backend.semantic.docx.DocxSemanticBackend;
+import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload;
+import com.demcha.compose.document.layout.payloads.ParagraphTextSpan;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentLetterSpacing;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.font.FontName;
+import org.apache.poi.xslf.usermodel.XMLSlideShow;
+import org.apache.poi.xslf.usermodel.XSLFShape;
+import org.apache.poi.xslf.usermodel.XSLFTextParagraph;
+import org.apache.poi.xslf.usermodel.XSLFTextRun;
+import org.apache.poi.xslf.usermodel.XSLFTextShape;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFRun;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.ByteArrayInputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * One tracking value, one fixed-layout number.
+ *
+ * A fixed-layout document reserves space, wraps, aligns and sizes its frames
+ * from the width the engine measured. PPTX can only declare spacing in
+ * hundredths of a point, so if the engine measured a finer value than that, the
+ * width it reserved is a width the deck will never draw. The engine therefore
+ * measures on the grid the file can express, and this holds the three numbers
+ * together: what the engine measured, what the PDF's {@code Tc} says, and what
+ * the deck's {@code spc} says.
+ *
+ * This is not a claim that a PDF and a deck rasterise identically.
+ * Measured by exporting both through PowerPoint, 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 one in this test is: it is arithmetic we perform, it is knowable,
+ * and it accumulates with the length of the string.
+ */
+class TrackingFixedLayoutParityTest {
+
+ /** Long enough that a per-code-point residue would be unmistakable. */
+ private static final String LONG = "ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMN";
+ private static final Pattern SPC = Pattern.compile("spc=\"(-?[0-9]+)\"");
+ private static final Pattern TC = Pattern.compile("(-?[0-9]*\\.?[0-9]+)\\s+Tc");
+ private static final Pattern W_SPACING = Pattern.compile("spacing[^/>]*val=\"(-?[0-9]+)\"");
+
+ private static DocumentTextStyle style(DocumentLetterSpacing spacing) {
+ return DocumentTextStyle.builder()
+ .fontName(FontName.LATO).size(20).letterSpacing(spacing).build();
+ }
+
+ // --- the invariant ---------------------------------------------------
+
+ @ParameterizedTest(name = "[{index}] {0} pt")
+ @ValueSource(doubles = {1.0 / 3.0, 0.005, -1.0 / 3.0, 0.125, 2.0 / 7.0})
+ void whatThePdfDeclaresIsWhatTheDeckDeclares(double points) throws Exception {
+ DocumentTextStyle style = style(DocumentLetterSpacing.points(points));
+
+ int spc = spcOf(render(style, s -> s.render(new PptxFixedLayoutBackend())));
+ double tc = tcOf(render(style, s -> s.render(new PdfFixedLayoutBackend())));
+
+ // Both files state the same distance. Unquantised, the PDF said
+ // 0.3333333333333333 where the deck said 0.33.
+ assertThat(spc / 100.0).as("PPTX spc=%d against PDF Tc=%s", spc, tc).isEqualTo(tc);
+ }
+
+ @ParameterizedTest(name = "[{index}] {0} pt")
+ @ValueSource(doubles = {1.0 / 3.0, 0.005, -1.0 / 3.0, 0.125, 2.0 / 7.0})
+ void whatTheEngineMeasuredIsWhatThoseFilesDeclare(double points) throws Exception {
+ DocumentTextStyle style = style(DocumentLetterSpacing.points(points));
+
+ int spc = spcOf(render(style, s -> s.render(new PptxFixedLayoutBackend())));
+
+ // Read back off the LayoutGraph's measured widths, so this is the number
+ // wrapping and alignment actually used. Derived as a difference of two
+ // summed line widths, so it carries ordinary float residue — the gap
+ // being ruled out is four orders of magnitude larger.
+ assertThat(engineTracking(style))
+ .as("engine measurement against declared spc=%d", spc)
+ .isCloseTo(spc / 100.0, within(1e-9));
+ }
+
+ @ParameterizedTest(name = "[{index}] {0} pt")
+ @ValueSource(doubles = {1.0 / 3.0, 0.005, -1.0 / 3.0, 2.0 / 7.0})
+ void aLongStringCannotAccumulateDriftBetweenPdfAndPptx(double points) throws Exception {
+ DocumentTextStyle style = style(DocumentLetterSpacing.points(points));
+
+ double engine = engineTracking(style);
+ int spc = spcOf(render(style, s -> s.render(new PptxFixedLayoutBackend())));
+ int codePoints = LONG.codePointCount(0, LONG.length());
+
+ // Per code point they agree, so over any length they still do.
+ // Unquantised, a third of a point was 0.0033 out per code point and
+ // 0.133pt out over this line — which is what this tolerance excludes.
+ assertThat((spc / 100.0) * codePoints)
+ .as("accumulated over %d code points", codePoints)
+ .isCloseTo(engine * codePoints, within(1e-6));
+ }
+
+ @Test
+ void aTrackingBelowHalfTheGridQuantisesAwayToNone() throws Exception {
+ // Half-up rounding, which is Java's Math.round and what the PPTX
+ // conversion already used: +0.005 reaches the first hundredth while
+ // -0.005 does not. Stated here because it is the one asymmetry the grid
+ // has, and it costs a hundredth of a point at the knife edge.
+ assertThat(engineTracking(style(DocumentLetterSpacing.points(0.005))))
+ .isCloseTo(0.01, within(1e-9));
+
+ DocumentTextStyle justUnder = style(DocumentLetterSpacing.points(-0.005));
+ assertThat(engineTracking(justUnder)).isCloseTo(0.0, within(1e-9));
+ // No tracking left to declare, so nothing is written.
+ assertThat(pptxRunXml(render(justUnder, s -> s.render(new PptxFixedLayoutBackend()))))
+ .noneMatch(xml -> SPC.matcher(xml).find());
+ }
+
+ @Test
+ void aValueAlreadyOnTheGridIsUntouched() throws Exception {
+ DocumentTextStyle style = style(DocumentLetterSpacing.points(0.25));
+
+ assertThat(engineTracking(style)).isCloseTo(0.25, within(1e-9));
+ assertThat(spcOf(render(style, s -> s.render(new PptxFixedLayoutBackend())))).isEqualTo(25);
+ }
+
+ @Test
+ void zeroStaysTheExactLegacyPath() throws Exception {
+ DocumentTextStyle style = style(DocumentLetterSpacing.NONE);
+
+ assertThat(engineTracking(style)).isCloseTo(0.0, within(1e-12));
+ // Still no attribute and no operator at all.
+ assertThat(pptxRunXml(render(style, s -> s.render(new PptxFixedLayoutBackend()))))
+ .noneMatch(xml -> SPC.matcher(xml).find());
+ assertThat(contentStream(render(style, s -> s.render(new PdfFixedLayoutBackend()))))
+ .doesNotContain(" Tc");
+ }
+
+ @Test
+ void theAuthoredValueItselfIsNeverRewritten() {
+ // Quantisation is a property of fixed layout, not of the value. What the
+ // author wrote is what the style still says.
+ DocumentLetterSpacing authored = DocumentLetterSpacing.points(1.0 / 3.0);
+ DocumentTextStyle style = style(authored);
+
+ assertThat(style.letterSpacing()).isSameAs(authored);
+ assertThat(style.letterSpacing().value()).isEqualTo(1.0 / 3.0);
+ assertThat(style.letterSpacing().resolve(20)).isEqualTo(1.0 / 3.0);
+ assertThat(engineTracking(style)).isCloseTo(0.33, within(1e-9));
+ }
+
+ // --- DOCX keeps its own, coarser grid --------------------------------
+
+ @Test
+ void docxRoundsToItsOwnTwentiethsFromTheAuthoredValue() throws Exception {
+ // Word's grid is 0.05pt, and a semantic export owes the fixed backends
+ // no coordinate — so it rounds the authored value itself rather than
+ // inheriting the hundredth the fixed path settled on.
+ DocumentTextStyle style = style(DocumentLetterSpacing.points(1.0 / 3.0));
+
+ // 1/3 pt -> 6.667 twentieths -> 7 twips = 0.35pt, which is neither the
+ // authored third nor the fixed path's 0.33.
+ assertThat(wSpacingOf(render(style, s -> s.export(new DocxSemanticBackend()))))
+ .isEqualTo(7);
+ assertThat(engineTracking(style)).isCloseTo(0.33, within(1e-9));
+ }
+
+ // --- range ------------------------------------------------------------
+
+ @Test
+ void aTrackingTooLargeForFixedLayoutIsRefusedRatherThanWrapped() {
+ // 2.2e7 points would be 2_200_000_000 hundredths, which lands at
+ // -2094967296 on the int cast: wide tracking silently becoming tight.
+ DocumentTextStyle style = style(DocumentLetterSpacing.points(2.2e7));
+
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> render(style, s -> s.render(new PdfFixedLayoutBackend())))
+ .withMessageContaining("fixed-layout");
+ }
+
+ @Test
+ void theFixedLayoutBoundIsTheOneTheSchemaActuallyEnforces() throws Exception {
+ // ST_TextPoint validates 400000 and rejects 400001, so 4000pt is in and
+ // anything past it is out.
+ DocumentTextStyle inRange = style(DocumentLetterSpacing.points(4000.0));
+ assertThat(spcOf(render(inRange, s -> s.render(new PptxFixedLayoutBackend()))))
+ .isEqualTo(400000);
+
+ DocumentTextStyle outOfRange = style(DocumentLetterSpacing.points(4000.01));
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> render(outOfRange, s -> s.render(new PptxFixedLayoutBackend())));
+ }
+
+ @Test
+ void aTrackingTooLargeForWordIsRefusedRatherThanWrapped() {
+ // 1e9 points would be 20_000_000_000 twentieths, landing at -1474836480.
+ DocumentTextStyle style = style(DocumentLetterSpacing.points(1e9));
+
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> render(style, s -> s.export(new DocxSemanticBackend())))
+ .withMessageContaining("Word run");
+ }
+
+ @Test
+ void theValueTypeItselfStillAcceptsAnyFiniteNumber() {
+ // The limits belong to the formats, not to the authored value: it is
+ // only refused where it cannot be written.
+ assertThat(DocumentLetterSpacing.points(1e9).resolve(20)).isEqualTo(1e9);
+ assertThat(DocumentLetterSpacing.ofFontSize(1e9).resolve(20)).isEqualTo(2e10);
+ }
+
+ // --- helpers ---------------------------------------------------------
+
+ /**
+ * The tracking the engine actually measured with, per code point.
+ *
+ * Read off the {@code LayoutGraph} — the measured span widths the whole
+ * fixed-layout pipeline reserves space from — rather than off any internal
+ * helper, so what is compared is the number that really drives wrapping,
+ * alignment and frame sizing. Derived as the difference the tracking made
+ * to the line, divided by the code points that were tracked.
+ */
+ private static double engineTracking(DocumentTextStyle style) {
+ double tracked = measuredLineWidth(style);
+ double plain = measuredLineWidth(style.withLetterSpacing(DocumentLetterSpacing.NONE));
+ return (tracked - plain) / LONG.codePointCount(0, LONG.length());
+ }
+
+ private static double measuredLineWidth(DocumentTextStyle style) {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(900, 200)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageFlow(page -> page.addParagraph(p -> p.text(LONG).textStyle(style)));
+ return session.layoutGraph().fragments().stream()
+ .map(fragment -> fragment.payload())
+ .filter(payload -> payload instanceof ParagraphFragmentPayload)
+ .map(payload -> (ParagraphFragmentPayload) payload)
+ .flatMap(payload -> payload.lines().stream())
+ .flatMap(line -> line.spans().stream())
+ .filter(span -> span instanceof ParagraphTextSpan)
+ .mapToDouble(span -> ((ParagraphTextSpan) span).width())
+ .sum();
+ }
+ }
+
+ @FunctionalInterface
+ private interface Export {
+ byte[] from(DocumentSession session) throws Exception;
+ }
+
+ private static byte[] render(DocumentTextStyle style, Export export) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(900, 200)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageFlow(page -> page.addParagraph(p -> p.text(LONG).textStyle(style)));
+ return export.from(session);
+ }
+ }
+
+ private static int spcOf(byte[] pptx) throws Exception {
+ for (String xml : pptxRunXml(pptx)) {
+ Matcher matcher = SPC.matcher(xml);
+ if (matcher.find()) {
+ return Integer.parseInt(matcher.group(1));
+ }
+ }
+ throw new AssertionError("no spc written");
+ }
+
+ private static int wSpacingOf(byte[] docx) throws Exception {
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ for (XWPFParagraph paragraph : document.getParagraphs()) {
+ for (XWPFRun run : paragraph.getRuns()) {
+ Matcher matcher = W_SPACING.matcher(run.getCTR().xmlText());
+ if (matcher.find()) {
+ return Integer.parseInt(matcher.group(1));
+ }
+ }
+ }
+ }
+ throw new AssertionError("no w:spacing written");
+ }
+
+ private static double tcOf(byte[] pdf) throws Exception {
+ Matcher matcher = TC.matcher(contentStream(pdf));
+ if (!matcher.find()) {
+ throw new AssertionError("no Tc written");
+ }
+ return Double.parseDouble(matcher.group(1));
+ }
+
+ private static List pptxRunXml(byte[] pptx) throws Exception {
+ List xml = new ArrayList<>();
+ try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) {
+ for (XSLFShape shape : show.getSlides().get(0).getShapes()) {
+ if (shape instanceof XSLFTextShape textShape) {
+ for (XSLFTextParagraph paragraph : textShape.getTextParagraphs()) {
+ for (XSLFTextRun run : paragraph.getTextRuns()) {
+ xml.add(run.getXmlObject().xmlText());
+ }
+ }
+ }
+ }
+ }
+ return xml;
+ }
+
+ private static String contentStream(byte[] pdf) throws Exception {
+ try (org.apache.pdfbox.pdmodel.PDDocument document =
+ org.apache.pdfbox.Loader.loadPDF(pdf)) {
+ return new String(document.getPage(0).getContents().readAllBytes(),
+ java.nio.charset.StandardCharsets.ISO_8859_1);
+ }
+ }
+}
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index 9c7e7048b..5a99ed492 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -1081,15 +1081,33 @@ private static void applyRunDirection(XWPFRun run, boolean rightToLeft) {
*
* No tracking writes no element, so a document that never asks for it
* carries exactly the run properties it carried before.
+ *
+ * Out of range is refused rather than wrapped. The value goes out as an
+ * {@code int} of twentieths, and a large enough tracking changes sign on the
+ * cast — {@code 1e9} points becomes {@code -1474836480}, turning wide
+ * tracking into tight. The limit is Word's, not the fixed backends': a
+ * semantic document is not held to what DrawingML can spell.
*/
private static void applyLetterSpacing(XWPFRun run, DocumentTextStyle style) {
double points = style.letterSpacing().resolve(style.size());
if (points == 0.0) {
return;
}
+ if (Math.abs(points) > MAX_TRACKING_POINTS) {
+ throw new IllegalArgumentException(
+ "Letter spacing resolves to " + points + "pt, beyond the "
+ + MAX_TRACKING_POINTS + "pt a Word run can express "
+ + "(w:spacing is twentieths of a point, written as an int).");
+ }
run.setCharacterSpacing((int) Math.round(points * 20.0));
}
+ /**
+ * The largest tracking a Word run can carry, in points — the point at
+ * which twentieths stop fitting in the {@code int} the value is written as.
+ */
+ private static final double MAX_TRACKING_POINTS = Integer.MAX_VALUE / 20.0;
+
private void applyStyle(XWPFRun run, DocumentTextStyle style) {
if (style == null) {
return;
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java
index 9d8037452..60508a668 100644
--- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java
@@ -196,12 +196,21 @@ private static void disableKerning(XSLFTextRun run) {
* line laid out against the PDF measurement arrives on a slide at the width
* it was given.
*
+ * The conversion is exact, not a rounding: the engine quantises tracking
+ * to hundredths before it reaches any fixed backend, precisely so the number
+ * measured and the number declared here are the same one. Rounding again
+ * costs nothing and keeps this readable as the unit conversion it is. The
+ * bound the schema puts on {@code spc} — {@code ST_TextPoint} accepts
+ * {@code 400000} and rejects {@code 400001} — is enforced at that same
+ * engine seam, so a value that would wrap its sign on the cast below is
+ * refused before it ever arrives.
+ *
* Zero writes nothing at all. The attribute's absence is the default, so
* a deck with no tracking in it is byte-identical to one produced before
* this existed.
*
* @param run the run being styled
- * @param letterSpacing tracking in points, already resolved by the engine
+ * @param letterSpacing tracking in points, already resolved and quantised
*/
private static void applyLetterSpacing(XSLFTextRun run, double letterSpacing) {
if (letterSpacing == 0.0) {
From c2a507ca6833fb43c0f40e51b2135885171fc02c Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 11 Sep 2026 10:44:20 +0100
Subject: [PATCH 05/10] feat(templates): set spaced caps with tracking, not
with padded strings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
CHANGELOG.md | 25 +++
.../document/style/DocumentLetterSpacing.java | 8 +-
.../engine/text/markdown/MarkDownParser.java | 7 +-
docs/recipes.md | 1 +
docs/recipes/letter-spacing.md | 87 ++++++++
examples/README.md | 21 ++
.../demcha/examples/GenerateAllExamples.java | 2 +
.../features/text/LetterSpacingExample.java | 186 ++++++++++++++++++
knowledge/api/templates.json | 13 +-
knowledge/api/templates.md | 5 +-
.../api/TrackingFixedLayoutParityTest.java | 39 ++++
.../cv/presets/CvPresetTextLayerTest.java | 32 +++
...SpacedCapsTextLayerAcrossBackendsTest.java | 165 ++++++++++++++++
.../blue_banner-page-0.png | Bin 26529 -> 26503 bytes
.../boxed_sections-page-0.png | Bin 32447 -> 32428 bytes
.../centered_headline-page-0.png | Bin 29868 -> 29893 bytes
.../classic_serif-page-0.png | Bin 34653 -> 34648 bytes
.../mint-editorial-letter-page-0.png | Bin 28794 -> 28739 bytes
.../monogram_sidebar-page-0.png | Bin 39224 -> 39213 bytes
.../sidebar_portrait-page-0.png | Bin 33182 -> 33217 bytes
.../timeline_minimal-page-0.png | Bin 30355 -> 30302 bytes
.../cv-v2-layered/blue_banner-page-0.png | Bin 112232 -> 112104 bytes
.../cv-v2-layered/boxed_sections-page-0.png | Bin 100725 -> 100546 bytes
.../cv-v2-layered/boxed_sections-page-1.png | Bin 43903 -> 43913 bytes
.../centered_headline-page-0.png | Bin 110471 -> 110540 bytes
.../centered_headline-page-1.png | Bin 21690 -> 21623 bytes
.../cv-v2-layered/classic_serif-page-0.png | Bin 125465 -> 125318 bytes
.../cv-v2-layered/classic_serif-page-1.png | Bin 27867 -> 27811 bytes
.../minimal_underlined-page-0.png | Bin 99157 -> 98937 bytes
.../minimal_underlined-page-1.png | Bin 43437 -> 43431 bytes
.../cv-v2-layered/mint_editorial-page-0.png | Bin 57638 -> 57474 bytes
.../cv-v2-layered/mint_editorial-page-1.png | Bin 26757 -> 26719 bytes
.../cv-v2-layered/monogram_sidebar-page-0.png | Bin 93067 -> 92806 bytes
.../cv-v2-layered/sidebar_portrait-page-0.png | Bin 92239 -> 92385 bytes
.../cv-v2-layered/timeline_minimal-page-0.png | Bin 103684 -> 103657 bytes
.../semantic/docx/DocxSemanticBackend.java | 7 +-
.../PdfTableRowFragmentRenderHandler.java | 21 +-
.../compose/engine/render/pdf/PdfFont.java | 9 +-
.../templates/core/identity/Headline.java | 24 ++-
.../templates/core/identity/Subheadline.java | 17 +-
.../templates/core/text/MarkdownText.java | 5 +
.../templates/core/text/TextOrnaments.java | 72 ++++---
.../presets/MintEditorialLetter.java | 12 +-
.../presets/MonogramSidebarLetter.java | 12 +-
.../presets/SidebarPortraitLetter.java | 6 +-
.../presets/TimelineMinimalLetter.java | 6 +-
.../templates/cv/presets/ClassicSerif.java | 6 +-
.../templates/cv/presets/MintEditorial.java | 49 +++--
.../templates/cv/presets/MonogramSidebar.java | 24 ++-
.../templates/cv/presets/SidebarPortrait.java | 33 +---
.../templates/cv/presets/TimelineMinimal.java | 24 +--
.../templates/cv/widgets/SectionHeader.java | 18 +-
.../templates/cv/widgets/SkillBar.java | 6 +-
53 files changed, 789 insertions(+), 153 deletions(-)
create mode 100644 docs/recipes/letter-spacing.md
create mode 100644 examples/src/main/java/com/demcha/examples/features/text/LetterSpacingExample.java
create mode 100644 qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SpacedCapsTextLayerAcrossBackendsTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36d23985f..deb79b5b5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -55,6 +55,31 @@ follow semantic versioning; release dates are ISO 8601.
Asking for no tracking writes nothing at all: no `spc` attribute, no `w:spacing` element,
no `Tc` operator. Every existing document is byte-for-byte what it was.
+- **The built-in CV and cover-letter presets now use real tracking, so their text is
+ readable again.** Spaced caps in those presets were drawn 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`, which is the one field a CV is searched
+ and parsed by. All 33 call sites are migrated — the name, the job title, section banners,
+ skill labels, education headings — and the text in the file is now the text that was
+ typed, in PDF, PPTX and DOCX alike.
+
+ **Headings also stop breaking mid-word.** Padding every letter out 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.
+
+ The tracking is `ofFontSize(0.18)`, one value for every preset, chosen by measuring what
+ the old transform produced: a space glyph between letters is 0.232–0.278 em in the faces
+ these presets use, and matching the old *total* width — real tracking adds a unit after
+ the last glyph and to the word space as well — puts the equivalent at 0.174–0.209 em.
+ Headings therefore occupy close to the width they did. Expect small visual differences on
+ the presets that use spaced caps; nine of the sixteen CV presets move, all by under 2% of
+ the page.
+
+ `TextOrnaments.spacedUpper` is gone, along with the two private copies that had grown in
+ `SidebarPortrait` and `TimelineMinimal`. `TextOrnaments.upper` replaces it and does only
+ what its name says.
+
- **A list can hang its wrapped lines under its own text instead of under its marker.**
`ListBuilder.hangingIndent(true)` gives an item a marker column and a content column, so
every visual line of it starts at one horizontal position — the first line, the lines it
diff --git a/core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java b/core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java
index 0b7ee9ad8..318c35ea7 100644
--- a/core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java
+++ b/core/src/main/java/com/demcha/compose/document/style/DocumentLetterSpacing.java
@@ -98,8 +98,12 @@ public enum Type {
if (!Double.isFinite(value)) {
throw new IllegalArgumentException("Letter spacing must be a finite number, got: " + value);
}
- // -0.0 renders identically to +0.0 but would compare unequal to NONE and
- // hash differently. Fold it so one behaviour has one value.
+ // -0.0 renders identically to +0.0 but would compare unequal to it and
+ // hash differently, so it is folded. Note this does not make zero a
+ // single value: the factories return NONE for it, but
+ // new DocumentLetterSpacing(FONT_SIZE, 0.0) is still constructible and
+ // is not equal(NONE) — same behaviour, different unit, and the unit is
+ // the caller's to state.
value = value == 0.0 ? 0.0 : value;
}
diff --git a/core/src/main/java/com/demcha/compose/engine/text/markdown/MarkDownParser.java b/core/src/main/java/com/demcha/compose/engine/text/markdown/MarkDownParser.java
index c207a5562..c5f1f5554 100644
--- a/core/src/main/java/com/demcha/compose/engine/text/markdown/MarkDownParser.java
+++ b/core/src/main/java/com/demcha/compose/engine/text/markdown/MarkDownParser.java
@@ -61,7 +61,12 @@ public List getBody(String markdown, TextStyle style) {
};
double newSize = style.size() * scale;
TextStyle headerStyle = new TextStyle(style.fontName(), newSize, TextDecoration.BOLD,
- style.color(), style.letterSpacing());
+ // Scaled with the size. The engine is handed tracking
+ // already resolved to points, so carrying it across
+ // unchanged would set a 2x heading at the body's
+ // tracking — a share of the font size that stops being
+ // a share the moment the size changes.
+ style.color(), style.letterSpacing() * scale);
// Add newline before header for better separation
// resultList.add(new TextDataBody("\n",
diff --git a/docs/recipes.md b/docs/recipes.md
index 9b243c2f4..d1e47c602 100644
--- a/docs/recipes.md
+++ b/docs/recipes.md
@@ -22,6 +22,7 @@ authoring API; public application code should not import
| [Tables](recipes/tables.md) | Row span, zebra rows, totals row, repeated header on page break |
| [Text direction](recipes/text-direction.md) | `TextDirection` — right-to-left paragraphs, `AUTO` resolved from the text, mixed lines, and the bundled Hebrew / Arabic families |
| [Rich text](recipes/rich-text.md) | `RichText` mixed-style runs in one paragraph: bold/accent/styled segments, inline links, inline images, inline SVG icons, emoji shortcodes, inline shapes and checkboxes |
+| [Letter spacing](recipes/letter-spacing.md) | `DocumentLetterSpacing` — real typographic tracking for spaced caps, declared natively in PDF / PPTX / DOCX so the text layer still holds the word |
| [Lists](recipes/lists.md) | `addList`: quick bulleted lists, marker customisation, nested lists with per-depth markers, spacing and styled items |
| [Timelines](recipes/timelines.md) | `addTimeline`: the leading / axis / content model, markers (dot / circle / numbered / square / custom), leading column, axis sizing, `markerOnRail()`, rail extent, pagination, backends |
| [Barcodes](recipes/barcodes.md) | QR / Code 128 / Code 39 / EAN / UPC / PDF417 / DataMatrix, tinting, quiet zone, card centring |
diff --git a/docs/recipes/letter-spacing.md b/docs/recipes/letter-spacing.md
new file mode 100644
index 000000000..ecdc4e1b7
--- /dev/null
+++ b/docs/recipes/letter-spacing.md
@@ -0,0 +1,87 @@
+# Letter spacing: spaced caps without wrecking the text
+
+Wide-set capitals are a typographic effect — a name across the top of a
+CV, a section banner, an eyebrow label. The obvious way to draw them is
+to put a space between every pair of letters, and it is the wrong way:
+the picture is right and the file is wrong. `"JANE DOE"` written as
+`"J A N E D O E"` is what search, copy/paste, a screen reader and an
+applicant-tracking parser then read.
+
+`DocumentLetterSpacing` moves the pen instead of the text.
+
+```java
+import com.demcha.compose.document.style.DocumentLetterSpacing;
+import com.demcha.compose.document.style.DocumentTextStyle;
+
+DocumentTextStyle headline = DocumentTextStyle.builder()
+ .fontName(FontName.LATO)
+ .size(22)
+ .letterSpacing(DocumentLetterSpacing.ofFontSize(0.18))
+ .build();
+
+section.addParagraph(p -> p.text("JANE DOE").textStyle(headline));
+```
+
+The page shows spaced caps; the file still says `JANE DOE`.
+
+## Two units, and why the value names its own
+
+```java
+DocumentLetterSpacing.ofFontSize(0.18) // 18% of the font size
+DocumentLetterSpacing.points(1.2) // 1.2 points, whatever the size
+```
+
+Prefer `ofFontSize`. Expressed as a share, the tracking scales with the
+type, so one style value reads the same on a 24pt name as on an 8pt
+label — and keeps its proportions under auto-size. `points` is there for
+designs specified in absolute measure.
+
+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.
+
+Negative values tighten. `DocumentLetterSpacing.NONE` is the default and
+resolves to zero at every size, so a style that never mentions tracking
+renders exactly as it did.
+
+## What each format can express
+
+Tracking is declared natively, never faked, in all three outputs — PDF's
+`Tc`, DrawingML's `spc`, Word's `w:spacing`. They do not all measure the
+same, though:
+
+| | granularity | why |
+|---|---|---|
+| PDF and PPTX | **0.01pt** | DrawingML states spacing in hundredths of a point, so that is the finest distinction a PDF and a deck can both make. The engine measures on that grid, which keeps the width it reserves equal to the width the file draws. |
+| DOCX | **0.05pt** | Word states spacing in twentieths and owns its own layout, so the export rounds to that grid independently. |
+
+The value you wrote is never rewritten: `resolve(fontSize)` returns what
+you asked for. Ask for a third of a point and the fixed-layout formats
+both use `0.33`; that is the grid, not a loss of your value.
+
+Tracking larger than a format can state is refused when the document is
+rendered rather than silently wrapped — fixed layout tops out at
+±4000pt, which is DrawingML's own bound.
+
+## Where the spacing lands
+
+One unit goes after **every** code point, the last one included, and an
+ordinary space is spaced like any other character. That is what the PDF
+`Tc` operator does, and PowerPoint and Word were measured doing the same.
+
+The practical consequence: a centred or right-aligned tracked line is
+aligned on a width that includes that trailing unit, so it sits half a
+unit left of where an untracked line of the same glyphs would. This is
+also how CSS `letter-spacing` behaves.
+
+## The built-in presets
+
+The bundled CV and cover-letter presets use `ofFontSize(0.18)` for their
+spaced-caps blocks, exposed as
+`TextOrnaments.SPACED_CAPS`. Reach for the same constant if you are
+writing a preset that should match them.
+
+Runnable showcase:
+[LetterSpacingExample](../../examples/src/main/java/com/demcha/examples/features/text/LetterSpacingExample.java)
+— renders the same name at four trackings and prints what each of the
+three formats says its text is.
diff --git a/examples/README.md b/examples/README.md
index 6652854de..1b7f7e102 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -119,6 +119,7 @@ are with the canonical DSL, then jump to its detailed section below.
| [World scripts](#world-scripts) | One card per bundled script — Arabic, Hebrew, Georgian, Armenian, Korean — each set in its own `FontName` family | [PDF](../assets/readme/examples/world-scripts.pdf) · [Source](src/main/java/com/demcha/examples/features/text/WorldScriptsExample.java) |
| [Inline shapes](#inline-shapes) | `InlineShapeRun` — dots, arrows, chevrons, diamonds, stars, checkmarks and checkboxes drawn as geometry on the text baseline | [PDF](../assets/readme/examples/inline-shapes.pdf) · [Source](src/main/java/com/demcha/examples/features/text/InlineShapesExample.java) |
| [Inline highlight chips](#inline-highlight-chips) | `RichText.code(text)` / `chip(text, fg, bg)` / `highlight(text, style, bg, radius, padding)` — text on a rounded padded fill (inline code + status badges), wrapping across lines | [PDF](../assets/readme/examples/inline-highlight-chips.pdf) · [Source](src/main/java/com/demcha/examples/features/text/InlineHighlightExample.java) |
+| [Letter spacing](#letter-spacing) | `DocumentTextStyle.builder().letterSpacing(DocumentLetterSpacing.ofFontSize(0.18))` — real typographic tracking through PDF `Tc`, DrawingML `spc` and Word `w:spacing`, so wide caps still copy and search as the word they are | [Source](src/main/java/com/demcha/examples/features/text/LetterSpacingExample.java) |
| [Inline SVG icons](#inline-svg-icons) | `RichText.svgIcon(icon, size)` — a parsed multi-colour `SvgIcon` on the text baseline, crisp at any zoom and carrying its own colours | [PDF](../assets/readme/examples/inline-svg-icons.pdf) · [Source](src/main/java/com/demcha/examples/features/text/InlineSvgIconExample.java) |
| [Colour emoji](#colour-emoji) | `RichText.emoji(":star:", size)` — GitHub-style shortcodes resolve to inline vector glyphs via the `graph-compose-emoji` artifact; unknown codes fall back to literal text | [PDF](../assets/readme/examples/emoji-shortcodes.pdf) · [Source](src/main/java/com/demcha/examples/features/text/EmojiShortcodeExample.java) |
| [Section presets](#section-presets) | `pageBackground`, `band`, `softPanel`, `accentLeft / Right / Top / Bottom`, per-corner `DocumentCornerRadius` | [PDF](../assets/readme/examples/section-presets.pdf) · [Source](src/main/java/com/demcha/examples/features/text/SectionPresetsExample.java) |
@@ -784,6 +785,26 @@ across lines, painting one continuous rounded fill per visual fragment. On
[📄 View PDF](../assets/readme/examples/inline-highlight-chips.pdf) ·
[📜 Full source](src/main/java/com/demcha/examples/features/text/InlineHighlightExample.java)
+### Letter spacing
+
+`DocumentTextStyle.builder().letterSpacing(...)` (`@since 2.4.0`) takes a
+`DocumentLetterSpacing` — `ofFontSize(0.18)` for a share of the type that scales
+with it, or `points(1.2)` for an absolute amount; negative values tighten. The
+advance is the format's own (PDF `Tc`, DrawingML `spc`, Word `w:spacing`), so the
+string in the file stays the string that was typed: a headline set in wide caps
+still copies, searches and parses as `JANE DOE` rather than `J A N E D O E`.
+The example prints back what each of the three formats says its text is.
+
+
+```java
+.textStyle(DocumentTextStyle.builder()
+ .fontName(FontName.LATO).size(22)
+ .letterSpacing(DocumentLetterSpacing.ofFontSize(0.18))
+ .build())
+```
+
+[📜 Full source](src/main/java/com/demcha/examples/features/text/LetterSpacingExample.java)
+
### Inline SVG icons
`RichText.svgIcon(icon, size)` / `ParagraphBuilder.inlineSvgIcon(...)`
diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
index a88b6c4e5..201445dee 100644
--- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
+++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
@@ -31,6 +31,7 @@
import com.demcha.examples.features.text.EmojiShortcodeExample;
import com.demcha.examples.features.text.EmojiSvgVsPngExample;
import com.demcha.examples.features.text.EmojiClipPathReportExample;
+import com.demcha.examples.features.text.LetterSpacingExample;
import com.demcha.examples.features.text.InlineShapesExample;
import com.demcha.examples.features.text.ArabicArticleExample;
import com.demcha.examples.features.text.HebrewInvoiceExample;
@@ -197,6 +198,7 @@ public static void main(String[] args) throws Exception {
// Text + sections
System.out.println("Generated: " + InlineShapesExample.generate());
+ System.out.println("Generated: " + LetterSpacingExample.generate());
System.out.println("Generated: " + TextDirectionExample.generate());
System.out.println("Generated: " + ArabicArticleExample.generate());
System.out.println("Generated: " + HebrewInvoiceExample.generate());
diff --git a/examples/src/main/java/com/demcha/examples/features/text/LetterSpacingExample.java b/examples/src/main/java/com/demcha/examples/features/text/LetterSpacingExample.java
new file mode 100644
index 000000000..14a2cb3e0
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/features/text/LetterSpacingExample.java
@@ -0,0 +1,186 @@
+package com.demcha.examples.features.text;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentPageSize;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.backend.semantic.docx.DocxSemanticBackend;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentLetterSpacing;
+import com.demcha.compose.document.style.DocumentTextDecoration;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.font.FontName;
+import com.demcha.examples.support.ExampleOutputPaths;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.text.PDFTextStripper;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * Runnable showcase for letter spacing ({@code @since 2.4.0}).
+ *
+ * Spaced caps used to be drawn by rewriting the string with a space between
+ * every pair of letters. That draws the right picture and ruins the file: the
+ * name in a CV came back out of it as {@code "J A N E D O E"}, so search,
+ * copy/paste, a screen reader and an applicant-tracking parser all missed the
+ * one field the document is looked up by.
+ *
+ * {@code DocumentLetterSpacing} moves the pen instead of the text. This
+ * example renders the same headline three ways, then reads its own output back
+ * and prints what each format says the text is — which is the whole
+ * point, and not something a look at the page can tell you.
+ */
+public final class LetterSpacingExample {
+
+ private static final String NAME = "Jane O'Doe-Smith 3rd";
+ private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38);
+ private static final DocumentColor MUTED = DocumentColor.rgb(112, 116, 128);
+ private static final DocumentColor BRAND = DocumentColor.rgb(20, 80, 95);
+
+ private LetterSpacingExample() {
+ }
+
+ public static Path generate() throws Exception {
+ Path outputFile = ExampleOutputPaths.prepare("features/text", "letter-spacing.pdf");
+ byte[] pdf;
+
+ try (DocumentSession document = GraphCompose.document()
+ .pageSize(DocumentPageSize.A4)
+ .margin(40, 40, 40, 40)
+ .create()) {
+ compose(document);
+ pdf = document.toPdfBytes();
+ }
+ Files.write(outputFile, pdf);
+
+ // The picture is on the page; this is the half of the feature that is
+ // not. Printed rather than asserted, because an example should show the
+ // thing it claims.
+ System.out.println("PDF text layer : \"" + extracted(pdf) + "\"");
+ System.out.println("PPTX text : \"" + pptxText() + "\"");
+ System.out.println("DOCX text : \"" + docxText() + "\"");
+
+ return outputFile;
+ }
+
+ private static void compose(DocumentSession document) {
+ document.pageFlow()
+ .name("LetterSpacingShowcase")
+ .spacing(18)
+ .addSection("Intro", section -> section
+ .spacing(6)
+ .addParagraph(p -> p
+ .text("Letter spacing")
+ .textStyle(heading())
+ .margin(DocumentInsets.zero()))
+ .addParagraph(p -> p
+ .text("The same name, set three ways. Select any of them and "
+ + "paste: the clipboard holds the name, not the spacing.")
+ .textStyle(body())
+ .margin(DocumentInsets.zero())))
+ .addSection("None", section -> specimen(section,
+ "no tracking", DocumentLetterSpacing.NONE))
+ .addSection("Editorial", section -> specimen(section,
+ "ofFontSize(0.18) — what the built-in CV presets use",
+ DocumentLetterSpacing.ofFontSize(0.18)))
+ .addSection("Wide", section -> specimen(section,
+ "ofFontSize(0.4) — deliberately extreme",
+ DocumentLetterSpacing.ofFontSize(0.4)))
+ .addSection("Tight", section -> specimen(section,
+ "points(-0.4) — negative tracking tightens",
+ DocumentLetterSpacing.points(-0.4)))
+ .build();
+ }
+
+ private static void specimen(com.demcha.compose.document.dsl.SectionBuilder section,
+ String caption,
+ DocumentLetterSpacing spacing) {
+ section.spacing(4)
+ .addParagraph(p -> p
+ .text(caption)
+ .textStyle(caption())
+ .margin(DocumentInsets.zero()))
+ .addParagraph(p -> p
+ .text(NAME.toUpperCase(java.util.Locale.ROOT))
+ .textStyle(display().withLetterSpacing(spacing))
+ .align(TextAlign.LEFT)
+ .margin(DocumentInsets.zero()));
+ }
+
+ private static String extracted(byte[] pdf) throws Exception {
+ try (PDDocument document = Loader.loadPDF(pdf)) {
+ String text = new PDFTextStripper().getText(document);
+ for (String line : text.split("\\R")) {
+ if (line.contains("O'DOE")) {
+ return line.trim();
+ }
+ }
+ return "(not found)";
+ }
+ }
+
+ private static String pptxText() throws Exception {
+ try (DocumentSession document = GraphCompose.document()
+ .pageSize(DocumentPageSize.A4).margin(40, 40, 40, 40).create()) {
+ compose(document);
+ byte[] pptx = document.toPptxBytes();
+ try (var show = new org.apache.poi.xslf.usermodel.XMLSlideShow(
+ new java.io.ByteArrayInputStream(pptx))) {
+ for (var shape : show.getSlides().get(0).getShapes()) {
+ if (shape instanceof org.apache.poi.xslf.usermodel.XSLFTextShape textShape
+ && textShape.getText().contains("O'DOE")) {
+ return textShape.getText().trim();
+ }
+ }
+ }
+ }
+ return "(not found)";
+ }
+
+ private static String docxText() throws Exception {
+ try (DocumentSession document = GraphCompose.document()
+ .pageSize(DocumentPageSize.A4).margin(40, 40, 40, 40).create()) {
+ compose(document);
+ byte[] docx = document.export(new DocxSemanticBackend());
+ try (var word = new org.apache.poi.xwpf.usermodel.XWPFDocument(
+ new java.io.ByteArrayInputStream(docx))) {
+ for (var paragraph : word.getParagraphs()) {
+ if (paragraph.getText().contains("O'DOE")) {
+ return paragraph.getText().trim();
+ }
+ }
+ }
+ }
+ return "(not found)";
+ }
+
+ private static DocumentTextStyle display() {
+ return DocumentTextStyle.builder()
+ .fontName(FontName.LATO).size(22)
+ .decoration(DocumentTextDecoration.BOLD).color(INK).build();
+ }
+
+ private static DocumentTextStyle heading() {
+ return DocumentTextStyle.builder()
+ .fontName(FontName.LATO).size(26)
+ .decoration(DocumentTextDecoration.BOLD).color(BRAND).build();
+ }
+
+ private static DocumentTextStyle body() {
+ return DocumentTextStyle.builder()
+ .fontName(FontName.LATO).size(10).color(MUTED).build();
+ }
+
+ private static DocumentTextStyle caption() {
+ return DocumentTextStyle.builder()
+ .fontName(FontName.LATO).size(8.5)
+ .decoration(DocumentTextDecoration.BOLD).color(MUTED).build();
+ }
+
+ public static void main(String[] args) throws Exception {
+ System.out.println("Wrote " + generate());
+ }
+}
diff --git a/knowledge/api/templates.json b/knowledge/api/templates.json
index 447855dbf..ac74f13e8 100644
--- a/knowledge/api/templates.json
+++ b/knowledge/api/templates.json
@@ -24,8 +24,8 @@
"counts": {
"types": 161,
"methods": 953,
- "constants": 117,
- "generated": 402
+ "constants": 118,
+ "generated": 403
},
"packages": [
{
@@ -1436,9 +1436,16 @@
],
"artifact": "graph-compose-templates",
"members": [
+ {
+ "kind": "constant",
+ "name": "SPACED_CAPS",
+ "static": true,
+ "origin": "generated",
+ "type": "DocumentLetterSpacing"
+ },
{
"kind": "method",
- "name": "spacedUpper",
+ "name": "upper",
"static": true,
"origin": "source",
"typeParameters": null,
diff --git a/knowledge/api/templates.md b/knowledge/api/templates.md
index cf770d0fd..a8656c9bf 100644
--- a/knowledge/api/templates.md
+++ b/knowledge/api/templates.md
@@ -28,7 +28,7 @@ note: "Generated from the pinned artifact's class files. Authoritative closed se
**GraphCompose version:** 2.4.0-SNAPSHOT
-Types: 161 · methods: 953 · constants: 117 · compiler-generated members: 402
+Types: 161 · methods: 953 · constants: 118 · compiler-generated members: 403
## com.demcha.compose.document.templates.api
@@ -134,8 +134,9 @@ Types: 161 · methods: 953 · constants: 117 · compiler-generated members: 402
- `void render(SectionBuilder host, String text, DocumentTextStyle style, double lineSpacing, DocumentInsets margin, TextAlign align)`
### TextOrnaments (class)
-- `String spacedUpper(String value)`
+- `String upper(String value)`
- `String joinPipe(String... parts)`
+- constants: `SPACED_CAPS`
### TextStyles (class)
- `DocumentTextStyle of(FontName font, double size, DocumentTextDecoration decoration, DocumentColor color)`
diff --git a/qa/src/test/java/com/demcha/compose/document/api/TrackingFixedLayoutParityTest.java b/qa/src/test/java/com/demcha/compose/document/api/TrackingFixedLayoutParityTest.java
index 49dfddbbd..04430ef32 100644
--- a/qa/src/test/java/com/demcha/compose/document/api/TrackingFixedLayoutParityTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/api/TrackingFixedLayoutParityTest.java
@@ -147,6 +147,32 @@ void zeroStaysTheExactLegacyPath() throws Exception {
.doesNotContain(" Tc");
}
+ @Test
+ void anUntrackedDocumentIsTheSameBytesItWouldHaveBeenWithoutTheFeature() throws Exception {
+ // The CHANGELOG says every existing document is byte-for-byte what it
+ // was. That is a claim about output, so it is asserted against output:
+ // a style that never mentions tracking and one that explicitly asks for
+ // NONE must produce the identical file.
+ DocumentTextStyle silent = DocumentTextStyle.builder()
+ .fontName(FontName.LATO).size(20).build();
+ DocumentTextStyle explicitNone = style(DocumentLetterSpacing.NONE);
+
+ // PDF embeds a creation date and a document ID, so whole-file equality
+ // is only a meaningful question in deterministic mode. It is the right
+ // question, though — it covers the content stream and every dictionary.
+ assertThat(render(explicitNone, s -> s.render(
+ PdfFixedLayoutBackend.builder().deterministic(true).build())))
+ .isEqualTo(render(silent, s -> s.render(
+ PdfFixedLayoutBackend.builder().deterministic(true).build())));
+
+ // The OOXML pair carry timestamps of their own, so they are compared on
+ // the run properties — which is where a stray zero would have shown up.
+ assertThat(pptxRunXml(render(explicitNone, s -> s.render(new PptxFixedLayoutBackend()))))
+ .isEqualTo(pptxRunXml(render(silent, s -> s.render(new PptxFixedLayoutBackend()))));
+ assertThat(docxRunXml(render(explicitNone, s -> s.export(new DocxSemanticBackend()))))
+ .isEqualTo(docxRunXml(render(silent, s -> s.export(new DocxSemanticBackend()))));
+ }
+
@Test
void theAuthoredValueItselfIsNeverRewritten() {
// Quantisation is a property of fixed layout, not of the value. What the
@@ -280,6 +306,19 @@ private static int spcOf(byte[] pptx) throws Exception {
throw new AssertionError("no spc written");
}
+ /** Every run's properties as the document spells them, in order. */
+ private static List docxRunXml(byte[] docx) throws Exception {
+ List xml = new ArrayList<>();
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ for (XWPFParagraph paragraph : document.getParagraphs()) {
+ for (XWPFRun run : paragraph.getRuns()) {
+ xml.add(run.getCTR().xmlText());
+ }
+ }
+ }
+ return xml;
+ }
+
private static int wSpacingOf(byte[] docx) throws Exception {
try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
for (XWPFParagraph paragraph : document.getParagraphs()) {
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvPresetTextLayerTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvPresetTextLayerTest.java
index a842aaff4..b26f46eb7 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvPresetTextLayerTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvPresetTextLayerTest.java
@@ -65,6 +65,38 @@ void theProfileTextIsInTheFileAsItWasWritten(
.contains(probe));
}
+ /**
+ * The spaced-caps blocks read back as words.
+ *
+ * These are the blocks the probe words above deliberately avoid, and the
+ * reason they had to: while the look was made by rewriting the string, a
+ * name came out of the file as {@code "J A N E D O E"}, so the one thing
+ * every reader of a CV searches for — the applicant's name — was the one
+ * thing not in it. Tracking is a style now, so the name is a name.
+ */
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("presets")
+ void theNameAndSectionTitlesReadBackAsWords(
+ String slug, double margin, Supplier> factory)
+ throws Exception {
+
+ String extracted = renderText(factory.get(), margin);
+
+ // Two words with the single space between them intact — the old
+ // transform widened an authored space into three, so this is exactly
+ // the case it broke, and the name is the field a CV is searched by.
+ assertThat(extracted)
+ .describedAs("the applicant's name is not in the text layer of %s", slug)
+ .containsIgnoringCase("JANE DOE");
+ // And nothing anywhere on the page is spelled out letter by letter.
+ // Asserted as a shape rather than against particular words, because
+ // presets word their headings differently and any of them regressing
+ // should be caught, not just the ones this test happened to name.
+ assertThat(extracted)
+ .describedAs("something is spelled out letter by letter in %s", slug)
+ .doesNotMatch("(?s).*\\b(?:[A-Za-z] ){3,}[A-Za-z]\\b.*");
+ }
+
private static String renderText(DocumentTemplate template, double margin)
throws Exception {
byte[] pdf;
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SpacedCapsTextLayerAcrossBackendsTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SpacedCapsTextLayerAcrossBackendsTest.java
new file mode 100644
index 000000000..3a87548b7
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/SpacedCapsTextLayerAcrossBackendsTest.java
@@ -0,0 +1,165 @@
+package com.demcha.compose.document.templates.cv.presets;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentPageSize;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.backend.fixed.pptx.PptxFixedLayoutBackend;
+import com.demcha.compose.document.backend.semantic.docx.DocxSemanticBackend;
+import com.demcha.compose.document.templates.api.DocumentTemplate;
+import com.demcha.compose.document.templates.cv.data.CvDocument;
+import com.demcha.compose.document.templates.cv.data.CvIdentity;
+import com.demcha.compose.document.templates.cv.data.EntriesSection;
+import com.demcha.compose.document.templates.cv.data.ParagraphSection;
+import com.demcha.compose.document.templates.cv.data.SkillsSection;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.apache.poi.xslf.usermodel.XMLSlideShow;
+import org.apache.poi.xslf.usermodel.XSLFShape;
+import org.apache.poi.xslf.usermodel.XSLFTextParagraph;
+import org.apache.poi.xslf.usermodel.XSLFTextRun;
+import org.apache.poi.xslf.usermodel.XSLFTextShape;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFRun;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.util.function.Supplier;
+import java.util.regex.Pattern;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A migrated preset, read back out of all three formats.
+ *
+ * The name in a CV is the field the document is searched by, and while the
+ * spaced-caps look was made by rewriting the string it was the one field not in
+ * the file: {@code "J A N E D O E"} is what an applicant-tracking parser got.
+ * This holds the migration to its purpose in each format — the picture is
+ * spaced, the text is not.
+ *
+ * The fixture carries the cases the old transform handled by three separate
+ * rules: several words, digits, and punctuation. It inserted one space between
+ * adjacent letters or digits, widened an authored space into three,
+ * and did neither around punctuation, so those are where it did the most
+ * damage.
+ */
+class SpacedCapsTextLayerAcrossBackendsTest {
+
+ private static final String FIRST = "Jane";
+ private static final String LAST = "O'Doe-Smith 3rd";
+ private static final String FULL_UPPER = "JANE O'DOE-SMITH 3RD";
+ private static final String TITLE_UPPER = "BACKEND ENGINEER";
+
+ /** Four or more single letters or digits in a row, each stranded by spaces. */
+ private static final Pattern SPELLED_OUT =
+ Pattern.compile("\\b(?:[A-Za-z0-9] ){3,}[A-Za-z0-9]\\b");
+
+ private static final Supplier> PRESET = MintEditorial::create;
+
+ @Test
+ void thePdfTextLayerHoldsTheNameWithItsDigitsAndPunctuation() throws Exception {
+ String text = pdfText();
+
+ assertThat(text).contains(FULL_UPPER);
+ assertThat(text).contains(TITLE_UPPER);
+ assertThat(text).doesNotContain("J A N E");
+ assertThat(SPELLED_OUT.matcher(text).find())
+ .describedAs("something is spelled out letter by letter in: %s", text)
+ .isFalse();
+ }
+
+ @Test
+ void thePptxRunsHoldTheNameAndDeclareTheTrackingNatively() throws Exception {
+ byte[] pptx = render(session -> session.render(new PptxFixedLayoutBackend()));
+
+ StringBuilder text = new StringBuilder();
+ boolean sawTracking = false;
+ try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) {
+ for (XSLFShape shape : show.getSlides().get(0).getShapes()) {
+ if (!(shape instanceof XSLFTextShape textShape)) {
+ continue;
+ }
+ for (XSLFTextParagraph paragraph : textShape.getTextParagraphs()) {
+ for (XSLFTextRun run : paragraph.getTextRuns()) {
+ text.append(run.getRawText()).append(' ');
+ sawTracking |= run.getXmlObject().xmlText().contains("spc=");
+ }
+ }
+ }
+ }
+
+ assertThat(text.toString()).contains(FULL_UPPER);
+ assertThat(sawTracking)
+ .describedAs("no run declared spc, so the deck is not actually tracked")
+ .isTrue();
+ assertThat(SPELLED_OUT.matcher(text.toString()).find()).isFalse();
+ }
+
+ @Test
+ void theDocxRunsHoldTheNameAndDeclareTheTrackingNatively() throws Exception {
+ byte[] docx = render(session -> session.export(new DocxSemanticBackend()));
+
+ StringBuilder text = new StringBuilder();
+ boolean sawTracking = false;
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ for (XWPFParagraph paragraph : document.getParagraphs()) {
+ for (XWPFRun run : paragraph.getRuns()) {
+ text.append(run.text()).append(' ');
+ sawTracking |= run.getCTR().xmlText().contains("spacing");
+ }
+ }
+ }
+
+ assertThat(text.toString()).contains(FULL_UPPER);
+ assertThat(sawTracking)
+ .describedAs("no run declared w:spacing, so the document is not tracked")
+ .isTrue();
+ assertThat(SPELLED_OUT.matcher(text.toString()).find()).isFalse();
+ }
+
+ // --- helpers ---------------------------------------------------------
+
+ private static String pdfText() throws Exception {
+ byte[] pdf = render(DocumentSession::toPdfBytes);
+ try (PDDocument document = Loader.loadPDF(pdf)) {
+ return new PDFTextStripper().getText(document).replaceAll("\\s+", " ");
+ }
+ }
+
+ @FunctionalInterface
+ private interface Export {
+ byte[] from(DocumentSession session) throws Exception;
+ }
+
+ private static byte[] render(Export export) throws Exception {
+ float m = (float) MintEditorial.RECOMMENDED_MARGIN;
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(DocumentPageSize.A4)
+ .margin(m, m, m, m)
+ .create()) {
+ PRESET.get().compose(session, document());
+ return export.from(session);
+ }
+ }
+
+ private static CvDocument document() {
+ return CvDocument.builder()
+ .identity(CvIdentity.builder()
+ .name(FIRST, LAST)
+ .jobTitle("Backend Engineer")
+ .contact("+44 0", "j@d.com", "London")
+ .build())
+ .sections(
+ new ParagraphSection("Professional Summary", "Platform work."),
+ SkillsSection.builder("Technical Skills")
+ .group("Languages", "Java", "Kotlin")
+ .build(),
+ EntriesSection.builder("Professional Experience")
+ .entry("Senior Engineer", "Acme Rendering",
+ "2021-2024", "Built rendering services.")
+ .build())
+ .build();
+ }
+}
diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/blue_banner-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/blue_banner-page-0.png
index 08389bb6612728da677eefd28a1f3aad6fa1fe2b..4f484c6e32335c3eaceb10b94cc0d4e6cd7a290f 100644
GIT binary patch
delta 22690
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