Mobile Computing · Cross-Platform Development

Text Metrics and Line Breaking Across Two Font Stacks

The same string occupies different space on two platforms because measurement, shaping, and fallback differ. Shared layout must pick one authority for metrics or accept divergence.

A cross-platform screen that looks right on one device and wrong on the other is usually a text problem. The layout code is shared, the constraints are identical, and the difference appears because the string occupies a different amount of space than the layout expected. Height differs by a few points, a line wraps one word early, a label clips, and a row that was supposed to be uniform is not.

The cause is that text is not a string of characters with fixed widths. It is the output of a pipeline: character selection and normalisation, bidirectional resolution, script itemisation, shaping with the font’s OpenType tables, line breaking under Unicode rules, justification, and finally rasterisation with hinting. Two platforms implement that pipeline with different fonts, different shapers, different default line-breaking behaviour, and different rounding. The shared layout cannot assume agreement unless it supplies the pipeline itself.

Measurement is the only input layout has

A layout engine treats a text run as a function from available width to size. Everything it decides, from row height to whether a label fits, derives from that function. If the two platforms compute different values for the same input, the layout produces different results even with identical logic.

Concretely, a paragraph of 12-point text in a system font has a line height determined by the font’s ascent, descent, and line gap metrics, plus whatever the platform adds. The same nominal size on two platforms can yield line heights that differ by a point or two, and over a twenty-line paragraph that accumulates to a visible difference in block height. A shared layout that assumes a fixed line height for a given font size will overflow on one platform and leave a gap on the other.

The same applies to advance widths. A proportional font’s advance for a character depends on the font, on kerning pairs defined in its GPOS table, and on ligature substitutions defined in GSUB. A string containing “fi” may be shaped as a single ligature glyph with one advance on one platform and as two glyphs with two advances on another, and the difference is a fraction of a point per occurrence. In a paragraph, fractions accumulate.

This is why cross-platform toolkits that own their rendering also own their text stack. Shipping the same font binaries and the same shaper removes the divergence at its source, at the cost of binary size and of losing the platform’s own typographic tuning.

Where the pipeline actually diverges

Five stages produce observable differences.

Font selection. A platform resolves a font family name against a system catalogue that differs per OS version and per device. Asking for a generic family yields different faces. A font that exists on one platform may be absent on the other, and the fallback chosen is platform policy. A string in a script the primary font does not cover is shaped from fallback fonts, and which fallback is chosen, and in what order, changes the appearance and the metrics.

Shaping. Different shapers implement the OpenType specification with different levels of feature support and different defaults. Complex scripts, where glyph selection depends on context, are where this shows: Arabic and Indic scripts require joining and reordering rules that are non-trivial, and a shaper that handles them differently produces different cluster boundaries and therefore different break opportunities.

Line breaking. UAX #14 defines break opportunities by character class. Implementations differ in how they treat the tail of the algorithm, particularly for scripts with no spaces, for hyphens, and for the interaction of punctuation with closing quotes. A platform may also apply its own paragraph-level rules, such as avoiding a line that ends with a single short word, which is a discretionary behaviour not required by the annex.

Measurement rounding. Advances are computed in fractional units and rounded to device pixels. The rounding policy, and whether the engine measures at the font’s design size or at the device’s scaled size, changes the total width of a line by up to a pixel per glyph. In a text field that must fit a fixed width, that is the difference between fitting and clipping.

Justification and hyphenation. Justification distributes slack across break opportunities; hyphenation adds opportunities using dictionaries. Both are optional and both are platform-configurable, so a paragraph justified on one platform may hyphenate and on the other may not, producing different line counts.

Making one side authoritative

There are two coherent answers and one incoherent one.

The coherent answers are: measure with the shared engine on both platforms, or measure with the platform on both and accept divergence as a per-platform adjustment. The incoherent answer, which is what most codebases do by default, is to measure with the platform and to write layout code that assumes the measurements agree.

The first answer requires the shared code to carry a text stack. It needs font binaries, a shaper, a line breaker implementing UAX #14, and a rasteriser. In exchange, a layout computed once is valid on both platforms, and a golden-image test on one platform predicts the other. The cost is binary size: a font with a few thousand glyphs is hundreds of kilobytes, and a full CJK font is several megabytes, which is why subsetting per locale is standard practice.

The second answer accepts that the layout is computed twice, once per platform, from the same description. That is viable if the description is expressed in terms the platform can satisfy: intrinsic sizing, flexible containers, minimum and maximum constraints, and no absolute positions derived from assumed text metrics. A description that says “this label is 14 points from the top of a 44-point row” is fragile; one that says “this label is vertically centred in a row whose height is at least 44 points and grows to fit” survives the difference. The divergence still exists but it does not break anything, because the layout adapts to whatever the platform measured.

The distinction is between layouts that depend on text metrics and layouts that respond to them. Responsive layouts survive a difference of a few percent in measurement. Fixed layouts do not.

Intrinsic size and the two-pass problem

A text run’s height depends on its width, because the number of lines depends on where it breaks. This makes text the classic case where a layout engine needs 2 passes: one to measure at the available width, one to position using the resulting height.

Frameworks handle this differently and the difference is visible in the API. Some measure eagerly during layout and cache the result keyed by width, which costs a shaping pass per distinct width and is correct. Some defer to the platform’s own text view, which measures itself when asked and caches internally, which is also correct and moves the cost to the platform. Some measure once at an assumed width and then clamp, which is cheap and wrong whenever the assumption fails.

The failure mode of the last approach is a truncated label: the text was measured at a wider width than it received, so the engine believed two lines would fit in a height that only accommodates one, and the overflow is clipped rather than wrapped. On a device with a larger font scale, which the user can set in accessibility settings, the same layout truncates where it previously fit. Supporting dynamic type means the layout must be re-measured whenever the scale changes, and a cache keyed only by width is stale.

Code: measuring a run and breaking it

A minimal illustration of why measurement and breaking must be the same function’s output. The shaper is abstracted; the point is the interface, not the implementation.

def layout_paragraph(shaper, text, max_width, line_height):
    lines, current, width = [], [], 0.0
    for cluster in shaper.cluster_boundaries(text):  # grapheme + break opportunities
        advance = shaper.advance(cluster)
        if current and width + advance > max_width:
            # Break at the last opportunity at or before this cluster, not
            # necessarily here: UAX #14 allows breaking inside a word only
            # where the rules permit it.
            lines.append(current)
            current, width = [], 0.0
        current.append(cluster)
        width += advance
    if current:
        lines.append(current)
    return lines, len(lines) * line_height

The important property is that advance and cluster_boundaries come from the same shaper. Measuring with one engine and breaking with another produces lines that are individually plausible and collectively wrong, because the break points do not correspond to the advances the measurement used. A line holds 60 to 90 Latin clusters at a comfortable measure, so a per-cluster divergence of a fraction of a point is enough to move the break by one cluster.

Numbers that matter

A few concrete quantities make the divergence tangible. A line of Latin text at twelve points typically contains 60 to 90 characters at a comfortable measure, and the advance of an average lowercase glyph in a proportional face is around half the point size, so a 320-point-wide column holds roughly 45 to 55 characters. A difference of two percent in measurement moves that by one character, which is enough to change the line count of a paragraph near a boundary.

Unicode normalisation matters for equality checks before layout: two visually identical strings may differ in composition, and a cache keyed on the raw string will miss. NFC is the standard form for interchange, and normalising before measurement is cheaper than measuring twice.

Finally, the font scale is not a small effect. Accessibility settings allow text to be scaled substantially above the default, and every measurement must be expressed in scaled units. A layout that hard-codes a line height in points breaks at the first scale change; one that derives line height from the scaled font’s own metrics survives. The same applies to the target sizes of the controls around the text, which are specified in the platform’s own units rather than in points, as One Description, Two Design Languages sets out.

What cannot be reconciled

Some divergence is not fixable by choosing an authority, because it reflects a platform decision the application cannot override. The system font is not licensed for redistribution in the general case, so a shared text stack cannot use it; it must ship a substitute, and the substitute will not match. The platform’s own text rendering may apply hinting or stem darkening tuned to its displays, which changes the weight of thin strokes. And the platform’s accessibility text sizes are defined in its own units and routinely exceed 100 percent of the default, so the mapping from a shared scale to the platform scale is a policy decision with no correct answer.

The practical resolution is to treat these as known, bounded differences and to design layouts with slack: containers that grow, text that wraps rather than clips, and no assumption that two platforms will produce the same line count. The alternative, pixel-exact parity, requires owning the whole pipeline including the rasteriser, which is the architecture question posed in Retained Native Widgets or an Owned Render Tree.

References

  1. Unicode Standard Annex #14: Unicode Line Breaking Algorithm — Unicode Consortium
  2. Unicode Standard Annex #29: Unicode Text Segmentation — Unicode Consortium
  3. HarfBuzz Shaping Engine — HarfBuzz project
  4. OpenType Specification — Microsoft