@solidtv/renderer 1.1.5 → 1.2.1

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 (65) 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/common/CommonTypes.d.ts +5 -0
  8. package/dist/src/core/CoreNode.js +4 -2
  9. package/dist/src/core/CoreNode.js.map +1 -1
  10. package/dist/src/core/CoreTextNode.d.ts +17 -0
  11. package/dist/src/core/CoreTextNode.js +33 -7
  12. package/dist/src/core/CoreTextNode.js.map +1 -1
  13. package/dist/src/core/Stage.js +6 -0
  14. package/dist/src/core/Stage.js.map +1 -1
  15. package/dist/src/core/renderers/webgl/WebGlRenderer.js +11 -8
  16. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  17. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +3 -2
  18. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
  19. package/dist/src/core/shaders/canvas/RadialProgress.d.ts +10 -0
  20. package/dist/src/core/shaders/canvas/RadialProgress.js +87 -0
  21. package/dist/src/core/shaders/canvas/RadialProgress.js.map +1 -0
  22. package/dist/src/core/shaders/templates/BorderTemplate.d.ts +1 -1
  23. package/dist/src/core/shaders/templates/BorderTemplate.js +1 -1
  24. package/dist/src/core/shaders/templates/BorderTemplate.js.map +1 -1
  25. package/dist/src/core/shaders/templates/RadialProgressTemplate.d.ts +61 -0
  26. package/dist/src/core/shaders/templates/RadialProgressTemplate.js +54 -0
  27. package/dist/src/core/shaders/templates/RadialProgressTemplate.js.map +1 -0
  28. package/dist/src/core/shaders/webgl/RadialProgress.d.ts +3 -0
  29. package/dist/src/core/shaders/webgl/RadialProgress.js +145 -0
  30. package/dist/src/core/shaders/webgl/RadialProgress.js.map +1 -0
  31. package/dist/src/core/text-rendering/CanvasTextRenderer.js +12 -2
  32. package/dist/src/core/text-rendering/CanvasTextRenderer.js.map +1 -1
  33. package/dist/src/core/text-rendering/SdfFontHandler.js +35 -0
  34. package/dist/src/core/text-rendering/SdfFontHandler.js.map +1 -1
  35. package/dist/src/core/text-rendering/SdfTextRenderer.js +32 -20
  36. package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
  37. package/dist/src/core/text-rendering/TextLayoutEngine.d.ts +7 -1
  38. package/dist/src/core/text-rendering/TextLayoutEngine.js +94 -10
  39. package/dist/src/core/text-rendering/TextLayoutEngine.js.map +1 -1
  40. package/dist/src/core/text-rendering/TextRenderer.d.ts +84 -10
  41. package/dist/src/main-api/Renderer.d.ts +23 -1
  42. package/dist/src/main-api/Renderer.js +6 -1
  43. package/dist/src/main-api/Renderer.js.map +1 -1
  44. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  45. package/exports/canvas-shaders.ts +1 -0
  46. package/exports/webgl-shaders.ts +1 -0
  47. package/package.json +1 -1
  48. package/src/common/CommonTypes.ts +5 -0
  49. package/src/core/CoreNode.ts +6 -2
  50. package/src/core/CoreTextNode.ts +34 -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/BorderTemplate.ts +2 -2
  57. package/src/core/shaders/templates/RadialProgressTemplate.test.ts +125 -0
  58. package/src/core/shaders/templates/RadialProgressTemplate.ts +112 -0
  59. package/src/core/shaders/webgl/RadialProgress.ts +163 -0
  60. package/src/core/text-rendering/CanvasTextRenderer.ts +14 -2
  61. package/src/core/text-rendering/SdfFontHandler.ts +37 -0
  62. package/src/core/text-rendering/SdfTextRenderer.ts +41 -22
  63. package/src/core/text-rendering/TextLayoutEngine.ts +99 -12
  64. package/src/core/text-rendering/TextRenderer.ts +85 -10
  65. package/src/main-api/Renderer.ts +33 -2
@@ -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
@@ -50,6 +52,7 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => {
50
52
  return {
51
53
  width: 0,
52
54
  height: 0,
55
+ trimmedHeight: 0,
53
56
  };
54
57
  }
55
58
 
@@ -61,6 +64,7 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => {
61
64
  hasRemainingText: false,
62
65
  width: layout.width,
63
66
  height: layout.height,
67
+ trimmedHeight: layout.trimmedHeight,
64
68
  layout,
65
69
  };
66
70
  }
@@ -72,6 +76,7 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => {
72
76
  return {
73
77
  width: 0,
74
78
  height: 0,
79
+ trimmedHeight: 0,
75
80
  };
76
81
  }
77
82
 
@@ -85,6 +90,7 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => {
85
90
  hasRemainingText: false,
86
91
  width: layout.width,
87
92
  height: layout.height,
93
+ trimmedHeight: layout.trimmedHeight,
88
94
  layout,
89
95
  };
90
96
  };
@@ -144,14 +150,12 @@ const renderQuads = (
144
150
  cache.vertices,
145
151
  cache.glyphCount,
146
152
  ctxTexture,
147
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
148
- renderProps.clippingRect as any,
153
+ renderProps.clippingRect,
149
154
  renderProps.worldAlpha,
150
155
  layout.width,
151
156
  layout.height,
152
157
  renderProps.parentHasRenderTexture,
153
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
154
- renderProps.framebufferDimensions as any,
158
+ renderProps.framebufferDimensions,
155
159
  sdfShader!,
156
160
  );
157
161
  return null;
@@ -160,7 +164,6 @@ const renderQuads = (
160
164
 
161
165
  // --- Cache-miss slow path -----------------------------------------------
162
166
  const startIdx = webGlRenderer.sdfBufferIdx;
163
-
164
167
  webGlRenderer.addSdfQuads(
165
168
  layout.glyphs,
166
169
  layout.fontScale,
@@ -169,13 +172,12 @@ const renderQuads = (
169
172
  renderProps.worldAlpha,
170
173
  layout.distanceRange,
171
174
  ctxTexture,
172
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
173
- renderProps.clippingRect as any,
175
+
176
+ renderProps.clippingRect,
174
177
  layout.width,
175
178
  layout.height,
176
179
  renderProps.parentHasRenderTexture,
177
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
178
- renderProps.framebufferDimensions as any,
180
+ renderProps.framebufferDimensions,
179
181
  sdfShader!,
180
182
  );
181
183
 
@@ -221,9 +223,14 @@ const generateTextLayout = (
221
223
  const fontData = fontCache.data;
222
224
  const commonFontData = fontData.common;
223
225
  const designFontSize = fontData.info.size;
224
- const designLineHeight = commonFontData.lineHeight;
225
- const lineHeight =
226
- props.lineHeight || (designLineHeight * fontSize) / designFontSize;
226
+ // common.base = distance from BMFont line-box top to the alphabetic baseline,
227
+ // in atlas design units. Used to convert per-glyph yoffset (BMFont top -> glyph top)
228
+ // into baseline-relative placement.
229
+ const atlasBase = commonFontData.base;
230
+ // When the user does not specify lineHeight, fall back to the engine's
231
+ // 'normal' line height (ascender + lineGap - descender) computed inside
232
+ // mapTextLayout via the supplied metrics. Passing 0 below triggers that path.
233
+ const lineHeight = props.lineHeight;
227
234
 
228
235
  const atlasWidth = commonFontData.scaleW;
229
236
  const atlasHeight = commonFontData.scaleH;
@@ -242,9 +249,9 @@ const generateTextLayout = (
242
249
  const maxHeight = props.maxHeight;
243
250
  const [
244
251
  lines,
245
- remainingLines,
246
- hasRemainingText,
247
- bareLineHeight,
252
+ _remainingLines,
253
+ _hasRemainingText,
254
+ _bareLineHeight,
248
255
  lineHeightPx,
249
256
  effectiveWidth,
250
257
  effectiveHeight,
@@ -267,15 +274,16 @@ const generateTextLayout = (
267
274
 
268
275
  const glyphs: GlyphLayout[] = [];
269
276
  let currentX = 0;
270
- let currentY = 0;
277
+ let baselineY = 0;
271
278
  for (let i = 0; i < lineAmount; i++) {
272
279
  const line = lines[i] as TextLineStruct;
273
280
  const textLine = line[0];
274
281
  const textLineLength = textLine.length;
275
282
  let prevGlyphId = 0;
276
283
  currentX = line[3];
277
- //convert Y coord to vertex value
278
- currentY = line[4] / fontScale;
284
+ // line[4] is the alphabetic baseline Y in screen px. Convert to atlas
285
+ // design units (where glyph.yoffset and atlasBase live).
286
+ baselineY = line[4] / fontScale;
279
287
 
280
288
  for (let j = 0; j < textLineLength; j++) {
281
289
  const codepoint = textLine.codePointAt(j) as number;
@@ -318,10 +326,13 @@ const generateTextLayout = (
318
326
  // Apply pair kerning before placing this glyph.
319
327
  currentX += kerning;
320
328
 
321
- // Calculate glyph position and atlas coordinates (in design units)
329
+ // Glyph position in atlas design units. yoffset is measured from the
330
+ // BMFont line-box top; subtracting atlasBase re-anchors it relative to
331
+ // the alphabetic baseline so fonts with different BMFont 'base' values
332
+ // share the same on-screen baseline.
322
333
  const glyphLayout: GlyphLayout = {
323
334
  x: currentX + glyph.xoffset,
324
- y: currentY + glyph.yoffset,
335
+ y: baselineY + glyph.yoffset - atlasBase,
325
336
  width: glyph.width,
326
337
  height: glyph.height,
327
338
  atlasX: glyph.x * invAtlasWidth,
@@ -336,15 +347,23 @@ const generateTextLayout = (
336
347
  currentX += glyph.xadvance + letterSpacing;
337
348
  prevGlyphId = glyph.id;
338
349
  }
339
- currentY += lineHeightPx;
340
350
  }
341
351
 
352
+ // Cap-top of first line to descender bottom of last line.
353
+ // descender is negative in NormalizedFontMetrics, so subtracting it
354
+ // adds the descender depth. Zero when there are no rendered lines.
355
+ const trimmedHeight =
356
+ lineAmount > 0
357
+ ? metrics.capHeight - metrics.descender + (lineAmount - 1) * lineHeightPx
358
+ : 0;
359
+
342
360
  // Convert final dimensions to pixel space for the layout
343
361
  return {
344
362
  glyphs,
345
363
  distanceRange: fontScale * fontData.distanceField.distanceRange,
346
364
  width: effectiveWidth * fontScale,
347
365
  height: effectiveHeight,
366
+ trimmedHeight,
348
367
  fontScale: fontScale,
349
368
  lineHeight: lineHeightPx,
350
369
  fontFamily,
@@ -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,18 @@ 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 its containing box.
185
235
  *
186
236
  * @remarks
187
- * This property sets the vertical align of the text.
188
- * Not yet implemented in the SDF renderer.
237
+ * The containing box is `maxHeight` if set, otherwise the node's
238
+ * own `h` (which a flex parent or the user may have grown beyond
239
+ * the intrinsic text height). Activates whenever the box is taller
240
+ * than the intrinsic text height. Composes with `textBaselineMode`
241
+ * (per-line anchor). CSS line-box semantics — `'top'` leaves
242
+ * half-leading above the first line's cap-top; `'bottom'` leaves
243
+ * half-leading below the last line's descender.
189
244
  *
190
- * @default middle
245
+ * @default top
191
246
  */
192
247
  verticalAlign: TextVerticalAlign;
193
248
  /**
@@ -262,6 +317,11 @@ export interface TextLayout {
262
317
  * Total text height
263
318
  */
264
319
  height: number;
320
+ /**
321
+ * Trimmed text height — cap-top of the first line to descender bottom
322
+ * of the last line. See `TextRenderInfo.trimmedHeight`.
323
+ */
324
+ trimmedHeight: number;
265
325
  /**
266
326
  * Font scale factor
267
327
  */
@@ -345,11 +405,11 @@ export interface TextRenderProps {
345
405
  offsetY: number;
346
406
  worldAlpha: number;
347
407
  globalTransform: Float32Array;
348
- clippingRect: unknown;
408
+ clippingRect: RectWithValid;
349
409
  width: number;
350
410
  height: number;
351
411
  parentHasRenderTexture: boolean;
352
- framebufferDimensions: unknown;
412
+ framebufferDimensions: Dimensions | null;
353
413
  stage: Stage;
354
414
  /** Optional SDF vertex cache — passed by CoreTextNode for cache-hit fast path. */
355
415
  sdfCache?: SdfVertexCache;
@@ -358,6 +418,21 @@ export interface TextRenderProps {
358
418
  export interface TextRenderInfo {
359
419
  width: number;
360
420
  height: number;
421
+ /**
422
+ * Height of the visible glyph extent — from the first line's cap-top to
423
+ * the last line's descender bottom. Excludes half-leading and the slack
424
+ * between the font's ascender and cap-top.
425
+ *
426
+ * @remarks
427
+ * Formula: `capHeight − descender + (lines − 1) × lineHeightPx`
428
+ * (descender is negative in font metrics, so subtracting it adds the
429
+ * descender depth). For empty text, this is 0.
430
+ *
431
+ * Use this when you want flex `alignItems: 'center'` (or any layout
432
+ * that aligns by node `h`) to optically center the visible glyphs.
433
+ * Set `node.h = node.trimmedHeight` after the `loaded` event.
434
+ */
435
+ trimmedHeight: number;
361
436
  hasRemainingText?: boolean;
362
437
  remainingLines?: number;
363
438
  imageData?: ImageData | null; // Image data for Canvas Text Renderer
@@ -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
  }