@solidtv/renderer 1.1.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/exports/canvas-shaders.d.ts +1 -0
  2. package/dist/exports/canvas-shaders.js +1 -0
  3. package/dist/exports/canvas-shaders.js.map +1 -1
  4. package/dist/exports/webgl-shaders.d.ts +1 -0
  5. package/dist/exports/webgl-shaders.js +1 -0
  6. package/dist/exports/webgl-shaders.js.map +1 -1
  7. package/dist/src/core/CoreNode.js +4 -2
  8. package/dist/src/core/CoreNode.js.map +1 -1
  9. package/dist/src/core/CoreTextNode.js +9 -7
  10. package/dist/src/core/CoreTextNode.js.map +1 -1
  11. package/dist/src/core/Stage.js +6 -0
  12. package/dist/src/core/Stage.js.map +1 -1
  13. package/dist/src/core/renderers/webgl/WebGlRenderer.js +11 -8
  14. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  15. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +3 -2
  16. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
  17. package/dist/src/core/shaders/canvas/RadialProgress.d.ts +10 -0
  18. package/dist/src/core/shaders/canvas/RadialProgress.js +87 -0
  19. package/dist/src/core/shaders/canvas/RadialProgress.js.map +1 -0
  20. package/dist/src/core/shaders/templates/RadialProgressTemplate.d.ts +61 -0
  21. package/dist/src/core/shaders/templates/RadialProgressTemplate.js +54 -0
  22. package/dist/src/core/shaders/templates/RadialProgressTemplate.js.map +1 -0
  23. package/dist/src/core/shaders/webgl/Border.js +6 -4
  24. package/dist/src/core/shaders/webgl/Border.js.map +1 -1
  25. package/dist/src/core/shaders/webgl/RadialProgress.d.ts +3 -0
  26. package/dist/src/core/shaders/webgl/RadialProgress.js +145 -0
  27. package/dist/src/core/shaders/webgl/RadialProgress.js.map +1 -0
  28. package/dist/src/core/shaders/webgl/RoundedWithBorder.js +6 -4
  29. package/dist/src/core/shaders/webgl/RoundedWithBorder.js.map +1 -1
  30. package/dist/src/core/shaders/webgl/RoundedWithBorderAndShadow.js +6 -4
  31. package/dist/src/core/shaders/webgl/RoundedWithBorderAndShadow.js.map +1 -1
  32. package/dist/src/core/text-rendering/CanvasTextRenderer.js +5 -2
  33. package/dist/src/core/text-rendering/CanvasTextRenderer.js.map +1 -1
  34. package/dist/src/core/text-rendering/SdfFontHandler.js +35 -0
  35. package/dist/src/core/text-rendering/SdfFontHandler.js.map +1 -1
  36. package/dist/src/core/text-rendering/SdfTextRenderer.js +21 -20
  37. package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
  38. package/dist/src/core/text-rendering/TextLayoutEngine.d.ts +7 -1
  39. package/dist/src/core/text-rendering/TextLayoutEngine.js +94 -10
  40. package/dist/src/core/text-rendering/TextLayoutEngine.js.map +1 -1
  41. package/dist/src/core/text-rendering/TextRenderer.d.ts +61 -10
  42. package/dist/src/main-api/Renderer.d.ts +23 -1
  43. package/dist/src/main-api/Renderer.js +6 -1
  44. package/dist/src/main-api/Renderer.js.map +1 -1
  45. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  46. package/exports/canvas-shaders.ts +1 -0
  47. package/exports/webgl-shaders.ts +1 -0
  48. package/package.json +1 -1
  49. package/src/core/CoreNode.ts +6 -2
  50. package/src/core/CoreTextNode.ts +9 -6
  51. package/src/core/Stage.ts +7 -0
  52. package/src/core/renderers/webgl/WebGlCtxTexture.test.ts +94 -0
  53. package/src/core/renderers/webgl/WebGlRenderer.ts +14 -10
  54. package/src/core/renderers/webgl/WebGlShaderProgram.ts +3 -2
  55. package/src/core/shaders/canvas/RadialProgress.ts +113 -0
  56. package/src/core/shaders/templates/RadialProgressTemplate.test.ts +125 -0
  57. package/src/core/shaders/templates/RadialProgressTemplate.ts +112 -0
  58. package/src/core/shaders/webgl/Border.ts +6 -4
  59. package/src/core/shaders/webgl/RadialProgress.ts +163 -0
  60. package/src/core/shaders/webgl/RoundedWithBorder.ts +6 -4
  61. package/src/core/shaders/webgl/RoundedWithBorderAndShadow.ts +6 -4
  62. package/src/core/text-rendering/CanvasTextRenderer.ts +5 -2
  63. package/src/core/text-rendering/SdfFontHandler.ts +37 -0
  64. package/src/core/text-rendering/SdfTextRenderer.ts +28 -22
  65. package/src/core/text-rendering/TextLayoutEngine.ts +99 -12
  66. package/src/core/text-rendering/TextRenderer.ts +62 -10
  67. package/src/main-api/Renderer.ts +33 -2
@@ -65,10 +65,12 @@ export const RoundedWithBorder: WebGlShaderType<RoundedWithBorderProps> = {
65
65
  v_outerSize = vec2(0.0);
66
66
 
67
67
  if(borderZero == 0.0) {
68
- float borderTop = u_borderWidth.x;
69
- float borderRight = u_borderWidth.y;
70
- float borderBottom = u_borderWidth.z;
71
- float borderLeft = u_borderWidth.w;
68
+ vec4 adjustedBorderWidth = u_borderWidth - 1.0 + clamp(u_borderWidth, -1.0, 1.0);
69
+
70
+ float borderTop = adjustedBorderWidth.x;
71
+ float borderRight = adjustedBorderWidth.y;
72
+ float borderBottom = adjustedBorderWidth.z;
73
+ float borderLeft = adjustedBorderWidth.w;
72
74
 
73
75
  v_outerBorderUv = vec2(0.0);
74
76
  v_innerBorderUv = vec2(0.0);
@@ -72,10 +72,12 @@ export const RoundedWithBorderAndShadow: WebGlShaderType<RoundedWithBorderAndSha
72
72
  v_outerSize = vec2(0.0);
73
73
 
74
74
  if(borderZero == 0.0) {
75
- float borderTop = u_borderWidth.x;
76
- float borderRight = u_borderWidth.y;
77
- float borderBottom = u_borderWidth.z;
78
- float borderLeft = u_borderWidth.w;
75
+ vec4 adjustedBorderWidth = u_borderWidth - 1.0 + clamp(u_borderWidth, -1.0, 1.0);
76
+
77
+ float borderTop = adjustedBorderWidth.x;
78
+ float borderRight = adjustedBorderWidth.y;
79
+ float borderBottom = adjustedBorderWidth.z;
80
+ float borderLeft = adjustedBorderWidth.w;
79
81
 
80
82
  v_outerBorderUv = vec2(0.0);
81
83
  v_innerBorderUv = vec2(0.0);
@@ -109,7 +109,10 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => {
109
109
  const font = `${fontStyle} ${fontSize}px Unknown, ${fontFamily}`;
110
110
  // Get font metrics and calculate line height
111
111
  measureContext.font = font;
112
- measureContext.textBaseline = 'hanging';
112
+ // The layout engine emits line[4] as the alphabetic baseline Y, matching
113
+ // CSS line box layout. Both contexts must use 'alphabetic' so fillText draws
114
+ // the baseline exactly at line[4].
115
+ measureContext.textBaseline = 'alphabetic';
113
116
 
114
117
  const metrics = CanvasFontHandler.getFontMetrics(fontFamily, fontSize);
115
118
 
@@ -150,7 +153,7 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => {
150
153
  const a = color & 0xff;
151
154
  context.fillStyle = `rgba(${r},${g},${b},${a / 255})`;
152
155
  context.font = font;
153
- context.textBaseline = 'hanging';
156
+ context.textBaseline = 'alphabetic';
154
157
 
155
158
  // Performance optimization for large fonts
156
159
  if (fontSize >= 128) {
@@ -229,6 +229,43 @@ const processFontData = (
229
229
  unitsPerEm: 1000,
230
230
  };
231
231
 
232
+ // Derive cap-height from the atlas when the metrics block doesn't already
233
+ // supply it. The layout engine uses this value to vertically center
234
+ // capital letters on each line. BMFont stores per-glyph `yoffset` as the
235
+ // distance from the line-box top to the glyph's top, and `common.base` as
236
+ // the distance from the line-box top to the alphabetic baseline — so the
237
+ // distance from the baseline up to the top of 'H' (atlas design px) is
238
+ // `common.base - H.yoffset`. Converted into font units it slots into
239
+ // `FontMetrics.capHeight` alongside the existing ascender / descender
240
+ // values and flows through `normalizeFontMetrics`.
241
+ if (metrics.capHeight === undefined) {
242
+ const capGlyph = glyphMap.get(72); // 'H'
243
+ if (capGlyph !== undefined) {
244
+ const capHeightAtlasPx = fontData.common.base - capGlyph.yoffset;
245
+ metrics = {
246
+ ...metrics,
247
+ capHeight: (capHeightAtlasPx / fontData.info.size) * metrics.unitsPerEm,
248
+ };
249
+ }
250
+ // If 'H' isn't in the atlas (icon-only fonts, etc.) we leave capHeight
251
+ // undefined and rely on the 0.7 × ascender fallback inside
252
+ // normalizeFontMetrics.
253
+ }
254
+
255
+ // Same derivation for x-height using glyph 'x' (id 120). Only consumed
256
+ // when `RendererMainSettings.textBaselineMode === 'x'`; otherwise the
257
+ // 0.5 × ascender fallback inside `normalizeFontMetrics` is sufficient.
258
+ if (metrics.xHeight === undefined) {
259
+ const xGlyph = glyphMap.get(120); // 'x'
260
+ if (xGlyph !== undefined) {
261
+ const xHeightAtlasPx = fontData.common.base - xGlyph.yoffset;
262
+ metrics = {
263
+ ...metrics,
264
+ xHeight: (xHeightAtlasPx / fontData.info.size) * metrics.unitsPerEm,
265
+ };
266
+ }
267
+ }
268
+
232
269
  // Cache processed data
233
270
  fontCache.set(fontFamily, {
234
271
  data: fontData,
@@ -16,6 +16,8 @@ import type { WebGlShaderNode } from '../renderers/webgl/WebGlShaderNode.js';
16
16
  import { isProductionEnvironment } from '../../utils.js';
17
17
  import type { TextLayout, GlyphLayout } from './TextRenderer.js';
18
18
  import { mapTextLayout } from './TextLayoutEngine.js';
19
+ import type { RectWithValid } from '../lib/utils.js';
20
+ import type { Dimensions } from '../../common/CommonTypes.js';
19
21
 
20
22
  // Type definition to match interface
21
23
  const type = 'sdf' as const;
@@ -35,7 +37,7 @@ const font: FontHandler = SdfFontHandler;
35
37
  const layoutCache = new Map<string, TextLayout>();
36
38
 
37
39
  const getLayoutCacheKey = (props: CoreTextNodeProps): string =>
38
- `${props.fontFamily}-${props.fontSize}-${props.letterSpacing}-${props.lineHeight}-${props.maxHeight}-${props.maxWidth}-${props.maxLines}-${props.textAlign}-${props.wordBreak}-${props.overflowSuffix}-${props.text}`;
40
+ `${props.fontFamily}-${props.fontStyle}-${props.fontSize}-${props.letterSpacing}-${props.lineHeight}-${props.maxHeight}-${props.maxWidth}-${props.maxLines}-${props.textAlign}-${props.wordBreak}-${props.overflowSuffix}-${props.text}`;
39
41
 
40
42
  /**
41
43
  * SDF text renderer using MSDF/SDF fonts with WebGL
@@ -144,14 +146,12 @@ const renderQuads = (
144
146
  cache.vertices,
145
147
  cache.glyphCount,
146
148
  ctxTexture,
147
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
148
- renderProps.clippingRect as any,
149
+ renderProps.clippingRect,
149
150
  renderProps.worldAlpha,
150
151
  layout.width,
151
152
  layout.height,
152
153
  renderProps.parentHasRenderTexture,
153
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
154
- renderProps.framebufferDimensions as any,
154
+ renderProps.framebufferDimensions,
155
155
  sdfShader!,
156
156
  );
157
157
  return null;
@@ -160,7 +160,6 @@ const renderQuads = (
160
160
 
161
161
  // --- Cache-miss slow path -----------------------------------------------
162
162
  const startIdx = webGlRenderer.sdfBufferIdx;
163
-
164
163
  webGlRenderer.addSdfQuads(
165
164
  layout.glyphs,
166
165
  layout.fontScale,
@@ -169,13 +168,12 @@ const renderQuads = (
169
168
  renderProps.worldAlpha,
170
169
  layout.distanceRange,
171
170
  ctxTexture,
172
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
173
- renderProps.clippingRect as any,
171
+
172
+ renderProps.clippingRect,
174
173
  layout.width,
175
174
  layout.height,
176
175
  renderProps.parentHasRenderTexture,
177
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
178
- renderProps.framebufferDimensions as any,
176
+ renderProps.framebufferDimensions,
179
177
  sdfShader!,
180
178
  );
181
179
 
@@ -221,9 +219,14 @@ const generateTextLayout = (
221
219
  const fontData = fontCache.data;
222
220
  const commonFontData = fontData.common;
223
221
  const designFontSize = fontData.info.size;
224
- const designLineHeight = commonFontData.lineHeight;
225
- const lineHeight =
226
- props.lineHeight || (designLineHeight * fontSize) / designFontSize;
222
+ // common.base = distance from BMFont line-box top to the alphabetic baseline,
223
+ // in atlas design units. Used to convert per-glyph yoffset (BMFont top -> glyph top)
224
+ // into baseline-relative placement.
225
+ const atlasBase = commonFontData.base;
226
+ // When the user does not specify lineHeight, fall back to the engine's
227
+ // 'normal' line height (ascender + lineGap - descender) computed inside
228
+ // mapTextLayout via the supplied metrics. Passing 0 below triggers that path.
229
+ const lineHeight = props.lineHeight;
227
230
 
228
231
  const atlasWidth = commonFontData.scaleW;
229
232
  const atlasHeight = commonFontData.scaleH;
@@ -242,9 +245,9 @@ const generateTextLayout = (
242
245
  const maxHeight = props.maxHeight;
243
246
  const [
244
247
  lines,
245
- remainingLines,
246
- hasRemainingText,
247
- bareLineHeight,
248
+ _remainingLines,
249
+ _hasRemainingText,
250
+ _bareLineHeight,
248
251
  lineHeightPx,
249
252
  effectiveWidth,
250
253
  effectiveHeight,
@@ -267,15 +270,16 @@ const generateTextLayout = (
267
270
 
268
271
  const glyphs: GlyphLayout[] = [];
269
272
  let currentX = 0;
270
- let currentY = 0;
273
+ let baselineY = 0;
271
274
  for (let i = 0; i < lineAmount; i++) {
272
275
  const line = lines[i] as TextLineStruct;
273
276
  const textLine = line[0];
274
277
  const textLineLength = textLine.length;
275
278
  let prevGlyphId = 0;
276
279
  currentX = line[3];
277
- //convert Y coord to vertex value
278
- currentY = line[4] / fontScale;
280
+ // line[4] is the alphabetic baseline Y in screen px. Convert to atlas
281
+ // design units (where glyph.yoffset and atlasBase live).
282
+ baselineY = line[4] / fontScale;
279
283
 
280
284
  for (let j = 0; j < textLineLength; j++) {
281
285
  const codepoint = textLine.codePointAt(j) as number;
@@ -318,10 +322,13 @@ const generateTextLayout = (
318
322
  // Apply pair kerning before placing this glyph.
319
323
  currentX += kerning;
320
324
 
321
- // Calculate glyph position and atlas coordinates (in design units)
325
+ // Glyph position in atlas design units. yoffset is measured from the
326
+ // BMFont line-box top; subtracting atlasBase re-anchors it relative to
327
+ // the alphabetic baseline so fonts with different BMFont 'base' values
328
+ // share the same on-screen baseline.
322
329
  const glyphLayout: GlyphLayout = {
323
330
  x: currentX + glyph.xoffset,
324
- y: currentY + glyph.yoffset,
331
+ y: baselineY + glyph.yoffset - atlasBase,
325
332
  width: glyph.width,
326
333
  height: glyph.height,
327
334
  atlasX: glyph.x * invAtlasWidth,
@@ -336,7 +343,6 @@ const generateTextLayout = (
336
343
  currentX += glyph.xadvance + letterSpacing;
337
344
  prevGlyphId = glyph.id;
338
345
  }
339
- currentY += lineHeightPx;
340
346
  }
341
347
 
342
348
  // Convert final dimensions to pixel space for the layout
@@ -2,6 +2,7 @@ import type {
2
2
  FontMetrics,
3
3
  MeasureTextFn,
4
4
  NormalizedFontMetrics,
5
+ TextBaselineMode,
5
6
  TextLayoutStruct,
6
7
  TextLineStruct,
7
8
  WrappedLinesStruct,
@@ -35,18 +36,53 @@ type WrapStrategyFn = (
35
36
  overflowWidth: number,
36
37
  ) => [string, number, string];
37
38
 
39
+ /**
40
+ * Generic Latin-font ratios used when exact metric values aren't available.
41
+ * Within a few percent of OS/2 sCapHeight / sxHeight across the common Latin
42
+ * families (Ubuntu, Noto, Roboto, Arial, etc.).
43
+ */
44
+ const CAP_HEIGHT_FALLBACK_RATIO = 0.7;
45
+ const X_HEIGHT_FALLBACK_RATIO = 0.5;
46
+
38
47
  export const normalizeFontMetrics = (
39
48
  metrics: FontMetrics,
40
49
  fontSize: number,
41
50
  ): NormalizedFontMetrics => {
42
51
  const scale = fontSize / metrics.unitsPerEm;
52
+ const capHeightUnits =
53
+ metrics.capHeight !== undefined
54
+ ? metrics.capHeight
55
+ : metrics.ascender * CAP_HEIGHT_FALLBACK_RATIO;
56
+ const xHeightUnits =
57
+ metrics.xHeight !== undefined
58
+ ? metrics.xHeight
59
+ : metrics.ascender * X_HEIGHT_FALLBACK_RATIO;
43
60
  return {
44
61
  ascender: metrics.ascender * scale,
45
62
  descender: metrics.descender * scale,
46
63
  lineGap: metrics.lineGap * scale,
64
+ capHeight: capHeightUnits * scale,
65
+ xHeight: xHeightUnits * scale,
47
66
  };
48
67
  };
49
68
 
69
+ /**
70
+ * Engine-wide per-line baseline anchor. Configured once at renderer creation
71
+ * via {@link RendererMainSettings.textBaselineMode}, not exposed per node so
72
+ * a single app can't mix anchor models across its text. Defaults to `'cap'`
73
+ * — see {@link TextBaselineMode} for the rationale.
74
+ */
75
+ let baselineMode: TextBaselineMode = 'cap';
76
+
77
+ /**
78
+ * Sets the engine-wide baseline anchor. Called by `Stage` during construction;
79
+ * not intended to be called from user code (changing this mid-session would
80
+ * silently reflow every cached text layout).
81
+ */
82
+ export const setBaselineMode = (mode: TextBaselineMode): void => {
83
+ baselineMode = mode;
84
+ };
85
+
50
86
  export const mapTextLayout = (
51
87
  measureText: MeasureTextFn,
52
88
  metrics: NormalizedFontMetrics,
@@ -61,14 +97,12 @@ export const mapTextLayout = (
61
97
  maxWidth: number,
62
98
  maxHeight: number,
63
99
  ): TextLayoutStruct => {
64
- const ascPx = metrics.ascender;
65
- const descPx = metrics.descender;
66
-
67
- const bareLineHeight = ascPx - descPx;
100
+ // Default line height matches CSS 'normal': ascender + lineGap - descender.
101
+ // metrics.descender is negative for descents below the baseline, so
102
+ // subtracting it adds the descent depth.
103
+ const bareLineHeight = metrics.ascender - metrics.descender + metrics.lineGap;
68
104
  const lineHeightPx =
69
105
  lineHeight <= 3 ? lineHeight * bareLineHeight : lineHeight;
70
- const lineHeightDelta = lineHeightPx - bareLineHeight;
71
- const halfDelta = lineHeightDelta * 0.5;
72
106
 
73
107
  let effectiveMaxLines = maxLines;
74
108
 
@@ -107,12 +141,27 @@ export const mapTextLayout = (
107
141
  effectiveMaxLines,
108
142
  );
109
143
 
110
- let effectiveLineAmount = lines.length;
144
+ const effectiveLineAmount = lines.length;
111
145
  let effectiveMaxWidth = 0;
112
146
 
147
+ // CSS letter-spacing applies between characters, not after the trailing one.
148
+ // measureText accumulates one advance + letterSpacing per glyph, so each
149
+ // line carries one extra trailing letterSpacing. Trim it once per line for
150
+ // alignment / reported width purposes. Wrap decisions inside the loops above
151
+ // intentionally still use the un-trimmed width (CSS engines also consider
152
+ // trailing letter-spacing during break decisions; only the rendered line
153
+ // width is trimmed).
154
+ if (letterSpacing !== 0) {
155
+ for (let i = 0; i < effectiveLineAmount; i++) {
156
+ const line = lines[i]!;
157
+ if (line[0].length > 0) {
158
+ line[1] -= letterSpacing;
159
+ }
160
+ }
161
+ }
162
+
113
163
  if (effectiveLineAmount > 0) {
114
164
  effectiveMaxWidth = lines[0]![1];
115
- //check for longest line
116
165
  if (effectiveLineAmount > 1) {
117
166
  for (let i = 1; i < effectiveLineAmount; i++) {
118
167
  effectiveMaxWidth = Math.max(effectiveMaxWidth, lines[i]![1]);
@@ -134,12 +183,50 @@ export const mapTextLayout = (
134
183
 
135
184
  const effectiveMaxHeight = effectiveLineAmount * lineHeightPx;
136
185
 
137
- let firstBaseLine = halfDelta;
138
-
139
- const startY = firstBaseLine;
186
+ // line[4] stores the alphabetic baseline Y of each line in screen px.
187
+ //
188
+ // ── Per-line anchor: cap-height centering ─────────────────────────────
189
+ //
190
+ // baselineY(i) = lineHeight/2 + capHeight/2 + i × lineHeight
191
+ //
192
+ // The baseline sits below the line's geometric mid-line by exactly
193
+ // `capHeight / 2`, so the top of an uppercase letter lands the same
194
+ // distance *above* the mid-line — i.e. capital letters bracket the
195
+ // center symmetrically. Cap-height centering matches what designers
196
+ // expect for UI text (button labels, headings, badges): TXYZ and 1234
197
+ // sit centered; descenders like 'gjpq' hang slightly below, mirroring
198
+ // CSS button behavior in browsers.
199
+ //
200
+ // Alternative anchors considered (kept here for the record):
201
+ //
202
+ // ── x-height centering ──────────────────────────────────────────────
203
+ // baselineY(i) = lineHeight/2 + xHeight/2 + i × lineHeight
204
+ // Centers lowercase letters on the mid-line. Matches CSS inline
205
+ // `vertical-align: middle`. Reads well for running body text but
206
+ // capitals appear high in headings/labels — wrong default for TV UI.
207
+ //
208
+ // ── line-box centering (pre-cap-height behavior) ───────────────────
209
+ // const halfLeading = (lineHeightPx − bareLineHeight) / 2
210
+ // baselineY(i) = halfLeading + ascender + i × lineHeight
211
+ // Centers the abstract asc-to-desc-plus-leading rectangle. The visible
212
+ // ink lands noticeably high because asc/(asc−desc) is asymmetric for
213
+ // most Latin fonts (~4.2:1 for Ubuntu). Mathematically tidy, visually
214
+ // wrong.
215
+ //
216
+ // The active anchor is configured at renderer creation via
217
+ // `RendererMainSettings.textBaselineMode`. Defaults to `'cap'`.
218
+ let firstBaselineY: number;
219
+ if (baselineMode === 'x') {
220
+ firstBaselineY = (lineHeightPx + metrics.xHeight) * 0.5;
221
+ } else if (baselineMode === 'linebox') {
222
+ const halfLeading = (lineHeightPx - bareLineHeight) * 0.5;
223
+ firstBaselineY = halfLeading + metrics.ascender;
224
+ } else {
225
+ firstBaselineY = (lineHeightPx + metrics.capHeight) * 0.5;
226
+ }
140
227
  for (let i = 0; i < effectiveLineAmount; i++) {
141
228
  const line = lines[i] as TextLineStruct;
142
- line[4] = startY + lineHeightPx * i;
229
+ line[4] = firstBaselineY + lineHeightPx * i;
143
230
  }
144
231
 
145
232
  return [
@@ -1,4 +1,6 @@
1
+ import type { Dimensions } from '../../common/CommonTypes.js';
1
2
  import type { CoreTextNodeProps } from '../CoreTextNode.js';
3
+ import type { RectWithValid } from '../lib/utils.js';
2
4
  import type { CoreRenderer } from '../renderers/CoreRenderer.js';
3
5
  import type { SdfRenderOp } from '../renderers/webgl/SdfRenderOp.js';
4
6
  import type { Stage } from '../Stage.js';
@@ -12,6 +14,24 @@ export type TextBaseline =
12
14
  | 'bottom';
13
15
  export type TextVerticalAlign = 'top' | 'middle' | 'bottom';
14
16
  export type TextRenderers = 'canvas' | 'sdf';
17
+
18
+ /**
19
+ * Selects which font-derived height the text layout engine centers on each
20
+ * line's geometric mid-line. Configured via {@link RendererMainSettings} and
21
+ * cannot be overridden per node — see the engine-wide reasoning in
22
+ * `TextLayoutEngine.mapTextLayout`.
23
+ *
24
+ * - `'cap'` (default): capital letters centered. Best for UI text — button
25
+ * labels, headings, badges. Capitals and digits bracket the center
26
+ * symmetrically; descenders hang slightly below, matching CSS button
27
+ * behavior in browsers.
28
+ * - `'x'`: lowercase x-height centered. Better for running body text;
29
+ * capitals appear slightly high in headings.
30
+ * - `'linebox'`: legacy. Centers the abstract asc-to-desc-plus-leading
31
+ * rectangle. Mathematically tidy but visually unbalanced because most
32
+ * Latin fonts have asymmetric asc/desc ratios.
33
+ */
34
+ export type TextBaselineMode = 'cap' | 'x' | 'linebox';
15
35
  /**
16
36
  * Structure mapping font family names to a set of font faces.
17
37
  */
@@ -39,24 +59,54 @@ export interface FontMetrics {
39
59
  * The number of font units per 1 EM.
40
60
  */
41
61
  unitsPerEm: number;
62
+ /**
63
+ * The distance, in font units, from the baseline to the top of an uppercase
64
+ * letter (OS/2 sCapHeight).
65
+ *
66
+ * Used by the layout engine to vertically center capital letters on each
67
+ * line's geometric mid-line. When absent, the SDF backend derives this
68
+ * value from glyph `H` (id 72) in the BMFont atlas; the Canvas backend
69
+ * falls back to `0.7 × ascender` (a generic Latin-font approximation).
70
+ */
71
+ capHeight?: number;
72
+ /**
73
+ * The distance, in font units, from the baseline to the top of a lowercase
74
+ * letter (OS/2 sxHeight). Optional; used only when the baseline-anchor
75
+ * mode is set to x-height centering (experimental).
76
+ */
77
+ xHeight?: number;
42
78
  }
43
79
 
44
80
  /**
45
- * Normalized font metrics where values are expressed as a fraction of 1 EM.
81
+ * Normalized font metrics where values are expressed in pixels at the
82
+ * configured font size (em-px).
46
83
  */
47
84
  export interface NormalizedFontMetrics {
48
85
  /**
49
- * The distance, as a fraction of 1 EM, from the baseline to the highest point of the font.
86
+ * The distance, in em-px, from the baseline to the highest point of the font.
50
87
  */
51
88
  ascender: number;
52
89
  /**
53
- * The distance, as a fraction of 1 EM, from the baseline to the lowest point of the font.
90
+ * The distance, in em-px, from the baseline to the lowest point of the font.
54
91
  */
55
92
  descender: number;
56
93
  /**
57
- * The additional space used in the calculation of the default line height as a fraction of 1 EM
94
+ * The additional space used in the calculation of the default line height, in em-px.
58
95
  */
59
96
  lineGap: number;
97
+ /**
98
+ * The distance, in em-px, from the baseline to the top of an uppercase letter.
99
+ * Always populated; derived or approximated when {@link FontMetrics.capHeight}
100
+ * is not provided by the caller.
101
+ */
102
+ capHeight: number;
103
+ /**
104
+ * The distance, in em-px, from the baseline to the top of a lowercase letter.
105
+ * Always populated; derived from glyph `x` for SDF, falls back to
106
+ * `0.5 × ascender` otherwise. Only used by the experimental x-height
107
+ * baseline-anchor mode.
108
+ */
109
+ xHeight: number;
60
110
  }
61
111
 
62
112
  /**
@@ -181,13 +231,15 @@ export interface TrProps extends TrFontProps {
181
231
  */
182
232
  maxLines: number;
183
233
  /**
184
- * Vertical Align for text when lineHeight > fontSize
234
+ * Vertical alignment of the text block within `maxHeight`.
185
235
  *
186
236
  * @remarks
187
- * This property sets the vertical align of the text.
188
- * Not yet implemented in the SDF renderer.
237
+ * Activates when `maxHeight > 0`. Composes with `textBaselineMode`
238
+ * (per-line anchor). CSS line-box semantics `'top'` leaves
239
+ * half-leading above the first line's cap-top; `'bottom'` leaves
240
+ * half-leading below the last line's descender.
189
241
  *
190
- * @default middle
242
+ * @default top
191
243
  */
192
244
  verticalAlign: TextVerticalAlign;
193
245
  /**
@@ -345,11 +397,11 @@ export interface TextRenderProps {
345
397
  offsetY: number;
346
398
  worldAlpha: number;
347
399
  globalTransform: Float32Array;
348
- clippingRect: unknown;
400
+ clippingRect: RectWithValid;
349
401
  width: number;
350
402
  height: number;
351
403
  parentHasRenderTexture: boolean;
352
- framebufferDimensions: unknown;
404
+ framebufferDimensions: Dimensions | null;
353
405
  stage: Stage;
354
406
  /** Optional SDF vertex cache — passed by CoreTextNode for cache-hit fast path. */
355
407
  sdfCache?: SdfVertexCache;
@@ -6,7 +6,10 @@ import { CoreNode, type CoreNodeProps } from '../core/CoreNode.js';
6
6
  import { type CoreTextNodeProps } from '../core/CoreTextNode.js';
7
7
  import type { INode, INodeProps, ITextNode, ITextNodeProps } from './INode.js';
8
8
  import type { TextureMemoryManagerSettings } from '../core/TextureMemoryManager.js';
9
- import type { TextRenderer } from '../core/text-rendering/TextRenderer.js';
9
+ import type {
10
+ TextBaselineMode,
11
+ TextRenderer,
12
+ } from '../core/text-rendering/TextRenderer.js';
10
13
  import type { CanvasRenderer } from '../core/renderers/canvas/CanvasRenderer.js';
11
14
  import type { WebGlRenderer } from '../core/renderers/webgl/WebGlRenderer.js';
12
15
  import type { Inspector, InspectorOptions } from './Inspector.js';
@@ -363,6 +366,28 @@ export type RendererMainSettings = RendererRuntimeSettings & {
363
366
  */
364
367
  fontEngines: TextRenderer[];
365
368
 
369
+ /**
370
+ * Per-line vertical baseline anchor used by the text layout engine.
371
+ *
372
+ * @remarks
373
+ * Picks which font-derived height is centered on each line's geometric
374
+ * mid-line. This is engine-wide and intentionally not exposed per node —
375
+ * mixing anchor models within one app produces visually inconsistent text.
376
+ *
377
+ * - `'cap'` (default): capital letters and digits sit centered on the line.
378
+ * Best fit for UI text (button labels, headings, badges); descenders on
379
+ * words like 'gjpq' hang slightly below center, matching CSS button
380
+ * behavior in browsers.
381
+ * - `'x'`: lowercase x-height is centered. Better for running body text;
382
+ * capitals appear slightly high in headings.
383
+ * - `'linebox'`: legacy mode. Centers the asc/lineGap/desc rectangle.
384
+ * Mathematically tidy but visually unbalanced because most Latin fonts
385
+ * have asymmetric asc/desc ratios.
386
+ *
387
+ * @defaultValue `'cap'`
388
+ */
389
+ textBaselineMode: TextBaselineMode;
390
+
366
391
  /**
367
392
  * Force WebGL2
368
393
  *
@@ -516,7 +541,7 @@ export class RendererMain extends EventEmitter {
516
541
  boundsMargin: settings.boundsMargin || 0,
517
542
  deviceLogicalPixelRatio: settings.deviceLogicalPixelRatio || 1,
518
543
  devicePhysicalPixelRatio:
519
- settings.devicePhysicalPixelRatio || window.devicePixelRatio || 1,
544
+ settings.devicePhysicalPixelRatio || this.windowDevicePixelRatio() || 1,
520
545
  clearColor: settings.clearColor ?? 0x00000000,
521
546
  fpsUpdateInterval: settings.fpsUpdateInterval || 0,
522
547
  enableClear: settings.enableClear ?? true,
@@ -530,6 +555,7 @@ export class RendererMain extends EventEmitter {
530
555
  renderEngine: settings.renderEngine,
531
556
  quadBufferSize: settings.quadBufferSize ?? 4 * 1024 * 1024,
532
557
  fontEngines: settings.fontEngines ?? [],
558
+ textBaselineMode: settings.textBaselineMode ?? 'cap',
533
559
  textureProcessingTimeLimit: settings.textureProcessingTimeLimit || 10,
534
560
  canvas: settings.canvas,
535
561
  createImageBitmapSupport: settings.createImageBitmapSupport || 'full',
@@ -588,6 +614,7 @@ export class RendererMain extends EventEmitter {
588
614
  eventBus: this,
589
615
  quadBufferSize: settings.quadBufferSize!,
590
616
  fontEngines: settings.fontEngines!,
617
+ textBaselineMode: settings.textBaselineMode!,
591
618
  inspector: settings.inspector !== null,
592
619
  targetFPS: settings.targetFPS!,
593
620
  textureProcessingTimeLimit: settings.textureProcessingTimeLimit!,
@@ -1039,4 +1066,8 @@ export class RendererMain extends EventEmitter {
1039
1066
  this.stage.options.targetFPS = fps > 0 ? fps : 0;
1040
1067
  this.stage.updateTargetFrameTime();
1041
1068
  }
1069
+
1070
+ private windowDevicePixelRatio() {
1071
+ return typeof window !== 'undefined' ? window.devicePixelRatio : undefined;
1072
+ }
1042
1073
  }