Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions examples/tests/text-vertical-center.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type { ExampleSettings } from '../common/ExampleSettings.js';

/**
* Visual inspection page for vertical text alignment when
* `lineHeight === boxHeight`.
*
* Each box is a fixed size with a colored background and a horizontal red
* line drawn at the geometric center (y = h / 2). A text node is placed at
* (0, 0) inside the box with `lineHeight = boxHeight` and varying `fontSize`.
*
* What "centered" should mean is the open question — current behavior is
* baseline anchoring, so the *baseline* sits at `halfLeading + ascender`
* from the top of the line box and the visual mass of the glyphs shifts
* with the font's asc / desc ratio. The red guide line lets you eyeball
* how far each glyph row's optical center drifts from the box's geometric
* center as fontSize changes.
*/
export default async function test({ renderer, testRoot }: ExampleSettings) {
testRoot.color = 0xf0f0f0ff;

const BOX_W = 220;
const BOX_H = 120;
const COL_GAP = 20;
const ROW_GAP = 20;
const MARGIN_X = 40;
const HEADER_H = 60;
const ROW_LABEL_W = 130;

const FONT_SIZES = [20, 40, 60, 80, 100];
const SAMPLES: Array<{ label: string; text: string }> = [
{ label: 'caps', text: 'TXYZ' },
{ label: 'mixed', text: 'Abcg' },
// Mixed-case word with no descenders (caps + lowercase ascenders +
// x-height-only letters). Useful for spotting whether lowercase letters
// sit comfortably with capitals when the descender tail isn't present
// to pull the eye downward.
{ label: 'no desc.', text: 'Acme' },
{ label: 'descend.', text: 'gjpqy' },
{ label: 'digits', text: '1234' },
{ label: 'punct.', text: ',._—' },
];

renderer.createTextNode({
parent: testRoot,
x: MARGIN_X,
y: 10,
text: 'Vertical centering — lineHeight = boxHeight (red line = box center)',
fontFamily: 'Ubuntu',
fontSize: 28,
color: 0x222222ff,
});

// Column labels (one per sample, above the first row)
for (let c = 0; c < SAMPLES.length; c++) {
const col = SAMPLES[c]!;
renderer.createTextNode({
parent: testRoot,
x: MARGIN_X + ROW_LABEL_W + c * (BOX_W + COL_GAP),
y: HEADER_H,
text: `${col.label}: "${col.text}"`,
fontFamily: 'Ubuntu',
fontSize: 18,
color: 0x333333ff,
});
}

const ROWS_Y = HEADER_H + 30;
// A repeating soft palette so each row is easy to distinguish.
const BOX_COLORS = [
0xdfe9f5ff, 0xf5e9dfff, 0xe2f0e2ff, 0xf0e2f0ff, 0xfff4ccff,
];

for (let r = 0; r < FONT_SIZES.length; r++) {
const fontSize = FONT_SIZES[r]!;
const rowY = ROWS_Y + r * (BOX_H + ROW_GAP);

// Row label: which fontSize this row uses.
renderer.createTextNode({
parent: testRoot,
x: MARGIN_X,
y: rowY + BOX_H / 2,
mountY: 0.5,
text: `fontSize ${fontSize}`,
fontFamily: 'Ubuntu',
fontSize: 20,
color: 0x222222ff,
});

for (let c = 0; c < SAMPLES.length; c++) {
const sample = SAMPLES[c]!;
const boxX = MARGIN_X + ROW_LABEL_W + c * (BOX_W + COL_GAP);

// Colored container.
const box = renderer.createNode({
parent: testRoot,
x: boxX,
y: rowY,
w: BOX_W,
h: BOX_H,
color: BOX_COLORS[r % BOX_COLORS.length]!,
clipping: true,
});

// Geometric center guide.
renderer.createNode({
parent: box,
x: 0,
y: Math.round(BOX_H / 2),
w: BOX_W,
h: 1,
color: 0xff0000ff,
});

// The text under test — lineHeight == box height.
renderer.createTextNode({
parent: box,
x: 0,
y: 0,
text: sample.text,
fontFamily: 'Ubuntu',
fontSize,
lineHeight: BOX_H,
color: 0x111111ff,
textAlign: 'center',
maxWidth: BOX_W,
});
}
}

return testRoot;
}
7 changes: 7 additions & 0 deletions src/core/Stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type TextRenderers,
type TrProps,
} from './text-rendering/TextRenderer.js';
import { setBaselineMode } from './text-rendering/TextLayoutEngine.js';

import { EventEmitter } from '../common/EventEmitter.js';
import { ContextSpy } from './lib/ContextSpy.js';
Expand Down Expand Up @@ -175,6 +176,12 @@ export class Stage {
'A CorePlatform is not provided in the options',
);

// Configure the engine-wide text baseline anchor before any node is
// created. TextLayoutEngine reads this value when laying out every line;
// setting it during Stage construction ensures it's stable for the
// lifetime of the renderer.
setBaselineMode(options.textBaselineMode);

this.platform = platform;

this.startTime = platform.getTimeStamp();
Expand Down
37 changes: 37 additions & 0 deletions src/core/text-rendering/SdfFontHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,43 @@ const processFontData = (
unitsPerEm: 1000,
};

// Derive cap-height from the atlas when the metrics block doesn't already
// supply it. The layout engine uses this value to vertically center
// capital letters on each line. BMFont stores per-glyph `yoffset` as the
// distance from the line-box top to the glyph's top, and `common.base` as
// the distance from the line-box top to the alphabetic baseline — so the
// distance from the baseline up to the top of 'H' (atlas design px) is
// `common.base - H.yoffset`. Converted into font units it slots into
// `FontMetrics.capHeight` alongside the existing ascender / descender
// values and flows through `normalizeFontMetrics`.
if (metrics.capHeight === undefined) {
const capGlyph = glyphMap.get(72); // 'H'
if (capGlyph !== undefined) {
const capHeightAtlasPx = fontData.common.base - capGlyph.yoffset;
metrics = {
...metrics,
capHeight: (capHeightAtlasPx / fontData.info.size) * metrics.unitsPerEm,
};
}
// If 'H' isn't in the atlas (icon-only fonts, etc.) we leave capHeight
// undefined and rely on the 0.7 × ascender fallback inside
// normalizeFontMetrics.
}

// Same derivation for x-height using glyph 'x' (id 120). Only consumed
// when `RendererMainSettings.textBaselineMode === 'x'`; otherwise the
// 0.5 × ascender fallback inside `normalizeFontMetrics` is sufficient.
if (metrics.xHeight === undefined) {
const xGlyph = glyphMap.get(120); // 'x'
if (xGlyph !== undefined) {
const xHeightAtlasPx = fontData.common.base - xGlyph.yoffset;
metrics = {
...metrics,
xHeight: (xHeightAtlasPx / fontData.info.size) * metrics.unitsPerEm,
};
}
}

// Cache processed data
fontCache.set(fontFamily, {
data: fontData,
Expand Down
91 changes: 79 additions & 12 deletions src/core/text-rendering/TextLayoutEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
FontMetrics,
MeasureTextFn,
NormalizedFontMetrics,
TextBaselineMode,
TextLayoutStruct,
TextLineStruct,
WrappedLinesStruct,
Expand Down Expand Up @@ -35,18 +36,53 @@ type WrapStrategyFn = (
overflowWidth: number,
) => [string, number, string];

/**
* Generic Latin-font ratios used when exact metric values aren't available.
* Within a few percent of OS/2 sCapHeight / sxHeight across the common Latin
* families (Ubuntu, Noto, Roboto, Arial, etc.).
*/
const CAP_HEIGHT_FALLBACK_RATIO = 0.7;
const X_HEIGHT_FALLBACK_RATIO = 0.5;

export const normalizeFontMetrics = (
metrics: FontMetrics,
fontSize: number,
): NormalizedFontMetrics => {
const scale = fontSize / metrics.unitsPerEm;
const capHeightUnits =
metrics.capHeight !== undefined
? metrics.capHeight
: metrics.ascender * CAP_HEIGHT_FALLBACK_RATIO;
const xHeightUnits =
metrics.xHeight !== undefined
? metrics.xHeight
: metrics.ascender * X_HEIGHT_FALLBACK_RATIO;
return {
ascender: metrics.ascender * scale,
descender: metrics.descender * scale,
lineGap: metrics.lineGap * scale,
capHeight: capHeightUnits * scale,
xHeight: xHeightUnits * scale,
};
};

/**
* Engine-wide per-line baseline anchor. Configured once at renderer creation
* via {@link RendererMainSettings.textBaselineMode}, not exposed per node so
* a single app can't mix anchor models across its text. Defaults to `'cap'`
* — see {@link TextBaselineMode} for the rationale.
*/
let baselineMode: TextBaselineMode = 'cap';

/**
* Sets the engine-wide baseline anchor. Called by `Stage` during construction;
* not intended to be called from user code (changing this mid-session would
* silently reflow every cached text layout).
*/
export const setBaselineMode = (mode: TextBaselineMode): void => {
baselineMode = mode;
};

export const mapTextLayout = (
measureText: MeasureTextFn,
metrics: NormalizedFontMetrics,
Expand All @@ -61,18 +97,12 @@ export const mapTextLayout = (
maxWidth: number,
maxHeight: number,
): TextLayoutStruct => {
const ascPx = metrics.ascender;
const descPx = metrics.descender;
const lineGapPx = metrics.lineGap;

// Default line height matches CSS 'normal': ascender + lineGap - descender.
// descPx is negative for descents below the baseline.
const bareLineHeight = ascPx - descPx + lineGapPx;
// metrics.descender is negative for descents below the baseline, so
// subtracting it adds the descent depth.
const bareLineHeight = metrics.ascender - metrics.descender + metrics.lineGap;
const lineHeightPx =
lineHeight <= 3 ? lineHeight * bareLineHeight : lineHeight;
// Half-leading: extra space split evenly above the ascent and below the descent.
// Negative when the user requests a line height smaller than the font's own extent.
const halfLeading = (lineHeightPx - bareLineHeight) * 0.5;

let effectiveMaxLines = maxLines;

Expand Down Expand Up @@ -154,9 +184,46 @@ export const mapTextLayout = (
const effectiveMaxHeight = effectiveLineAmount * lineHeightPx;

// line[4] stores the alphabetic baseline Y of each line in screen px.
// The first baseline sits half-leading + ascender below the line box top,
// matching CSS line box layout.
const firstBaselineY = halfLeading + ascPx;
//
// ── Per-line anchor: cap-height centering ─────────────────────────────
//
// baselineY(i) = lineHeight/2 + capHeight/2 + i × lineHeight
//
// The baseline sits below the line's geometric mid-line by exactly
// `capHeight / 2`, so the top of an uppercase letter lands the same
// distance *above* the mid-line — i.e. capital letters bracket the
// center symmetrically. Cap-height centering matches what designers
// expect for UI text (button labels, headings, badges): TXYZ and 1234
// sit centered; descenders like 'gjpq' hang slightly below, mirroring
// CSS button behavior in browsers.
//
// Alternative anchors considered (kept here for the record):
//
// ── x-height centering ──────────────────────────────────────────────
// baselineY(i) = lineHeight/2 + xHeight/2 + i × lineHeight
// Centers lowercase letters on the mid-line. Matches CSS inline
// `vertical-align: middle`. Reads well for running body text but
// capitals appear high in headings/labels — wrong default for TV UI.
//
// ── line-box centering (pre-cap-height behavior) ───────────────────
// const halfLeading = (lineHeightPx − bareLineHeight) / 2
// baselineY(i) = halfLeading + ascender + i × lineHeight
// Centers the abstract asc-to-desc-plus-leading rectangle. The visible
// ink lands noticeably high because asc/(asc−desc) is asymmetric for
// most Latin fonts (~4.2:1 for Ubuntu). Mathematically tidy, visually
// wrong.
//
// The active anchor is configured at renderer creation via
// `RendererMainSettings.textBaselineMode`. Defaults to `'cap'`.
let firstBaselineY: number;
if (baselineMode === 'x') {
firstBaselineY = (lineHeightPx + metrics.xHeight) * 0.5;
} else if (baselineMode === 'linebox') {
const halfLeading = (lineHeightPx - bareLineHeight) * 0.5;
firstBaselineY = halfLeading + metrics.ascender;
} else {
firstBaselineY = (lineHeightPx + metrics.capHeight) * 0.5;
}
for (let i = 0; i < effectiveLineAmount; i++) {
const line = lines[i] as TextLineStruct;
line[4] = firstBaselineY + lineHeightPx * i;
Expand Down
Loading
Loading