an.stage.text#

Words on screen: title cards, labels, and text you can animate word by word.

Epic #9 Wave 8, first slice; the design question was an#155. A text block is a prop whose document kind is TextDescriptor — the shape stroked paths took (an.stage.paths) — so a scene names it with an ordinary AssetRef(kind="prop", ...), its overrides supply the per-shot string, and nothing in the scene IR changed (no field, no migration).

>>> title = TextDescriptor(name="title", text="Words on screen", layer="overlay")
>>> title.kind, title.unit, title.layer
('TextDescriptor', 'word', 'overlay')

Typesetting is not done here. The sibling package tituli owns it — shaping, real glyph metrics, wrapping, alignment, the title-safe area — and emits a Layout of placed Run``s, one per unit. :func:`layout_text` asks it for that layout and for each run's glyph contours (``tituli.run_outline), and the compiler turns each unit into one node whose visual is an SVG sprite converted at compile time. The runtime never learns what text is, so fonts never reach the frame path and the determinism perimeter is unchanged.

Units are addressable, so text animates with ordinary tweens. A block of unit="word" builds <id>/word_0, <id>/word_1, …; "glyph" and "line" likewise. index counts DRAWN units in reading order (spaces are not units). reveal_units() is a Python-side generator of ordinary actions for a staggered reveal — a preset, not a new IR node (the general combinator is an.ir.compose.stagger()):

>>> from an.ir.compose import flatten
>>> reveal = reveal_units("title", 3, "alpha", to=1.0, from_=0.0, duration=0.3, step=0.1)
>>> [(f.action.target, round(f.start, 3)) for a in reveal for f in flatten(a)
...  if f.action.kind == "tween"]
[('title/word_0', 0.0), ('title/word_1', 0.1), ('title/word_2', 0.2)]

Two layers. layer="world" is in the scene and moves with the camera, like any prop. layer="overlay" is drawn in a second top-level container the camera never touches — a title card stays exactly where it is through a push-in while an in-world label grows with the scene.

Fonts fail loudly and never depend on the machine. font=None is the face Pillow embeds (Aileron, CC0 — “No Rights Reserved”), requested through tituli.EMBEDDED so the installed fonts are never consulted: the same bytes on every machine with the same Pillow, and no typeface is vendored in this package. font otherwise names a font FILE (.ttf/.otf/.ttc), absolute or relative to the text document’s directory in the props store; a path that is not a file, or a file the typesetter could not use, RAISES (TextFontError). A family NAME is refused on purpose: resolving one would make the picture depend on what a machine has installed. A character the face has no glyph for raises too — an is English-first and refuses loudly rather than drawing a box. The face’s identity (sha256 of its bytes) is recorded in the compiled document.

Module Attributes

TEXT_DOCUMENT_KIND

Its own versioned document kind, registered from the module that owns the schema (the rule PathDescriptor and PropDescriptor follow).

DFLT_TEXT_SIZE

0.06 is 65 px at 1080p, and the same block reads the same at 720p and at 4K.

DFLT_TEXT_COLOUR

Ink when the document names none — a near-black that reads on the default white background.

RESERVED_TEXT_IDS

the runtime indexes the scene's container as root (the camera's target), and the overlay container is named overlay.

BLOCK_UNIT

The unit that typesets the whole string as ONE node, block_0 (an#341): what a replacement set swaps, since per-word units of different strings do not line up.

TEXT_SET

The swap set a text block with texts declares (an#341).

Functions

resolve_text(document[, overrides])

The text block an entity draws: its stored document with overrides on top.

font_base_dir(props_store, ref)

What a relative font path resolves against: the text document's own directory in an on-disk props store — None for an in-memory one, where a relative path then RAISES rather than resolving against the working directory (which would make the picture depend on where you ran it).

text_entity_problem(entity, desc)

What is wrong with WHERE this entity puts its block, or None.

layout_text(desc, *, width, height[, base_dir])

Set desc on a width x height frame and take each unit's contours.

unit_names(desc, *, width, height[, base_dir])

The node names a block builds — what <id>/<name> targets may address.

layout_text_set(desc, *, width, height[, ...])

Every string of a block's replacement set, set once: {key: layout}.

text_set_keys(desc)

{key: key} for a block's replacement set — the text set as a swap declaration states it ({} for a single-string block).

reveal_units(entity_id, count, property, *, ...)

Tween property from from_ to to on units 0..count-1 of a text block, each step seconds after the last — a word-by-word (or letter-by-letter) reveal.

Classes

TextDescriptor(**data)

The on-disk text schema, saved as a prop's prop.json.

Counter(**data)

A counter block's number-to-text rule (an#342): format in the declared d3-format subset (an.formats), and the value shown before any value action (start; None = the first action's value).

TextUnit(name, text, box, d)

One addressable unit: its node name, its string, its box and its ink.

TextLayout(units, origin, font[, bounds])

A placed block: its units, its reference point, and the face that set it.

FontIdentity(family, style, sha256, embedded)

Which face drew a block — by its bytes, not its name.

Exceptions

TextFontError

A text block's font cannot be used: not a file, not a font, or not the face the typesetter actually used.

TextLayoutError

The text cannot be set as asked (a glyph the face lacks, nothing to draw).

TextOutlineWarning

A unit's outline is wide enough to cover its neighbour's glyphs.

an.stage.text.BLOCK_UNIT: str = 'block'#

The unit that typesets the whole string as ONE node, block_0 (an#341): what a replacement set swaps, since per-word units of different strings do not line up.

class an.stage.text.Counter(**data)[source]#

Bases: BaseModel

A counter block’s number-to-text rule (an#342): format in the declared d3-format subset (an.formats), and the value shown before any value action (start; None = the first action’s value).

>>> Counter(format="Day {d}", start=1).format
'Day {d}'
model_config: ClassVar[ConfigDict] = {'extra': 'forbid'}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

show(value)[source]#

value as this counter draws it.

Return type:

str

an.stage.text.DFLT_TEXT_COLOUR: str = '#1a1a1a'#

Ink when the document names none — a near-black that reads on the default white background.

an.stage.text.DFLT_TEXT_SIZE: float = 0.06#

0.06 is 65 px at 1080p, and the same block reads the same at 720p and at 4K.

Type:

Type size as a FRACTION OF FRAME HEIGHT (tituli’s convention)

class an.stage.text.FontIdentity(family, style, sha256, embedded, layout_engine='basic', features=())[source]#

Bases: object

Which face drew a block — by its bytes, not its name.

features: tuple[str, ...] = ()#

a tag asked for and missing here was not in the font. Labelled only when there is one, so a block without features labels as before.

Type:

The requested OpenType features this face applied (an#362)

label()[source]#

"Aileron Regular (embedded) sha256:6985… layout:basic" — what the compiled document records.

Return type:

str

layout_engine: str = 'basic'#

Pillow’s layout engine for this face (basic or raqm). Recorded because it moves glyph ADVANCES: RAQM (HarfBuzz) applies kerning and ligatures and is used for a font file whenever libraqm can be loaded, so the same bytes can set differently on two machines. The embedded face is always basic.

an.stage.text.RESERVED_TEXT_IDS: frozenset[str] = frozenset({'overlay', 'root'})#

the runtime indexes the scene’s container as root (the camera’s target), and the overlay container is named overlay. An overlay block called root would overwrite the camera’s node in the runtime’s index and take the push-in with it.

Type:

Entity ids a text block may not take

an.stage.text.TEXT_DOCUMENT_KIND: DocumentKind = DocumentKind(name='TextDescriptor', version_field='schema_version', current_version='0.1.0')#

Its own versioned document kind, registered from the module that owns the schema (the rule PathDescriptor and PropDescriptor follow).

an.stage.text.TEXT_SET: str = 'text'#

The swap set a text block with texts declares (an#341).

class an.stage.text.TextDescriptor(**data)[source]#

Bases: BaseModel

The on-disk text schema, saved as a prop’s prop.json.

extra="forbid" (the PathDescriptor precedent): a text block is a precise instruction, and a misspelt colour that silently did nothing is the defect class this package refuses. Set-but-inert combinations raise too:

>>> TextDescriptor(name="t", text="hi", anchor="top")
Traceback (most recent call last):
...
pydantic_core._pydantic_core.ValidationError: 1 validation error for TextDescriptor
  Value error, `anchor` places a block in the title-safe area of the FRAME, which only an overlay has; a world-layer block is placed with the entity's `stage.at` [type=value_error, input_value=..., input_type=dict]
...
anchor: str | None#

one of tituli’s nine anchors ("top", "bottom-left", "center", …) inside the title-safe area. None centres the block on the node origin (the frame centre, or stage.at).

Type:

Overlay only

color: str#

#rrggbb.

property content: str#

its start, else zero, until the compiler lowers its value channel).

Type:

The string the block draws at rest (a counter

counter: Counter | None#

tween <id> value a→b and set <id> value v are lowered at compile to a replacement set of the strings the frames show. Requires unit="block"; no text/texts.

Type:

A number that reads as text (an#342)

features: list[str] | None#

["tnum"] sets tabular figures. None = the block’s default: a counter asks for COUNTER_FEATURES, anything else for none. Whether the face has them is recorded with it (meta.fonts, features:).

Type:

OpenType features to apply, by tag (an#362)

font: str | None#

None = the embedded face; else a font FILE path (see the module doc).

max_width: float | None#

Wrap width as a fraction of frame WIDTH; None = break only at newlines.

model_config: ClassVar[ConfigDict] = {'extra': 'forbid'}#

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

rest: str | None#

the first key.

Type:

The key drawn when nothing has been set; default

property rest_key: str | None#

The key a block with texts shows at rest (rest, else the first key); None for a single-string block.

size: float#

Fraction of frame height.

stroke_color: str#

The outline’s #rrggbb; needs stroke_width.

stroke_width: float#

An outline around every glyph (an#313, OverSimplified’s white labels edged in black): its visible thickness OUTSIDE the glyph, in scene pixels, drawn under the fill in the same texture, so a per-unit reveal shows outline and fill together. 0 = none.

text: str | None#

The words. Explicit newlines break lines; max_width wraps. Exactly one of text and texts is given.

texts: dict[str, str] | None#

{key: string}, every string one drawing of the block, swapped by set <id> text <key> — replacement animation applied to text. Keys obey the swap-key rules (no /, no ::). Requires unit="block".

Type:

A replacement set (an#341)

tracking: float#

tituli sets a tracked string one run per glyph, so a word unit would not exist.

Type:

Extra advance per glyph, in em. Only with unit="glyph"

property typeset_features: tuple[str, ...]#

its own, else a counter’s tabular figures (an#362), so a number does not change width as it counts.

>>> TextDescriptor(name="n", counter={"format": "{d}"}, unit="block").typeset_features
('tnum',)
>>> TextDescriptor(name="t", text="x").typeset_features
()
Type:

The features the block is set with

unit: Literal['word', 'glyph', 'line', 'block']#

a word, a glyph, a whole line, or the whole block (block_0, one node however many lines).

Type:

What one addressable node is

exception an.stage.text.TextFontError[source]#

Bases: ValueError

A text block’s font cannot be used: not a file, not a font, or not the face the typesetter actually used. Raised instead of falling back, because a fallback face is a different picture wearing the right one’s clothes.

class an.stage.text.TextLayout(units, origin, font, bounds=(0.0, 0.0, 0.0, 0.0))[source]#

Bases: object

A placed block: its units, its reference point, and the face that set it.

origin is the block’s reference point in frame pixels — the frame centre, or its title-safe anchor position — which is where the block’s own node sits; each unit’s node is placed relative to it.

bounds: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0)#

advances and line heights, not ink, so two strings of one face share a baseline and their align edges can be lined up (an#341).

Type:

The block’s LAYOUT box (x0, y0, x1, y1) in frame pixels

exception an.stage.text.TextLayoutError[source]#

Bases: ValueError

The text cannot be set as asked (a glyph the face lacks, nothing to draw).

exception an.stage.text.TextOutlineWarning[source]#

Bases: UserWarning

A unit’s outline is wide enough to cover its neighbour’s glyphs.

class an.stage.text.TextUnit(name, text, box, d)[source]#

Bases: object

One addressable unit: its node name, its string, its box and its ink.

box is (x0, y0, x1, y1) in frame pixels, snapped OUTWARD to whole pixels and containing both the layout box and the ink, so the sprite’s corners sit on the pixel grid and nothing is clipped. d is the unit’s glyph contours as SVG path data in frame pixels.

property center: tuple[float, float]#

The box centre in frame pixels — where the unit’s node sits.

property size: tuple[int, int]#

(width, height) of box.

an.stage.text.font_base_dir(props_store, ref)[source]#

What a relative font path resolves against: the text document’s own directory in an on-disk props store — None for an in-memory one, where a relative path then RAISES rather than resolving against the working directory (which would make the picture depend on where you ran it).

Return type:

Path | None

an.stage.text.layout_text(desc, *, width, height, base_dir=None)[source]#

Set desc on a width x height frame and take each unit’s contours.

base_dir is what a relative font path resolves against — the text document’s own directory in the props store. A block with texts is set with its rest string (layout_text_set() sets every one).

Return type:

TextLayout

>>> lay = layout_text(TextDescriptor(name="t", text="Hello big world"), width=1920, height=1080)
>>> [u.name for u in lay.units]
['word_0', 'word_1', 'word_2']
>>> lay.origin
(960.0, 540.0)
>>> lay.font.family, lay.font.embedded
('Aileron', True)
>>> [u.name for u in layout_text(TextDescriptor(name="t", text="two\nlines", unit="block"), width=1920, height=1080).units]
['block_0']
an.stage.text.layout_text_set(desc, *, width, height, base_dir=None)[source]#

Every string of a block’s replacement set, set once: {key: layout}.

Each is set exactly as a single-string block would be, so each is centred (or anchored) on its own; lining their align edges up is the builder’s job (an.stage.text_layout.build_text_subtree()). A block with no texts returns {}.

Return type:

dict[str, TextLayout]

>>> lays = layout_text_set(TextDescriptor(name="t", texts={"a": "1", "b": "22"}, unit="block"), width=320, height=240)
>>> sorted(lays), [lay.units[0].name for lay in lays.values()]
(['a', 'b'], ['block_0', 'block_0'])
an.stage.text.resolve_text(document, overrides=None)[source]#

The text block an entity draws: its stored document with overrides on top.

Validated strictly AFTER the merge, so a stored document may be a reusable style with no text of its own, and each entity supplies the words:

Return type:

TextDescriptor

>>> style = {"kind": "TextDescriptor", "name": "label", "size": 0.04}
>>> resolve_text(style, {"text": "Paris"}).text
'Paris'
an.stage.text.reveal_units(entity_id, count, property, *, to, from_, duration, step, start=0.0, unit='word', easing='ease_out')[source]#

Tween property from from_ to to on units 0..count-1 of a text block, each step seconds after the last — a word-by-word (or letter-by-letter) reveal. Unit i starts at start + i*step.

A unit whose tween starts later also gets a set to from_ at 0, which HOLDS until its tween begins (the compiler’s set-hold rule) — without it the word would show its built value until its turn and then snap to from_. That is also why from_ is required.

Returns a LIST of top-level actions — set, a bare tween, or the sequence(delay(start), tween) wrapper the scene.md parser itself produces for a start: key — so shot.actions.extend(stagger(...)) round-trips through scene.md in its short form, one entry per unit.

Named stagger until an#241 gave the core a general combinator of that name (an.ir.compose.stagger(), any actions, one parallel); an.stage.text.stagger stays as an alias of this function so old imports keep working.

Return type:

list

>>> [a.kind for a in reveal_units("t", 2, "alpha", to=1, from_=0, duration=0.2, step=0.1)]
['tween', 'set', 'sequence']
an.stage.text.text_entity_problem(entity, desc)[source]#

What is wrong with WHERE this entity puts its block, or None.

The one statement of the placement rules, called by the compiler (which raises) and by an validate (which reports) so the two agree:

  • a reserved id (RESERVED_TEXT_IDS) — root would hijack the camera’s node in the runtime’s index;

  • anchor and stage.at together — two answers to one question.

Return type:

str | None

an.stage.text.text_set_keys(desc)[source]#

{key: key} for a block’s replacement set — the text set as a swap declaration states it ({} for a single-string block).

Return type:

dict[str, str]

>>> text_set_keys(TextDescriptor(name="t", texts={"a": "1", "b": "2"}, unit="block"))
{'a': 'a', 'b': 'b'}
an.stage.text.unit_names(desc, *, width, height, base_dir=None)[source]#

The node names a block builds — what <id>/<name> targets may address.

Return type:

list[str]