Source code for tituli.compose

"""The composed pieces: title cards, captions with attribution, lower thirds.

Each function turns a few strings into a :class:`~tituli.layout.Layout` placed
on a :class:`~tituli.frame.Frame`, applying the ink-and-scrim decision the frame's
knowledge allows. The pipeline order is fixed and matters: **shape/wrap →
measure → decide ink → cut the scrim to the measured block → compose**. A scrim
sized before the text is measured is how a three-line caption ends up hanging
off its own backdrop.

>>> from tituli.frame import Frame
>>> lay = title_card("The Apple", "a concrete poem", frame=Frame.blank((1920, 1080)))
>>> "".join(r.text for r in lay.runs)      # tracked styles set one run per glyph
'The Applea concrete poem'
>>> lay.meta["anchor"], lay.meta["ink"]
('center', 'background unknown: white on dark scrim')
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Sequence

from tituli.color import (
    WCAG_NORMAL_MIN,
    WHITE,
    RGBA,
    contrast_from_luminance,
    ink_for,
    parse_color,
    with_alpha,
)
from tituli.frame import Frame
from tituli.geometry import Box
from tituli.layout import Layout, Plate, Run, block, fit_size, measure, wrap
from tituli.style import (
    ATTRIBUTION,
    CAPTION,
    KICKER,
    LOWER_THIRD_NAME,
    LOWER_THIRD_ROLE,
    NOTE_HEADLINE,
    NOTE_LINE,
    SUBTITLE,
    TITLE,
    TextStyle,
)

# Scrim tuning. Opacity is what a dark scrim needs to carry white text over
# white paper (the worst case a found-image film throws at it) — the corner
# falloff keeps it from dimming the whole frame on everything else.
SCRIM_DARK: RGBA = (6, 7, 10, 210)
SCRIM_LIGHT: RGBA = (250, 249, 246, 210)
_BUSY_STD = 0.12  # luminance stddev above which a patch is "busy"
_SCRIM_PAD_EM = 1.0  # full-strength margin around a block, in em of its largest face
_SCRIM_BLEED_EM = 9.0  # the falloff beyond that margin, in em
_GAP_EM = 0.55  # vertical gap between the parts of a card, in em
_TITLE_MAX_WIDTH = 0.8  # fraction of the safe box a title may span
_CAPTION_MAX_WIDTH = 0.55
_LOWER_THIRD_MAX_WIDTH = 0.6
_NOTE_MAX_WIDTH = 0.62
_NOTE_MAX_LINES = 4  # per line of the note, after wrapping
_ELLIPSIS = "…"
_ACCENT: RGBA = (214, 170, 94, 255)  # a warm gold, used only for the accent rule
_RULE_EM = (1.6, 0.09)  # accent rule length and thickness, in em of the title face


[docs] @dataclass(frozen=True) class InkDecision: """What the frame told us to do about legibility.""" ink: RGBA scrim: RGBA | None # None = no scrim needed luminance: float | None reason: str
[docs] def decide_ink(frame: Frame, box: Box, *, preferred: RGBA | None = None) -> InkDecision: """Choose ink and whether a scrim is needed under ``box``. Rung (a) (nothing known): white on a dark scrim, the universal default. Rung (b)/(c): the ink with more contrast; a scrim only when contrast is still short of WCAG 4.5:1 or the patch is busy. ``preferred`` is honoured when it already has enough contrast. """ stats = frame.luminance_stats(box) if stats is None: return InkDecision( WHITE, SCRIM_DARK, None, "background unknown: white on dark scrim" ) lum, std = stats if ( preferred is not None and contrast_from_luminance(preferred, lum) >= WCAG_NORMAL_MIN and std < _BUSY_STD ): return InkDecision(preferred, None, lum, "preferred ink has enough contrast") ink = ink_for(luminance=lum) ok = contrast_from_luminance(ink, lum) >= WCAG_NORMAL_MIN if ok and std < _BUSY_STD: return InkDecision(ink, None, lum, "background contrast sufficient") scrim = SCRIM_DARK if ink == WHITE else SCRIM_LIGHT why = "busy background" if ok else "insufficient contrast" return InkDecision(ink, scrim, lum, f"{why}: scrim added")
def _scrim_plate( block_box: Box, frame: Frame, scrim: RGBA, *, em: float, anchor: str ) -> Plate: """A 2-D corner (or edge) falloff cut to the block, bleeding away from it.""" pad = em * _SCRIM_PAD_EM bleed = em * _SCRIM_BLEED_EM core = Box( block_box.x0 - pad, block_box.y0 - pad, block_box.x1 + pad, block_box.y1 + pad ) horizontal = "left" in anchor or "right" in anchor vertical = "top" in anchor or "bottom" in anchor # The plate runs from the frame edge the block hugs, through the core, and # `bleed` further; `solid` is the core's share of that extent per axis. x0 = 0.0 if "left" in anchor else core.x0 - bleed x1 = float(frame.width) if "right" in anchor else core.x1 + bleed y0 = 0.0 if "top" in anchor else core.y0 - bleed y1 = float(frame.height) if "bottom" in anchor else core.y1 + bleed if not horizontal: x0, x1 = 0.0, float(frame.width) if not vertical: y0, y1 = core.y0 - bleed, core.y1 + bleed b = Box(x0, y0, x1, y1).intersection(frame.bounds) solid_x = (core.x1 if "left" in anchor else b.x1 - core.x0) / max(1.0, b.width) solid_y = (core.y1 if "top" in anchor else b.y1 - core.y0) / max(1.0, b.height) solid = (min(1.0, solid_x), min(1.0, solid_y)) if horizontal and vertical: return Plate(b, scrim, f"corner-{anchor}", solid=solid) # type: ignore[arg-type] if "bottom" in anchor: return Plate(b, scrim, "gradient-bottom", solid=solid) if "top" in anchor: return Plate(b, scrim, "gradient-top", solid=solid) return Plate(b, scrim, "box", radius=em * 0.3)
[docs] def truncate( text: str, style: TextStyle, frame_height: float, *, max_width: float, max_lines: int, ) -> str: """Wrap and hard-truncate with an ellipsis at ``max_lines`` lines. For text whose length you do not control (a licence template blob pasted into an artist field). The result always fits. """ lines = wrap(text, style, frame_height, max_width=max_width) if len(lines) <= max_lines: return "\n".join(lines) kept = lines[:max_lines] last = kept[-1] while last and measure(last + _ELLIPSIS, style, frame_height) > max_width: last = last[:-1].rstrip() kept[-1] = last + _ELLIPSIS return "\n".join(kept)
def _stack( parts: Sequence[tuple[str, TextStyle]], frame: Frame, *, max_width: float, align: str | None = None, ) -> Layout: """Lay out several (text, style) blocks top-to-bottom with style-sized gaps.""" y = 0.0 out = Layout() for i, (text, style) in enumerate(parts): if not text: continue st = style.with_(align=align) if align else style lay = block(text, st, frame, max_width=max_width, y=y) out = out + lay y += ( len(text.split("\n")) * lay.meta["line_height"] + st.face(frame.height).size * _GAP_EM ) return out
[docs] def title_card( title: str, subtitle: str = "", *, frame: Frame, kicker: str = "", anchor: str = "center", title_style: TextStyle = TITLE, subtitle_style: TextStyle = SUBTITLE, kicker_style: TextStyle = KICKER, ink: RGBA | None = None, scrim: bool | None = None, ) -> Layout: """An opening card: optional kicker, title, optional subtitle. Fits the title to 80 % of the safe width (shrinking, never clipping), stacks the parts with a gap proportional to the type, places the block at ``anchor`` (subject-avoiding when the frame knows the picture), and picks ink by the frame's contrast. ``scrim`` forces a scrim on/off; None decides. """ safe = frame.safe max_w = safe.width * _TITLE_MAX_WIDTH tstyle = fit_size( title, title_style, frame.height, max_width=max_w, max_height=safe.height * 0.6 ) parts = [(kicker, kicker_style), (title, tstyle), (subtitle, subtitle_style)] align = ( "center" if anchor in ("top", "center", "bottom") else ("left" if "left" in anchor else "right") ) lay = _stack(parts, frame, max_width=max_w, align=align) bb = lay.bbox() where, box = frame.place((bb.width, bb.height), anchor=anchor) lay = lay.moved_to(box) return _finish( lay, frame, where, ink=ink, scrim=scrim, em=tstyle.face(frame.height).size, preferred=parse_color(title_style.color), )
[docs] def caption( text: str, attribution: str = "", *, frame: Frame, anchor: str | Sequence[str] = "auto", style: TextStyle = CAPTION, attribution_style: TextStyle = ATTRIBUTION, max_lines: int = 3, max_width: float = _CAPTION_MAX_WIDTH, ink: RGBA | None = None, scrim: bool | None = None, accent: bool = True, ) -> Layout: """A museum label over a picture: what is on screen, plus a tiny credit line. ``text`` is wrapped to ``max_width`` of the safe box and hard-truncated at ``max_lines``; ``attribution`` is set small and slightly transparent — present enough to credit, small enough not to compete. Placement avoids the frame's ``avoid`` boxes and its delivery target's reserved zones; the scrim is a 2-D corner falloff cut to the measured block. """ safe = frame.safe max_w = safe.width * max_width body = truncate(text, style, frame.height, max_width=max_w, max_lines=max_lines) credit = ( truncate( attribution, attribution_style, frame.height, max_width=max_w, max_lines=1 ) if attribution else "" ) # Try the layout at each candidate anchor's alignment; alignment follows the side. lay = _stack( [(body, style), (credit, attribution_style)], frame, max_width=max_w, align="left", ) bb = lay.bbox() where, box = frame.place((bb.width, bb.height), anchor=anchor) if "right" in where: lay = _stack( [(body, style), (credit, attribution_style)], frame, max_width=max_w, align="right", ) elif where in ("top", "center", "bottom"): lay = _stack( [(body, style), (credit, attribution_style)], frame, max_width=max_w, align="center", ) bb = lay.bbox() lay = lay.moved_to(box) em = style.face(frame.height).size out = _finish( lay, frame, where, ink=ink, scrim=scrim, em=em, preferred=parse_color(style.color), ) if accent: out = _with_accent_rule(out, em, where) return out
[docs] def lower_third( name: str, role: str = "", *, frame: Frame, anchor: str = "bottom-left", name_style: TextStyle = LOWER_THIRD_NAME, role_style: TextStyle = LOWER_THIRD_ROLE, ink: RGBA | None = None, scrim: bool | None = None, ) -> Layout: """Who is speaking: a name and a role, left-anchored, scrimmed if needed. Respects the frame's delivery zones — with ``delivery="youtube"`` the block moves above the subtitle band rather than into it. """ max_w = frame.safe.width * _LOWER_THIRD_MAX_WIDTH name_t = truncate(name, name_style, frame.height, max_width=max_w, max_lines=1) role_t = ( truncate(role, role_style, frame.height, max_width=max_w, max_lines=1) if role else "" ) lay = _stack( [(name_t, name_style), (role_t, role_style)], frame, max_width=max_w, align="left", ) bb = lay.bbox() where, box = frame.place((bb.width, bb.height), anchor=anchor) lay = lay.moved_to(box) em = name_style.face(frame.height).size out = _finish( lay, frame, where, ink=ink, scrim=scrim, em=em, preferred=parse_color(name_style.color), ) return _with_accent_rule(out, em, where)
[docs] def note( lines: Sequence[str] | str, *, frame: Frame, headline: str = "", anchor: str | Sequence[str] = "top-left", headline_style: TextStyle = NOTE_HEADLINE, line_style: TextStyle = NOTE_LINE, max_width: float = _NOTE_MAX_WIDTH, ink: RGBA | None = None, scrim: bool | None = None, accent: bool = True, ) -> Layout: """An editorial context card: an optional headline over equal-weight lines. The block for what the audio assumes and a cold viewer does not have ("what *Hamilton* is", "Philip died at 19"). Heavier than a caption — schedule it with a higher ``weight`` so the two never share a corner. Each line wraps to ``max_width`` of the safe box and is truncated at ``_NOTE_MAX_LINES`` rows; lines are laid out as written, so keep them short. """ if isinstance(lines, str): lines = [l for l in lines.split("\n") if l.strip()] max_w = frame.safe.width * max_width body = [ truncate( l, line_style, frame.height, max_width=max_w, max_lines=_NOTE_MAX_LINES ) for l in lines ] head = ( truncate(headline, headline_style, frame.height, max_width=max_w, max_lines=2) if headline else "" ) parts = [(head, headline_style)] + [(b, line_style) for b in body] align = "left" lay = _stack(parts, frame, max_width=max_w, align=align) bb = lay.bbox() where, box = frame.place((bb.width, bb.height), anchor=anchor) if "right" in where or where in ("top", "center", "bottom"): align = "right" if "right" in where else "center" lay = _stack(parts, frame, max_width=max_w, align=align) lay = lay.moved_to(box) em = (headline_style if head else line_style).face(frame.height).size out = _finish( lay, frame, where, ink=ink, scrim=scrim, em=em, preferred=parse_color(line_style.color), ) return _with_accent_rule(out, em, where) if accent else out
def _with_accent_rule(lay: Layout, em: float, anchor: str) -> Layout: bb = lay.bbox() length, thick = em * _RULE_EM[0], max(2.0, em * _RULE_EM[1]) gap = em * 0.45 if "right" in anchor: x0 = bb.x1 - length elif "left" in anchor: x0 = bb.x0 else: x0 = (bb.x0 + bb.x1 - length) / 2 rule = Plate(Box(x0, bb.y0 - gap - thick, x0 + length, bb.y0 - gap), _ACCENT, "box") return lay.with_plates(rule) def _finish( lay: Layout, frame: Frame, anchor: str, *, ink: RGBA | None, scrim: bool | None, em: float, preferred: RGBA, ) -> Layout: """Apply the ink decision and, if needed, the scrim — after measuring.""" bb = lay.bbox() decision = decide_ink(frame, bb, preferred=preferred) chosen = ink if ink is not None else decision.ink runs = tuple( Run(**{**r.__dict__, "color": with_alpha(chosen, r.color[3] / 255)}) for r in lay.runs ) lay = Layout( runs, lay.plates, {**lay.meta, "ink": decision.reason, "anchor": anchor} ) want_scrim = decision.scrim is not None if scrim is None else scrim if want_scrim: color = decision.scrim or (SCRIM_DARK if chosen == WHITE else SCRIM_LIGHT) plate = _scrim_plate(bb, frame, color, em=em, anchor=anchor) lay = Layout(lay.runs, (plate,) + lay.plates, lay.meta) return lay
[docs] def intertitle(text: str, *, frame: Frame, style: TextStyle | None = None) -> Layout: """A silent-film style card: serif italic prose, centred on the frame.""" from tituli.style import INTERTITLE st = style or INTERTITLE safe = frame.safe st = fit_size( text, st, frame.height, max_width=safe.width * 0.7, max_height=safe.height * 0.7 ) lay = block(text, st, frame, max_width=safe.width * 0.7) bb = lay.bbox() _, box = frame.place((bb.width, bb.height), anchor="center") lay = lay.moved_to(box) return _finish( lay, frame, "center", ink=None, scrim=None, em=st.face(frame.height).size, preferred=parse_color(st.color), )
__all__ = [ "InkDecision", "decide_ink", "truncate", "title_card", "caption", "lower_third", "note", "intertitle", "SCRIM_DARK", "SCRIM_LIGHT", ]