Source code for an.stage.text_layout

"""A text block, compiled: one node per unit, each an SVG sprite (an#155).

:func:`an.stage.text.layout_text` asks tituli where every unit goes and what its
glyphs look like; this module turns that into the wire. The block is one
node (the entity), each unit a child named ``word_<i>`` / ``glyph_<i>`` /
``line_<i>`` whose visual is an ``svg_sprite`` — the kind the runtime already
draws — backed by a texture whose ``src`` is a ``data:`` URI holding that
unit's contours. So:

- the runtime gains no visual kind and never rasterises a font (option 2 of
  an#155); text reaches the frame path as SVG art, exactly as rig parts do;
- the compiled document is self-contained — no staging, no file to lose — and
  the scene contract hash covers the glyphs themselves, so a different face
  moves it;
- each unit node sits at its box centre with the sprite anchored at 0.5, so a
  ``scale_x``/``scale_y`` pop or a ``rotation`` pivots about the unit's middle;
- a block with a replacement set (``texts``, an#341) is one ``block_0`` node
  whose visual carries a ``text`` swap set, one texture per string and a box
  per string on the ``align`` edge — replacement animation, which the runtime
  already draws (``applySwap``).

**Crispness.** The SVG declares ``TEXT_TEXTURE_OVERSAMPLE`` x its box as its
intrinsic size, and the sprite is fitted back into the box (``fit="contain"``),
so a texel is half a scene pixel: an overlay at 1:1 samples it as a clean 2:1
box filter, and a world label survives a push-in. Every box is snapped outward
to whole pixels, so at zoom 1 a sprite's corners sit on the pixel grid.
"""

from __future__ import annotations

import base64
import hashlib
from pathlib import Path
from typing import Any, Mapping

from an.stage.serialize import (
    AssetJSON,
    AssetResolutionJSON,
    NodeJSON,
    TransformJSON,
    VisualJSON,
)
from an.stage.text import (
    TEXT_SET,
    TextDescriptor,
    TextLayout,
    TextUnit,
    layout_text,
    layout_text_set,
    resolve_text,
    text_set_keys,
)

#: Texels per scene pixel in a unit's texture. See the module docstring.
TEXT_TEXTURE_OVERSAMPLE: int = 2

#: What a text texture's alias starts with — `text.<entity>.<unit>.<digest>`.
TEXT_TEXTURE_PREFIX: str = "text."

#: Hex digits of the texture's sha256 kept in its alias.
TEXT_ALIAS_DIGEST_LEN: int = 12

#: The ``src`` scheme of a texture that carries its bytes inline. The staging
#: step skips it (there is nothing to copy) instead of warning that the prefix
#: names no store.
INLINE_SRC_PREFIX: str = "data:"

_SVG_NS = "http://www.w3.org/2000/svg"


[docs] def unit_svg( d: str, box: tuple[int, int, int, int], *, color: str, stroke_width: float = 0.0, stroke_color: str | None = None, ) -> str: """One unit's texture: its contours in a viewBox equal to its frame-pixel box. >>> unit_svg("M0 0L2 0L2 2Z", (0, 0, 4, 4), color="#123456")[:60] '<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"' With an outline (an#313) the same contours are drawn first as a stroke TWICE ``stroke_width`` wide, round-joined, so ``stroke_width`` shows outside the glyph once the fill covers the inner half: two paths rather than ``paint-order``, so no rasteriser's support for that attribute is assumed. >>> 'stroke-width="6"' in unit_svg("M0 0Z", (0, 0, 4, 4), color="#fff", stroke_width=3, stroke_color="#000") True """ x0, y0, x1, y1 = box w, h = x1 - x0, y1 - y0 k = TEXT_TEXTURE_OVERSAMPLE outline = ( f'<path fill="none" stroke="{stroke_color}" stroke-width="{2 * stroke_width:g}" ' f'stroke-linejoin="round" d="{d}"/>' if stroke_width > 0 else "" ) return ( f'<svg xmlns="{_SVG_NS}" width="{w * k}" height="{h * k}" ' f'viewBox="{x0} {y0} {w} {h}">{outline}<path fill="{color}" d="{d}"/></svg>' )
[docs] def svg_data_uri(svg: str) -> str: """``data:image/svg+xml;base64,…`` — the form the vendored engine's SVG loader recognises by prefix.""" return "data:image/svg+xml;base64," + base64.b64encode(svg.encode("utf-8")).decode( "ascii" )
[docs] def text_document(entity: Any, props_store: Mapping) -> Mapping[str, Any] | None: """The stored document behind a prop entity if it is a text block, else None.""" try: doc = props_store[entity.ref] except (KeyError, TypeError): return None if isinstance(doc, dict) and doc.get("kind") == "TextDescriptor": return doc return None
[docs] def text_swap_declaration(entity: Any, mall: Any) -> Any: """The core ``prop`` kind's swap declaration (an#341): a text block with ``texts`` declares its ``text`` set, ``{key: key}``; any other prop declares nothing (its built nodes' sets are its declaration). Through :attr:`an.genres.EntityKind.swap_declaration`, so the compiler's swap vocabulary, its swap checks and ``an validate`` learn the set the way they learn a rig's. ``descriptor`` stays ``None``: the text block has no lowering, and a descriptor is what a genre's passes read as a rig. A document that does not resolve declares nothing here; the builder raises on it, naming why. """ from an.genres import SwapDeclaration props = mall.get("props") if hasattr(mall, "get") else None document = text_document(entity, props) if props is not None else None if document is None: return None try: desc = resolve_text(document, entity.overrides) except ValueError: return None if desc.texts is None: return None return SwapDeclaration(sets={TEXT_SET: text_set_keys(desc)})
def _unit_texture( entity_id: str, unit: TextUnit, desc: TextDescriptor, textures: dict[str, AssetJSON] ) -> str: """Register one unit's texture and return its alias. CONTENT-addressed: the alias changes whenever the glyphs do. A name-only alias (`text.title.word_0`) survives an edit of the words, and the runtime's loader ignores a re-added alias on hot reload, so `an preview` would keep showing the old word — and two blocks sharing an id would silently share one texture. Two keys of one set with one string share one texture. """ src = svg_data_uri( unit_svg( unit.d, unit.box, color=desc.color, stroke_width=desc.stroke_width, stroke_color=desc.stroke_color, ) ) digest = hashlib.sha256(src.encode("ascii")).hexdigest()[:TEXT_ALIAS_DIGEST_LEN] alias = f"{TEXT_TEXTURE_PREFIX}{entity_id}.{unit.name}.{digest}" textures[alias] = AssetJSON(src=src) return alias def _align_offset(desc: TextDescriptor, rest: TextLayout, other: TextLayout) -> float: """How far ``other`` moves horizontally so its ``align`` edge sits on the rest string's: a right-aligned counter grows leftwards, a left-aligned one rightwards, a centred one both ways (an#341). Layout boxes, not ink, so "1" and "11" share their right edge where their advances end.""" (r0, _, r1, _), (o0, _, o1, _) = rest.bounds, other.bounds if desc.align == "right": return r1 - o1 if desc.align == "left": return r0 - o0 return (r0 + r1) / 2.0 - (o0 + o1) / 2.0 def _text_set_visual( entity_id: str, desc: TextDescriptor, rest: TextLayout, layouts: dict[str, TextLayout], textures: dict[str, AssetJSON], ) -> VisualJSON: """``block_0``'s visual for a replacement set: the rest key's sprite, the ``text`` set naming every key's texture, and a box per key whose geometry differs from the rest's, placed on the ``align`` edge.""" (rest_unit,) = rest.units rest_alias = _unit_texture(entity_id, rest_unit, desc, textures) rest_cx, rest_cy = rest_unit.center built = { "width": float(rest_unit.size[0]), "height": float(rest_unit.size[1]), "anchor_x": 0.5, "anchor_y": 0.5, "x": 0.0, "y": 0.0, } (_, ry0, _, ry1) = rest.bounds key_map: dict[str, str] = {} geometry: dict[str, dict[str, float]] = {} for key, lay in layouts.items(): (unit,) = lay.units alias = _unit_texture(entity_id, unit, desc, textures) key_map[key] = alias if alias == rest_alias: continue (_, oy0, _, oy1) = lay.bounds cx, cy = unit.center w, h = unit.size box = { "width": float(w), "height": float(h), "anchor_x": 0.5, "anchor_y": 0.5, "x": cx + _align_offset(desc, rest, lay) - rest_cx, # Vertically every key is centred where the rest string is, by # layout box, so one-line keys share a baseline. "y": cy + ((ry0 + ry1) - (oy0 + oy1)) / 2.0 - rest_cy, } if box != built: # only keys drawn differently are listed (an#211) geometry[alias] = box w, h = rest_unit.size return VisualJSON( kind="svg_sprite", fit="contain", asset_id=rest_alias, asset_sets={TEXT_SET: key_map}, asset_geometry=geometry or None, width=float(w), height=float(h), color=desc.color, )
[docs] def build_text_subtree( entity: Any, document: Mapping[str, Any], *, width: int, height: int, base_dir: Path | None, textures: dict[str, AssetJSON], resolutions: list[AssetResolutionJSON], ) -> tuple[NodeJSON, TextDescriptor, TextLayout]: """The block's node and its unit children, plus what it resolved to. Raises ``ValueError`` subclasses (`TextFontError`, `TextLayoutError`, pydantic's `ValidationError`) — the compiler wraps them. """ desc = resolve_text(document, entity.overrides) lay = layout_text(desc, width=width, height=height, base_dir=base_dir) ox, oy = lay.origin children: list[NodeJSON] = [] if desc.texts is not None: # A replacement set (an#341): one `block_0` node drawing the rest # string, swapping to any other key's drawing — a `discrete` string # the runtime's `applySwap` already handles. layouts = layout_text_set(desc, width=width, height=height, base_dir=base_dir) (unit,) = lay.units cx, cy = unit.center children.append( NodeJSON( name=unit.name, transform=TransformJSON(x=cx - ox, y=cy - oy), visual=_text_set_visual(entity.id, desc, lay, layouts, textures), ) ) for unit in lay.units if desc.texts is None else (): alias = _unit_texture(entity.id, unit, desc, textures) cx, cy = unit.center w, h = unit.size children.append( NodeJSON( name=unit.name, transform=TransformJSON(x=cx - ox, y=cy - oy), visual=VisualJSON( kind="svg_sprite", fit="contain", asset_id=alias, width=float(w), height=float(h), color=desc.color, ), ) ) resolutions.append( AssetResolutionJSON( id=entity.id, kind="prop", store=entity.store, ref=entity.ref, resolved="text", ) ) node = NodeJSON( name=entity.id, transform=TransformJSON(x=ox - width / 2.0, y=oy - height / 2.0), children=children, ) return node, desc, lay