Source code for simvx.core.text.measure

"""Text measurement: widths taken from a font's own glyph advances.

Widget auto-sizing, the test harness, and the renderer must agree on how wide a
string is, or a button is drawn narrower than the text inside it. They agree by
measuring the same way: walk the glyph advances of the font the text will be
drawn with, adding kerning between neighbours, and take the widest line.

The font used for measurement is the one the engine bundles, loaded on first
use so that importing the engine never reads a TTF. A renderer that draws with
a different font, or that has metrics but no font file at all (the browser
runtime, where the glyph advances arrive pre-baked in the atlas), calls
:func:`set_metrics_font` to say so, and measurement follows it. Because that can
happen after a scene has already sized itself, every change notifies the
listeners registered with :func:`add_metrics_listener`, which is how content-sized
widgets re-fit themselves instead of keeping a width measured against a font that
is not the one being drawn.

A renderer that draws characters its primary font lacks by borrowing them from
other fonts passes that lookup as *fallback*, so measurement borrows from the
same fonts rather than assuming a tofu box. Nothing is borrowed for a character
defined to leave no mark of its own, on either side: the renderer will not draw
one from a face that happens to carry it, so measuring one that way would reserve
room the text is never drawn in.

Kerning is looked up between neighbours the font draws as glyphs, including a
blank one such as the space. A character no font can draw, which is reserved the
room the renderer draws its missing-glyph box in, breaks the pair on both sides:
a box is not a glyph of the face, so no pair in the face's kerning table
describes it, and the layout that draws it does not kern either side of one.
Measuring it any other way would size a widget to a width that is never drawn.
A character that no face draws and that occupies no space either is the opposite
case: it has no room and no ink to come between its neighbours, so it is stepped
over entirely and the pair reaches across it, which again is what the layout
does.

Text that sits on a character grid, a terminal or a code view or a tabular
readout, says so by passing the pitch of that grid as *cell_width*. A character
nothing can draw then occupies exactly one cell, whichever form the renderer puts
in it, so every column after it stays where the grid puts it. Nothing about text
off a grid changes.

Measurement never raises. If no font can be loaded the width falls back to a
fixed per-character advance, which is approximate but keeps a game laying out
text on a machine whose font data is gone.
"""

import logging
import math
import random
from collections.abc import Callable
from typing import Any

from .msdf import is_zero_width_character, missing_glyph_advance, missing_glyph_advances, writes_out_codepoint

log = logging.getLogger(__name__)

__all__ = [
    "add_metrics_listener",
    "invalidate_text_metrics",
    "measure_text_width",
    "metrics_font",
    "set_metrics_font",
    "text_minimum_width",
    "text_prefix_widths",
]

#: Font size a text ``scale`` of 1.0 stands for, matching what the renderer draws.
LOGICAL_EM = 16.0

#: Pixel size the bundled font is loaded at when nothing else is registered.
#: The renderer builds its glyph atlas at this size, so advances resolve to the
#: identical pixel widths.
DEFAULT_METRICS_PX = 48.0

#: Advance, as a fraction of the em, assumed for every character when no font
#: resolves at all and there are no metrics to measure anything by. A character
#: a font was found for but that nothing can draw is reserved
#: :func:`~simvx.core.text.missing_glyph_advance` instead, which is what the
#: renderer draws its box in.
FALLBACK_ADVANCE = 0.6

#: Distinct strings whose measurement is remembered before entries are evicted.
#: Text that changes every frame (a score, a clock) must not grow it forever.
WIDTH_CACHE_LIMIT = 8192

#: Entries dropped when the cache fills. Evicting a slice rather than the whole
#: cache keeps a working set larger than the limit partly cached instead of
#: missing on everything; the victims are drawn at random because the natural
#: alternatives (oldest first, least recently used) evict exactly the entries a
#: cyclic working set is about to ask for again.
WIDTH_CACHE_EVICT = WIDTH_CACHE_LIMIT // 8

_UNRESOLVED = object()

# Font text is measured against, loaded on first use. ``None`` once a load has
# been attempted and failed.
_metrics_font: object = _UNRESOLVED
# Pixel size ``_metrics_font`` reports its advances in.
_metrics_px: float = DEFAULT_METRICS_PX
# char -> font to borrow it from when ``_metrics_font`` has no glyph for it.
_fallback_lookup: Callable[[str], Any] | None = None
# char -> advance in metrics-font pixels, taking a character nothing can draw as
# the plain box that small text gets.
_advance_cache: dict[str, float] = {}
# The rest of the room a character nothing can draw is reserved once the text is
# large enough for its codepoint to be written out, in the same pixels. Only
# characters that are actually boxed appear here, so it is empty for as long as
# every character measured has had a glyph, and the width of ordinary text never
# consults it.
_hex_extra: dict[str, float] = {}
# Characters reserved the room the renderer draws its box in, which no kerning
# pair reaches across. Empty for as long as every character measured has had a
# glyph, so ordinary text costs one test on an empty set per character.
_boxed: set[str] = set()
# Characters no face draws that occupy no space either: a variation selector, a
# zero-width joiner. They are transparent, so the walks step over them without
# advancing and without disturbing the pair around them. Empty for as long as
# text holds none, at the same cost as ``_boxed``.
_ignored: set[str] = set()
# string -> width of its widest line in metrics-font pixels. Keyed by the text
# alone: a label re-measured at another font size reuses the same entry.
_width_cache: dict[str, float] = {}
# The same rest, summed over a string, for the strings that have any.
_width_extra: dict[str, float] = {}
# Called after any change to the font or the cached measurements.
_listeners: list[Callable[[str], None]] = []


[docs] def set_metrics_font(font, /, fallback: Callable[[str], Any] | None = None) -> None: """Measure text against *font* from now on, instead of the bundled default. *font* answers ``has_glyph(char)``, ``get_glyph(char).advance_x`` and ``get_kerning(left, right)`` in pixels, and reports the pixel size those advances are in as ``size``: :class:`~simvx.core.text.Font` and the browser runtime's pre-baked metrics both qualify. Passing ``None`` restores the bundled font. *fallback* maps a character *font* has no glyph for to the font that will be drawn in its place, or to ``None`` for a tofu box. A renderer that borrows CJK or icon glyphs from other faces passes it, so that a label holding such text is measured as wide as it is drawn instead of as a row of tofu. A renderer calls this with the font it actually draws with, so that a widget which sizes itself to fit its text really does fit it. Content-sized widgets that measured against the previous font are re-fitted, so this may be called at any point, including after a scene has been built. """ global _metrics_font, _metrics_px, _fallback_lookup if font is None: reset() return size = float(getattr(font, "size", 0.0)) or DEFAULT_METRICS_PX if font is _metrics_font and size == _metrics_px and fallback is _fallback_lookup: return _metrics_font = font _metrics_px = size _fallback_lookup = fallback invalidate_text_metrics()
[docs] def add_metrics_listener(callback: Callable[[str], None], /) -> None: """Call *callback* whenever the font or the cached measurements change. Widths already computed are stale from that moment on, so whatever caches them (content-sized widgets, a layout) re-measures here. The callback is passed the characters whose metrics moved, or an empty string when the whole font changed and every measurement is suspect. Listeners are held for the life of the process and so belong to modules, not to instances. """ _listeners.append(callback)
[docs] def invalidate_text_metrics(chars: str = "", /) -> None: """Drop cached measurements and tell listeners the widths have moved. Called when the font changes, and by a renderer that has just gained glyphs it previously had to approximate. In the second case the renderer passes the characters it gained, so that only text using them is re-measured: a browser rasterising a page of glyphs a few at a time invalidates on many consecutive frames, and re-fitting every widget on each of them costs more than the rasterising does. Cached string widths are dropped whichever case it is. Working out which cached strings contain a character costs several milliseconds at the sizes this cache reaches, which is more than simply measuring them again. """ if chars: for char in chars: _advance_cache.pop(char, None) _hex_extra.pop(char, None) _boxed.discard(char) _ignored.discard(char) else: _advance_cache.clear() _hex_extra.clear() _boxed.clear() _ignored.clear() _width_cache.clear() _width_extra.clear() for callback in _listeners: try: callback(chars) except Exception: # A listener must not stop the rest from re-measuring. log.exception("Text-metrics listener failed")
[docs] def metrics_font(): """The font text is measured against, loading the bundled one if needed. ``None`` when no font could be loaded at all, in which case measurement approximates with a fixed per-character advance. """ global _metrics_font, _metrics_px if _metrics_font is _UNRESOLVED: _metrics_font = _load_bundled_font() _metrics_px = float(getattr(_metrics_font, "size", 0.0)) or DEFAULT_METRICS_PX return _metrics_font
[docs] def measure_text_width(text: str, scale: float = 1.0, *, cell_width: float = 0.0) -> float: """Width in pixels of *text* drawn at *scale*, from the font's own advances. ``scale`` is the renderer's text scale: 1.0 draws at a font size of 16. Multi-line strings return their widest line, newlines advancing nothing and breaking the kerning pair, which is the contract the renderer's own measurement follows. A character no font can supply a glyph for advances by :func:`~simvx.core.text.missing_glyph_advance`, which is the room the renderer draws its box in. That room depends on *scale*, because large enough text writes the codepoint out across several boxes where small text draws one, so such a string is not simply proportional to *scale* the way ordinary text is. The walk itself is still cached per string: what the size decides is only whether the extra room the written-out form needs is added to it. *cell_width* is the pitch of the character grid the text sits on, in the pixels this returns, and is how a caller that has a grid says so. On a grid a boxed character is reserved exactly one cell, so that the columns after it stay where the grid puts them; text that is not on a grid leaves it at zero and measures exactly as before. """ if not text: return 0.0 font = metrics_font() width = _width_in_font_units(text, font) if cell_width > 0.0 and _boxed and font is not None: deltas = _grid_deltas(text, font, LOGICAL_EM * scale, cell_width) if deltas: # Per line, because the room the grid adds can make a line that was # not the widest become it. width = max( _width_in_font_units(line, font) + sum(deltas.get(char, 0.0) for char in line) for line in text.split("\n") ) elif _width_extra: extra = _width_extra.get(text) if extra and font is not None and writes_out_codepoint(font, LOGICAL_EM * scale): width += extra return width * (LOGICAL_EM * scale / _metrics_px)
[docs] def text_prefix_widths(text: str, scale: float = 1.0, *, cell_width: float = 0.0) -> list[float]: """Width of every prefix of *text* at *scale*, from none of it to all of it. Entry ``i`` is the width of the first ``i`` characters, so the list is one longer than the text and starts at 0.0. Hit-testing a click against a line of text needs all of these, and asking :func:`measure_text_width` for each prefix separately would both re-walk the line for every column and leave a cache entry per column behind. This walks the line once and caches nothing. *text* is a single line: a newline is measured as advancing nothing, the same as anywhere else, rather than starting the widths over. *cell_width* is the pitch of the character grid the text sits on, and means what it does in :func:`measure_text_width`. Hit-testing takes it from the same place the layout does, so a click lands on the column the glyph under it was drawn in even when a boxed character earlier on the line took a cell of its own rather than the room a box asks for in proportional text. """ font = metrics_font() to_pixels = LOGICAL_EM * scale / _metrics_px fallback = _metrics_px * FALLBACK_ADVANCE widths = [0.0] total = 0.0 previous = "" for char in text: if char == "\n": widths.append(total * to_pixels) previous = "" continue if font is None: total += fallback else: advance = _advance_cache.get(char) if advance is None: advance = _advance_of(char, font) _advance_cache[char] = advance # A character with no room and no mark is stepped over whole: it adds # no advance and no pair, and leaves ``previous`` where it was, so a # click lands on the same column whether or not the line carries one. if not (_ignored and char in _ignored): boxed = bool(_boxed) and char in _boxed if previous and not boxed: total += font.get_kerning(previous, char) total += advance previous = "" if boxed else char widths.append(total * to_pixels) # The room a boxed character is given beyond the plain box the walk measured # it as: what writing the codepoint out adds, or the one cell a grid gives it. # Spread over the positions after it, and reached only once something has # actually been boxed, so ordinary text walks once and stops. if _boxed and font is not None: deltas: dict[str, float] = {} if cell_width > 0.0: deltas = _grid_deltas(text, font, LOGICAL_EM * scale, cell_width) elif writes_out_codepoint(font, LOGICAL_EM * scale): deltas = _hex_extra if deltas: bonus = 0.0 for index, char in enumerate(text, start=1): bonus += deltas.get(char, 0.0) widths[index] += bonus * to_pixels return widths
def _grid_deltas(text: str, font: Any, em_pixels: float, cell_width: float) -> dict[str, float]: """Room each boxed character in *text* gains on a grid, in metrics-font pixels. The difference between the one cell of *cell_width* a character nothing can draw is given on a grid and the plain box the walks measured it as, which is negative on a grid narrower than that box. Empty when nothing in *text* is boxed, so a monospace widget showing ordinary text pays one set lookup per distinct character and nothing else. The room itself comes from :func:`~simvx.core.text.missing_glyph_advance`, which is where the layout and the renderer take theirs, so measurement cannot reserve a character something other than the cell it is drawn in. """ deltas: dict[str, float] = {} for char in dict.fromkeys(text): if char not in _boxed: continue plain = _advance_cache.get(char) if plain is None: continue delta = _metrics_px * missing_glyph_advance(char, font, em_pixels, cell_width=cell_width) - plain if delta: deltas[char] = delta return deltas
[docs] def reset() -> None: """Forget the registered font and every cached measurement.""" global _metrics_font, _metrics_px, _fallback_lookup _metrics_font = _UNRESOLVED _metrics_px = DEFAULT_METRICS_PX _fallback_lookup = None invalidate_text_metrics()
def _load_bundled_font(): from .bundled import bundled_font_path from .font import Font path = bundled_font_path("ui") if path is None: log.warning("No font available to measure text with; widget auto-sizing will approximate.") return None try: return Font(path, size=int(DEFAULT_METRICS_PX)) except Exception as exc: # A broken font must not stop a game from laying out text. log.warning("Could not load %s for text measurement (%s); widget auto-sizing will approximate.", path, exc) return None def _advance_of(char: str, font: Any) -> float: """Advance of *char*, borrowing from a fallback font when *font* lacks it. The borrowed advance is used as the fallback font reports it, because that is what the renderer packs into its atlas and draws: measuring it any other way would size a widget to something other than the text inside it. A character defined to occupy no space, a variation selector or a zero-width joiner, advances nothing once no face has drawn it, so a label holding an emoji written with an explicit presentation measures the width of the emoji rather than of the emoji plus a tofu. It is recorded in ``_ignored``, and the walks then step over it as if the text did not contain it: its neighbours are drawn side by side, so the pair the face lists for *them* is the one that describes what is drawn. It is not borrowed from the fallback chain either, for the same reason the renderer does not borrow one: a face that happens to carry a spacing glyph for it would take room nothing draws in. What no font can supply is reserved the room the renderer draws its box in. The plain box is returned, and the rest of the room the written-out codepoint needs is recorded in ``_hex_extra`` for the caller to add where the text is large enough to be drawn that way: the walk is cached per string and the choice is not, so the two are kept apart. The character is recorded in ``_boxed`` as well, so that the walks break the kerning pair on both sides of it the way the layout that draws it does. """ if font.has_glyph(char): return float(font.get_glyph(char).advance_x) if is_zero_width_character(char): _ignored.add(char) return 0.0 if _fallback_lookup is not None: try: borrowed = _fallback_lookup(char) except Exception: # A failed font search must not stop text from measuring. log.debug("Fallback font lookup failed for %r", char, exc_info=True) borrowed = None if borrowed is not None and borrowed.has_glyph(char): return float(borrowed.get_glyph(char).advance_x) plain, written_out = missing_glyph_advances(char) _hex_extra[char] = _metrics_px * (written_out - plain) _boxed.add(char) return _metrics_px * plain def _width_in_font_units(text: str, font: Any) -> float: """Width of the widest line of *text*, in metrics-font pixels. Measured with every missing character drawn as the plain box. What a written-out codepoint would add on top is recorded in ``_width_extra``, and only for text that has any: it is the difference between the two widest lines, because the extra room can make a line that was not the widest become it. """ cached = _width_cache.get(text) if cached is not None: return cached fallback = _metrics_px * FALLBACK_ADVANCE # One entry per line, because the extra room is charged per line and can make # a line that was not the widest become it. Kept even when there is no extra: # a list of one costs less than a test on every character would. lines = [] if font is None: lines = [len(line) * fallback for line in text.split("\n")] else: line_w = 0.0 previous = "" for char in text: if char == "\n": lines.append(line_w) line_w = 0.0 previous = "" continue advance = _advance_cache.get(char) if advance is None: advance = _advance_of(char, font) _advance_cache[char] = advance if _ignored and char in _ignored: # No room and no mark: the line is exactly as if it were absent, # ``previous`` included, so the pair reaches across it. continue boxed = bool(_boxed) and char in _boxed if previous and not boxed: line_w += font.get_kerning(previous, char) line_w += advance previous = "" if boxed else char lines.append(line_w) widest = max(lines) if len(_width_cache) >= WIDTH_CACHE_LIMIT: for victim in random.sample(list(_width_cache), WIDTH_CACHE_EVICT): del _width_cache[victim] _width_extra.pop(victim, None) _width_cache[text] = widest # Only once some character somewhere has actually been boxed: until then # there is nothing to add and the walk above is the whole of the work. if _hex_extra: written_out = max( base + sum(_hex_extra.get(char, 0.0) for char in line) for base, line in zip(lines, text.split("\n"), strict=True) ) if written_out > widest: _width_extra[text] = written_out - widest return widest
[docs] def text_minimum_width(text: str, scale: float = 1.0, *, cell_width: float = 0.0) -> float: """:func:`measure_text_width` rounded up to a whole pixel. Widgets size themselves to this. Layout containers work in whole pixels, so a fractional minimum can be rounded down to just under what the text needs and clip its last column; asking for the next whole pixel cannot be. """ return float(math.ceil(measure_text_width(text, scale, cell_width=cell_width)))