Skip to content

Commit 0b37989

Browse files
authored
Merge pull request #151 from DemchaAV/refactor/font-vertical-metrics-contract
refactor(engine): backend-neutral text line metrics via the Font contract
2 parents 587cc8c + ef6ebed commit 0b37989

6 files changed

Lines changed: 245 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,22 @@ Open cycle — bug-fix / housekeeping. Entries land here as they merge.
162162
public entry point and carries no binary-compatibility promise, so the move is
163163
excluded from the japicmp gate rather than treated as a breaking removal.
164164

165+
### Internal
166+
167+
- **Text-measurement line metrics resolve through the `Font` contract instead of a
168+
PDF-specific fast path.** `FontLibraryTextMeasurementSystem` previously
169+
special-cased `instanceof PdfFont` to obtain real ascent/descent/leading — every
170+
other backend font fell back to a degraded `lineHeight`-only metric — which
171+
coupled the shared measurement system to `engine.render.pdf.PdfFont` and meant a
172+
new backend could get first-class metrics only by editing shared code. Vertical
173+
metrics and the process-wide cache key now live on the backend-neutral `Font<T>`
174+
seam (`Font.lineMetrics(...)` + `Font.measurementCacheKey(...)`, both `default`
175+
methods; new `FontLineMetrics` record), so a backend supplies first-class metrics
176+
by overriding the contract and the shared measurement system no longer imports
177+
`PdfFont`. Binary-compatible (default methods only; japicmp green) and
178+
behaviour-neutral — PDF and Word produce identical metrics, covered by the
179+
existing suite plus new polymorphism tests.
180+
165181
### Tests / tooling
166182

167183
- **Benchmark regression gate and measurement probe (benchmarks module, not part

src/main/java/com/demcha/compose/engine/font/Font.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,40 @@ default T fontType(TextDecoration textDecoration) {
4444

4545
double getCapHeight(TextStyle style);
4646

47+
/**
48+
* Resolves backend-neutral vertical metrics (ascent, descent, leading) for the
49+
* supplied style.
50+
*
51+
* <p>The shared text-measurement system calls this polymorphically, so a
52+
* backend supplies first-class line metrics by overriding this method rather
53+
* than being special-cased in shared measurement code. The default derives a
54+
* degraded metric from {@link #getLineHeight} with zero descent and leading;
55+
* backends with real font metrics (ascent/descent/leading) should override.</p>
56+
*
57+
* @param style the resolved text style
58+
* @return vertical metrics in document units
59+
*/
60+
default FontLineMetrics lineMetrics(TextStyle style) {
61+
return new FontLineMetrics(Math.max(0.0, getLineHeight(style)), 0.0, 0.0);
62+
}
63+
64+
/**
65+
* Returns a process-stable key identifying this font's metrics for the supplied
66+
* style, or {@code null} to opt out of the shared process-wide line-metrics
67+
* cache (per-session caching still applies).
68+
*
69+
* <p>Backends whose {@link #lineMetrics} computation is expensive — e.g. it
70+
* reads font-descriptor tables — should return a stable non-null key so
71+
* identical styles resolve once per process across sessions and threads. Cheap
72+
* or stub backends can leave the default and skip the global cache.</p>
73+
*
74+
* @param style the resolved text style
75+
* @return a stable cache key, or {@code null} to skip the global cache
76+
*/
77+
default String measurementCacheKey(TextStyle style) {
78+
return null;
79+
}
80+
4781
double scale(double size);
4882

4983
/**
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.demcha.compose.engine.font;
2+
3+
/**
4+
* Backend-neutral vertical text metrics for one resolved text style: the
5+
* baseline-relative {@code ascent} and {@code descent} plus inter-line
6+
* {@code leading}, all in document units.
7+
*
8+
* <p>This is the font-layer counterpart of
9+
* {@code engine.measurement.TextMeasurementSystem.LineMetrics}. It lives in
10+
* {@code engine.font} so the {@link Font} contract can expose vertical metrics
11+
* without {@code engine.font} depending on {@code engine.measurement} (which
12+
* already depends on {@code engine.font}); the measurement system converts this
13+
* record into its own {@code LineMetrics} for layout consumers.</p>
14+
*
15+
* <p>Carries only the three primitive components; the measurement system reads
16+
* them via {@code ascent()}/{@code descent()}/{@code leading()} and converts to
17+
* {@code TextMeasurementSystem.LineMetrics} (which owns the derived
18+
* {@code lineHeight()} / baseline helpers) for layout consumers.</p>
19+
*
20+
* @param ascent distance from the baseline to the glyph top
21+
* @param descent distance from the baseline to the glyph bottom (non-negative)
22+
* @param leading extra line leading applied by the backend font metrics
23+
*/
24+
public record FontLineMetrics(double ascent, double descent, double leading) {
25+
}

src/main/java/com/demcha/compose/engine/measurement/FontLibraryTextMeasurementSystem.java

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import com.demcha.compose.engine.components.content.text.TextStyle;
66
import com.demcha.compose.engine.components.geometry.ContentSize;
77
import com.demcha.compose.engine.font.Font;
8-
import com.demcha.compose.engine.render.pdf.PdfFont;
8+
import com.demcha.compose.engine.font.FontLineMetrics;
99

1010
import java.util.HashMap;
1111
import java.util.Map;
@@ -16,18 +16,24 @@
1616
/**
1717
* Default measurement system backed by a document font library and a concrete
1818
* font implementation class supplied by the backend runtime.
19+
*
20+
* <p>Line metrics resolve polymorphically through the {@link Font} contract
21+
* ({@link Font#lineMetrics(TextStyle)} plus {@link Font#measurementCacheKey(TextStyle)}),
22+
* so every backend font — not only the PDF font — gets first-class metrics and
23+
* can opt into the process-wide cache without this shared class being modified
24+
* or special-cased per backend.</p>
1925
*/
2026
public final class FontLibraryTextMeasurementSystem implements TextMeasurementSystem {
2127
private static final int GLOBAL_LINE_METRICS_CACHE_LIMIT = 50_000;
2228
private static final int SESSION_TEXT_WIDTH_CACHE_LIMIT = 10_000;
23-
private static final ConcurrentMap<GlobalPdfStyleKey, LineMetrics> GLOBAL_PDF_LINE_METRICS_CACHE = new ConcurrentHashMap<>();
29+
private static final ConcurrentMap<GlobalStyleKey, LineMetrics> GLOBAL_LINE_METRICS_CACHE = new ConcurrentHashMap<>();
2430

2531
private final FontLibrary fonts;
2632
private final Class<? extends Font<?>> fontClass;
2733
private final Map<TextStyle, Font<?>> fontCache = new HashMap<>();
2834
private final Map<TextStyle, LineMetrics> lineMetricsCache = new HashMap<>();
2935
private final Map<TextStyle, Map<String, Double>> textWidthCache = new HashMap<>();
30-
private final Map<TextStyle, GlobalPdfStyleKey> globalPdfStyleKeyCache = new HashMap<>();
36+
private final Map<TextStyle, GlobalStyleKey> globalStyleKeyCache = new HashMap<>();
3137

3238
public FontLibraryTextMeasurementSystem(FontLibrary fonts, Class<? extends Font<?>> fontClass) {
3339
this.fonts = Objects.requireNonNull(fonts, "fonts");
@@ -69,20 +75,24 @@ public LineMetrics lineMetrics(TextStyle style) {
6975

7076
private LineMetrics resolveLineMetrics(TextStyle style) {
7177
Font<?> font = resolveFont(style);
72-
if (font instanceof PdfFont pdfFont) {
73-
GlobalPdfStyleKey cacheKey = globalPdfStyleKey(pdfFont, style);
74-
LineMetrics cached = GLOBAL_PDF_LINE_METRICS_CACHE.get(cacheKey);
75-
if (cached != null) {
76-
return cached;
77-
}
78-
var metrics = pdfFont.verticalMetrics(style);
79-
LineMetrics resolved = new LineMetrics(metrics.ascent(), metrics.descent(), metrics.leading());
80-
cacheGlobalLineMetrics(cacheKey, resolved);
81-
return resolved;
78+
String cacheKey = font.measurementCacheKey(style);
79+
if (cacheKey == null) {
80+
// Backend opted out of the process-wide cache; the per-session
81+
// lineMetricsCache (via lineMetrics(...)) still memoizes per style.
82+
return toLineMetrics(font.lineMetrics(style));
8283
}
84+
GlobalStyleKey key = globalStyleKey(style, cacheKey);
85+
LineMetrics cached = GLOBAL_LINE_METRICS_CACHE.get(key);
86+
if (cached != null) {
87+
return cached;
88+
}
89+
LineMetrics resolved = toLineMetrics(font.lineMetrics(style));
90+
cacheGlobalLineMetrics(key, resolved);
91+
return resolved;
92+
}
8393

84-
double lineHeight = Math.max(0.0, font.getLineHeight(style));
85-
return new LineMetrics(lineHeight, 0.0, 0.0);
94+
private static LineMetrics toLineMetrics(FontLineMetrics metrics) {
95+
return new LineMetrics(metrics.ascent(), metrics.descent(), metrics.leading());
8696
}
8797

8898
private double resolveTextWidth(Font<?> font, TextStyle style, String text) {
@@ -95,20 +105,25 @@ private Font<?> resolveFont(TextStyle style) {
95105
.orElseThrow(() -> new IllegalStateException("Font not found for style: " + key.fontName())));
96106
}
97107

98-
private GlobalPdfStyleKey globalPdfStyleKey(PdfFont font, TextStyle style) {
99-
return globalPdfStyleKeyCache.computeIfAbsent(style, key -> GlobalPdfStyleKey.from(font, key));
108+
private GlobalStyleKey globalStyleKey(TextStyle style, String cacheKey) {
109+
// Namespace the process-wide cache by backend font type: distinct backends
110+
// may return the same measurementCacheKey (e.g. both key on "Helvetica")
111+
// for different metrics, so without fontClass they would collide in the
112+
// shared static cache.
113+
return globalStyleKeyCache.computeIfAbsent(style,
114+
key -> new GlobalStyleKey(fontClass.getName(), cacheKey, key.size(), key.decoration()));
100115
}
101116

102-
private static void cacheGlobalLineMetrics(GlobalPdfStyleKey key, LineMetrics metrics) {
117+
private static void cacheGlobalLineMetrics(GlobalStyleKey key, LineMetrics metrics) {
103118
// Safety cap on the process-wide line-metrics cache. Distinct styles are
104119
// few in real use (a handful of font/size/decoration combos); this only
105120
// guards a pathological style explosion. Stop inserting once full instead
106121
// of clear()-ing: the old full flush wiped every hot entry under
107122
// concurrent rendering (a thundering-herd recompute), so keeping the
108123
// existing entries is strictly better. This runs on a cache miss only,
109124
// never on the per-measurement get() path.
110-
if (GLOBAL_PDF_LINE_METRICS_CACHE.size() < GLOBAL_LINE_METRICS_CACHE_LIMIT) {
111-
GLOBAL_PDF_LINE_METRICS_CACHE.putIfAbsent(key, metrics);
125+
if (GLOBAL_LINE_METRICS_CACHE.size() < GLOBAL_LINE_METRICS_CACHE_LIMIT) {
126+
GLOBAL_LINE_METRICS_CACHE.putIfAbsent(key, metrics);
112127
}
113128
}
114129

@@ -117,7 +132,7 @@ public void clearCaches() {
117132
fontCache.clear();
118133
lineMetricsCache.clear();
119134
textWidthCache.clear();
120-
globalPdfStyleKeyCache.clear();
135+
globalStyleKeyCache.clear();
121136
}
122137

123138
int sessionTextWidthCacheSize() {
@@ -128,10 +143,7 @@ int sessionTextWidthCacheSize() {
128143
return total;
129144
}
130145

131-
private record GlobalPdfStyleKey(String fontKey, double size, TextDecoration decoration) {
132-
private static GlobalPdfStyleKey from(PdfFont font, TextStyle style) {
133-
return new GlobalPdfStyleKey(font.measurementCacheKey(style), style.size(), style.decoration());
134-
}
146+
private record GlobalStyleKey(String fontType, String fontKey, double size, TextDecoration decoration) {
135147
}
136148

137149
}

src/main/java/com/demcha/compose/engine/render/pdf/PdfFont.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.demcha.compose.engine.components.content.text.TextStyle;
44
import com.demcha.compose.engine.components.geometry.ContentSize;
55
import com.demcha.compose.engine.font.FontBase;
6+
import com.demcha.compose.engine.font.FontLineMetrics;
67
import lombok.experimental.Accessors;
78
import lombok.extern.slf4j.Slf4j;
89
import org.apache.fontbox.util.BoundingBox;
@@ -69,12 +70,28 @@ public VerticalMetrics verticalMetrics(TextStyle style) {
6970
return metrics;
7071
}
7172

73+
/**
74+
* Bridges the PDFBox-derived {@link VerticalMetrics} to the backend-neutral
75+
* {@link FontLineMetrics} the shared text-measurement system consumes, so the
76+
* measurement system resolves PDF line metrics polymorphically rather than via
77+
* an {@code instanceof PdfFont} special case.
78+
*
79+
* @param style the resolved text style
80+
* @return ascent, descent, and leading in document units
81+
*/
82+
@Override
83+
public FontLineMetrics lineMetrics(TextStyle style) {
84+
VerticalMetrics metrics = verticalMetrics(style);
85+
return new FontLineMetrics(metrics.ascent(), metrics.descent(), metrics.leading());
86+
}
87+
7288
/**
7389
* Returns a stable font identity for text measurement caches.
7490
*
7591
* @param style style selecting the concrete font variant
7692
* @return backend font name used for width and metric calculations
7793
*/
94+
@Override
7895
public String measurementCacheKey(TextStyle style) {
7996
return fontType(style.decoration()).getName();
8097
}

src/test/java/com/demcha/compose/engine/measurement/FontLibraryTextMeasurementSystemTest.java

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
package com.demcha.compose.engine.measurement;
22

33
import com.demcha.compose.engine.components.content.text.TextStyle;
4+
import com.demcha.compose.engine.components.geometry.ContentSize;
5+
import com.demcha.compose.engine.font.FontBase;
6+
import com.demcha.compose.engine.font.FontLineMetrics;
47
import com.demcha.compose.engine.render.pdf.PdfFont;
58
import com.demcha.compose.font.DefaultFonts;
9+
import com.demcha.compose.font.FontLibrary;
10+
import com.demcha.compose.font.FontName;
611
import org.junit.jupiter.api.Test;
712

813
import java.lang.reflect.Modifier;
@@ -45,4 +50,115 @@ void clearCachesShouldDiscardSessionTextWidthCache() {
4550

4651
assertThat(measurement.sessionTextWidthCacheSize()).isZero();
4752
}
53+
54+
@Test
55+
void resolvesBackendLineMetricsPolymorphicallyWithoutPdfSpecialCase() {
56+
// A backend font that is NOT a PdfFont but supplies first-class metrics by
57+
// overriding Font#lineMetrics. The shared measurement system must honour
58+
// them via the contract, with no instanceof PdfFont fast-path.
59+
FontLibrary library = DefaultFonts.standardLibrary();
60+
library.addFont(FontName.HELVETICA, FirstClassMetricsFont.class, new FirstClassMetricsFont());
61+
FontLibraryTextMeasurementSystem measurement =
62+
new FontLibraryTextMeasurementSystem(library, FirstClassMetricsFont.class);
63+
TextStyle style = helveticaStyle();
64+
65+
TextMeasurementSystem.LineMetrics metrics = measurement.lineMetrics(style);
66+
67+
assertThat(metrics.ascent()).isEqualTo(10.0);
68+
assertThat(metrics.descent())
69+
.describedAs("a non-PDF backend must get its real descent, not the degraded descent=0 fallback")
70+
.isEqualTo(3.0);
71+
assertThat(metrics.leading()).isEqualTo(1.0);
72+
assertThat(metrics.lineHeight()).isEqualTo(14.0);
73+
}
74+
75+
@Test
76+
void defaultLineMetricsDeriveFromLineHeightWithZeroDescentAndLeading() {
77+
// A backend font that does NOT override Font#lineMetrics falls back to the
78+
// contract default: ascent = line height, descent = leading = 0.
79+
FontLibrary library = DefaultFonts.standardLibrary();
80+
library.addFont(FontName.HELVETICA, DefaultMetricsFont.class, new DefaultMetricsFont(20.0));
81+
FontLibraryTextMeasurementSystem measurement =
82+
new FontLibraryTextMeasurementSystem(library, DefaultMetricsFont.class);
83+
TextStyle style = helveticaStyle();
84+
85+
TextMeasurementSystem.LineMetrics metrics = measurement.lineMetrics(style);
86+
87+
assertThat(metrics.ascent()).isEqualTo(20.0);
88+
assertThat(metrics.descent()).isZero();
89+
assertThat(metrics.leading()).isZero();
90+
assertThat(metrics.lineHeight()).isEqualTo(20.0);
91+
}
92+
93+
@Test
94+
void globalMetricsCacheIsNamespacedByBackendFontType() {
95+
// Two different backend font types that return the SAME measurementCacheKey
96+
// must not collide in the process-wide cache — the multi-backend invariant.
97+
FontLibrary library = DefaultFonts.standardLibrary();
98+
library.addFont(FontName.HELVETICA, BackendAFont.class, new BackendAFont());
99+
library.addFont(FontName.HELVETICA, BackendBFont.class, new BackendBFont());
100+
FontLibraryTextMeasurementSystem a = new FontLibraryTextMeasurementSystem(library, BackendAFont.class);
101+
FontLibraryTextMeasurementSystem b = new FontLibraryTextMeasurementSystem(library, BackendBFont.class);
102+
TextStyle style = helveticaStyle();
103+
104+
// Resolve A first so it populates the shared static cache under the colliding key.
105+
assertThat(a.lineMetrics(style).ascent()).isEqualTo(10.0);
106+
assertThat(b.lineMetrics(style).ascent())
107+
.describedAs("backend B must get its own metrics, not backend A's value cached under the shared key")
108+
.isEqualTo(20.0);
109+
}
110+
111+
private static TextStyle helveticaStyle() {
112+
return new TextStyle(FontName.HELVETICA, 12.0,
113+
TextStyle.DEFAULT_STYLE.decoration(), TextStyle.DEFAULT_STYLE.color());
114+
}
115+
116+
/** Minimal non-PDF backend font that relies on the {@link com.demcha.compose.engine.font.Font} metric defaults. */
117+
private static class DefaultMetricsFont extends FontBase<Object> {
118+
private final double lineHeight;
119+
120+
DefaultMetricsFont(double lineHeight) {
121+
super(new Object(), new Object(), new Object(), new Object());
122+
this.lineHeight = lineHeight;
123+
}
124+
125+
@Override public double getTextWidth(TextStyle style, String text) { return text == null ? 0.0 : text.length(); }
126+
@Override public double getTextWidthNoSanitize(TextStyle style, String text) { return getTextWidth(style, text); }
127+
@Override public double getLineHeight(TextStyle style) { return lineHeight; }
128+
@Override public double getTextHeight(TextStyle style) { return lineHeight; }
129+
@Override public double getCapHeight(TextStyle style) { return 0.0; }
130+
@Override public double scale(double size) { return size; }
131+
@Override public TextStyle adjustFontSizeToFit(String text, TextStyle style, double availableWidth) { return style; }
132+
@Override public ContentSize getTightBounds(String text, TextStyle style) { return new ContentSize(0.0, 0.0); }
133+
}
134+
135+
/** Non-PDF backend font that overrides the metric seam with first-class ascent/descent/leading. */
136+
private static final class FirstClassMetricsFont extends DefaultMetricsFont {
137+
FirstClassMetricsFont() {
138+
super(99.0); // deliberately distinct from the overridden metrics below
139+
}
140+
141+
@Override
142+
public FontLineMetrics lineMetrics(TextStyle style) {
143+
return new FontLineMetrics(10.0, 3.0, 1.0);
144+
}
145+
146+
@Override
147+
public String measurementCacheKey(TextStyle style) {
148+
return "first-class|" + style.size();
149+
}
150+
}
151+
152+
/** Two backends below deliberately share a measurementCacheKey to exercise cross-backend cache isolation. */
153+
private static final class BackendAFont extends DefaultMetricsFont {
154+
BackendAFont() { super(0.0); }
155+
@Override public FontLineMetrics lineMetrics(TextStyle style) { return new FontLineMetrics(10.0, 0.0, 0.0); }
156+
@Override public String measurementCacheKey(TextStyle style) { return "shared-collision-key"; }
157+
}
158+
159+
private static final class BackendBFont extends DefaultMetricsFont {
160+
BackendBFont() { super(0.0); }
161+
@Override public FontLineMetrics lineMetrics(TextStyle style) { return new FontLineMetrics(20.0, 0.0, 0.0); }
162+
@Override public String measurementCacheKey(TextStyle style) { return "shared-collision-key"; }
163+
}
48164
}

0 commit comments

Comments
 (0)