"""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)))