Skip to the content.

Text Measurement

How the framework computes text dimensions for layout — the abstraction, the two implementations (approximation vs real font metrics), and the Google Fonts convenience loader.

Implemented in:

The design discussion (why an abstraction, why opentype.js, what’s deferred) is in ../../visual-engine-design.md §11.

1. Why an abstraction

Text measurement is per-font and per-glyph: proportional fonts have varying character widths, kerning shifts adjacent pairs, ligatures collapse multiple glyphs into one. There’s no single answer — accurate measurement depends on the environment:

Environment Best measurement strategy
Modern browser ctx.measureText via Canvas 2D — accurate, sync, fast
Node (no DOM) Parse the font file with opentype.js / fontkit
No font available Approximation from font size and character count

The library defines a TextMeasurer interface, ships a stateless approximation as default, and provides a FontMetricsMeasurer implementation that uses opentype.js for real metrics in both Node and browser.

2. The interface

interface TextMetrics {
    readonly Width:   number;   // text width in DIPs
    readonly Height:  number;   // text height in DIPs (Ascent + Descent)
    readonly Ascent:  number;   // distance from top of bounding box to baseline
    readonly Descent: number;   // distance from baseline to bottom
}

interface TextMeasurer {
    LoadFont(
        family: string,
        source: ArrayBuffer | Uint8Array,
        weight?: string,      // 'normal' | 'bold' (matches FontWeight enum values)
        style?:  string,      // 'normal' | 'italic'
    ): void;

    Measure(
        text: string,
        fontFamily: string,
        fontSize:   number,
        fontWeight: string,
        fontStyle:  string,
    ): TextMetrics;
}

A measurer lives on the host (VisualHost.TextMeasurer), so any Visual reaches it via this.target?.TextMeasurer. TextBlock.MeasureOverride uses this to size its content.

LoadFont is sync — parsing a font from a buffer doesn’t require network or I/O. Async font fetching is the caller’s concern.

3. ApproximateTextMeasurer

The stateless default. No font files, no real metrics — just consistent heuristics calibrated to typical sans-serif proportions:

Field Formula
Width glyphCount × fontSize × 0.6 (slightly overestimated)
Height fontSize × 1.2 (CSS / WPF default line-height ratio)
Ascent fontSize × 0.85
Descent Height - Ascent

glyphCount is Array.from(text).length — code-point aware, so emoji counts as one glyph rather than two UTF-16 units.

LoadFont is a no-op. The measurer ignores font data entirely; the approximation doesn’t change based on FontFamily / FontWeight / FontStyle.

Singleton access

import { APPROXIMATE_TEXT_MEASURER, ApproximateTextMeasurer } from '../runtime/index.js';

APPROXIMATE_TEXT_MEASURER          // shared singleton (stateless, safe to share)
new ApproximateTextMeasurer()      // instantiate if you want a separate one (no benefit)

The shared singleton is what every PresentationTarget uses by default:

public TextMeasurer: TextMeasurer = APPROXIMATE_TEXT_MEASURER;

When to keep using it

When to upgrade

4. FontMetricsMeasurer

Real per-glyph widths + ascent / descent / kerning, backed by opentype.js. Works in both Node and browser — opentype.js parses TTF / OTF / WOFF1 buffers.

import { FontMetricsMeasurer } from '../visual-engine/index.js';

const measurer = new FontMetricsMeasurer();
measurer.LoadFont('Inter', interBuffer);              // ArrayBuffer or Uint8Array
measurer.LoadFont('Inter', interBoldBuffer, 'bold');  // explicit weight tag

target.TextMeasurer = measurer;

Loading fonts

LoadFont(family, source, weight?, style?): void

Loading from Node:

import { readFile } from 'node:fs/promises';
const buf = await readFile('./fonts/Inter-Regular.ttf');
measurer.LoadFont('Inter', buf);

Loading from browser:

const buf = await fetch('/fonts/Inter-Regular.woff').then(r => r.arrayBuffer());
measurer.LoadFont('Inter', buf);

Resolution

Measure(text, family, size, weight, style) walks the comma-separated family stack:

measurer.Measure('Hello', 'Inter, Helvetica, sans-serif', 16, 'bold', 'normal');

For each family in order:

  1. Exact match — weight|style is loaded → use that font.
  2. Fall back to normal — that family’s normal|normal variant.
  3. Any variant beats no variant — any loaded variant in that family.

If no family in the stack is loaded, falls through to ApproximateTextMeasurer so callers always get sensible output rather than zeros. (This is why mixing accurate and approximate output is possible — a TextBlock with an unloaded FontFamily degrades silently.)

Width computation

Width is computed character-by-character using font.charToGlyph(ch) and the glyph’s advanceWidth, plus font.getKerningValue(prev, curr) between adjacent glyphs.

This bypasses opentype.js’s full layout pipeline (font.getAdvanceWidth), which throws on common modern fonts whose GSUB tables use feature substitutions opentype.js doesn’t yet implement (Inter, Roboto, most variable fonts).

Trade-off: we keep pairwise kerning but lose ligature substitution (fi → fi) and contextual alternates. For Latin UI text the visible difference is small; for body text with frequent ligature triggers (“office”, “fluff”) or for non-Latin scripts (Arabic, Devanagari), this would matter — see code-review.md and the design doc for the path to a HarfBuzz-based measurer.

Iterating code points

Like ApproximateTextMeasurer, FontMetricsMeasurer iterates code points via Array.from(text) — emoji counts as one glyph.

5. Google Fonts loader

A convenience for fetching fonts from Google Fonts without manual URL hunting.

import { loadGoogleFont, loadGoogleFontInto } from '../visual-engine/index.js';

// Convenience: fetch + register in one call
await loadGoogleFontInto(measurer, 'Inter', { weights: [400, 700] });

// Lower-level: fetch buffers, register manually
const variants = await loadGoogleFont('Inter', { weights: [400, 700] });
for (const v of variants) {
    measurer.LoadFont(v.family, v.buffer, v.weight, v.style);
}

Options

interface GoogleFontOptions {
    weights?: number[];   // default [400]; specify [400, 700] for normal + bold
    italics?: boolean;    // when true, also fetches italic variants for each weight
}

How it works

  1. Constructs the Google Fonts CSS2 URL with the requested weight/italic combinations.
  2. Fetches the CSS with an old-Firefox User-Agent. Google returns TTF for older UAs and WOFF2 for modern ones — opentype.js can’t parse WOFF2, so we masquerade as Firefox 30 (2014) to land in the TTF bucket.
  3. Parses the response for @font-face blocks and extracts the binary URLs, weights, and styles.
  4. Dedupes by (weight, style, url) — Google sometimes emits multiple @font-face blocks per variant for different unicode-range subsets, all pointing at the same TTF.
  5. Fetches each unique TTF in parallel via Promise.all.

Loaded shape

interface LoadedGoogleFont {
    family: string;
    weight: 'normal' | 'bold';
    style:  'normal' | 'italic';
    buffer: ArrayBuffer;
}

loadGoogleFont returns LoadedGoogleFont[]; loadGoogleFontInto calls measurer.LoadFont for each variant.

Failure modes

6. Wiring into a target

Targets default to the approximation. Swap to a real measurer when you want accurate metrics:

import { FontMetricsMeasurer } from '../visual-engine/index.js';
import { HeadlessTarget } from '../visual-engine/index.js';

const target = new HeadlessTarget(400, 200, myScene);

// Install a real measurer BEFORE the first Render, so the first
// MeasureOverride on TextBlock uses the new measurer.
target.TextMeasurer = new FontMetricsMeasurer();
await loadGoogleFontInto(target.TextMeasurer, 'Inter', { weights: [400, 700] });
// Or LoadFont with your own buffer:
// target.LoadFont('Inter', interBuffer);
// (target.LoadFont is a convenience method that delegates to TextMeasurer.LoadFont)

7. FormattedText and renderer-side metrics

TextBlock.MeasureOverride stores the TextMetrics it got from the measurer into _metrics, then passes it through FormattedText to the renderer:

class FormattedText {
    readonly Text:       string;
    readonly FontFamily: string;
    readonly FontSize:   number;
    readonly Foreground: Brush | undefined;
    readonly FontWeight: FontWeight;
    readonly FontStyle:  FontStyle;
    readonly Metrics:    TextMetrics | undefined;   // ← from the measurer
}

SvgDrawingContext.DrawText reads Metrics.Ascent (when present) for baseline placement (y = origin.Y + Ascent). Without metrics, falls back to fontSize × 0.85. This is why loading a real font visibly shifts the text baseline in the SVG output — the real ascent rarely matches the 0.85 approximation exactly.

8. Performance notes

9. Building a custom measurer

Implement the two methods, plug it in:

import type { TextMeasurer, TextMetrics } from '../runtime/index.js';

class CanvasTextMeasurer implements TextMeasurer {
    private canvas = new OffscreenCanvas(1, 1);
    private ctx    = this.canvas.getContext('2d')!;

    LoadFont(): void { /* nothing — browser handles font loading via CSS */ }

    Measure(text, fontFamily, fontSize, fontWeight, fontStyle): TextMetrics {
        this.ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`;
        const m = this.ctx.measureText(text);
        const ascent  = m.actualBoundingBoxAscent  ?? fontSize * 0.85;
        const descent = m.actualBoundingBoxDescent ?? fontSize * 0.35;
        return {
            Width:   m.width,
            Height:  ascent + descent,
            Ascent:  ascent,
            Descent: descent,
        };
    }
}

// Install:
target.TextMeasurer = new CanvasTextMeasurer();

That’s the path to a browser-side CanvasTextMeasurer for HtmlTarget’s default — accurate, sync, fast, no font-loading bookkeeping required.

10. Common patterns

Approximate, no setup (the default — works out of the box):

const target = new HeadlessTarget(...);   // TextMeasurer is APPROXIMATE_TEXT_MEASURER

Real metrics with a local font file (Node):

import { readFile } from 'node:fs/promises';
import { FontMetricsMeasurer } from '../visual-engine/index.js';

target.TextMeasurer = new FontMetricsMeasurer();
const buf = await readFile('./fonts/Inter-Regular.ttf');
target.LoadFont('Inter', buf);

Real metrics from Google Fonts:

import { FontMetricsMeasurer, loadGoogleFontInto } from '../visual-engine/index.js';

target.TextMeasurer = new FontMetricsMeasurer();
await loadGoogleFontInto(target.TextMeasurer, 'Inter', {
    weights: [400, 700],
    italics: true,
});

Use the loaded font in a TextBlock:

const text = new TextBlock('Hello');
text.FontFamily = 'Inter, sans-serif';      // family stack — falls back if Inter isn't loaded
text.FontSize   = 24;
text.FontWeight = FontWeight.Bold;

The FontMetricsMeasurer finds the bold Inter variant by exact match; if you only loaded the regular weight, it’d fall back to normal|normal within Inter rather than giving up.