> built 2026-10-10 14:47 UTC from 23c59af (main) · tituli 0.0.9. Details: build_info.json

# index.html.md

<!-- generated by epythet -->

# tituli

Text in video, with tasteful defaults: **title cards**, **credits**, **captions with a tiny attribution**, **lower thirds**, **context notes**, and **calligrams** — from one layout model, rendered with Pillow, composited with ffmpeg.

```bash
pip install tituli            # Pillow only; ffmpeg on PATH for video output
```

```python
from tituli import Frame, title_card, caption, render

frame = Frame.blank((1920, 1080), color="#101014")
render(
    title_card("Il pleut", "Apollinaire, 1918", kicker="Episode 3", frame=frame), frame
).save("title.png")

frame = Frame.from_image(
    "still.jpg", delivery="youtube"
)  # add avoid=burns.salient_box to keep off the subject
render(
    caption("Eliza Hamilton", "Ralph Earl, 1787 · public domain", frame=frame), frame
).save("captioned.png")
```

Or from the shell (`pip install tituli[cli]`):

```bash
python -m tituli title_card "The Apple" "a concrete poem" --background "#101014" --out title.png
python -m tituli credits credits.json --mode crawl --out credits.mp4
python -m tituli caption still.jpg "Angelica Schuyler Church" "John Trumbull, 1785" --out cap.png
python -m tituli calligram "round and round the apple goes" --shape circle --out calligram.png
```

## One model

Every use case fills a `Layout` — placed `Run`s (a string, a baseline origin, an angle, a resolved face, a colour) over `Plate`s (scrims, rules) — on a `Frame`. A block of prose is one run per line; a calligram is one run per glyph. The humble cases are the degenerate calligram. `render(layout, frame)` gives an RGBA overlay (frame without a background) or an RGB composite; `tituli.video` puts either onto footage.

### The frame-knowledge ladder

“A video that *is* text” and “text over a video” are the same problem; what differs is **how much the layout engine knows about the picture**. `Frame` carries all four rungs, every one optional, and the engine *asks* rather than branching:

| rung   | the Frame knows                                                | so the engine can                                                                                       |
|--------|----------------------------------------------------------------|---------------------------------------------------------------------------------------------------------|
| a      | its size                                                       | place by anchor in the title-safe area; assume the worst → white on a dark scrim                        |
| b      | a solid colour (`Frame.blank(size, color=…)`)                  | pick ink by WCAG contrast; no scrim                                                                     |
| c      | the pixels (`Frame.from_image(still)` or `Frame.over(stills)`) | sample luminance under the candidate box; ink by contrast; scrim only if the patch is mid-toned or busy |
| d      | c + regions to keep clear (`avoid=`)                           | rank the nine anchor positions by how little they cover the subject                                     |

`Frame.over(stills)` samples every frame a camera move will pass through and reports the *worst instant*, so a scrim over a Ken Burns pan is as light as the whole window allows. `delivery="youtube"` hard-excludes the bottom 22 % (subtitle track + control bar) from placement — modelled as named reserved zones, not a rule to remember.

### Pipeline order (it matters)

**shape/wrap → fit → measure → decide ink → cut the scrim to the measured block → compose.** Your words are set complete or not at all: `caption`, `lower_third` and `note` shrink the type until it fits and raise `TextDoesNotFit` rather than ship a cut label — because `1981 · Centr…` is not a shortened label, it is a wrong one, and only the caller can choose between shorter wording, more lines and no overlay. Text whose length you genuinely don’t control (a licence template pasted into an artist field) still truncates, asked for by name: `on_overflow="truncate"`. And never burn text into stills that a camera move will pan and zoom — composite onto the finished motion video (`tituli.video.overlay`).

## What you get

| call                                                        | what it makes                                                                                                                                                                                                                                                                                                                           |
|-------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `title_card(title, subtitle, kicker=…, frame=…)`            | opening card; title fitted (shrunk, never clipped) to 80 % of the safe width                                                                                                                                                                                                                                                            |
| `caption(text, attribution, frame=…)`                       | museum label: what’s on screen + a small credit line; subject-avoiding; corner-falloff scrim                                                                                                                                                                                                                                            |
| `lower_third(name, role, frame=…)`                          | who is speaking                                                                                                                                                                                                                                                                                                                         |
| `note(lines, headline=…, frame=…)`                          | editorial context: a headline over equal-weight lines (“what *Hamilton* is”)                                                                                                                                                                                                                                                            |
| `intertitle(text, frame=…)`                                 | silent-film card, serif italic                                                                                                                                                                                                                                                                                                          |
| `Credits.from_dict(…)` → `credits_cards` / `credits_crawl`  | structured roll: sections, role/name pairs on a gutter, tracked small-cap headings; **never truncates** (paginates, or raises if you cap the cards)                                                                                                                                                                                     |
| `on_path(text, shape="circle"|"wave"|SVG d|Path)`           | glyphs riding any path, rotated to the tangent or kept upright                                                                                                                                                                                                                                                                          |
| `rain(lines)`                                               | Apollinaire’s *Il pleut*: upright letters stepping down fanning streaks (the 1918 measurements as defaults)                                                                                                                                                                                                                             |
| `in_shape(text, mask)`                                      | prose poured into a silhouette                                                                                                                                                                                                                                                                                                          |
| `schedule_labels(spans, label_for, suppressed_by=cards)`    | one label per shot with first-appearance, repeat-gap and suppression rules **inside** the loop                                                                                                                                                                                                                                          |
| `run_outline(run)`                                          | a placed run’s glyph contours as one SVG `d` string in frame pixels (`pip install tituli[outlines]`) — text drawn with no font engine; a missing glyph raises `MissingGlyphError`                                                                                                                                                       |
| `TextStyle(features=("tnum",))`                             | OpenType features by tag; `face.applied_features` says which the font has. One-for-one substitutions (`tnum`, `lnum`, `zero`, `smcp` …) are applied by measurement and `run_outline` from the font’s tables (`tituli[outlines]`); the Pillow engine refuses a run they change (it would draw other glyphs), `HarfBuzzEngine` draws them |
| `stack(blocks, frame=…, gap=…)` / `grid(blocks, columns=…)` | several blocks composed into **one** `Layout` — a page that renders once and times like any overlay; gaps are fractions of frame height (one per seam if the page needs a rhythm)                                                                                                                                                       |
| `glossed([("カ", "ka"), …], frame=…)`                        | a row of big glyphs, each with its reading centred under it, on one pitch                                                                                                                                                                                                                                                               |
| `block(text, …, break_at=" · ")`                            | a `a · b · c` list that wraps only between items (never `バス` / `bus` across a line); U+00A0 binds two words in any wrap                                                                                                                                                                                                                 |
| `tituli.video.still / overlay / crawl / frames_to_video`    | ffmpeg output; only `overlay`/`fade`/`crop` needed (plus `tpad`/`adelay` for `overlay(pad_start=…)`) — never `drawtext`/`libass`. `overlay` takes layouts, payloads, or a frame-sized `image` (`TimedOverlay(None, 0, 3, image="title.png", fade_in=0)`)                                                                                |

All sizes are fractions of frame height, so a style reads the same at 720p and 4K. The presets (`tituli.style`) are one type ramp shared by overlays and the end card, so a film is one design.

### Scheduling: tituli owns it, or you do — no middle

A **reserved zone binds every anchor**, not just `anchor="auto"`: with `delivery="youtube"` a block that would land in the subtitle band is slid the shortest way clear of it, keeping the anchor you named. `anchor="bottom-left"` therefore means “as low as the platform allows”.

`schedule_labels` derives labels from a cut with the three rules built in. A label suppressed by a heavier overlay is *truncated* to the time before it, kept if what remains is readable, and otherwise skipped **without being recorded as shown** — so the portrait a cold viewer most needs named still gets labelled the next time it appears. `resolve()` applies the identical rule to hand-built overlays (one overlay per slot at a time; equal weights colliding raise). `label_for` returns a `Label` or the explicit `UNLABELLED`; `None` raises — “no caption” is a stated choice, never the cheap default, because an unlabelled still beside a labelled one is an implicit claim.

## Seams

|   # | seam                                           | v1 default (no new dependency)                        | replacement you can point at                                                           |
|-----|------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------|
|   1 | subject avoidance — `Frame.from_image(avoid=)` | none (boxes you pass)                                 | `burns.salient_box`, any `burns.FacesDetector` (`pip install tituli[saliency]`)        |
|   2 | the rasteriser — `render(engine=)`             | `PillowEngine` (FreeType via Pillow, bitmap rotation) | `HarfBuzzEngine` — shaping + outline transforms (`pip install tituli[shaping]`)        |
|   3 | the shape — `on_path(shape=)`                  | named presets                                         | an SVG `d` string or any `Path` (a traced outline)                                     |
|   4 | delivery zones — `Frame(delivery=)`            | none                                                  | `DELIVERY_RESERVED["youtube"]`; add a target, not a rule                               |
|   5 | the graph — `tituli.bodies`                    | plain dict from `body_for()`                          | `annot://schema/text-overlay/v1` registered with lacing (`pip install tituli[lacing]`) |

Surface for v1: **CLI** (`python -m tituli`, `cw.dispatch` over `tituli.tools._dispatch_funcs`). MCP/HTTP would project the same list. Agent skill shipped (`tituli/data/skills/tituli`).
NOT seams: the scrim design, the anchor grid, the type ramp, the ffmpeg encode args — written directly, on purpose.

## Where this sits in the fleet

- **`an` — [thorwhalen/an#155](https://github.com/thorwhalen/an/issues/155)** asked whether per-glyph text belongs in `an` or a sibling package. tituli is the sibling: it owns *typesetting for video* (shaping, metrics, wrap, contrast, safe area, reserved zones, path placement, the title/credits/caption conventions) and emits placed glyphs (`Run` with `unit=glyph|word|line` and an `index`). `an` keeps *motion as structure* and consumes those placements as its option-2 “svg_sprite per glyph, converted at compile time”: `run_outline` turns each run into SVG path data, so an’s runtime never learns what text is. tituli’s own time envelope is deliberately small (fades, stagger, crawl).
- **`muvid`** keeps its lyric-video vocabulary and ASS burn-in; its `calligram` archetype’s streak solver is `tituli.rain`, generalised over real glyph metrics and any frame, and `text_on_path` / `concrete_page` ceilings (a hardcoded sine; centred rows only; a 0.62-em character estimate) are what `on_path` / `in_shape` / real `Face.length` remove.
- **`braidio.video.credits_card`** is the plain-list case: `Credits.from_lines(lines)` → `credits_cards`, same never-truncate rule, designed type.
- **`burns`** owns saliency and the Ken Burns move; tituli only consumes `salient_box`.
- **`mixing`** owns subtitle (SRT) burn-in; tituli does not do subtitles.
- The Hamilton film’s `overlays.py` (captions + context cards → transparent PNG → one ffmpeg `overlay` chain) was the seed for `caption`, `note`, `schedule_labels` and `video.overlay`, with its lessons kept: per-input `-loop 1 -t`, fade on the still’s own clock, `-c:a copy`, the corner scrim cut to the block, and the suppression check inside the scheduler.

### Linked artifacts (lacing)

A caption is an annotation **on the image**: `reference = MediaRef(asset_id=<image hash>)`, body `annot://schema/text-overlay/v1` (`tituli.bodies.body_for(layout, frame, text=…)` builds it; `register()` registers it, lazily, only with `tituli[lacing]`). For the (image, audio-segment) **pair** no N-ary reference is invented: as `artful.PanelBody` does, the pair annotation’s `reference` is the interval on the segment and `provenance.was_derived_from` lists both the image `asset_id` and the caption annotation id. The body records `unlabelled=True` when a still was deliberately left without a label.

## Style defaults (why they look right)

Title-safe 90 % (SMPTE ST 2046-1); WCAG 4.5:1 aimed for, 3:1 floor; sans working set Helvetica Neue → Inter → Helvetica → Avenir Next → Roboto → … → DejaVu Sans; ≥ 36 px-equivalent at 1080p for anything meant to be read; credits cards hold ≥ 3 s, crawls ≈ 97 px/s at 1080p; reveals 300–500 ms. Sources and the full rationale: [`misc/docs/style.md`](). No fonts ship in the package — system discovery with Pillow’s embedded Aileron as the fallback, so a bare CI box still renders. When output must not depend on what is installed, ask for `family=(EMBEDDED,)`: it never scans the system, and `face_digest(face)` (sha256 of the font’s bytes) is the identity to record.

## Optional extras

| extra      | adds                                                                               |
|------------|------------------------------------------------------------------------------------|
| `shaping`  | `uharfbuzz` + `freetype-py`: ligatures, kerning, complex scripts, outline rotation |
| `saliency` | `burns` for `avoid=salient_box`                                                    |
| `outlines` | `fonttools`: `run_outline` (glyph contours as SVG path data)                       |
| `lacing`   | the body schema                                                                    |
| `cli`      | `cw` for `python -m tituli`                                                        |

## Skills

`gh skill install thorwhalen/tituli tituli` — or, after `pip install`, link `tituli/data/skills/tituli` into your agent’s skills directory.

<p class="epythet-aggregates">This documentation as a single file: <a href="tituli.md">tituli.md</a> (Markdown, for agents).</p>


# _autosummary/tituli.arrange.html.md

# tituli.arrange

Layout combinators: several blocks composed into one `Layout`.

`title_card` and `note` each set one block. A page with a headline, a row of
glossed glyphs, a wrapped list and a footnote is several blocks — and composing
them by hand means measuring each `bbox()`, translating, and compositing one
rendered image per block. These combinators take `Layout`s and return
one `Layout`, so the page renders once and `tituli.video.overlay` can time
it like any other overlay.

* [`stack()`](_autosummary/tituli.arrange.html.md#tituli.arrange.stack) — blocks top to bottom, aligned left/centre/right;
* [`grid()`](_autosummary/tituli.arrange.html.md#tituli.arrange.grid) — blocks in uniform cells, row-major (one row by default);
* [`glossed()`](_autosummary/tituli.arrange.html.md#tituli.arrange.glossed) — the glossed-glyph row: big glyph over a small reading,
  centred per column (a grid of two-block stacks).

Gaps are fractions of frame **height**, like every size in tituli. A combinator
given a [`Frame`](_autosummary/tituli.frame.html.md#tituli.frame.Frame) places its result with `frame.place` (so
the title-safe area and any delivery reservation bind); given a bare height, or
`anchor=None`, it leaves the result at the origin for an outer combinator to
place — nesting is the point.

```pycon
>>> from tituli.frame import Frame
>>> from tituli.layout import block
>>> from tituli.style import TextStyle
>>> f = Frame.blank((1920, 1080))
>>> head = block("Heading", TextStyle(size=0.06), f)
>>> body = block("body text", TextStyle(size=0.03), f)
>>> page = stack([head, body], frame=f, gap=0.02)
>>> [r.text for r in page.runs]
['Heading', 'body text']
>>> b0, b1 = (r.bbox() for r in page.runs)
>>> round(b1.y0 - b0.y1) == round(0.02 * 1080)      # the gap, in frame height
True
```

### Functions

| [`stack`](_autosummary/tituli.arrange.html.md#tituli.arrange.stack)(items, \*, frame[, gap, align, anchor])      | Blocks top to bottom, `gap` apart, aligned within the widest.       |
|-----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------|
| [`grid`](_autosummary/tituli.arrange.html.md#tituli.arrange.grid)(items, \*, frame[, columns, gap, align, ...]) | Blocks in uniform cells, row-major; one row when `columns` is None. |
| [`glossed`](_autosummary/tituli.arrange.html.md#tituli.arrange.glossed)(pairs, \*, frame[, glyph, gloss, ...])     | A row of big glyphs, each with a small reading centred under it.    |

### tituli.arrange.glossed(pairs, , frame, glyph=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.13, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.0, case='as-is', align='center', opacity=1.0, features=()), gloss=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.045, weight=600, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.1, case='as-is', align='center', opacity=1.0, features=()), gap=0.012, column_gap=0.08, columns=None, anchor='center')

A row of big glyphs, each with a small reading centred under it.

`pairs` is `[(glyph, gloss), ...]` — `[("カ", "ka"), ("ス", "su")]`.
Columns share one pitch (the widest column), so readings line up under
their glyphs and the row reads as a row. `columns` wraps a long row into
several. Runs are tagged `glyph:i` / `gloss:i` (for staggered reveals).

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

```pycon
>>> lay = glossed([("カ", "ka"), ("ス", "su")], frame=1080)
>>> [r.text for r in lay.runs]
['カ', 'ka', 'ス', 'su']
>>> [r.tags for r in lay.runs][:2]
[('glyph:0',), ('gloss:0',)]
```

### tituli.arrange.grid(items, , frame, columns=None, gap=0.03, align='center', anchor='center')

Blocks in uniform cells, row-major; one row when `columns` is None.

Every cell is as wide as the widest item (so a row of glyphs keeps one
pitch whatever each glyph’s advance), each row as tall as its tallest
item; items are aligned `align` within their cell and to its top.
`gap` is a fraction of frame height, or `(column_gap, row_gap)`.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

```pycon
>>> from tituli.layout import block
>>> from tituli.style import TextStyle
>>> cells = [block(t, TextStyle(size=0.05), 1080) for t in "a b c d".split()]
>>> g = grid(cells, frame=1080, columns=2)
>>> tops = [round(r.bbox().y0) for r in g.runs]
>>> tops[0] == tops[1] < tops[2] == tops[3]      # two rows of two
True
```

### tituli.arrange.stack(items, , frame, gap=0.02, align='center', anchor='center')

Blocks top to bottom, `gap` apart, aligned within the widest.

`gap` is a fraction of frame height: one number for every seam, or one
per seam (`len(items) - 1` values) when the page needs a rhythm. A block
with no runs is kept as a zero-height item, so per-seam gaps still line up.
Box plates travel with their block; a frame-anchored scrim raises (see
`_measurable`). A page larger than the title-safe area raises rather
than run off the frame. Parts’ loss reports (`overflow`/`unplaced`)
are summed into the result’s meta, and each part’s meta is kept under
`meta["parts"]`.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

```pycon
>>> from tituli.layout import block
>>> from tituli.style import TextStyle
>>> a = block("wide line of text", TextStyle(size=0.05), 1080)
>>> b = block("x", TextStyle(size=0.05), 1080)
>>> s = stack([a, b], frame=1080, align="left")
>>> s.runs[0].bbox().x0 == s.runs[1].bbox().x0
True
```


# _autosummary/tituli.bodies.html.md

# tituli.bodies

A lacing body schema for a rendered caption (`pip install tituli[lacing]`).

URI: `annot://schema/text-overlay/v1`. A caption is an annotation \*\*on the
image\*\* (`MediaRef(asset_id=<image hash>)`), and when that image sits under an
audio segment the pair is expressed the way `artful.PanelBody` does it: the
annotation’s `reference` is the interval on the segment and
`provenance.was_derived_from` lists both the image `asset_id` and the
caption annotation id. No N-ary reference type is invented; the timing lives
on the reference, not in this body.

Registration is lazy and idempotent ([`register()`](_autosummary/tituli.bodies.html.md#tituli.bodies.register)), so `import tituli`
never touches lacing.

### Functions

| [`register`](_autosummary/tituli.bodies.html.md#tituli.bodies.register)()                               | Register the schema with lacing (once).                             |
|-------------------------------------------------------------------------------------------|---------------------------------------------------------------------|
| [`body_for`](_autosummary/tituli.bodies.html.md#tituli.bodies.body_for)(layout, frame, \*, text[, ...]) | The body dict for a rendered layout — plain data, no lacing needed. |

### tituli.bodies.body_for(layout, frame, , text, attribution='', kind='caption', unlabelled=False)

The body dict for a rendered layout — plain data, no lacing needed.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

```pycon
>>> from tituli.frame import Frame
>>> from tituli.compose import caption
>>> f = Frame.blank((1920, 1080))
>>> b = body_for(caption("A still", "PD", frame=f), f, text="A still", attribution="PD")
>>> b["kind"], b["anchor"], len(b["box"])
('caption', 'bottom-left', 4)
```

### tituli.bodies.register()

Register the schema with lacing (once). Returns the URI.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)


# _autosummary/tituli.calligram.html.md

# tituli.calligram

Calligrams and concrete poems: text whose shape is part of the meaning.

Three treatments, all producing an ordinary [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout):

* [`on_path()`](_autosummary/tituli.calligram.html.md#tituli.calligram.on_path) — glyphs ride a [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path) (any shape:
  circle, wave, Bézier, SVG `d`), rotated to the tangent or kept upright;
* [`rain()`](_autosummary/tituli.calligram.html.md#tituli.calligram.rain) — Apollinaire’s *Il pleut*: upright letters stepping down
  fanning streaks (a preset of the same machinery, with the 1918 measurements
  as defaults);
* [`in_shape()`](_autosummary/tituli.calligram.html.md#tituli.calligram.in_shape) — prose poured into a silhouette, the concrete-page case.

`shape=` accepts a named preset, an SVG path string, or a
[`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path), so a caller can say `"circle"` and a
designer can hand over a traced outline — the same seam.

```pycon
>>> from tituli.frame import Frame
>>> lay = on_path("round and round", shape="circle", frame=Frame.blank((800, 800)))
>>> len(lay.runs) == len("round and round".replace(" ", ""))
True
```

### Functions

| [`on_path`](_autosummary/tituli.calligram.html.md#tituli.calligram.on_path)(text, \*[, shape, style, box, ...])        | Set `text` along a shape inside `box` (default: the safe area, inset).      |
|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------|
| [`rain`](_autosummary/tituli.calligram.html.md#tituli.calligram.rain)(lines, \*, frame[, style, box, slants, ...])  | *Il pleut*: each line a streak of upright letters falling across the frame. |
| [`in_shape`](_autosummary/tituli.calligram.html.md#tituli.calligram.in_shape)(text, mask, \*, frame[, style, box, ...]) | Pour prose into a silhouette (light = inside).                              |
| [`resolve_shape`](_autosummary/tituli.calligram.html.md#tituli.calligram.resolve_shape)(shape, box)                          | Turn a name, an SVG `d` string or a Path into a Path fitted to `box`.       |

### tituli.calligram.in_shape(text, mask, , frame, style=TextStyle(family=('Georgia', 'Palatino', 'Baskerville', 'Liberation Serif', 'DejaVu Serif', 'Times New Roman'), size=0.035, weight=400, italic=False, condensed=False, color=(17, 17, 17), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0, features=()), box=None, repeat=True)

Pour prose into a silhouette (light = inside). See [`tituli.layout.fill_shape()`](_autosummary/tituli.layout.html.md#tituli.layout.fill_shape).

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.calligram.on_path(text, , shape='wave', frame, style=TextStyle(family=('Georgia', 'Palatino', 'Baskerville', 'Liberation Serif', 'DejaVu Serif', 'Times New Roman'), size=0.035, weight=400, italic=False, condensed=False, color=(17, 17, 17), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0, features=()), box=None, upright=False, align='center', fit_text=True)

Set `text` along a shape inside `box` (default: the safe area, inset).

With `fit_text` the type is shrunk until the whole string fits the path
length (never clipped); the resulting size is in `meta["size"]`.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.calligram.rain(lines, , frame, style=TextStyle(family=('Georgia', 'Palatino', 'Baskerville', 'Liberation Serif', 'DejaVu Serif', 'Times New Roman'), size=0.035, weight=400, italic=False, condensed=False, color=(17, 17, 17), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0, features=()), box=None, slants=(0.186, 0.22, 0.257, 0.298, 0.353), head_offsets=(0.0, 7.0, 11.7, 16.4, 19.3), size_ratio=0.86)

*Il pleut*: each line a streak of upright letters falling across the frame.

Wants a portrait frame. `slants` (dx per step down) and `head_offsets`
(where each streak starts, in slot units) are shape parameters, not
coordinates — the defaults are Apollinaire’s.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.calligram.resolve_shape(shape, box)

Turn a name, an SVG `d` string or a Path into a Path fitted to `box`.

Names: `"line"`, `"circle"`, `"arc"`, `"wave"`, `"diagonal"`,
`"s-curve"`. Anything starting with `M`/`m` is parsed as SVG.

* **Return type:**
  [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path)


# _autosummary/tituli.color.html.md

# tituli.color

Colours, WCAG contrast, and the ink-for-this-background decision.

Everything here is pure arithmetic on `(r, g, b[, a])` tuples in 0–255, so it
costs nothing to import and is easy to test.

```pycon
>>> contrast_ratio((255, 255, 255), (0, 0, 0))
21.0
>>> ink_for(luminance=0.9)   # light background -> dark ink
(17, 17, 17)
>>> ink_for(luminance=0.1)   # dark background -> light ink
(255, 255, 255)
```

### Functions

| [`contrast_from_luminance`](_autosummary/tituli.color.html.md#tituli.color.contrast_from_luminance)(ink, luminance)    | Contrast of `ink` over a background of known relative luminance.       |
|---------------------------------------------------------------------------------------------|------------------------------------------------------------------------|
| [`contrast_ratio`](_autosummary/tituli.color.html.md#tituli.color.contrast_ratio)(a, b)                       | WCAG contrast ratio between two colours (1..21).                       |
| [`ink_for`](_autosummary/tituli.color.html.md#tituli.color.ink_for)(\*, luminance[, light, dark])      | Pick the ink (light or dark) with more contrast against `luminance`.   |
| [`mean_luminance`](_autosummary/tituli.color.html.md#tituli.color.mean_luminance)(pixels)                     | Mean relative luminance of a pixel sample.                             |
| [`mix`](_autosummary/tituli.color.html.md#tituli.color.mix)(a, b, t)                               | Linear blend, `t=0` -> `a`, `t=1` -> `b`.                              |
| [`needs_scrim`](_autosummary/tituli.color.html.md#tituli.color.needs_scrim)(ink, luminance, \*[, minimum]) | Whether `ink` over that background falls short of `minimum` contrast.  |
| [`parse_color`](_autosummary/tituli.color.html.md#tituli.color.parse_color)(color)                         | Accept `"#rgb"`, `"#rrggbb"`, `"#rrggbbaa"`, a Pillow name or a tuple. |
| [`relative_luminance`](_autosummary/tituli.color.html.md#tituli.color.relative_luminance)(color)                  | WCAG relative luminance in 0..1.                                       |
| [`with_alpha`](_autosummary/tituli.color.html.md#tituli.color.with_alpha)(color, alpha)                   | Same colour with opacity `alpha` in 0..1.                              |

### tituli.color.contrast_from_luminance(ink, luminance)

Contrast of `ink` over a background of known relative luminance.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

### tituli.color.contrast_ratio(a, b)

WCAG contrast ratio between two colours (1..21).

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

```pycon
>>> round(contrast_ratio("#777", "#fff"), 2)
4.48
```

### tituli.color.ink_for(, luminance, light=(255, 255, 255), dark=(17, 17, 17))

Pick the ink (light or dark) with more contrast against `luminance`.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]

### tituli.color.mean_luminance(pixels)

Mean relative luminance of a pixel sample.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

### tituli.color.mix(a, b, t)

Linear blend, `t=0` -> `a`, `t=1` -> `b`.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]

### tituli.color.needs_scrim(ink, luminance, , minimum=4.5)

Whether `ink` over that background falls short of `minimum` contrast.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

```pycon
>>> needs_scrim((255, 255, 255), luminance=0.5)
True
>>> needs_scrim((255, 255, 255), luminance=0.02)
False
```

### tituli.color.parse_color(color)

Accept `"#rgb"`, `"#rrggbb"`, `"#rrggbbaa"`, a Pillow name or a tuple.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]

```pycon
>>> parse_color("#fff")
(255, 255, 255, 255)
>>> parse_color((10, 20, 30))
(10, 20, 30, 255)
```

### tituli.color.relative_luminance(color)

WCAG relative luminance in 0..1.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

```pycon
>>> round(relative_luminance((255, 255, 255)), 3)
1.0
>>> relative_luminance((0, 0, 0))
0.0
```

### tituli.color.with_alpha(color, alpha)

Same colour with opacity `alpha` in 0..1.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]

```pycon
>>> with_alpha("#000", 0.5)
(0, 0, 0, 128)
```


# _autosummary/tituli.compose.html.md

# tituli.compose

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

Each function turns a few strings into a [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout) placed
on a [`Frame`](_autosummary/tituli.frame.html.md#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.

```pycon
>>> 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')
```

### Functions

| [`decide_ink`](_autosummary/tituli.compose.html.md#tituli.compose.decide_ink)(frame, box, \*[, preferred])          | Choose ink and whether a scrim is needed under `box`.                      |
|---------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------|
| [`truncate`](_autosummary/tituli.compose.html.md#tituli.compose.truncate)(text, style, frame_height, \*, ...)     | Wrap and hard-truncate with an ellipsis at `max_lines` lines.              |
| [`title_card`](_autosummary/tituli.compose.html.md#tituli.compose.title_card)(title[, subtitle, kicker, ...])       | An opening card: optional kicker, title, optional subtitle.                |
| [`caption`](_autosummary/tituli.compose.html.md#tituli.compose.caption)(text[, attribution, anchor, style, ...]) | A museum label over a picture: what is on screen, plus a tiny credit line. |
| [`lower_third`](_autosummary/tituli.compose.html.md#tituli.compose.lower_third)(name[, role, anchor, ...])           | Who is speaking: a name and a role, left-anchored, scrimmed if needed.     |
| [`note`](_autosummary/tituli.compose.html.md#tituli.compose.note)(lines, \*, frame[, headline, anchor, ...])  | An editorial context card: an optional headline over equal-weight lines.   |
| [`intertitle`](_autosummary/tituli.compose.html.md#tituli.compose.intertitle)(text, \*, frame[, style])             | A silent-film style card: serif italic prose, centred on the frame.        |

### Classes

| [`InkDecision`](_autosummary/tituli.compose.html.md#tituli.compose.InkDecision)(ink, scrim, luminance, reason)   | What the frame told us to do about legibility.   |
|-----------------------------------------------------------------------------------------------|--------------------------------------------------|

### *class* tituli.compose.InkDecision(ink, scrim, luminance, reason)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

What the frame told us to do about legibility.

### tituli.compose.caption(text, attribution='', , frame, anchor='auto', style=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.04, weight=600, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.25, case='as-is', align='left', opacity=1.0, features=()), attribution_style=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.02, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.03, leading=1.2, case='as-is', align='left', opacity=0.85, features=()), max_lines=3, max_width=0.55, ink=None, scrim=None, accent=True, on_overflow='fit')

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 **shrunk to fit**
within `max_lines` rather than cut; `attribution` is set small and
slightly transparent — present enough to credit, small enough not to
compete. If the words cannot be set legibly this raises
[`TextDoesNotFit`](_autosummary/tituli.html.md#tituli.TextDoesNotFit); pass `on_overflow="truncate"` for text whose
length you do not control (see `OVERFLOW_POLICIES`). 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.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.compose.decide_ink(frame, box, , preferred=None)

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.

* **Return type:**
  [`InkDecision`](_autosummary/tituli.compose.html.md#tituli.compose.InkDecision)

### tituli.compose.intertitle(text, , frame, style=None)

A silent-film style card: serif italic prose, centred on the frame.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.compose.lower_third(name, role='', , frame, anchor='bottom-left', name_style=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.042, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0, features=()), role_style=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.026, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.04, leading=1.2, case='as-is', align='left', opacity=0.9, features=()), ink=None, scrim=None, on_overflow='fit')

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.

The name and role are **shrunk to fit** rather than cut, and
[`TextDoesNotFit`](_autosummary/tituli.html.md#tituli.TextDoesNotFit) is raised if they cannot be set legibly. A lower
third reading `1981 · Centr…` once shipped in a finished film; that is not
a shortened label but a false one.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.compose.note(lines, , frame, headline='', anchor='top-left', headline_style=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.055, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.15, case='as-is', align='left', opacity=1.0, features=()), line_style=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.03, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.4, case='as-is', align='left', opacity=0.92, features=()), max_width=0.62, ink=None, scrim=None, accent=True, on_overflow='fit')

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, shrinking to fit within
`_NOTE_MAX_LINES` rows rather than being cut; every line shares the
smallest size any of them needed, so the block reads as one block.
Raises [`TextDoesNotFit`](_autosummary/tituli.html.md#tituli.TextDoesNotFit) if a line cannot be set legibly.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.compose.title_card(title, subtitle='', , frame, kicker='', anchor='center', title_style=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.075, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=-0.01, leading=1.1, case='as-is', align='center', opacity=1.0, features=()), subtitle_style=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.032, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.02, leading=1.3, case='as-is', align='center', opacity=1.0, features=()), kicker_style=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.022, weight=500, italic=False, condensed=False, color=(255, 255, 255), tracking=0.18, leading=1.2, case='upper', align='center', opacity=1.0, features=()), ink=None, scrim=None)

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.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.compose.truncate(text, style, frame_height, , max_width, max_lines)

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.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)


# _autosummary/tituli.credits.html.md

# tituli.credits

Credits: structured data in, designed cards or a crawl out.

The data model is the point. A credits roll is not a list of filenames; it is
*sections* of *entries*, each entry a role and a name (or just a name, or a
line of licence prose). Given that structure the layout can do what a title
designer does: tracked small-caps section headings, role/name pairs on a
shared gutter, a single column that reads top to bottom, generous leading, and
a **never-truncate** rule — attributions are a licence surface, so content that
does not fit paginates onto more cards or raises when the caller capped the
count; it is never silently dropped.

```pycon
>>> c = Credits.from_dict({
...     "title": "The Apple",
...     "sections": [{"heading": "Voices", "entries": [["Narrator", "A. Reader"]]},
...                  {"heading": "Images", "entries": ["Still Life — Cézanne — public domain"]}],
... })
>>> len(c.sections), c.sections[0].entries[0].role
(2, 'Narrator')
```

### Functions

| [`credits_crawl`](_autosummary/tituli.credits.html.md#tituli.credits.credits_crawl)(credits, \*, frame[, style])      | One tall layout and its total height in pixels (for [`tituli.video.crawl()`](_autosummary/tituli.video.html.md#tituli.video.crawl)).   |
|--------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|
| [`credits_cards`](_autosummary/tituli.credits.html.md#tituli.credits.credits_cards)(credits, \*, frame[, style, ...]) | Paginate the roll into cards that each fit the safe area, centred.                                                                               |
| [`credits_frame`](_autosummary/tituli.credits.html.md#tituli.credits.credits_frame)(size[, style])                    | The canonical credits frame: near-black, so ink defaults to white.                                                                               |

### Classes

| [`Entry`](_autosummary/tituli.credits.html.md#tituli.credits.Entry)(name[, role])                             | One credit line: `role` + `name`, a bare `name`, or prose.   |
|--------------------------------------------------------------------------------------------------|--------------------------------------------------------------|
| [`Section`](_autosummary/tituli.credits.html.md#tituli.credits.Section)([heading, entries, kind])               | A heading over a run of entries.                             |
| [`Credits`](_autosummary/tituli.credits.html.md#tituli.credits.Credits)([sections, title, closing, meta])       | The whole roll.                                              |
| [`CreditsStyle`](_autosummary/tituli.credits.html.md#tituli.credits.CreditsStyle)([title, heading, role, name, ...]) | The type ramp for a roll.                                    |

### *class* tituli.credits.Credits(sections=(), title='', closing=(), meta=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The whole roll.

#### *classmethod* from_dict(d)

Build from `{"title", "sections": [{"heading", "entries", "kind"}], "closing"}`.

The caller owns making the text presentable: tituli sets whatever it is
given, so a raw manifest — camera filenames as roles, `"Unknown
authorUnknown author"` from a concatenated field — comes out as a
designed card of raw manifest strings. Map titles, artists and
licences into readable roles and names first.

* **Return type:**
  [`Credits`](_autosummary/tituli.credits.html.md#tituli.credits.Credits)

#### *classmethod* from_lines(lines, , heading='Credits', title='')

The plain-list case (what `braidio.video.credits_card` takes).

Each line is set as prose, verbatim. Lines built from a fetch manifest
(`"<file> — <author> — <licence>"`) read as a terminal dump however
well they are typeset; prefer [`from_dict()`](_autosummary/tituli.credits.html.md#tituli.credits.Credits.from_dict) with `{"role", "name"}`
entries (“Portrait of Eliza Hamilton”, “Ralph Earl, 1787 · public domain”).

* **Return type:**
  [`Credits`](_autosummary/tituli.credits.html.md#tituli.credits.Credits)

### *class* tituli.credits.CreditsStyle(title=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.06, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=-0.005, leading=1.2, case='as-is', align='center', opacity=1.0, features=()), heading=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.024, weight=500, italic=False, condensed=False, color=(255, 255, 255), tracking=0.22, leading=1.2, case='upper', align='center', opacity=0.75, features=()), role=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.026, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.03, leading=1.2, case='as-is', align='right', opacity=0.8, features=()), name=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.032, weight=600, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0, features=()), line=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.03, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.4, case='as-is', align='center', opacity=1.0, features=()), ink=(255, 255, 255), background=(8, 8, 10))

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The type ramp for a roll. One object so a film is one design.

### *class* tituli.credits.Entry(name, role='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One credit line: `role` + `name`, a bare `name`, or prose.

### *class* tituli.credits.Section(heading='', entries=(), kind='auto')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A heading over a run of entries. `kind` picks the treatment:

`"pairs"` role/name on a gutter (cast, crew); `"list"` centred names;
`"prose"` wrapped small text (licences, thanks).

### tituli.credits.credits_cards(credits, , frame, style=CreditsStyle(title=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.06, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=-0.005, leading=1.2, case='as-is', align='center', opacity=1.0, features=()), heading=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.024, weight=500, italic=False, condensed=False, color=(255, 255, 255), tracking=0.22, leading=1.2, case='upper', align='center', opacity=0.75, features=()), role=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.026, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.03, leading=1.2, case='as-is', align='right', opacity=0.8, features=()), name=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.032, weight=600, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0, features=()), line=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.03, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.4, case='as-is', align='center', opacity=1.0, features=()), ink=(255, 255, 255), background=(8, 8, 10)), max_cards=None)

Paginate the roll into cards that each fit the safe area, centred.

A heading is never orphaned at the bottom of a card. Raises when
`max_cards` would force truncation — losing an attribution silently is a
licence failure invisible to the person responsible for it.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)]

### tituli.credits.credits_crawl(credits, , frame, style=CreditsStyle(title=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.06, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=-0.005, leading=1.2, case='as-is', align='center', opacity=1.0, features=()), heading=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.024, weight=500, italic=False, condensed=False, color=(255, 255, 255), tracking=0.22, leading=1.2, case='upper', align='center', opacity=0.75, features=()), role=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.026, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.03, leading=1.2, case='as-is', align='right', opacity=0.8, features=()), name=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.032, weight=600, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0, features=()), line=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.03, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.4, case='as-is', align='center', opacity=1.0, features=()), ink=(255, 255, 255), background=(8, 8, 10)))

One tall layout and its total height in pixels (for [`tituli.video.crawl()`](_autosummary/tituli.video.html.md#tituli.video.crawl)).

The layout starts one frame-height down and ends one frame-height before
its bottom, so the crawl enters from an empty frame and leaves to one.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout), [`int`](https://docs.python.org/3/builtins/functions.html#int)]

### tituli.credits.credits_frame(size, style=CreditsStyle(title=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.06, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=-0.005, leading=1.2, case='as-is', align='center', opacity=1.0, features=()), heading=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.024, weight=500, italic=False, condensed=False, color=(255, 255, 255), tracking=0.22, leading=1.2, case='upper', align='center', opacity=0.75, features=()), role=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.026, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.03, leading=1.2, case='as-is', align='right', opacity=0.8, features=()), name=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.032, weight=600, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0, features=()), line=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.03, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.4, case='as-is', align='center', opacity=1.0, features=()), ink=(255, 255, 255), background=(8, 8, 10)))

The canonical credits frame: near-black, so ink defaults to white.

* **Return type:**
  [`Frame`](_autosummary/tituli.frame.html.md#tituli.frame.Frame)


# _autosummary/tituli.features.html.md

# tituli.features

OpenType features a style asks for, applied without a shaper where they can be (tituli#4).

A style may ask for features by tag (`TextStyle(features=("tnum",))`: tabular
figures, so the digits of a changing number do not change width). Whether the
font HAS a feature is a property of its bytes, so a face records which of the
requested ones it applies ([`tituli.fonts.Face.applied_features`](_autosummary/tituli.fonts.html.md#tituli.fonts.Face.applied_features)); a
feature the font lacks is not applied, and the caller can say so.

Pillow applies features only through libraqm, which is often absent, and its
embedded face always lays out without it. So the features that are glyph
SUBSTITUTIONS one for one (GSUB lookup type 1, also inside an extension lookup:
`tnum`, `lnum`, `onum`, `pnum`, `zero`, `smcp` …) are applied here,
from the font’s own tables: [`featured_glyphs()`](_autosummary/tituli.features.html.md#tituli.features.featured_glyphs) gives the substituted glyph
names and [`featured_length()`](_autosummary/tituli.features.html.md#tituli.features.featured_length) their advances. Measurement
([`length()`](_autosummary/tituli.fonts.html.md#tituli.fonts.Face.length)) and outlines ([`tituli.run_outline()`](_autosummary/tituli.html.md#tituli.run_outline))
read both, so they cannot disagree. A requested feature built from any other
kind of lookup needs a shaper and is refused ([`FeatureError`](_autosummary/tituli.features.html.md#tituli.features.FeatureError)).

Needs fontTools (`tituli[outlines]`), like the outlines.

### Functions

| [`font_features`](_autosummary/tituli.features.html.md#tituli.features.font_features)(path[, index])   | The GSUB feature tags a font declares.                                                                                               |
|---------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
| [`applied_features`](_autosummary/tituli.features.html.md#tituli.features.applied_features)(face)         | The face's requested features that its font has, in request order.                                                                   |
| [`featured_glyphs`](_autosummary/tituli.features.html.md#tituli.features.featured_glyphs)(face, text)    | The glyph name each character of `text` draws with the face's applied features (`None` where the font has no glyph for it).          |
| [`featured_length`](_autosummary/tituli.features.html.md#tituli.features.featured_length)(face, text)    | Advance width of `text` in pixels, from the substituted glyphs' own advances (the font's design metrics, scaled to the face's size). |

### Exceptions

| [`FeatureError`](_autosummary/tituli.features.html.md#tituli.features.FeatureError)   | A requested feature cannot be applied as asked; the message says what would.   |
|-----------------------------------------------------------------|--------------------------------------------------------------------------------|

### *exception* tituli.features.FeatureError

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

A requested feature cannot be applied as asked; the message says what would.

### tituli.features.applied_features(face)

The face’s requested features that its font has, in request order.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

### tituli.features.featured_glyphs(face, text)

The glyph name each character of `text` draws with the face’s applied
features (`None` where the font has no glyph for it).

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)]

### tituli.features.featured_length(face, text)

Advance width of `text` in pixels, from the substituted glyphs’ own
advances (the font’s design metrics, scaled to the face’s size).

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

### tituli.features.font_features(path, index=0)

The GSUB feature tags a font declares.

* **Return type:**
  [`frozenset`](https://docs.python.org/3/builtins/stdtypes.html#frozenset)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

```pycon
>>> from tituli import EMBEDDED, resolve_face
>>> "tnum" in font_features(resolve_face(EMBEDDED, size=12).path)
False
```


# _autosummary/tituli.fonts.html.md

# tituli.fonts

Font discovery with no bundled fonts.

`tituli` never ships a typeface (licensing), so a *typeface request* — a family
name, weight and slant — is resolved against the fonts already installed on the
machine, and falls back to the scalable face Pillow embeds (Aileron). That fallback
is a real font, not a stub, so every render works out of the box on a bare CI
runner; it just looks better on a machine with Helvetica Neue.

Resolution order is a *preference list*: the first family found wins.

```pycon
>>> face = resolve_face(["No Such Family", "DejaVu Sans"], size=48)
>>> face.size
48
>>> face.family in ("DejaVu Sans", "Aileron") or isinstance(face.family, str)
True
```

### Module Attributes

| [`EMBEDDED`](_autosummary/tituli.fonts.html.md#tituli.fonts.EMBEDDED)   | A family request that means *Pillow's embedded face, and never the system*.   |
|-------------------------------------------------------------|-------------------------------------------------------------------------------|

### Functions

| [`face_bytes`](_autosummary/tituli.fonts.html.md#tituli.fonts.face_bytes)(face)                                   | The bytes of the font file behind `face` — the embedded face's too.                                             |
|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------|
| [`face_digest`](_autosummary/tituli.fonts.html.md#tituli.fonts.face_digest)(face)                                  | `sha256` of [`face_bytes()`](_autosummary/tituli.fonts.html.md#tituli.fonts.face_bytes) — what makes two faces the same face. |
| [`families`](_autosummary/tituli.fonts.html.md#tituli.fonts.families)()                                         | Sorted family names installed on this machine.                                                                  |
| [`find_font`](_autosummary/tituli.fonts.html.md#tituli.fonts.find_font)(family, \*[, weight, italic, condensed]) | First installed family in the preference list, at the closest style.                                            |
| [`font_dirs`](_autosummary/tituli.fonts.html.md#tituli.fonts.font_dirs)()                                        | Platform font directories that exist on this machine.                                                           |
| [`font_index`](_autosummary/tituli.fonts.html.md#tituli.fonts.font_index)()                                       | Installed faces grouped by family name.                                                                         |
| [`resolve_face`](_autosummary/tituli.fonts.html.md#tituli.fonts.resolve_face)([family, weight, italic, ...])        | Resolve a typeface request to a sized `Face`; never fails.                                                      |

### Classes

| [`Face`](_autosummary/tituli.fonts.html.md#tituli.fonts.Face)(family, style, size, path[, index, ...])   | A resolved, sized font ready to measure and draw with Pillow.   |
|--------------------------------------------------------------------------------------------------|-----------------------------------------------------------------|
| [`FontFile`](_autosummary/tituli.fonts.html.md#tituli.fonts.FontFile)(path, index, family, style)            | One face inside a font file (a `.ttc` holds several).           |

### tituli.fonts.EMBEDDED *= 'tituli:embedded'*

A family request that means *Pillow’s embedded face, and never the system*.
In a preference list it is a stop: the names before it are looked up as
usual, and if none is installed the embedded face is used without scanning
further. Alone (or first) it never scans the system at all, so the result is
the same bytes on every machine with the same Pillow — the deterministic
choice for a caller whose output must not depend on the installed fonts.
(Asking for `"Aileron"` by name is not the same thing: a machine that has
Aileron installed would resolve to *that* file.)

### *class* tituli.fonts.Face(family, style, size, path, index=0, features=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A resolved, sized font ready to measure and draw with Pillow.

#### *property* applied_features *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), ...]*

The requested [`features`](_autosummary/tituli.fonts.html.md#tituli.fonts.Face.features) this font has, so the ones measured and
outlined with; a requested tag missing here was not applied.

```pycon
>>> resolve_face(EMBEDDED, size=12).applied_features
()
```

#### *property* ascent *: [int](https://docs.python.org/3/builtins/functions.html#int)*

Ascent in pixels.

#### *property* descent *: [int](https://docs.python.org/3/builtins/functions.html#int)*

Descent in pixels.

#### features *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), ...]* *= ()*

OpenType feature tags the style asked for (tituli#4); which of them the
font has is [`applied_features`](_autosummary/tituli.fonts.html.md#tituli.fonts.Face.applied_features).

#### length(text)

Advance width of `text` in pixels.

With [`applied_features`](_autosummary/tituli.fonts.html.md#tituli.fonts.Face.applied_features), the advances of the substituted glyphs
([`tituli.features.featured_length()`](_autosummary/tituli.features.html.md#tituli.features.featured_length)): the font’s design advances,
without hinting or kerning, which is what tabular figures are for.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

#### *property* line_height *: [int](https://docs.python.org/3/builtins/functions.html#int)*

Ascent + descent.

#### *property* pil *: FreeTypeFont*

The Pillow font object (cached per `(path, index, size)`).

#### with_size(size)

Same face at another pixel size.

* **Return type:**
  [`Face`](_autosummary/tituli.fonts.html.md#tituli.fonts.Face)

### *class* tituli.fonts.FontFile(path, index, family, style)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One face inside a font file (a `.ttc` holds several).

#### *property* condensed *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

Whether the style name says condensed/narrow/compressed.

#### *property* italic *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

Whether the style name says italic/oblique.

#### *property* weight *: [int](https://docs.python.org/3/builtins/functions.html#int)*

CSS-style weight parsed from the style name (`"Bold"` -> 700).

### tituli.fonts.face_bytes(face)

The bytes of the font file behind `face` — the embedded face’s too.

Pillow keeps the embedded face’s bytes on the font object it builds
(`font_bytes`), so the fallback has an identity like any file does.

* **Return type:**
  [`bytes`](https://docs.python.org/3/builtins/stdtypes.html#bytes)

```pycon
>>> face_bytes(resolve_face(EMBEDDED, size=12))[:4] in (b"\x00\x01\x00\x00", b"true", b"OTTO")
True
```

### tituli.fonts.face_digest(face)

`sha256` of [`face_bytes()`](_autosummary/tituli.fonts.html.md#tituli.fonts.face_bytes) — what makes two faces the same face.

A family name is not an identity (two machines can install different files
under one name, and the embedded face can change with Pillow); the bytes are.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### tituli.fonts.families()

Sorted family names installed on this machine.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### tituli.fonts.find_font(family, , weight=400, italic=False, condensed=False)

First installed family in the preference list, at the closest style.

`family` may be one name or a preference list. A path to a font file is
accepted too, and wins outright.

* **Return type:**
  [`FontFile`](_autosummary/tituli.fonts.html.md#tituli.fonts.FontFile) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

```pycon
>>> find_font("definitely-not-installed-xyz") is None
True
```

### tituli.fonts.font_dirs()

Platform font directories that exist on this machine.

Extra directories can be prepended with `TITULI_FONT_DIRS` (`os.pathsep`
separated), which is also how a project supplies its own licensed fonts.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)]

### tituli.fonts.font_index()

Installed faces grouped by family name. Scanned once per process.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`FontFile`](_autosummary/tituli.fonts.html.md#tituli.fonts.FontFile), [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]]

```pycon
>>> idx = font_index()
>>> all(isinstance(k, str) for k in idx)
True
```

### tituli.fonts.resolve_face(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), , size, weight=400, italic=False, condensed=False, features=())

Resolve a typeface request to a sized `Face`; never fails.

`features` are OpenType feature tags to apply (`("tnum",)`), carried by
the face; see [`Face.applied_features`](_autosummary/tituli.fonts.html.md#tituli.fonts.Face.applied_features) for which the font has.

Falls back to Pillow’s embedded Aileron when nothing in the list is installed,
so a render on a fontless CI box still produces a real (if plainer) result.
[`EMBEDDED`](_autosummary/tituli.fonts.html.md#tituli.fonts.EMBEDDED) in the list stops the search there (see its comment):

* **Return type:**
  [`Face`](_autosummary/tituli.fonts.html.md#tituli.fonts.Face)

```pycon
>>> resolve_face(EMBEDDED, size=20).path is None
True
>>> resolve_face(["definitely-not-installed-xyz", EMBEDDED], size=20).family
'Aileron'
```


# _autosummary/tituli.frame.html.md

# tituli.frame

The frame: what the layout engine is allowed to know about the picture.

This module is the answer to “is a text-only video a special case of text over
video?” — yes, and the axis that unifies them is \*\*how much the layout engine
knows about the frame\*\*, a ladder:

| rung   | the Frame knows            | so the engine can                                                                                        |
|--------|----------------------------|----------------------------------------------------------------------------------------------------------|
|        | only its size              | place by anchor; must assume the<br/>worst and put a scrim under text                                    |
|        | a solid background colour  | choose ink by contrast, no scrim                                                                         |
|        | the actual pixels          | sample luminance under a candidate<br/>box; choose ink; scrim only if the<br/>patch is mid-toned or busy |
|        | 1. + regions to keep clear | rank candidate positions by how<br/>little they cover the subject                                        |

A single [`Frame`](_autosummary/tituli.frame.html.md#tituli.frame.Frame) type carries all four rungs; every rung is optional and
the engine asks (`luminance_under`, `overlap`) rather than branching on kind.
The regions-to-avoid input is one keyword, `avoid=`, which takes boxes \*or a
callable\* `image -> box(es)` — the shape of `burns.salient_box` and of
`burns.FacesDetector` — so subject detection plugs in without a dependency.

```pycon
>>> f = Frame.blank((1920, 1080))
>>> f.luminance_under(f.safe) is None      # rung (a): unknown
True
>>> Frame.blank((1920, 1080), color="#fff").luminance_under(f.safe)
1.0
```

### Functions

| [`cover_fit`](_autosummary/tituli.frame.html.md#tituli.frame.cover_fit)(img, size)     | Scale `img` to fill `size` and crop the overflow, centred.   |
|---------------------------------------------------------------------------|--------------------------------------------------------------|
| [`reserved_zones`](_autosummary/tituli.frame.html.md#tituli.frame.reserved_zones)(delivery) | Normalised boxes a delivery target keeps for itself.         |

### Classes

| [`Frame`](_autosummary/tituli.frame.html.md#tituli.frame.Frame)(width, height[, color, image, avoid, ...])   | Size plus whatever else is known about the picture text will sit on.   |
|-----------------------------------------------------------------------------------------------------|------------------------------------------------------------------------|

### *class* tituli.frame.Frame(width, height, color=None, image=None, avoid=(), reserved=(), samples=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Size plus whatever else is known about the picture text will sit on.

#### *classmethod* blank(size=(1920, 1080), , color=None)

Rung (a) when `color` is None, rung (b) when it is given.

* **Return type:**
  [`Frame`](_autosummary/tituli.frame.html.md#tituli.frame.Frame)

#### *classmethod* from_image(image, , avoid=None, size=None, delivery=None)

Rung (c), and rung (d) when `avoid` is given.

`image` may also be a **sequence** of frames (the stills a camera move
will pass through under the text): the first is what gets composited,
all of them feed [`luminance_stats()`](_autosummary/tituli.frame.html.md#tituli.frame.Frame.luminance_stats), which then reports the *worst*
instant — so a scrim is as light as the whole window allows, not as
heavy as one frame demands.

`avoid` is normalised boxes, or a callable taking the PIL image and
returning one box or several (`burns.salient_box` fits directly). It
runs on every sampled frame. `size` cover-fits the picture(s) to the
frame first. `delivery` names a target whose reserved zones
(`"youtube"`: subtitle band + control bar) placement must keep clear.

* **Return type:**
  [`Frame`](_autosummary/tituli.frame.html.md#tituli.frame.Frame)

#### luminance_stats(box)

`(mean, stddev)` of relative luminance under `box`; None if unknown.

A high stddev means a busy patch — even a well-contrasted mean will not
keep every glyph readable, which is when a scrim earns its place.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)] | [`None`](https://docs.python.org/3/builtins/constants.html#None)

#### luminance_under(box)

Mean relative luminance of the pixels under `box`; None if unknown.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

#### *classmethod* over(stills, , avoid=None, size=None, delivery=None)

The frame a caption will sit on *over a time window* of stills.

The named entry point for the sequence case: the first still is what
gets composited, all of them are sampled, and `luminance_stats`
reports the worst instant — so a scrim over a Ken Burns move is as
light as the whole window allows. (`from_image` accepts a sequence
too; this name says what it is for.)

* **Return type:**
  [`Frame`](_autosummary/tituli.frame.html.md#tituli.frame.Frame)

#### overlap(box)

Fraction of `box` that covers something in `avoid` (0 when nothing).

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

#### place(size, , anchor='auto', region=None)

Where to put a block of `size`: a named anchor and its pixel box.

`anchor` may be one name, a preference list, or `"auto"` (the default
order). With rung (d) knowledge the candidate that least covers the
subject wins; ties fall to preference order.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Box`](_autosummary/tituli.geometry.html.md#tituli.geometry.Box)]

```pycon
>>> f = Frame.blank((1000, 500))
>>> f.place((200, 50), anchor="bottom")[0]
'bottom'
```

#### *property* safe *: [Box](_autosummary/tituli.geometry.html.md#tituli.geometry.Box)*

The title-safe box (SMPTE ST 2046-1, 90 %).

#### with_avoid(avoid)

Same frame with (additional) regions to keep clear.

* **Return type:**
  [`Frame`](_autosummary/tituli.frame.html.md#tituli.frame.Frame)

#### with_delivery(delivery)

Same frame, placement keeping clear of that target’s reserved zones.

* **Return type:**
  [`Frame`](_autosummary/tituli.frame.html.md#tituli.frame.Frame)

### tituli.frame.cover_fit(img, size)

Scale `img` to fill `size` and crop the overflow, centred.

* **Return type:**
  `Image`

```pycon
>>> cover_fit(Image.new("RGB", (100, 50)), (50, 50)).size
(50, 50)
```

### tituli.frame.reserved_zones(delivery)

Normalised boxes a delivery target keeps for itself.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)], [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

```pycon
>>> reserved_zones("youtube")
((0.0, 0.78, 1.0, 0.22),)
>>> reserved_zones(None)
()
```


# _autosummary/tituli.geometry.html.md

# tituli.geometry

Boxes, anchors, safe areas and parametric paths — the coordinate vocabulary.

Two coordinate systems, deliberately:

* **normalised** `(x, y, w, h)` in `0..1` of the frame, top-left origin — the
  resolution-independent form used by specs, by `burns.salient_box` and by the
  lacing body. This is what a *request* is written in.
* **pixel** [`Box`](_autosummary/tituli.geometry.html.md#tituli.geometry.Box) `(x0, y0, x1, y1)` floats — what layout and rendering
  compute in once a [`Frame`](_autosummary/tituli.frame.html.md#tituli.frame.Frame) size is known.

A [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path) is a curve you can lay glyphs along: `point(s)` and `angle(s)`
by arc length. Constructors cover lines, arcs, waves, Béziers, polylines and SVG
`d` strings, which is what lets a calligram be *any* shape rather than a
hard-coded sine.

```pycon
>>> b = Box(10, 20, 110, 70)
>>> b.width, b.height, b.center
(100, 50, (60.0, 45.0))
>>> Box.from_norm((0.25, 0.5, 0.5, 0.25), 1920, 1080)
Box(x0=480.0, y0=540.0, x1=1440.0, y1=810.0)
>>> p = Path.line((0, 0), (100, 0))
>>> p.length, p.point(50), p.angle(50)
(100.0, (50.0, 0.0), 0.0)
```

### Functions

| [`anchor_box`](_autosummary/tituli.geometry.html.md#tituli.geometry.anchor_box)(region, size, anchor)         | Place a block of `size` inside `region` at a named anchor.   |
|-------------------------------------------------------------------------------------------|--------------------------------------------------------------|
| [`safe_area`](_autosummary/tituli.geometry.html.md#tituli.geometry.safe_area)(width, height, \*[, fraction]) | The centred box holding `fraction` of each dimension.        |

### Classes

| [`Box`](_autosummary/tituli.geometry.html.md#tituli.geometry.Box)(x0, y0, x1, y1)   | A pixel-space rectangle `(x0, y0, x1, y1)`; edges are floats.   |
|------------------------------------------------------------------------|-----------------------------------------------------------------|
| [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path)(points)          | A polyline with an arc-length parameterisation.                 |

### *class* tituli.geometry.Box(x0, y0, x1, y1)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A pixel-space rectangle `(x0, y0, x1, y1)`; edges are floats.

#### *classmethod* from_norm(nb, width, height)

Scale a normalised `(x, y, w, h)` into a frame of that size.

* **Return type:**
  [`Box`](_autosummary/tituli.geometry.html.md#tituli.geometry.Box)

#### *classmethod* from_size(x, y, w, h)

Build from an origin and a size.

* **Return type:**
  [`Box`](_autosummary/tituli.geometry.html.md#tituli.geometry.Box)

#### inset(dx, dy=None)

Shrink by `dx` horizontally and `dy` (default `dx`) vertically.

* **Return type:**
  [`Box`](_autosummary/tituli.geometry.html.md#tituli.geometry.Box)

#### overlap_fraction(other)

Fraction of *this* box’s area covered by `other`.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

```pycon
>>> Box(0, 0, 10, 10).overlap_fraction(Box(5, 0, 20, 10))
0.5
```

#### to_norm(width, height)

The inverse of [`from_norm()`](_autosummary/tituli.geometry.html.md#tituli.geometry.Box.from_norm).

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]

### *class* tituli.geometry.Path(points)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A polyline with an arc-length parameterisation.

Build one with the constructors (`line()`, [`arc()`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path.arc), [`wave()`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path.wave),
[`bezier()`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path.bezier), `polyline()`, [`from_svg()`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path.from_svg), [`from_function()`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path.from_function)) and
query it by distance along the curve: `point(s)` and `angle(s)` (degrees,
clockwise-positive on screen because y points down).

#### angle(s)

Tangent direction at `s` in degrees (0 = rightwards, 90 = down).

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

#### *classmethod* arc(center, radius, start_deg, end_deg, , samples=512)

Circular arc; angles in degrees, 0 = right, 90 = down (screen coords).

* **Return type:**
  [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path)

#### *classmethod* bezier(\*controls, samples=512)

A Bézier curve of any degree through `controls` (De Casteljau).

* **Return type:**
  [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path)

#### *classmethod* circle(center, radius, , start_deg=-90.0)

A full circle starting at the top by default, running clockwise.

* **Return type:**
  [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path)

#### *property* closed *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

Whether the path ends where it starts (a circle, a closed SVG path).

```pycon
>>> Path.circle((0, 0), 10).closed, Path.line((0, 0), (1, 1)).closed
(True, False)
```

#### fit(box, , keep_aspect=True)

Scale and translate so the path’s bounding box fills `box`.

Degenerate axes (a horizontal line has zero height) are centred rather
than scaled.

* **Return type:**
  [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path)

#### *classmethod* from_function(fn, , samples=512)

Sample `fn(t)` for `t` in `[0, 1]`.

* **Return type:**
  [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path)

#### *classmethod* from_svg(d)

Parse an SVG path `d` string (M L H V C S Q T Z, absolute or relative).

Arcs (`A`) are not supported — approximate them with a Bézier.

* **Return type:**
  [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path)

```pycon
>>> Path.from_svg("M 0 0 L 10 0 L 10 10").length
20.0
```

#### point(s)

Position at arc length `s` (clamped to the path).

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]

#### *classmethod* wave(start, end, , amplitude, cycles=1.0, samples=512)

A sine wave along the segment `start -> end`.

* **Return type:**
  [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path)

### tituli.geometry.anchor_box(region, size, anchor)

Place a block of `size` inside `region` at a named anchor.

* **Return type:**
  [`Box`](_autosummary/tituli.geometry.html.md#tituli.geometry.Box)

```pycon
>>> anchor_box(Box(0, 0, 100, 100), (20, 10), "bottom-right")
Box(x0=80.0, y0=90.0, x1=100.0, y1=100.0)
```

### tituli.geometry.safe_area(width, height, , fraction=0.9)

The centred box holding `fraction` of each dimension.

* **Return type:**
  [`Box`](_autosummary/tituli.geometry.html.md#tituli.geometry.Box)

```pycon
>>> safe_area(1000, 500)
Box(x0=50.0, y0=25.0, x1=950.0, y1=475.0)
```


# _autosummary/tituli.html.md

# tituli

tituli — text in video: title cards, credits, captions and calligrams.

One model underneath: a [`Layout`](_autosummary/tituli.html.md#tituli.Layout) of placed `Run`s on a
:class:`Frame` that knows as much (or as little) about the picture as you tell
it. Everything else is a convenience over that.

Quick start:

```default
from tituli import Frame, title_card, caption, credits_crawl, on_path, render

frame = Frame.blank((1920, 1080), color="#101014")
render(title_card("Il pleut", "Apollinaire, 1918", frame=frame), frame).save("title.png")

frame = Frame.from_image("still.jpg", delivery="youtube")   # add avoid=burns.salient_box
render(caption("Eliza Hamilton", "Ralph Earl, 1787 · public domain", frame=frame), frame).save("cap.png")
```

Optional layers, none imported here: `tituli[outlines]` (fontTools, for
[`run_outline()`](_autosummary/tituli.html.md#tituli.run_outline) — glyph contours as SVG path data), `tituli[shaping]` (HarfBuzz engine),
`tituli[saliency]` (`burns` subject avoidance), `tituli[lacing]` (the
text-overlay body schema), `tituli[cli]` (`python -m tituli`).

### Functions

| [`title_card`](_autosummary/tituli.html.md#tituli.title_card)(title[, subtitle, kicker, ...])         | An opening card: optional kicker, title, optional subtitle.                                                                                    |
|-----------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|
| [`caption`](_autosummary/tituli.html.md#tituli.caption)(text[, attribution, anchor, style, ...])   | A museum label over a picture: what is on screen, plus a tiny credit line.                                                                     |
| [`lower_third`](_autosummary/tituli.html.md#tituli.lower_third)(name[, role, anchor, ...])             | Who is speaking: a name and a role, left-anchored, scrimmed if needed.                                                                         |
| [`note`](_autosummary/tituli.html.md#tituli.note)(lines, \*, frame[, headline, anchor, ...])    | An editorial context card: an optional headline over equal-weight lines.                                                                       |
| [`intertitle`](_autosummary/tituli.html.md#tituli.intertitle)(text, \*, frame[, style])               | A silent-film style card: serif italic prose, centred on the frame.                                                                            |
| [`fit`](_autosummary/tituli.html.md#tituli.fit)(text, style, frame_height, \*, max_width, ...) | Wrap `text` complete, shrinking the type until it fits — never cutting it.                                                                     |
| [`truncate`](_autosummary/tituli.html.md#tituli.truncate)(text, style, frame_height, \*, ...)       | Wrap and hard-truncate with an ellipsis at `max_lines` lines.                                                                                  |
| [`decide_ink`](_autosummary/tituli.html.md#tituli.decide_ink)(frame, box, \*[, preferred])            | Choose ink and whether a scrim is needed under `box`.                                                                                          |
| [`credits_cards`](_autosummary/tituli.html.md#tituli.credits_cards)(credits, \*, frame[, style, ...])    | Paginate the roll into cards that each fit the safe area, centred.                                                                             |
| [`credits_crawl`](_autosummary/tituli.html.md#tituli.credits_crawl)(credits, \*, frame[, style])         | One tall layout and its total height in pixels (for [`tituli.video.crawl()`](_autosummary/tituli.video.html.md#tituli.video.crawl)). |
| [`credits_frame`](_autosummary/tituli.html.md#tituli.credits_frame)(size[, style])                       | The canonical credits frame: near-black, so ink defaults to white.                                                                             |
| [`on_path`](_autosummary/tituli.html.md#tituli.on_path)(text, \*[, shape, style, box, ...])        | Set `text` along a shape inside `box` (default: the safe area, inset).                                                                         |
| [`rain`](_autosummary/tituli.html.md#tituli.rain)(lines, \*, frame[, style, box, slants, ...])  | *Il pleut*: each line a streak of upright letters falling across the frame.                                                                    |
| [`in_shape`](_autosummary/tituli.html.md#tituli.in_shape)(text, mask, \*, frame[, style, box, ...]) | Pour prose into a silhouette (light = inside).                                                                                                 |
| [`resolve_shape`](_autosummary/tituli.html.md#tituli.resolve_shape)(shape, box)                          | Turn a name, an SVG `d` string or a Path into a Path fitted to `box`.                                                                          |
| [`block`](_autosummary/tituli.html.md#tituli.block)(text, style, frame, \*[, max_width, x, ...]) | Lay out prose as lines from the top-left corner `(x, y)`.                                                                                      |
| [`stack`](_autosummary/tituli.html.md#tituli.stack)(items, \*, frame[, gap, align, anchor])      | Blocks top to bottom, `gap` apart, aligned within the widest.                                                                                  |
| [`grid`](_autosummary/tituli.html.md#tituli.grid)(items, \*, frame[, columns, gap, align, ...]) | Blocks in uniform cells, row-major; one row when `columns` is None.                                                                            |
| [`glossed`](_autosummary/tituli.html.md#tituli.glossed)(pairs, \*, frame[, glyph, gloss, ...])     | A row of big glyphs, each with a small reading centred under it.                                                                               |
| [`along_path`](_autosummary/tituli.html.md#tituli.along_path)(text, path, style, frame, \*[, ...])    | Set `text` glyph by glyph along `path` (the path is the baseline).                                                                             |
| [`wrap`](_autosummary/tituli.html.md#tituli.wrap)(text, style, frame_height, \*, max_width)     | Greedy word wrap on measured widths.                                                                                                           |
| [`measure`](_autosummary/tituli.html.md#tituli.measure)(text, style, frame_height)                 | Width in pixels of `text` set in `style` on a frame of that height.                                                                            |
| [`fit_size`](_autosummary/tituli.html.md#tituli.fit_size)(text, style, frame_height, \*, max_width) | Shrink `style.size` until `text` wraps within the given bounds.                                                                                |
| [`render`](_autosummary/tituli.html.md#tituli.render)(layout, frame, \*[, t, engine])             | Render onto the frame: RGBA overlay if it has no background, else RGB.                                                                         |
| [`render_overlay`](_autosummary/tituli.html.md#tituli.render_overlay)(layout, size, \*[, t, engine])      | The layout on a transparent canvas of `size` (RGBA).                                                                                           |
| [`frames`](_autosummary/tituli.html.md#tituli.frames)(layout, frame, \*, duration[, fps, ...])    | Yield one image per video frame, honouring the runs' reveal envelopes.                                                                         |
| [`make_engine`](_autosummary/tituli.html.md#tituli.make_engine)([name])                                | Resolve an engine by name: `"pillow"` (default) or `"harfbuzz"`.                                                                               |
| [`schedule_labels`](_autosummary/tituli.html.md#tituli.schedule_labels)(spans, label_for, \*[, ...])       | One label per span, held `hold_s`, with the three rules built in.                                                                              |
| [`resolve`](_autosummary/tituli.html.md#tituli.resolve)(overlays, \*[, min_readable_s])            | Enforce one overlay per slot at a time: the heavier wins, the lighter yields.                                                                  |
| [`families`](_autosummary/tituli.html.md#tituli.families)()                                         | Sorted family names installed on this machine.                                                                                                 |
| [`find_font`](_autosummary/tituli.html.md#tituli.find_font)(family, \*[, weight, italic, condensed]) | First installed family in the preference list, at the closest style.                                                                           |
| [`resolve_face`](_autosummary/tituli.html.md#tituli.resolve_face)([family, weight, italic, ...])        | Resolve a typeface request to a sized `Face`; never fails.                                                                                     |
| [`face_digest`](_autosummary/tituli.html.md#tituli.face_digest)(face)                                  | `sha256` of `face_bytes()` — what makes two faces the same face.                                                                               |
| [`run_outline`](_autosummary/tituli.html.md#tituli.run_outline)(run, \*[, precision])                  | The contours of `run` as one SVG path, placed where the run is drawn.                                                                          |
| [`contrast_ratio`](_autosummary/tituli.html.md#tituli.contrast_ratio)(a, b)                               | WCAG contrast ratio between two colours (1..21).                                                                                               |
| [`ink_for`](_autosummary/tituli.html.md#tituli.ink_for)(\*, luminance[, light, dark])              | Pick the ink (light or dark) with more contrast against `luminance`.                                                                           |
| [`parse_color`](_autosummary/tituli.html.md#tituli.parse_color)(color)                                 | Accept `"#rgb"`, `"#rrggbb"`, `"#rrggbbaa"`, a Pillow name or a tuple.                                                                         |
| [`safe_area`](_autosummary/tituli.html.md#tituli.safe_area)(width, height, \*[, fraction])           | The centred box holding `fraction` of each dimension.                                                                                          |
| [`reserved_zones`](_autosummary/tituli.html.md#tituli.reserved_zones)(delivery)                           | Normalised boxes a delivery target keeps for itself.                                                                                           |
| [`cover_fit`](_autosummary/tituli.html.md#tituli.cover_fit)(img, size)                               | Scale `img` to fill `size` and crop the overflow, centred.                                                                                     |

### Classes

| [`Frame`](_autosummary/tituli.html.md#tituli.Frame)(width, height[, color, image, avoid, ...])   | Size plus whatever else is known about the picture text will sit on.   |
|-----------------------------------------------------------------------------------------------------|------------------------------------------------------------------------|
| [`Layout`](_autosummary/tituli.html.md#tituli.Layout)([runs, plates, meta])                       | Everything the renderer needs: runs on top of plates, in frame pixels. |
| [`Run`](_autosummary/tituli.html.md#tituli.Run)(text, x, y, face, color[, angle, ...])         | A string drawn from a baseline origin, possibly rotated.               |
| [`Plate`](_autosummary/tituli.html.md#tituli.Plate)(box, color[, kind, radius, solid])           | A backdrop drawn under the runs: a scrim, a box, a rule.               |
| [`Box`](_autosummary/tituli.html.md#tituli.Box)(x0, y0, x1, y1)                                | A pixel-space rectangle `(x0, y0, x1, y1)`; edges are floats.          |
| [`Path`](_autosummary/tituli.html.md#tituli.Path)(points)                                       | A polyline with an arc-length parameterisation.                        |
| [`TextStyle`](_autosummary/tituli.html.md#tituli.TextStyle)([family, size, weight, italic, ...])     | How a run of text looks.                                               |
| [`Face`](_autosummary/tituli.html.md#tituli.Face)(family, style, size, path[, index, ...])      | A resolved, sized font ready to measure and draw with Pillow.          |
| [`Credits`](_autosummary/tituli.html.md#tituli.Credits)([sections, title, closing, meta])          | The whole roll.                                                        |
| [`CreditsStyle`](_autosummary/tituli.html.md#tituli.CreditsStyle)([title, heading, role, name, ...])    | The type ramp for a roll.                                              |
| [`Entry`](_autosummary/tituli.html.md#tituli.Entry)(name[, role])                                | One credit line: `role` + `name`, a bare `name`, or prose.             |
| [`Section`](_autosummary/tituli.html.md#tituli.Section)([heading, entries, kind])                  | A heading over a run of entries.                                       |
| [`Label`](_autosummary/tituli.html.md#tituli.Label)(text[, attribution, key])                    | What goes on a museum label: the thing, and where it came from.        |
| [`Span`](_autosummary/tituli.html.md#tituli.Span)(start, end, key[, data])                      | A time range of the cut showing one picture.                           |
| [`TimedOverlay`](_autosummary/tituli.html.md#tituli.TimedOverlay)(layout, start, end[, slot, ...])      | A layout (or a thing to lay out) on screen from `start` to `end`.      |
| [`Outline`](_autosummary/tituli.html.md#tituli.Outline)(d, bbox)                                   | A run's contours as SVG path data, in frame pixels.                    |

### Exceptions

| [`TextDoesNotFit`](_autosummary/tituli.html.md#tituli.TextDoesNotFit)(text, tried_size, ...)   | The text cannot be set completely without going below legibility.   |
|------------------------------------------------------------------------------------------|---------------------------------------------------------------------|
| [`MissingGlyphError`](_autosummary/tituli.html.md#tituli.MissingGlyphError)                       | The run's face has no glyph for one of its characters.              |

### *class* tituli.Box(x0, y0, x1, y1)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A pixel-space rectangle `(x0, y0, x1, y1)`; edges are floats.

#### *classmethod* from_norm(nb, width, height)

Scale a normalised `(x, y, w, h)` into a frame of that size.

* **Return type:**
  [`Box`](_autosummary/tituli.geometry.html.md#tituli.geometry.Box)

#### *classmethod* from_size(x, y, w, h)

Build from an origin and a size.

* **Return type:**
  [`Box`](_autosummary/tituli.geometry.html.md#tituli.geometry.Box)

#### inset(dx, dy=None)

Shrink by `dx` horizontally and `dy` (default `dx`) vertically.

* **Return type:**
  [`Box`](_autosummary/tituli.geometry.html.md#tituli.geometry.Box)

#### overlap_fraction(other)

Fraction of *this* box’s area covered by `other`.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

```pycon
>>> Box(0, 0, 10, 10).overlap_fraction(Box(5, 0, 20, 10))
0.5
```

#### to_norm(width, height)

The inverse of [`from_norm()`](_autosummary/tituli.html.md#tituli.Box.from_norm).

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]

### *class* tituli.Credits(sections=(), title='', closing=(), meta=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The whole roll.

#### *classmethod* from_dict(d)

Build from `{"title", "sections": [{"heading", "entries", "kind"}], "closing"}`.

The caller owns making the text presentable: tituli sets whatever it is
given, so a raw manifest — camera filenames as roles, `"Unknown
authorUnknown author"` from a concatenated field — comes out as a
designed card of raw manifest strings. Map titles, artists and
licences into readable roles and names first.

* **Return type:**
  [`Credits`](_autosummary/tituli.credits.html.md#tituli.credits.Credits)

#### *classmethod* from_lines(lines, , heading='Credits', title='')

The plain-list case (what `braidio.video.credits_card` takes).

Each line is set as prose, verbatim. Lines built from a fetch manifest
(`"<file> — <author> — <licence>"`) read as a terminal dump however
well they are typeset; prefer [`from_dict()`](_autosummary/tituli.html.md#tituli.Credits.from_dict) with `{"role", "name"}`
entries (“Portrait of Eliza Hamilton”, “Ralph Earl, 1787 · public domain”).

* **Return type:**
  [`Credits`](_autosummary/tituli.credits.html.md#tituli.credits.Credits)

### *class* tituli.CreditsStyle(title=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.06, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=-0.005, leading=1.2, case='as-is', align='center', opacity=1.0, features=()), heading=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.024, weight=500, italic=False, condensed=False, color=(255, 255, 255), tracking=0.22, leading=1.2, case='upper', align='center', opacity=0.75, features=()), role=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.026, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.03, leading=1.2, case='as-is', align='right', opacity=0.8, features=()), name=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.032, weight=600, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0, features=()), line=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.03, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.4, case='as-is', align='center', opacity=1.0, features=()), ink=(255, 255, 255), background=(8, 8, 10))

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

The type ramp for a roll. One object so a film is one design.

### *class* tituli.Entry(name, role='')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

One credit line: `role` + `name`, a bare `name`, or prose.

### *class* tituli.Face(family, style, size, path, index=0, features=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A resolved, sized font ready to measure and draw with Pillow.

#### *property* applied_features *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), ...]*

The requested [`features`](_autosummary/tituli.features.html.md#module-tituli.features) this font has, so the ones measured and
outlined with; a requested tag missing here was not applied.

```pycon
>>> resolve_face(EMBEDDED, size=12).applied_features
()
```

#### *property* ascent *: [int](https://docs.python.org/3/builtins/functions.html#int)*

Ascent in pixels.

#### *property* descent *: [int](https://docs.python.org/3/builtins/functions.html#int)*

Descent in pixels.

#### features *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), ...]* *= ()*

OpenType feature tags the style asked for (tituli#4); which of them the
font has is [`applied_features`](_autosummary/tituli.html.md#tituli.Face.applied_features).

#### length(text)

Advance width of `text` in pixels.

With [`applied_features`](_autosummary/tituli.html.md#tituli.Face.applied_features), the advances of the substituted glyphs
([`tituli.features.featured_length()`](_autosummary/tituli.features.html.md#tituli.features.featured_length)): the font’s design advances,
without hinting or kerning, which is what tabular figures are for.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

#### *property* line_height *: [int](https://docs.python.org/3/builtins/functions.html#int)*

Ascent + descent.

#### *property* pil *: FreeTypeFont*

The Pillow font object (cached per `(path, index, size)`).

#### with_size(size)

Same face at another pixel size.

* **Return type:**
  [`Face`](_autosummary/tituli.fonts.html.md#tituli.fonts.Face)

### *class* tituli.Frame(width, height, color=None, image=None, avoid=(), reserved=(), samples=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Size plus whatever else is known about the picture text will sit on.

#### *classmethod* blank(size=(1920, 1080), , color=None)

Rung (a) when `color` is None, rung (b) when it is given.

* **Return type:**
  [`Frame`](_autosummary/tituli.frame.html.md#tituli.frame.Frame)

#### *classmethod* from_image(image, , avoid=None, size=None, delivery=None)

Rung (c), and rung (d) when `avoid` is given.

`image` may also be a **sequence** of frames (the stills a camera move
will pass through under the text): the first is what gets composited,
all of them feed [`luminance_stats()`](_autosummary/tituli.html.md#tituli.Frame.luminance_stats), which then reports the *worst*
instant — so a scrim is as light as the whole window allows, not as
heavy as one frame demands.

`avoid` is normalised boxes, or a callable taking the PIL image and
returning one box or several (`burns.salient_box` fits directly). It
runs on every sampled frame. `size` cover-fits the picture(s) to the
frame first. `delivery` names a target whose reserved zones
(`"youtube"`: subtitle band + control bar) placement must keep clear.

* **Return type:**
  [`Frame`](_autosummary/tituli.frame.html.md#tituli.frame.Frame)

#### luminance_stats(box)

`(mean, stddev)` of relative luminance under `box`; None if unknown.

A high stddev means a busy patch — even a well-contrasted mean will not
keep every glyph readable, which is when a scrim earns its place.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)] | [`None`](https://docs.python.org/3/builtins/constants.html#None)

#### luminance_under(box)

Mean relative luminance of the pixels under `box`; None if unknown.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

#### *classmethod* over(stills, , avoid=None, size=None, delivery=None)

The frame a caption will sit on *over a time window* of stills.

The named entry point for the sequence case: the first still is what
gets composited, all of them are sampled, and `luminance_stats`
reports the worst instant — so a scrim over a Ken Burns move is as
light as the whole window allows. (`from_image` accepts a sequence
too; this name says what it is for.)

* **Return type:**
  [`Frame`](_autosummary/tituli.frame.html.md#tituli.frame.Frame)

#### overlap(box)

Fraction of `box` that covers something in `avoid` (0 when nothing).

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

#### place(size, , anchor='auto', region=None)

Where to put a block of `size`: a named anchor and its pixel box.

`anchor` may be one name, a preference list, or `"auto"` (the default
order). With rung (d) knowledge the candidate that least covers the
subject wins; ties fall to preference order.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Box`](_autosummary/tituli.geometry.html.md#tituli.geometry.Box)]

```pycon
>>> f = Frame.blank((1000, 500))
>>> f.place((200, 50), anchor="bottom")[0]
'bottom'
```

#### *property* safe *: [Box](_autosummary/tituli.geometry.html.md#tituli.geometry.Box)*

The title-safe box (SMPTE ST 2046-1, 90 %).

#### with_avoid(avoid)

Same frame with (additional) regions to keep clear.

* **Return type:**
  [`Frame`](_autosummary/tituli.frame.html.md#tituli.frame.Frame)

#### with_delivery(delivery)

Same frame, placement keeping clear of that target’s reserved zones.

* **Return type:**
  [`Frame`](_autosummary/tituli.frame.html.md#tituli.frame.Frame)

### *class* tituli.Label(text, attribution='', key=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

What goes on a museum label: the thing, and where it came from.

### *class* tituli.Layout(runs=(), plates=(), meta=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Everything the renderer needs: runs on top of plates, in frame pixels.

#### bbox()

Bounds of the runs (plates excluded).

* **Return type:**
  [`Box`](_autosummary/tituli.geometry.html.md#tituli.geometry.Box)

#### *property* duration_hint *: [float](https://docs.python.org/3/builtins/functions.html#float)*

Latest `t_full` among the runs (0 when untimed).

#### moved_to(box)

Translate so the runs’ bbox top-left lands on `box`’s top-left.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

#### staggered(, start=0.0, step, ramp)

Reveal runs one after another: run `i` starts at `start + i*step`.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

#### with_timing(t_in, t_full)

Give every run the same reveal envelope.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### *exception* tituli.MissingGlyphError

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

The run’s face has no glyph for one of its characters.

### *class* tituli.Outline(d, bbox)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A run’s contours as SVG path data, in frame pixels.

`bbox` is the ink’s bounds (`None` when nothing is inked, e.g. a space).

### *class* tituli.Path(points)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A polyline with an arc-length parameterisation.

Build one with the constructors (`line()`, [`arc()`](_autosummary/tituli.html.md#tituli.Path.arc), [`wave()`](_autosummary/tituli.html.md#tituli.Path.wave),
[`bezier()`](_autosummary/tituli.html.md#tituli.Path.bezier), `polyline()`, [`from_svg()`](_autosummary/tituli.html.md#tituli.Path.from_svg), [`from_function()`](_autosummary/tituli.html.md#tituli.Path.from_function)) and
query it by distance along the curve: `point(s)` and `angle(s)` (degrees,
clockwise-positive on screen because y points down).

#### angle(s)

Tangent direction at `s` in degrees (0 = rightwards, 90 = down).

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

#### *classmethod* arc(center, radius, start_deg, end_deg, , samples=512)

Circular arc; angles in degrees, 0 = right, 90 = down (screen coords).

* **Return type:**
  [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path)

#### *classmethod* bezier(\*controls, samples=512)

A Bézier curve of any degree through `controls` (De Casteljau).

* **Return type:**
  [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path)

#### *classmethod* circle(center, radius, , start_deg=-90.0)

A full circle starting at the top by default, running clockwise.

* **Return type:**
  [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path)

#### *property* closed *: [bool](https://docs.python.org/3/builtins/functions.html#bool)*

Whether the path ends where it starts (a circle, a closed SVG path).

```pycon
>>> Path.circle((0, 0), 10).closed, Path.line((0, 0), (1, 1)).closed
(True, False)
```

#### fit(box, , keep_aspect=True)

Scale and translate so the path’s bounding box fills `box`.

Degenerate axes (a horizontal line has zero height) are centred rather
than scaled.

* **Return type:**
  [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path)

#### *classmethod* from_function(fn, , samples=512)

Sample `fn(t)` for `t` in `[0, 1]`.

* **Return type:**
  [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path)

#### *classmethod* from_svg(d)

Parse an SVG path `d` string (M L H V C S Q T Z, absolute or relative).

Arcs (`A`) are not supported — approximate them with a Bézier.

* **Return type:**
  [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path)

```pycon
>>> Path.from_svg("M 0 0 L 10 0 L 10 10").length
20.0
```

#### point(s)

Position at arc length `s` (clamped to the path).

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]

#### *classmethod* wave(start, end, , amplitude, cycles=1.0, samples=512)

A sine wave along the segment `start -> end`.

* **Return type:**
  [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path)

### *class* tituli.Plate(box, color, kind='box', radius=0.0, solid=(0.5, 0.5))

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A backdrop drawn under the runs: a scrim, a box, a rule.

### *class* tituli.Run(text, x, y, face, color, angle=0.0, opacity=1.0, tracking=0.0, unit='line', index=0, t_in=None, t_full=None, tags=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A string drawn from a baseline origin, possibly rotated.

#### bbox()

Axis-aligned bounds (ignores rotation — fine for placement).

* **Return type:**
  [`Box`](_autosummary/tituli.geometry.html.md#tituli.geometry.Box)

### *class* tituli.Section(heading='', entries=(), kind='auto')

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A heading over a run of entries. `kind` picks the treatment:

`"pairs"` role/name on a gutter (cast, crew); `"list"` centred names;
`"prose"` wrapped small text (licences, thanks).

### *class* tituli.Span(start, end, key, data=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A time range of the cut showing one picture. `key` identifies the picture.

(Named `Span` rather than `Panel` so it never collides with
`braidio.video.Panel`, which callers of both will import alongside it.)

### *exception* tituli.TextDoesNotFit(text, tried_size, lines_at_min, max_lines)

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

The text cannot be set completely without going below legibility.

Raised rather than truncating, because the caller is the only one who can
decide what to do: shorten the wording (“1981 · Central Park, live” ->
“1981 · Central Park”, or “1981”), give it more lines, widen the box, or
drop the overlay. Silently ellipsising picks the one option nobody wants —
a label that is *wrong* rather than absent.

#### text

what could not be set.

#### tried_size

the smallest size attempted, as a fraction of frame height.

#### lines_at_min

how many lines it still needed there.

#### max_lines

how many were allowed.

### *class* tituli.TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.04, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.2, case='as-is', align='center', opacity=1.0, features=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

How a run of text looks. All fields keyword-only; build variants with `with_`.

#### face(frame_height)

The resolved, sized font for this style.

* **Return type:**
  [`Face`](_autosummary/tituli.fonts.html.md#tituli.fonts.Face)

#### features *: [Sequence](https://docs.python.org/3/library/typing.html#typing.Sequence)[[str](https://docs.python.org/3/builtins/stdtypes.html#str)] | [Mapping](https://docs.python.org/3/library/typing.html#typing.Mapping)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [bool](https://docs.python.org/3/builtins/functions.html#bool)]* *= ()*

`("tnum",)` or `{"tnum": True}`
(tituli#4). Kept as a tuple of tags; [`fonts.Face.applied_features`](_autosummary/tituli.fonts.html.md#tituli.fonts.Face.applied_features)
says which ones the resolved font has.

* **Type:**
  OpenType features to apply, by tag

#### px(frame_height)

Font size in pixels for a frame of that height.

* **Return type:**
  [`int`](https://docs.python.org/3/builtins/functions.html#int)

#### with_(\*\*changes)

A copy with some fields replaced.

* **Return type:**
  [`TextStyle`](_autosummary/tituli.style.html.md#tituli.style.TextStyle)

```pycon
>>> TextStyle().with_(weight=700).weight
700
```

### *class* tituli.TimedOverlay(layout, start, end, slot='top-left', weight=1, fade=0.45, payload=None, meta=<factory>, image=None, fade_in=None, fade_out=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A layout (or a thing to lay out) on screen from `start` to `end`.

`slot` names the region it occupies; `weight` orders overlays that
contend for a slot — the heavier suppresses the lighter, never stacks.
`payload` is whatever the caller wants to carry to rendering (a
[`Label`](_autosummary/tituli.html.md#tituli.Label), a dict, …); `layout` is filled in once rendered.

`image` is the other way in: an already-rendered frame-sized picture (a
PIL image or a path) — a title page made elsewhere, a logo plate — timed
like any layout. `fade_in` / `fade_out` override `fade` for one end
(`fade_in=0` for a title that must be up on the first frame).

### tituli.along_path(text, path, style, frame, , start=0.0, align='start', offset=0.0, upright=False, color=None, tags=())

Set `text` glyph by glyph along `path` (the path is the baseline).

Each glyph’s origin is the point at its arc length and it is rotated to the
tangent there — unless `upright` (a calligram convention: letters stay
vertical while their *positions* follow the shape). `offset` shifts the
baseline perpendicular to the path (negative = above, in screen terms).
`align` positions the whole string on the path from `start`.

Text longer than the path keeps its spacing and runs off the end — that is
reported in `meta["overflow"]` rather than silently squeezed.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.block(text, style, frame, , max_width=None, x=0.0, y=0.0, color=None, unit='line', tags=(), break_at=None)

Lay out prose as lines from the top-left corner `(x, y)`.

`text` is a string (wrapped to `max_width` when given) or pre-broken
lines. Alignment follows `style.align` within `max_width` (or the widest
line when no width is given). `unit="glyph"` emits one run per character
(needed for tracking and for per-glyph reveals). `break_at` (e.g.
`" · "`) wraps a list only between its items — see [`wrap()`](_autosummary/tituli.html.md#tituli.wrap).

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.caption(text, attribution='', , frame, anchor='auto', style=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.04, weight=600, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.25, case='as-is', align='left', opacity=1.0, features=()), attribution_style=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.02, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.03, leading=1.2, case='as-is', align='left', opacity=0.85, features=()), max_lines=3, max_width=0.55, ink=None, scrim=None, accent=True, on_overflow='fit')

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 **shrunk to fit**
within `max_lines` rather than cut; `attribution` is set small and
slightly transparent — present enough to credit, small enough not to
compete. If the words cannot be set legibly this raises
[`TextDoesNotFit`](_autosummary/tituli.html.md#tituli.TextDoesNotFit); pass `on_overflow="truncate"` for text whose
length you do not control (see `OVERFLOW_POLICIES`). 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.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.contrast_ratio(a, b)

WCAG contrast ratio between two colours (1..21).

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

```pycon
>>> round(contrast_ratio("#777", "#fff"), 2)
4.48
```

### tituli.cover_fit(img, size)

Scale `img` to fill `size` and crop the overflow, centred.

* **Return type:**
  `Image`

```pycon
>>> cover_fit(Image.new("RGB", (100, 50)), (50, 50)).size
(50, 50)
```

### tituli.credits_cards(credits, , frame, style=CreditsStyle(title=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.06, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=-0.005, leading=1.2, case='as-is', align='center', opacity=1.0, features=()), heading=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.024, weight=500, italic=False, condensed=False, color=(255, 255, 255), tracking=0.22, leading=1.2, case='upper', align='center', opacity=0.75, features=()), role=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.026, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.03, leading=1.2, case='as-is', align='right', opacity=0.8, features=()), name=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.032, weight=600, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0, features=()), line=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.03, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.4, case='as-is', align='center', opacity=1.0, features=()), ink=(255, 255, 255), background=(8, 8, 10)), max_cards=None)

Paginate the roll into cards that each fit the safe area, centred.

A heading is never orphaned at the bottom of a card. Raises when
`max_cards` would force truncation — losing an attribution silently is a
licence failure invisible to the person responsible for it.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)]

### tituli.credits_crawl(credits, , frame, style=CreditsStyle(title=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.06, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=-0.005, leading=1.2, case='as-is', align='center', opacity=1.0, features=()), heading=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.024, weight=500, italic=False, condensed=False, color=(255, 255, 255), tracking=0.22, leading=1.2, case='upper', align='center', opacity=0.75, features=()), role=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.026, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.03, leading=1.2, case='as-is', align='right', opacity=0.8, features=()), name=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.032, weight=600, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0, features=()), line=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.03, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.4, case='as-is', align='center', opacity=1.0, features=()), ink=(255, 255, 255), background=(8, 8, 10)))

One tall layout and its total height in pixels (for [`tituli.video.crawl()`](_autosummary/tituli.video.html.md#tituli.video.crawl)).

The layout starts one frame-height down and ends one frame-height before
its bottom, so the crawl enters from an empty frame and leaves to one.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout), [`int`](https://docs.python.org/3/builtins/functions.html#int)]

### tituli.credits_frame(size, style=CreditsStyle(title=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.06, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=-0.005, leading=1.2, case='as-is', align='center', opacity=1.0, features=()), heading=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.024, weight=500, italic=False, condensed=False, color=(255, 255, 255), tracking=0.22, leading=1.2, case='upper', align='center', opacity=0.75, features=()), role=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.026, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.03, leading=1.2, case='as-is', align='right', opacity=0.8, features=()), name=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.032, weight=600, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0, features=()), line=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.03, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.4, case='as-is', align='center', opacity=1.0, features=()), ink=(255, 255, 255), background=(8, 8, 10)))

The canonical credits frame: near-black, so ink defaults to white.

* **Return type:**
  [`Frame`](_autosummary/tituli.frame.html.md#tituli.frame.Frame)

### tituli.decide_ink(frame, box, , preferred=None)

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.

* **Return type:**
  [`InkDecision`](_autosummary/tituli.compose.html.md#tituli.compose.InkDecision)

### tituli.face_digest(face)

`sha256` of `face_bytes()` — what makes two faces the same face.

A family name is not an identity (two machines can install different files
under one name, and the embedded face can change with Pillow); the bytes are.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### tituli.families()

Sorted family names installed on this machine.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### tituli.find_font(family, , weight=400, italic=False, condensed=False)

First installed family in the preference list, at the closest style.

`family` may be one name or a preference list. A path to a font file is
accepted too, and wins outright.

* **Return type:**
  [`FontFile`](_autosummary/tituli.fonts.html.md#tituli.fonts.FontFile) | [`None`](https://docs.python.org/3/builtins/constants.html#None)

```pycon
>>> find_font("definitely-not-installed-xyz") is None
True
```

### tituli.fit(text, style, frame_height, , max_width, max_lines, min_size=0.022)

Wrap `text` complete, shrinking the type until it fits — never cutting it.

Returns the wrapped text and the (possibly smaller) style to set it in.

\*\*Why this exists rather than [`truncate()`](_autosummary/tituli.html.md#tituli.truncate).\*\* Type here is sized as a
fraction of frame *height* but has to fit the frame’s *width*. On a portrait
frame those pull apart hard: a lower third at 0.042 of height is 81px tall on
a 1080x1920 frame and has to fit inside ~1080px of width, so a perfectly
ordinary line overflows. Truncating then produced a shipped caption reading
`1981 · Centr…` — which is not a shortened label, it is a wrong one.

Shrinking is tried first because it is invisible to the viewer and costs
nothing. Only when shrinking would make the text unreadable does this raise
[`TextDoesNotFit`](_autosummary/tituli.html.md#tituli.TextDoesNotFit), handing the decision back to whoever wrote the words.

* **Parameters:**
  * **text** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – the words, set in full or not at all.
  * **style** ([`TextStyle`](_autosummary/tituli.style.html.md#tituli.style.TextStyle)) – the style to start from; its `size` is the ceiling.
  * **frame_height** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – pixels, for resolving em sizes.
  * **max_width** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – pixels available.
  * **max_lines** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – how many lines the design allows.
  * **min_size** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – legibility floor, as a fraction of frame height.
* **Raises:**
  [**TextDoesNotFit**](_autosummary/tituli.html.md#tituli.TextDoesNotFit) – when even `min_size` needs more than `max_lines`.
* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`TextStyle`](_autosummary/tituli.style.html.md#tituli.style.TextStyle)]

### Examples

```pycon
>>> from tituli.style import TextStyle
>>> st = TextStyle(size=0.05)
>>> body, used = fit("short", st, 1000.0, max_width=900, max_lines=1)
>>> body, used.size == st.size
('short', True)
```

### tituli.fit_size(text, style, frame_height, , max_width, max_height=None, min_size=0.012, step=0.9, break_at=None)

Shrink `style.size` until `text` wraps within the given bounds.

Shrinks geometrically by `step` and never below `min_size` (a fraction
of frame height, like `size` itself).

* **Return type:**
  [`TextStyle`](_autosummary/tituli.style.html.md#tituli.style.TextStyle)

### tituli.frames(layout, frame, , duration, fps=30.0, engine=None, hold_after=0.0)

Yield one image per video frame, honouring the runs’ reveal envelopes.

`duration` is the whole clip; the last reveal completes at
`layout.duration_hint` and the layout then holds. Frames are rendered
lazily so a long clip never sits in memory at once.

* **Return type:**
  [`Iterator`](https://docs.python.org/3/library/typing.html#typing.Iterator)[`Image`]

### tituli.glossed(pairs, , frame, glyph=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.13, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.0, case='as-is', align='center', opacity=1.0, features=()), gloss=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.045, weight=600, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.1, case='as-is', align='center', opacity=1.0, features=()), gap=0.012, column_gap=0.08, columns=None, anchor='center')

A row of big glyphs, each with a small reading centred under it.

`pairs` is `[(glyph, gloss), ...]` — `[("カ", "ka"), ("ス", "su")]`.
Columns share one pitch (the widest column), so readings line up under
their glyphs and the row reads as a row. `columns` wraps a long row into
several. Runs are tagged `glyph:i` / `gloss:i` (for staggered reveals).

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

```pycon
>>> lay = glossed([("カ", "ka"), ("ス", "su")], frame=1080)
>>> [r.text for r in lay.runs]
['カ', 'ka', 'ス', 'su']
>>> [r.tags for r in lay.runs][:2]
[('glyph:0',), ('gloss:0',)]
```

### tituli.grid(items, , frame, columns=None, gap=0.03, align='center', anchor='center')

Blocks in uniform cells, row-major; one row when `columns` is None.

Every cell is as wide as the widest item (so a row of glyphs keeps one
pitch whatever each glyph’s advance), each row as tall as its tallest
item; items are aligned `align` within their cell and to its top.
`gap` is a fraction of frame height, or `(column_gap, row_gap)`.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

```pycon
>>> from tituli.layout import block
>>> from tituli.style import TextStyle
>>> cells = [block(t, TextStyle(size=0.05), 1080) for t in "a b c d".split()]
>>> g = grid(cells, frame=1080, columns=2)
>>> tops = [round(r.bbox().y0) for r in g.runs]
>>> tops[0] == tops[1] < tops[2] == tops[3]      # two rows of two
True
```

### tituli.in_shape(text, mask, , frame, style=TextStyle(family=('Georgia', 'Palatino', 'Baskerville', 'Liberation Serif', 'DejaVu Serif', 'Times New Roman'), size=0.035, weight=400, italic=False, condensed=False, color=(17, 17, 17), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0, features=()), box=None, repeat=True)

Pour prose into a silhouette (light = inside). See [`tituli.layout.fill_shape()`](_autosummary/tituli.layout.html.md#tituli.layout.fill_shape).

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.ink_for(, luminance, light=(255, 255, 255), dark=(17, 17, 17))

Pick the ink (light or dark) with more contrast against `luminance`.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]

### tituli.intertitle(text, , frame, style=None)

A silent-film style card: serif italic prose, centred on the frame.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.lower_third(name, role='', , frame, anchor='bottom-left', name_style=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.042, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0, features=()), role_style=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.026, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.04, leading=1.2, case='as-is', align='left', opacity=0.9, features=()), ink=None, scrim=None, on_overflow='fit')

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.

The name and role are **shrunk to fit** rather than cut, and
[`TextDoesNotFit`](_autosummary/tituli.html.md#tituli.TextDoesNotFit) is raised if they cannot be set legibly. A lower
third reading `1981 · Centr…` once shipped in a finished film; that is not
a shortened label but a false one.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.make_engine(name='pillow')

Resolve an engine by name: `"pillow"` (default) or `"harfbuzz"`.

* **Return type:**
  `Engine`

### tituli.measure(text, style, frame_height)

Width in pixels of `text` set in `style` on a frame of that height.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

### tituli.note(lines, , frame, headline='', anchor='top-left', headline_style=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.055, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.15, case='as-is', align='left', opacity=1.0, features=()), line_style=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.03, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.4, case='as-is', align='left', opacity=0.92, features=()), max_width=0.62, ink=None, scrim=None, accent=True, on_overflow='fit')

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, shrinking to fit within
`_NOTE_MAX_LINES` rows rather than being cut; every line shares the
smallest size any of them needed, so the block reads as one block.
Raises [`TextDoesNotFit`](_autosummary/tituli.html.md#tituli.TextDoesNotFit) if a line cannot be set legibly.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.on_path(text, , shape='wave', frame, style=TextStyle(family=('Georgia', 'Palatino', 'Baskerville', 'Liberation Serif', 'DejaVu Serif', 'Times New Roman'), size=0.035, weight=400, italic=False, condensed=False, color=(17, 17, 17), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0, features=()), box=None, upright=False, align='center', fit_text=True)

Set `text` along a shape inside `box` (default: the safe area, inset).

With `fit_text` the type is shrunk until the whole string fits the path
length (never clipped); the resulting size is in `meta["size"]`.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.parse_color(color)

Accept `"#rgb"`, `"#rrggbb"`, `"#rrggbbaa"`, a Pillow name or a tuple.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]

```pycon
>>> parse_color("#fff")
(255, 255, 255, 255)
>>> parse_color((10, 20, 30))
(10, 20, 30, 255)
```

### tituli.rain(lines, , frame, style=TextStyle(family=('Georgia', 'Palatino', 'Baskerville', 'Liberation Serif', 'DejaVu Serif', 'Times New Roman'), size=0.035, weight=400, italic=False, condensed=False, color=(17, 17, 17), tracking=0.0, leading=1.2, case='as-is', align='left', opacity=1.0, features=()), box=None, slants=(0.186, 0.22, 0.257, 0.298, 0.353), head_offsets=(0.0, 7.0, 11.7, 16.4, 19.3), size_ratio=0.86)

*Il pleut*: each line a streak of upright letters falling across the frame.

Wants a portrait frame. `slants` (dx per step down) and `head_offsets`
(where each streak starts, in slot units) are shape parameters, not
coordinates — the defaults are Apollinaire’s.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.render(layout, frame, , t=None, engine=None)

Render onto the frame: RGBA overlay if it has no background, else RGB.

`engine` is the rasteriser seam (default Pillow; see [`tituli.shaping`](_autosummary/tituli.shaping.html.md#module-tituli.shaping)).

* **Return type:**
  `Image`

### tituli.render_overlay(layout, size, , t=None, engine=None)

The layout on a transparent canvas of `size` (RGBA).

* **Return type:**
  `Image`

### tituli.reserved_zones(delivery)

Normalised boxes a delivery target keeps for itself.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)], [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]

```pycon
>>> reserved_zones("youtube")
((0.0, 0.78, 1.0, 0.22),)
>>> reserved_zones(None)
()
```

### tituli.resolve(overlays, , min_readable_s=1.5)

Enforce one overlay per slot at a time: the heavier wins, the lighter yields.

A lighter overlay is truncated to the time before the heavier one starts
and dropped only if what remains is under `min_readable_s` (see
`_yield_to`). Equal weights that collide raise — that is an authoring
error, not a rule. Use this on a hand-built list to guarantee two weights
never stack.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`TimedOverlay`](_autosummary/tituli.schedule.html.md#tituli.schedule.TimedOverlay)]

### tituli.resolve_face(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), , size, weight=400, italic=False, condensed=False, features=())

Resolve a typeface request to a sized `Face`; never fails.

`features` are OpenType feature tags to apply (`("tnum",)`), carried by
the face; see [`Face.applied_features`](_autosummary/tituli.html.md#tituli.Face.applied_features) for which the font has.

Falls back to Pillow’s embedded Aileron when nothing in the list is installed,
so a render on a fontless CI box still produces a real (if plainer) result.
`EMBEDDED` in the list stops the search there (see its comment):

* **Return type:**
  [`Face`](_autosummary/tituli.fonts.html.md#tituli.fonts.Face)

```pycon
>>> resolve_face(EMBEDDED, size=20).path is None
True
>>> resolve_face(["definitely-not-installed-xyz", EMBEDDED], size=20).family
'Aileron'
```

### tituli.resolve_shape(shape, box)

Turn a name, an SVG `d` string or a Path into a Path fitted to `box`.

Names: `"line"`, `"circle"`, `"arc"`, `"wave"`, `"diagonal"`,
`"s-curve"`. Anything starting with `M`/`m` is parsed as SVG.

* **Return type:**
  [`Path`](_autosummary/tituli.geometry.html.md#tituli.geometry.Path)

### tituli.run_outline(run, , precision=2)

The contours of `run` as one SVG path, placed where the run is drawn.

Glyph `i` starts at `face.length(text[:i]) + i * tracking` along the
baseline — the same advances the layout measured with, kerning included,
so the outline and the layout cannot disagree about where a glyph is.

* **Return type:**
  [`Outline`](_autosummary/tituli.outlines.html.md#tituli.outlines.Outline)

### tituli.safe_area(width, height, , fraction=0.9)

The centred box holding `fraction` of each dimension.

* **Return type:**
  [`Box`](_autosummary/tituli.geometry.html.md#tituli.geometry.Box)

```pycon
>>> safe_area(1000, 500)
Box(x0=50.0, y0=25.0, x1=950.0, y1=475.0)
```

### tituli.schedule_labels(spans, label_for, , suppressed_by=(), hold_s=4.6, repeat_gap_s=150.0, min_readable_s=1.5, slot='top-left', weight=1)

One label per span, held `hold_s`, with the three rules built in.

* **first appearance**: a picture is labelled the first time it is shown;
* **repeat gap**: and again only if `repeat_gap_s` has passed since;
* **suppression**: a heavier overlay in the same slot owns that moment. The
  label is *truncated* to the time before the heavier one starts, and
  skipped only if what remains is shorter than `min_readable_s` — and a
  skipped label is *not recorded as shown*, so it gets its next chance.

`label_for` returns a [`Label`](_autosummary/tituli.html.md#tituli.Label) or `UNLABELLED`; `None` raises.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`TimedOverlay`](_autosummary/tituli.schedule.html.md#tituli.schedule.TimedOverlay)]

### tituli.stack(items, , frame, gap=0.02, align='center', anchor='center')

Blocks top to bottom, `gap` apart, aligned within the widest.

`gap` is a fraction of frame height: one number for every seam, or one
per seam (`len(items) - 1` values) when the page needs a rhythm. A block
with no runs is kept as a zero-height item, so per-seam gaps still line up.
Box plates travel with their block; a frame-anchored scrim raises (see
`_measurable`). A page larger than the title-safe area raises rather
than run off the frame. Parts’ loss reports (`overflow`/`unplaced`)
are summed into the result’s meta, and each part’s meta is kept under
`meta["parts"]`.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

```pycon
>>> from tituli.layout import block
>>> from tituli.style import TextStyle
>>> a = block("wide line of text", TextStyle(size=0.05), 1080)
>>> b = block("x", TextStyle(size=0.05), 1080)
>>> s = stack([a, b], frame=1080, align="left")
>>> s.runs[0].bbox().x0 == s.runs[1].bbox().x0
True
```

### tituli.title_card(title, subtitle='', , frame, kicker='', anchor='center', title_style=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.075, weight=700, italic=False, condensed=False, color=(255, 255, 255), tracking=-0.01, leading=1.1, case='as-is', align='center', opacity=1.0, features=()), subtitle_style=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.032, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.02, leading=1.3, case='as-is', align='center', opacity=1.0, features=()), kicker_style=TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.022, weight=500, italic=False, condensed=False, color=(255, 255, 255), tracking=0.18, leading=1.2, case='upper', align='center', opacity=1.0, features=()), ink=None, scrim=None)

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.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.truncate(text, style, frame_height, , max_width, max_lines)

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.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### tituli.wrap(text, style, frame_height, , max_width, break_at=None)

Greedy word wrap on measured widths. Explicit newlines are honoured.

`break_at` makes a list wrap only *between* its items: with `" · "`,
`"バス bus · スープ soup"` never ends a line on `バス`. An item wider
than the whole line falls back to word wrapping inside that item (it is
never cut). A no-break space (U+00A0) binds two words in any mode.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

```pycon
>>> from tituli.style import CAPTION
>>> lines = wrap("one two three four five six", CAPTION, 1080, max_width=300)
>>> len(lines) >= 2 and all(measure(l, CAPTION, 1080) <= 300 for l in lines)
True
```

### Modules

| [`arrange`](_autosummary/tituli.arrange.html.md#module-tituli.arrange)     | Layout combinators: several blocks composed into one [`Layout`](_autosummary/tituli.html.md#tituli.Layout).       |
|------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|
| [`bodies`](_autosummary/tituli.bodies.html.md#module-tituli.bodies)       | A lacing body schema for a rendered caption (`pip install tituli[lacing]`).                                         |
| [`calligram`](_autosummary/tituli.calligram.html.md#module-tituli.calligram) | Calligrams and concrete poems: text whose shape is part of the meaning.                                             |
| [`color`](_autosummary/tituli.color.html.md#module-tituli.color)         | Colours, WCAG contrast, and the ink-for-this-background decision.                                                   |
| [`compose`](_autosummary/tituli.compose.html.md#module-tituli.compose)     | The composed pieces: title cards, captions with attribution, lower thirds.                                          |
| [`credits`](_autosummary/tituli.credits.html.md#module-tituli.credits)     | Credits: structured data in, designed cards or a crawl out.                                                         |
| [`features`](_autosummary/tituli.features.html.md#module-tituli.features)   | OpenType features a style asks for, applied without a shaper where they can be (tituli#4).                          |
| [`fonts`](_autosummary/tituli.fonts.html.md#module-tituli.fonts)         | Font discovery with no bundled fonts.                                                                               |
| [`frame`](_autosummary/tituli.frame.html.md#module-tituli.frame)         | The frame: what the layout engine is allowed to know about the picture.                                             |
| [`geometry`](_autosummary/tituli.geometry.html.md#module-tituli.geometry)   | Boxes, anchors, safe areas and parametric paths — the coordinate vocabulary.                                        |
| [`layout`](_autosummary/tituli.layout.html.md#module-tituli.layout)       | The layout model: placed runs of text, and the engines that place them.                                             |
| [`outlines`](_autosummary/tituli.outlines.html.md#module-tituli.outlines)   | Glyph outlines as SVG path data — the vector form of a placed [`Run`](_autosummary/tituli.html.md#tituli.Run). |
| [`schedule`](_autosummary/tituli.schedule.html.md#module-tituli.schedule)   | When overlays appear: timed overlays, and a scheduler that owns the rules.                                          |
| [`shaping`](_autosummary/tituli.shaping.html.md#module-tituli.shaping)     | Optional engine: HarfBuzz shaping + FreeType rendering (`pip install tituli[shaping]`).                             |
| [`style`](_autosummary/tituli.style.html.md#module-tituli.style)         | Text styles and the tasteful presets.                                                                               |
| [`tools`](_autosummary/tituli.tools.html.md#module-tituli.tools)         | The SSOT of dispatchable operations: flat, JSON-able in, JSON-able out.                                             |
| [`video`](_autosummary/tituli.video.html.md#module-tituli.video)         | Video output through ffmpeg: stills to clips, overlays onto footage, crawls.                                        |


# _autosummary/tituli.layout.html.md

# tituli.layout

The layout model: placed runs of text, and the engines that place them.

A [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout) is the single intermediate every use case produces and the
renderer consumes — a title card, a credits crawl and a calligram are all just
different ways of filling one. A [`Run`](_autosummary/tituli.layout.html.md#tituli.layout.Run) is the unit: a string, a baseline
origin in frame pixels, an angle, a resolved face and a colour. A block of prose
is a few runs (one per line); a calligram is one run per glyph. That is the whole
unification — the humble cases are the degenerate calligram, exactly as hoped.

Real font metrics throughout (`Face.length`), never an average-character
estimate.

```pycon
>>> from tituli.frame import Frame
>>> from tituli.style import TextStyle
>>> lay = block("Hello\nWorld", TextStyle(size=0.05), Frame.blank((1920, 1080)))
>>> len(lay.runs)                      # one run per line (a tracked style would give one per glyph)
2
>>> lay.bbox().width > 0
True
```

### Functions

| [`along_path`](_autosummary/tituli.layout.html.md#tituli.layout.along_path)(text, path, style, frame, \*[, ...])    | Set `text` glyph by glyph along `path` (the path is the baseline).         |
|-----------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------|
| [`block`](_autosummary/tituli.layout.html.md#tituli.layout.block)(text, style, frame, \*[, max_width, x, ...]) | Lay out prose as lines from the top-left corner `(x, y)`.                  |
| [`fill_shape`](_autosummary/tituli.layout.html.md#tituli.layout.fill_shape)(text, mask, style, frame, \*, box)      | Pour prose into a silhouette, row by row — the concrete-poem 'shape' case. |
| [`fit_size`](_autosummary/tituli.layout.html.md#tituli.layout.fit_size)(text, style, frame_height, \*, max_width) | Shrink `style.size` until `text` wraps within the given bounds.            |
| [`glyph_columns`](_autosummary/tituli.layout.html.md#tituli.layout.glyph_columns)(lines, style, frame, \*, box, ...)   | Upright glyphs stepping down slanted streaks — Apollinaire's *Il pleut*.   |
| [`measure`](_autosummary/tituli.layout.html.md#tituli.layout.measure)(text, style, frame_height)                 | Width in pixels of `text` set in `style` on a frame of that height.        |
| [`merge`](_autosummary/tituli.layout.html.md#tituli.layout.merge)(layouts)                                     | Concatenate layouts.                                                       |
| [`wrap`](_autosummary/tituli.layout.html.md#tituli.layout.wrap)(text, style, frame_height, \*, max_width)     | Greedy word wrap on measured widths.                                       |

### Classes

| [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)([runs, plates, meta])               | Everything the renderer needs: runs on top of plates, in frame pixels.   |
|---------------------------------------------------------------------------------------------|--------------------------------------------------------------------------|
| [`Plate`](_autosummary/tituli.layout.html.md#tituli.layout.Plate)(box, color[, kind, radius, solid])   | A backdrop drawn under the runs: a scrim, a box, a rule.                 |
| [`Run`](_autosummary/tituli.layout.html.md#tituli.layout.Run)(text, x, y, face, color[, angle, ...]) | A string drawn from a baseline origin, possibly rotated.                 |

### *class* tituli.layout.Layout(runs=(), plates=(), meta=<factory>)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Everything the renderer needs: runs on top of plates, in frame pixels.

#### bbox()

Bounds of the runs (plates excluded).

* **Return type:**
  [`Box`](_autosummary/tituli.geometry.html.md#tituli.geometry.Box)

#### *property* duration_hint *: [float](https://docs.python.org/3/builtins/functions.html#float)*

Latest `t_full` among the runs (0 when untimed).

#### moved_to(box)

Translate so the runs’ bbox top-left lands on `box`’s top-left.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

#### staggered(, start=0.0, step, ramp)

Reveal runs one after another: run `i` starts at `start + i*step`.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

#### with_timing(t_in, t_full)

Give every run the same reveal envelope.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### *class* tituli.layout.Plate(box, color, kind='box', radius=0.0, solid=(0.5, 0.5))

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A backdrop drawn under the runs: a scrim, a box, a rule.

### *class* tituli.layout.Run(text, x, y, face, color, angle=0.0, opacity=1.0, tracking=0.0, unit='line', index=0, t_in=None, t_full=None, tags=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A string drawn from a baseline origin, possibly rotated.

#### bbox()

Axis-aligned bounds (ignores rotation — fine for placement).

* **Return type:**
  [`Box`](_autosummary/tituli.geometry.html.md#tituli.geometry.Box)

### tituli.layout.along_path(text, path, style, frame, , start=0.0, align='start', offset=0.0, upright=False, color=None, tags=())

Set `text` glyph by glyph along `path` (the path is the baseline).

Each glyph’s origin is the point at its arc length and it is rotated to the
tangent there — unless `upright` (a calligram convention: letters stay
vertical while their *positions* follow the shape). `offset` shifts the
baseline perpendicular to the path (negative = above, in screen terms).
`align` positions the whole string on the path from `start`.

Text longer than the path keeps its spacing and runs off the end — that is
reported in `meta["overflow"]` rather than silently squeezed.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.layout.block(text, style, frame, , max_width=None, x=0.0, y=0.0, color=None, unit='line', tags=(), break_at=None)

Lay out prose as lines from the top-left corner `(x, y)`.

`text` is a string (wrapped to `max_width` when given) or pre-broken
lines. Alignment follows `style.align` within `max_width` (or the widest
line when no width is given). `unit="glyph"` emits one run per character
(needed for tracking and for per-glyph reveals). `break_at` (e.g.
`" · "`) wraps a list only between its items — see [`wrap()`](_autosummary/tituli.layout.html.md#tituli.layout.wrap).

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.layout.fill_shape(text, mask, style, frame, , box, threshold=128, min_span=0.5, color=None, tags=(), repeat=True)

Pour prose into a silhouette, row by row — the concrete-poem ‘shape’ case.

`mask` is any Pillow image (or path): dark pixels are *outside*, light
pixels are the shape (invert your mask if it is the other way round). It is
fitted into `box`; for each line of text height the horizontal spans of
the shape are found and filled with as many words as fit, in reading order.
With `repeat` the text cycles until the shape is full; otherwise leftover
spans stay empty and leftover words are reported in `meta["unplaced"]`.

* **Return type:**
  Layout

### tituli.layout.fit_size(text, style, frame_height, , max_width, max_height=None, min_size=0.012, step=0.9, break_at=None)

Shrink `style.size` until `text` wraps within the given bounds.

Shrinks geometrically by `step` and never below `min_size` (a fraction
of frame height, like `size` itself).

* **Return type:**
  [`TextStyle`](_autosummary/tituli.style.html.md#tituli.style.TextStyle)

### tituli.layout.glyph_columns(lines, style, frame, , box, slants, head_offsets, word_gap_slots=1, size_ratio=0.86, color=None, tags=())

Upright glyphs stepping down slanted streaks — Apollinaire’s *Il pleut*.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

Line `k` becomes a streak: glyph `i` sits at 

```
``
```

(u, v) = (head_offsets[k]

+ i \* slants[k], i)\`\` on an abstract slot grid, fitted to `box` in one
  step, so the shape is aspect-independent. Letters stay upright. This is the
  solver muvid’s `calligram` archetype introduced, generalised over real
  glyph metrics and any frame — the streaks are `Path` lines, which is
  how it stays one preset of [`along_path()`](_autosummary/tituli.layout.html.md#tituli.layout.along_path) rather than a second family.

`slants` and `head_offsets` are cycled if shorter than `lines`.

### tituli.layout.measure(text, style, frame_height)

Width in pixels of `text` set in `style` on a frame of that height.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

### tituli.layout.merge(layouts)

Concatenate layouts.

* **Return type:**
  [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout)

### tituli.layout.wrap(text, style, frame_height, , max_width, break_at=None)

Greedy word wrap on measured widths. Explicit newlines are honoured.

`break_at` makes a list wrap only *between* its items: with `" · "`,
`"バス bus · スープ soup"` never ends a line on `バス`. An item wider
than the whole line falls back to word wrapping inside that item (it is
never cut). A no-break space (U+00A0) binds two words in any mode.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

```pycon
>>> from tituli.style import CAPTION
>>> lines = wrap("one two three four five six", CAPTION, 1080, max_width=300)
>>> len(lines) >= 2 and all(measure(l, CAPTION, 1080) <= 300 for l in lines)
True
```


# _autosummary/tituli.outlines.html.md

# tituli.outlines

Glyph outlines as SVG path data — the vector form of a placed `Run`.

A [`Layout`](_autosummary/tituli.layout.html.md#tituli.layout.Layout) says *where* each run goes; this module says
*what it looks like as geometry*: the font’s own contours, scaled to the run’s
face size, advanced along the run exactly as `Face.length()` measures it,
rotated by the run’s angle and translated to its baseline origin. The result is
an SVG `d` string in frame pixels (y down), so a caller can draw text with no
font engine at all — an animation runtime that only knows how to draw SVG, for
instance (thorwhalen/an#155: \*glyphs as SVG sprites, converted at compile
time\*).

```pycon
>>> from tituli.fonts import EMBEDDED, resolve_face
>>> from tituli.layout import Run
>>> run = Run("Hi", 10.0, 50.0, resolve_face(EMBEDDED, size=40), (0, 0, 0, 255))
>>> out = run_outline(run)
>>> out.d.startswith("M")
True
>>> out.bbox.y1 <= 50.0 + 1 and out.bbox.y0 < 50.0   # ink sits on the baseline
True
```

Missing glyphs **raise** rather than drawing a box or nothing: a string the
face cannot set is the caller’s decision (another face, other words), never a
silent substitution.

```pycon
>>> run_outline(Run("é", 0, 0, resolve_face(EMBEDDED, size=40), (0, 0, 0, 255)))
Traceback (most recent call last):
...
tituli.outlines.MissingGlyphError: 'Aileron' Regular has no glyph for 'é' (U+00E9)
```

Needs `fontTools` (`pip install tituli[outlines]`); imported lazily, so the
rest of the package never pays for it.

### Module Attributes

| [`DFLT_PRECISION`](_autosummary/tituli.outlines.html.md#tituli.outlines.DFLT_PRECISION)   | Decimal places kept in the path data.   |
|-------------------------------------------------------------------|-----------------------------------------|

### Functions

| [`run_outline`](_autosummary/tituli.outlines.html.md#tituli.outlines.run_outline)(run, \*[, precision])   | The contours of `run` as one SVG path, placed where the run is drawn.   |
|--------------------------------------------------------------------------------------|-------------------------------------------------------------------------|

### Classes

| [`Outline`](_autosummary/tituli.outlines.html.md#tituli.outlines.Outline)(d, bbox)   | A run's contours as SVG path data, in frame pixels.   |
|---------------------------------------------------------------------|-------------------------------------------------------|

### Exceptions

| [`MissingGlyphError`](_autosummary/tituli.outlines.html.md#tituli.outlines.MissingGlyphError)   | The run's face has no glyph for one of its characters.   |
|----------------------------------------------------------------------|----------------------------------------------------------|

### tituli.outlines.DFLT_PRECISION *= 2*

Decimal places kept in the path data. Two is a hundredth of a pixel —
invisible, and it keeps the string short and byte-stable.

### *exception* tituli.outlines.MissingGlyphError

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

The run’s face has no glyph for one of its characters.

### *class* tituli.outlines.Outline(d, bbox)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A run’s contours as SVG path data, in frame pixels.

`bbox` is the ink’s bounds (`None` when nothing is inked, e.g. a space).

### tituli.outlines.run_outline(run, , precision=2)

The contours of `run` as one SVG path, placed where the run is drawn.

Glyph `i` starts at `face.length(text[:i]) + i * tracking` along the
baseline — the same advances the layout measured with, kerning included,
so the outline and the layout cannot disagree about where a glyph is.

* **Return type:**
  [`Outline`](_autosummary/tituli.outlines.html.md#tituli.outlines.Outline)


# _autosummary/tituli.schedule.html.md

# tituli.schedule

When overlays appear: timed overlays, and a scheduler that owns the rules.

Two ways in, both whole:

* **You schedule.** Build [`TimedOverlay`](_autosummary/tituli.schedule.html.md#tituli.schedule.TimedOverlay) objects at the times you want
  and hand them to [`tituli.video.overlay()`](_autosummary/tituli.video.html.md#tituli.video.overlay). tituli composites; it does
  not second-guess your times.
* **tituli schedules.** [`schedule_labels()`](_autosummary/tituli.schedule.html.md#tituli.schedule.schedule_labels) derives one label per panel of
  a cut, with *first appearance*, *repeat gap* and \*suppression by a heavier
  overlay\* as first-class rules — and the suppression check lives **inside**
  the loop, so a label that loses its moment to a title card is not recorded
  as shown and gets its next chance. (That ordering bug once left the one
  portrait a cold viewer most needed named unlabelled for nine minutes.)

There is deliberately no middle: nothing here owns *some* of the timing and
leaves the caller to reconcile the rest.

A label is either a [`Label`](_autosummary/tituli.schedule.html.md#tituli.schedule.Label) or the explicit `UNLABELLED` sentinel.
`None` is refused: a still left uncaptioned next to a captioned one is an
implicit claim about what it is, so “no caption” must be said, never defaulted.

```pycon
>>> spans = [Span(0, 5, "eliza"), Span(5, 10, "eliza"), Span(10, 15, "map")]
>>> labels = {"eliza": Label("Eliza Hamilton", "Ralph Earl, 1787"), "map": UNLABELLED}
>>> cards = [TimedOverlay(None, 0, 3, slot="top-left", weight=2)]
>>> out = schedule_labels(spans, lambda p: labels[p.key], suppressed_by=cards)
>>> [(round(o.start), round(o.end)) for o in out]   # first span lost to the card
[(5, 10)]
```

### Functions

| [`schedule_labels`](_autosummary/tituli.schedule.html.md#tituli.schedule.schedule_labels)(spans, label_for, \*[, ...])   | One label per span, held `hold_s`, with the three rules built in.             |
|-------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------|
| [`resolve`](_autosummary/tituli.schedule.html.md#tituli.schedule.resolve)(overlays, \*[, min_readable_s])        | Enforce one overlay per slot at a time: the heavier wins, the lighter yields. |

### Classes

| [`Label`](_autosummary/tituli.schedule.html.md#tituli.schedule.Label)(text[, attribution, key])               | What goes on a museum label: the thing, and where it came from.   |
|------------------------------------------------------------------------------------------------|-------------------------------------------------------------------|
| [`Span`](_autosummary/tituli.schedule.html.md#tituli.schedule.Span)(start, end, key[, data])                 | A time range of the cut showing one picture.                      |
| [`TimedOverlay`](_autosummary/tituli.schedule.html.md#tituli.schedule.TimedOverlay)(layout, start, end[, slot, ...]) | A layout (or a thing to lay out) on screen from `start` to `end`. |

### *class* tituli.schedule.Label(text, attribution='', key=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

What goes on a museum label: the thing, and where it came from.

### *class* tituli.schedule.Span(start, end, key, data=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A time range of the cut showing one picture. `key` identifies the picture.

(Named `Span` rather than `Panel` so it never collides with
`braidio.video.Panel`, which callers of both will import alongside it.)

### *class* tituli.schedule.TimedOverlay(layout, start, end, slot='top-left', weight=1, fade=0.45, payload=None, meta=<factory>, image=None, fade_in=None, fade_out=None)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A layout (or a thing to lay out) on screen from `start` to `end`.

`slot` names the region it occupies; `weight` orders overlays that
contend for a slot — the heavier suppresses the lighter, never stacks.
`payload` is whatever the caller wants to carry to rendering (a
[`Label`](_autosummary/tituli.schedule.html.md#tituli.schedule.Label), a dict, …); `layout` is filled in once rendered.

`image` is the other way in: an already-rendered frame-sized picture (a
PIL image or a path) — a title page made elsewhere, a logo plate — timed
like any layout. `fade_in` / `fade_out` override `fade` for one end
(`fade_in=0` for a title that must be up on the first frame).

### tituli.schedule.resolve(overlays, , min_readable_s=1.5)

Enforce one overlay per slot at a time: the heavier wins, the lighter yields.

A lighter overlay is truncated to the time before the heavier one starts
and dropped only if what remains is under `min_readable_s` (see
`_yield_to`). Equal weights that collide raise — that is an authoring
error, not a rule. Use this on a hand-built list to guarantee two weights
never stack.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`TimedOverlay`](_autosummary/tituli.schedule.html.md#tituli.schedule.TimedOverlay)]

### tituli.schedule.schedule_labels(spans, label_for, , suppressed_by=(), hold_s=4.6, repeat_gap_s=150.0, min_readable_s=1.5, slot='top-left', weight=1)

One label per span, held `hold_s`, with the three rules built in.

* **first appearance**: a picture is labelled the first time it is shown;
* **repeat gap**: and again only if `repeat_gap_s` has passed since;
* **suppression**: a heavier overlay in the same slot owns that moment. The
  label is *truncated* to the time before the heavier one starts, and
  skipped only if what remains is shorter than `min_readable_s` — and a
  skipped label is *not recorded as shown*, so it gets its next chance.

`label_for` returns a [`Label`](_autosummary/tituli.schedule.html.md#tituli.schedule.Label) or `UNLABELLED`; `None` raises.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`TimedOverlay`](_autosummary/tituli.schedule.html.md#tituli.schedule.TimedOverlay)]


# _autosummary/tituli.shaping.html.md

# tituli.shaping

Optional engine: HarfBuzz shaping + FreeType rendering (`pip install tituli[shaping]`).

The Pillow default handles Latin text well and rotates glyph *bitmaps*. This
engine shapes the run with HarfBuzz (kerning, ligatures, Arabic/Devanagari
joining) and asks FreeType to apply the rotation to the **outline** before
rasterising, which is the typographically correct order. Same `Run`
in, same canvas out — it is the `engine=` seam of `tituli.render.render()`.

Import cost is paid only when constructed; the module itself imports nothing
optional at top level.

### Functions

| [`shape`](_autosummary/tituli.shaping.html.md#tituli.shaping.shape)(text, path, index, size, \*[, features])   | `[(glyph_id, x_advance, y_advance, x_offset, y_offset), ...]` in pixels.   |
|---------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------|

### Classes

| [`HarfBuzzEngine`](_autosummary/tituli.shaping.html.md#tituli.shaping.HarfBuzzEngine)()   | Draw runs through HarfBuzz + FreeType.   |
|---------------------------------------------------------------------|------------------------------------------|

### *class* tituli.shaping.HarfBuzzEngine

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

Draw runs through HarfBuzz + FreeType. Falls back to Pillow for the embedded font.

### tituli.shaping.shape(text, path, index, size, , features=None)

`[(glyph_id, x_advance, y_advance, x_offset, y_offset), ...]` in pixels.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]]


# _autosummary/tituli.style.html.md

# tituli.style

Text styles and the tasteful presets.

A [`TextStyle`](_autosummary/tituli.style.html.md#tituli.style.TextStyle) is resolution-independent: `size` is a \*\*fraction of the
frame height\*\* (`0.04` is 43 px at 1080p, 86 px at 4K) so the same style reads
identically at any resolution. It is resolved to pixels by [`TextStyle.face()`](_autosummary/tituli.style.html.md#tituli.style.TextStyle.face)
once a frame is known.

The presets encode the style research (see `misc/docs/style.md`): sans-serif
working set, >= 36 px-equivalent at 1080p for anything meant to be read,
title-safe margins, tracked small caps for labels, generous leading.

```pycon
>>> s = TITLE.with_(size=0.1)
>>> s.size, s.weight
(0.1, 700)
>>> s.px(1080)
108
```

### Classes

| [`TextStyle`](_autosummary/tituli.style.html.md#tituli.style.TextStyle)([family, size, weight, italic, ...])   | How a run of text looks.   |
|---------------------------------------------------------------------------------------------------|----------------------------|

### *class* tituli.style.TextStyle(family=('Helvetica Neue', 'Inter', 'Helvetica', 'Avenir Next', 'Roboto', 'Univers', 'Liberation Sans', 'DejaVu Sans', 'Arial'), size=0.04, weight=400, italic=False, condensed=False, color=(255, 255, 255), tracking=0.0, leading=1.2, case='as-is', align='center', opacity=1.0, features=())

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

How a run of text looks. All fields keyword-only; build variants with `with_`.

#### face(frame_height)

The resolved, sized font for this style.

* **Return type:**
  [`Face`](_autosummary/tituli.fonts.html.md#tituli.fonts.Face)

#### features *: [Sequence](https://docs.python.org/3/library/typing.html#typing.Sequence)[[str](https://docs.python.org/3/builtins/stdtypes.html#str)] | [Mapping](https://docs.python.org/3/library/typing.html#typing.Mapping)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [bool](https://docs.python.org/3/builtins/functions.html#bool)]* *= ()*

`("tnum",)` or `{"tnum": True}`
(tituli#4). Kept as a tuple of tags; `fonts.Face.applied_features`
says which ones the resolved font has.

* **Type:**
  OpenType features to apply, by tag

#### px(frame_height)

Font size in pixels for a frame of that height.

* **Return type:**
  [`int`](https://docs.python.org/3/builtins/functions.html#int)

#### with_(\*\*changes)

A copy with some fields replaced.

* **Return type:**
  [`TextStyle`](_autosummary/tituli.style.html.md#tituli.style.TextStyle)

```pycon
>>> TextStyle().with_(weight=700).weight
700
```


# _autosummary/tituli.tools.html.md

# tituli.tools

The SSOT of dispatchable operations: flat, JSON-able in, JSON-able out.

These are the functions every surface projects: the CLI (`python -m tituli`)
today, an MCP server or HTTP route tomorrow, an agent skill in prose. Nothing
here imports a surface library; each function takes paths and strings, writes
a file, and returns a dict describing what it wrote.

```pycon
>>> sorted(f.__name__ for f in _dispatch_funcs)
['calligram', 'caption', 'credits', 'fonts', 'lower_third', 'overlay_video', 'title_card']
```

### Functions

| [`calligram`](_autosummary/tituli.tools.html.md#tituli.tools.calligram)(text, \*[, out, shape, size, ...])     | Lay `text` along a shape (`circle`, `wave`, `arc`, an SVG path, `rain`).         |
|---------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------|
| [`caption`](_autosummary/tituli.tools.html.md#tituli.tools.caption)(image, text[, attribution, out, ...])    | Put a museum-label caption and a tiny attribution on `image`.                    |
| [`credits`](_autosummary/tituli.tools.html.md#tituli.tools.credits)(spec, \*[, out, mode, size, speed, ...]) | Render credits from a JSON spec file (or JSON string) to `out`.                  |
| [`fonts`](_autosummary/tituli.tools.html.md#tituli.tools.fonts)([query])                                   | List installed font families (optionally filtered by substring).                 |
| [`lower_third`](_autosummary/tituli.tools.html.md#tituli.tools.lower_third)(name[, role, out, background, ...])  | A name + role block, bottom-left, as a transparent overlay (or on a picture).    |
| [`overlay_video`](_autosummary/tituli.tools.html.md#tituli.tools.overlay_video)(video, overlays, \*[, out, ...])   | Composite captions/lower thirds/images onto a finished video in one ffmpeg pass. |
| [`title_card`](_autosummary/tituli.tools.html.md#tituli.tools.title_card)(title[, subtitle, out, ...])          | Render a title card to `out` (PNG, or MP4 when `duration` is given).             |

### tituli.tools.calligram(text, , out='calligram.png', shape='wave', size=None, background='#f4f1e8', upright=False, mask=None, reveal=None)

Lay `text` along a shape (`circle`, `wave`, `arc`, an SVG path, `rain`).

`shape="rain"` is Apollinaire’s *Il pleut* (lines become streaks; give
it a portrait `size`). `mask` (an image path, light = inside) pours the
text into a silhouette instead. `reveal` (seconds) writes an MP4 with a
glyph-by-glyph reveal rather than a PNG.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### tituli.tools.caption(image, text, attribution='', , out='captioned.png', anchor='auto', avoid='saliency', delivery='youtube', size=None, overlay_only=False)

Put a museum-label caption and a tiny attribution on `image`.

Placement avoids the subject (`avoid="saliency"` uses `burns` when
installed, otherwise nothing is avoided) and the delivery target’s
reserved zones. `overlay_only` writes a transparent PNG to composite
onto motion video instead of the flattened picture.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### tituli.tools.credits(spec, , out='credits.mp4', mode='crawl', size=None, speed=None, hold=None, background=None, audio=None)

Render credits from a JSON spec file (or JSON string) to `out`.

Spec: `{"title": ..., "sections": [{"heading": ..., "entries":
[["Role", "Name"], "Name", ...]}], "closing": [...]}` — or a plain JSON
list of lines. `mode` is `"crawl"` (one MP4) or `"cards"` (PNG per
card, or one MP4 when `out` ends in .mp4). Never truncates.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### tituli.tools.fonts(query='')

List installed font families (optionally filtered by substring).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### tituli.tools.lower_third(name, role='', , out='lower_third.png', background=None, size=None, delivery='youtube')

A name + role block, bottom-left, as a transparent overlay (or on a picture).

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### tituli.tools.overlay_video(video, overlays, , out='overlaid.mp4', delivery='youtube', pad_start=0.0)

Composite captions/lower thirds/images onto a finished video in one ffmpeg pass.

`overlays` is a JSON file/string: a list of `{"start", "end", "text",
"attribution"?, "kind"?: "caption"|"lower_third", "role"?, "anchor"?}`
or `{"start", "end", "image", "fade_in"?, "fade_out"?}` (a frame-sized
PNG, e.g. a title page; it occupies its own slot and suppresses no
caption — time captions after it). Text is laid out against the video’s frame size;
the picture under each overlay is not sampled (pass a still through
`caption` for that). `pad_start` seconds are added at the head (first
frame held, audio delayed); overlay times are on the padded timeline.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)

### tituli.tools.title_card(title, subtitle='', , out='title.png', background=None, size=None, kicker='', anchor='center', avoid=None, duration=None)

Render a title card to `out` (PNG, or MP4 when `duration` is given).

`background` is a colour (`#101014`), an image path, or nothing (a
transparent overlay). `anchor` is a grid position; `avoid` is
`"saliency"` or `"x,y,w,h"` to keep the title off the subject.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)


# _autosummary/tituli.video.html.md

# tituli.video

Video output through ffmpeg: stills to clips, overlays onto footage, crawls.

Everything here composites **rendered PNGs**; ffmpeg never draws text. That is
deliberate twice over: the system ffmpeg on a developer Mac commonly lacks
`drawtext`/`libass` (this one does), and typographically `drawtext` is
crude. What ffmpeg is good at — `overlay`, `fade`, `crop`, encoding — is
all it is asked to do, and [`require_filters()`](_autosummary/tituli.video.html.md#tituli.video.require_filters) fails loudly if even that
is missing rather than emitting a text-free video.

The overlay chain follows the shape verified in production on a found-image
film: each PNG is a looped input **with its own duration** (a shorter input
silently vanishes under `eof_action=pass`), faded on its own clock, shifted
with `setpts`, gated with `enable=between(t, …)`, and the audio is
stream-copied so the finished mix is never re-encoded.

Never burn text into stills that a camera move will pan and zoom — composite
onto the finished motion video, which is what [`overlay()`](_autosummary/tituli.video.html.md#tituli.video.overlay) does.

### Functions

| [`materialize`](_autosummary/tituli.video.html.md#tituli.video.materialize)(overlays, \*, frame)                   | Give every overlay a layout, rendering payloads; raise naming any that can't be.              |
|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
| [`ffmpeg_path`](_autosummary/tituli.video.html.md#tituli.video.ffmpeg_path)()                                      | The ffmpeg binary: `$TITULI_FFMPEG` or the first on PATH.                                     |
| [`available_filters`](_autosummary/tituli.video.html.md#tituli.video.available_filters)([binary])                        | Names of the filters this ffmpeg build has.                                                   |
| [`require_filters`](_autosummary/tituli.video.html.md#tituli.video.require_filters)(\*names)                           | Raise [`FfmpegError`](_autosummary/tituli.video.html.md#tituli.video.FfmpegError) naming any missing filter. |
| [`still`](_autosummary/tituli.video.html.md#tituli.video.still)(image, dst, \*, duration[, fps, ...])        | A still held for `duration` seconds with fades — a title or credits card.                     |
| [`overlay`](_autosummary/tituli.video.html.md#tituli.video.overlay)(video, overlays, dst, \*[, size, ...])     | Composite timed overlays onto `video` in one ffmpeg pass; audio copied.                       |
| [`crawl`](_autosummary/tituli.video.html.md#tituli.video.crawl)(tall, dst, \*, size, speed_px_s[, fps, ...]) | Scroll a tall image up through a `size` window at `speed_px_s`.                               |
| [`frames_to_video`](_autosummary/tituli.video.html.md#tituli.video.frames_to_video)(frames, dst, \*, size[, fps, ...]) | Encode a lazy stream of RGB frames (the kinetic path: per-glyph reveals).                     |
| [`probe_size`](_autosummary/tituli.video.html.md#tituli.video.probe_size)(video)                                  | `(width, height)` of the first video stream, via ffprobe.                                     |
| [`has_audio`](_autosummary/tituli.video.html.md#tituli.video.has_audio)(video)                                   | Whether `video` has an audio stream, via ffprobe.                                             |
| [`audio_streams`](_autosummary/tituli.video.html.md#tituli.video.audio_streams)(video)                               | How many audio streams `video` has, via ffprobe.                                              |

### Exceptions

| [`FfmpegError`](_autosummary/tituli.video.html.md#tituli.video.FfmpegError)   | ffmpeg is missing, or lacks a filter tituli needs.   |
|----------------------------------------------------------------|------------------------------------------------------|

### *exception* tituli.video.FfmpegError

Bases: [`RuntimeError`](https://docs.python.org/3/builtins/exceptions.html#RuntimeError)

ffmpeg is missing, or lacks a filter tituli needs.

### tituli.video.audio_streams(video)

How many audio streams `video` has, via ffprobe.

* **Return type:**
  [`int`](https://docs.python.org/3/builtins/functions.html#int)

### tituli.video.available_filters(binary=None)

Names of the filters this ffmpeg build has.

* **Return type:**
  [`frozenset`](https://docs.python.org/3/builtins/stdtypes.html#frozenset)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str)]

### tituli.video.crawl(tall, dst, , size, speed_px_s, fps=30, audio=None, crf=18)

Scroll a tall image up through a `size` window at `speed_px_s`.

The image should already carry its own lead-in and tail (as
[`tituli.credits.credits_crawl()`](_autosummary/tituli.credits.html.md#tituli.credits.credits_crawl) does), so the clip starts and ends on
an empty frame. Duration is `(image height - window height) / speed`.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### tituli.video.ffmpeg_path()

The ffmpeg binary: `$TITULI_FFMPEG` or the first on PATH.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### tituli.video.frames_to_video(frames, dst, , size, fps=30, audio=None, crf=18)

Encode a lazy stream of RGB frames (the kinetic path: per-glyph reveals).

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### tituli.video.has_audio(video)

Whether `video` has an audio stream, via ffprobe.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

### tituli.video.materialize(overlays, , frame)

Give every overlay a layout, rendering payloads; raise naming any that can’t be.

* **Return type:**
  [`list`](https://docs.python.org/3/builtins/stdtypes.html#list)[[`TimedOverlay`](_autosummary/tituli.schedule.html.md#tituli.schedule.TimedOverlay)]

```pycon
>>> from tituli.frame import Frame
>>> from tituli.schedule import Label, TimedOverlay
>>> f = Frame.blank((640, 360))
>>> done = materialize([TimedOverlay(None, 0, 2, payload=Label("Eliza", "Earl, 1787"))], frame=f)
>>> done[0].layout is not None
True
```

### tituli.video.overlay(video, overlays, dst, , size=None, frame=None, delivery='youtube', workdir=None, crf=18, preset='medium', engine=None, pad_start=0.0, pad_mode='clone')

Composite timed overlays onto `video` in one ffmpeg pass; audio copied.

Every overlay is rendered: one with a `layout` as it is; one with an
`image` (a frame-sized PIL image or path, e.g. a title page) as that
picture; one with only a `payload` (a [`Label`](_autosummary/tituli.schedule.html.md#tituli.schedule.Label)
from `schedule_labels`, or a dict with `text`/`attribution`/`kind`)
is laid out here against `frame` (default: a blank frame of the video’s
size with `delivery`’s reserved zones) at its `slot`. Anything that
cannot be rendered makes the call **raise, naming it** — nothing is ever
silently left off the film.

`pad_start` (seconds) lengthens the film at the head before compositing:
the first frame is held (`pad_mode="clone"`) or the head is black
(`"black"`), and the audio is delayed to match (and so re-encoded, AAC).
Overlay times are on the **padded** timeline — `TimedOverlay(None, 0, 3,
image="title.png", fade_in=0)` with `pad_start=0.8` is “the title over
the intro, the song starting under it 0.8 s in”.

* **Return type:**
  Path

### tituli.video.probe_size(video)

`(width, height)` of the first video stream, via ffprobe.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]

### tituli.video.require_filters(\*names)

Raise [`FfmpegError`](_autosummary/tituli.video.html.md#tituli.video.FfmpegError) naming any missing filter.

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### tituli.video.still(image, dst, , duration, fps=30, fade_in=0.4, fade_out=0.4, audio=None, crf=18)

A still held for `duration` seconds with fades — a title or credits card.

Transparent PNGs are flattened onto black.

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)


# about-this-build.html.md

<!-- generated by epythet -->

# About this build

This documentation was built on **2026-10-10 14:47 UTC** from commit <a href="https://github.com/reeleehq/tituli/commit/23c59af40473fd8928fdad032cc2c0622f68a951"><code>23c59af</code></a> on branch <code>main</code>, for **tituli 0.0.9** (from <code>pyproject.toml</code>).

#### NOTE
Nothing suggests a mismatch: the tree was clean at the commit above, and the documented version is the one on PyPI.

## Source

|                     |                                                                                                                                                        |
|---------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
| Commit              | <a href="https://github.com/reeleehq/tituli/commit/23c59af40473fd8928fdad032cc2c0622f68a951"><code>23c59af40473fd8928fdad032cc2c0622f68a951</code></a> |
| Branch              | <code>main</code>                                                                                                                                      |
| Tags at this commit | <code>0.0.9</code>                                                                                                                                     |
| Working tree        | clean                                                                                                                                                  |
| Remote              | <code>https://github.com/reeleehq/tituli</code>                                                                                                        |

## Continuous integration

|              |                                                                                            |
|--------------|--------------------------------------------------------------------------------------------|
| Repository   | <code>reeleehq/tituli</code>                                                               |
| Run          | <a href="https://github.com/reeleehq/tituli/actions/runs/38060863621">38060863621</a>      |
| Ref          | <code>refs/heads/main</code>                                                               |
| Event commit | <code>6035cd7c2949659fba8fbf36b91aceb0a54c3233</code> (in the history of the built commit) |

## Tools

|          |         |
|----------|---------|
| epythet  | 0.2.12  |
| Sphinx   | 9.1.0   |
| docutils | 0.22.4  |
| Python   | 3.12.15 |

## Configuration as resolved

|               |                                                                   |
|---------------|-------------------------------------------------------------------|
| theme         | <code>auto</code> (Sphinx theme <code>sphinxawesome_theme</code>) |
| accent        | <code>#654394</code>                                              |
| api_generator | <code>autosummary</code>                                          |
| ignore        | <code>tests/</code>, <code>scrap/</code>, <code>examples/</code>  |
| agent_outputs | <code>true</code>                                                 |
| aggregates    | <code>md</code>                                                   |
| ai_artifacts  | <code>true</code>                                                 |

## Package on PyPI

Latest release: <a href="https://pypi.org/project/tituli/0.0.9/">0.0.9</a>, the same as the documented version.

## Reproduce

```bash
git clone https://github.com/reeleehq/tituli && cd tituli
git checkout 23c59af40473fd8928fdad032cc2c0622f68a951
pip install "epythet==0.2.12"
epythet quickstart . --ignore tests/ scrap/ examples/
```

The same data, for machines: <a href="build_info.json"><code>build_info.json</code></a> (schema version 1).


# ai-agents.html.md

<!-- generated by epythet -->

# For AI agents

`tituli` ships artifacts for coding agents alongside its code. This page lists
them, says where each lives in the repository, and points at the
machine-readable copies of this documentation.

## Skills

Skills are folders holding a `SKILL.md` (the [Agent Skills](https://agentskills.io) format): a description that tells an agent when to use it and a body with the procedure. Install one into your agent with `gh skill` (any host: `--agent claude-code`, `copilot`, `cursor`, `codex`, `gemini`), or use the copy bundled in the wheel.

### `tituli`

Put tasteful text on video or stills with the `tituli` Python package — title cards, end credits from structured data, captions with a tiny source attribution that stay off the subject, lower thirds, editorial context notes, and calligrams / concrete poems (text along a circle, wave, SVG path, or falling like Apollinaire’s “Il pleut”). Use when asked to “add credits”, “add a title card”, “caption this image”, “label these stills”, “put a name and role on screen”, “add context cards”, “overlay text on this video”, “make a calligram”, “text on a path”, or when a film needs on-screen text that looks designed rather than debug-dumped. Owns placement (title-safe, subject-avoiding, YouTube subtitle band reserved), ink/scrim by contrast, wrapping and shrink-to-fit, scheduling of captions against heavier cards, and ffmpeg compositing. Not for SRT subtitles (use `mixing`).

```bash
gh skill install thorwhalen/tituli tituli --agent claude-code
```

Source: [`tituli/data/skills/tituli`](https://github.com/thorwhalen/tituli/tree/HEAD/tituli/data/skills/tituli) (bundled with the pip package).

The bundled skills are also on disk after `pip install tituli`, under the package’s `data/skills/` directory; link them into an agent without network access with `skill link-skills <that directory>`.

## Instruction files

Files agents read before working in this repository.

- [`.claude/CLAUDE.md`](https://github.com/thorwhalen/tituli/tree/HEAD/.claude/CLAUDE.md): read by Claude Code

## Machine-readable documentation

This site publishes the same documentation in forms that fit an agent’s context window:

- [`llms.txt`](https://thorwhalen.github.io/tituli/llms.txt): an index of every page with a one-line description ([llms.txt](https://llmstxt.org) format)
- [`tituli.md`](https://thorwhalen.github.io/tituli/tituli.md): the whole documentation as one Markdown file
- `<page>.html.md`: a rendered Markdown twin of every page, advertised from each page’s `<head>` with `<link rel="alternate" type="text/markdown">`
- [`objects.inv`](https://thorwhalen.github.io/tituli/objects.inv): the Sphinx inventory: a symbol-to-URL index (`sphobjinv convert plain objects.inv -`)


# api.html.md

# API reference

| [`tituli`](_autosummary/tituli.html.md#module-tituli)   | tituli — text in video: title cards, credits, captions and calligrams.   |
|-------------------------------------------------------------------------|--------------------------------------------------------------------------|


