tituli 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tituli/__init__.py +127 -0
- tituli/__main__.py +8 -0
- tituli/bodies.py +119 -0
- tituli/calligram.py +164 -0
- tituli/color.py +158 -0
- tituli/compose.py +485 -0
- tituli/credits.py +323 -0
- tituli/fonts.py +309 -0
- tituli/frame.py +369 -0
- tituli/geometry.py +475 -0
- tituli/layout.py +623 -0
- tituli/render.py +263 -0
- tituli/schedule.py +227 -0
- tituli/shaping.py +134 -0
- tituli/style.py +129 -0
- tituli/tools.py +379 -0
- tituli/video.py +478 -0
- tituli-0.0.2.dist-info/METADATA +162 -0
- tituli-0.0.2.dist-info/RECORD +21 -0
- tituli-0.0.2.dist-info/WHEEL +4 -0
- tituli-0.0.2.dist-info/licenses/LICENSE +21 -0
tituli/__init__.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""tituli — text in video: title cards, credits, captions and calligrams.
|
|
2
|
+
|
|
3
|
+
One model underneath: a :class:`Layout` of placed :class:`Run`s on a
|
|
4
|
+
:class:`Frame` that knows as much (or as little) about the picture as you tell
|
|
5
|
+
it. Everything else is a convenience over that.
|
|
6
|
+
|
|
7
|
+
Quick start::
|
|
8
|
+
|
|
9
|
+
from tituli import Frame, title_card, caption, credits_crawl, on_path, render
|
|
10
|
+
|
|
11
|
+
frame = Frame.blank((1920, 1080), color="#101014")
|
|
12
|
+
render(title_card("Il pleut", "Apollinaire, 1918", frame=frame), frame).save("title.png")
|
|
13
|
+
|
|
14
|
+
frame = Frame.from_image("still.jpg", delivery="youtube") # add avoid=burns.salient_box
|
|
15
|
+
render(caption("Eliza Hamilton", "Ralph Earl, 1787 · public domain", frame=frame), frame).save("cap.png")
|
|
16
|
+
|
|
17
|
+
Optional layers, none imported here: ``tituli[shaping]`` (HarfBuzz engine),
|
|
18
|
+
``tituli[saliency]`` (``burns`` subject avoidance), ``tituli[lacing]`` (the
|
|
19
|
+
text-overlay body schema), ``tituli[cli]`` (``python -m tituli``).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from tituli.calligram import in_shape, on_path, rain, resolve_shape
|
|
23
|
+
from tituli.color import contrast_ratio, ink_for, parse_color
|
|
24
|
+
from tituli.compose import (
|
|
25
|
+
caption,
|
|
26
|
+
decide_ink,
|
|
27
|
+
intertitle,
|
|
28
|
+
lower_third,
|
|
29
|
+
note,
|
|
30
|
+
title_card,
|
|
31
|
+
truncate,
|
|
32
|
+
)
|
|
33
|
+
from tituli.credits import (
|
|
34
|
+
Credits,
|
|
35
|
+
CreditsStyle,
|
|
36
|
+
Entry,
|
|
37
|
+
Section,
|
|
38
|
+
credits_cards,
|
|
39
|
+
credits_crawl,
|
|
40
|
+
credits_frame,
|
|
41
|
+
)
|
|
42
|
+
from tituli.fonts import Face, families, find_font, resolve_face
|
|
43
|
+
from tituli.frame import DELIVERY_RESERVED, Frame, cover_fit, reserved_zones
|
|
44
|
+
from tituli.geometry import ANCHORS, TITLE_SAFE, Box, Path, safe_area
|
|
45
|
+
from tituli.layout import Layout, Plate, Run, along_path, block, fit_size, measure, wrap
|
|
46
|
+
from tituli.render import frames, make_engine, render, render_overlay
|
|
47
|
+
from tituli.schedule import (
|
|
48
|
+
UNLABELLED,
|
|
49
|
+
Label,
|
|
50
|
+
Span,
|
|
51
|
+
TimedOverlay,
|
|
52
|
+
resolve,
|
|
53
|
+
schedule_labels,
|
|
54
|
+
)
|
|
55
|
+
from tituli.style import (
|
|
56
|
+
ATTRIBUTION,
|
|
57
|
+
CALLIGRAM,
|
|
58
|
+
CAPTION,
|
|
59
|
+
CREDITS_NAME,
|
|
60
|
+
KICKER,
|
|
61
|
+
SUBTITLE,
|
|
62
|
+
TITLE,
|
|
63
|
+
TextStyle,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"Frame",
|
|
68
|
+
"Layout",
|
|
69
|
+
"Run",
|
|
70
|
+
"Plate",
|
|
71
|
+
"Box",
|
|
72
|
+
"Path",
|
|
73
|
+
"TextStyle",
|
|
74
|
+
"Face",
|
|
75
|
+
"title_card",
|
|
76
|
+
"caption",
|
|
77
|
+
"lower_third",
|
|
78
|
+
"note",
|
|
79
|
+
"intertitle",
|
|
80
|
+
"truncate",
|
|
81
|
+
"decide_ink",
|
|
82
|
+
"Credits",
|
|
83
|
+
"CreditsStyle",
|
|
84
|
+
"Entry",
|
|
85
|
+
"Section",
|
|
86
|
+
"credits_cards",
|
|
87
|
+
"credits_crawl",
|
|
88
|
+
"credits_frame",
|
|
89
|
+
"on_path",
|
|
90
|
+
"rain",
|
|
91
|
+
"in_shape",
|
|
92
|
+
"resolve_shape",
|
|
93
|
+
"block",
|
|
94
|
+
"along_path",
|
|
95
|
+
"wrap",
|
|
96
|
+
"measure",
|
|
97
|
+
"fit_size",
|
|
98
|
+
"render",
|
|
99
|
+
"render_overlay",
|
|
100
|
+
"frames",
|
|
101
|
+
"make_engine",
|
|
102
|
+
"Label",
|
|
103
|
+
"Span",
|
|
104
|
+
"TimedOverlay",
|
|
105
|
+
"UNLABELLED",
|
|
106
|
+
"schedule_labels",
|
|
107
|
+
"resolve",
|
|
108
|
+
"families",
|
|
109
|
+
"find_font",
|
|
110
|
+
"resolve_face",
|
|
111
|
+
"contrast_ratio",
|
|
112
|
+
"ink_for",
|
|
113
|
+
"parse_color",
|
|
114
|
+
"safe_area",
|
|
115
|
+
"ANCHORS",
|
|
116
|
+
"TITLE_SAFE",
|
|
117
|
+
"DELIVERY_RESERVED",
|
|
118
|
+
"reserved_zones",
|
|
119
|
+
"cover_fit",
|
|
120
|
+
"TITLE",
|
|
121
|
+
"SUBTITLE",
|
|
122
|
+
"KICKER",
|
|
123
|
+
"CAPTION",
|
|
124
|
+
"ATTRIBUTION",
|
|
125
|
+
"CREDITS_NAME",
|
|
126
|
+
"CALLIGRAM",
|
|
127
|
+
]
|
tituli/__main__.py
ADDED
tituli/bodies.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""A lacing body schema for a rendered caption (``pip install tituli[lacing]``).
|
|
2
|
+
|
|
3
|
+
URI: ``annot://schema/text-overlay/v1``. A caption is an annotation **on the
|
|
4
|
+
image** (``MediaRef(asset_id=<image hash>)``), and when that image sits under an
|
|
5
|
+
audio segment the pair is expressed the way ``artful.PanelBody`` does it: the
|
|
6
|
+
annotation's ``reference`` is the interval on the segment and
|
|
7
|
+
``provenance.was_derived_from`` lists both the image ``asset_id`` and the
|
|
8
|
+
caption annotation id. No N-ary reference type is invented; the timing lives
|
|
9
|
+
on the reference, not in this body.
|
|
10
|
+
|
|
11
|
+
Registration is lazy and idempotent (:func:`register`), so ``import tituli``
|
|
12
|
+
never touches lacing.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
TEXT_OVERLAY_V1 = "annot://schema/text-overlay/v1"
|
|
20
|
+
_REGISTERED = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _model():
|
|
24
|
+
from pydantic import BaseModel, Field
|
|
25
|
+
|
|
26
|
+
class TextOverlayBodyV1(BaseModel):
|
|
27
|
+
"""What was written over the picture, and how. Placement is normalised."""
|
|
28
|
+
|
|
29
|
+
model_config = {"frozen": True, "extra": "forbid"}
|
|
30
|
+
|
|
31
|
+
text: str = Field(
|
|
32
|
+
..., description="The caption as displayed (after wrapping/truncation)."
|
|
33
|
+
)
|
|
34
|
+
attribution: str = Field(
|
|
35
|
+
"", description="The small credit line under the caption, if any."
|
|
36
|
+
)
|
|
37
|
+
kind: str = Field(
|
|
38
|
+
"caption",
|
|
39
|
+
description="Free string: 'caption', 'lower_third', 'title', 'intertitle', ...",
|
|
40
|
+
)
|
|
41
|
+
anchor: str = Field(
|
|
42
|
+
"", description="Grid position the block was placed at ('top-left', ...)."
|
|
43
|
+
)
|
|
44
|
+
box: tuple[float, float, float, float] | None = Field(
|
|
45
|
+
None, description="Normalised (x, y, w, h) of the text block in the frame."
|
|
46
|
+
)
|
|
47
|
+
ink: str | None = Field(None, description="Ink colour as #rrggbb, if recorded.")
|
|
48
|
+
scrim: bool | None = Field(
|
|
49
|
+
None, description="Whether a scrim was drawn under the text."
|
|
50
|
+
)
|
|
51
|
+
reason: str | None = Field(
|
|
52
|
+
None, description="The ink/scrim decision the frame's knowledge led to."
|
|
53
|
+
)
|
|
54
|
+
style: dict[str, Any] | None = Field(
|
|
55
|
+
None, description="The TextStyle fields used, for re-rendering."
|
|
56
|
+
)
|
|
57
|
+
unlabelled: bool = Field(
|
|
58
|
+
False,
|
|
59
|
+
description="True when the still was deliberately shown without a label (an explicit choice, not an omission).",
|
|
60
|
+
)
|
|
61
|
+
overlay_asset_id: str | None = Field(
|
|
62
|
+
None, description="asset_id of the rendered transparent PNG, if stored."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return TextOverlayBodyV1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def register() -> str:
|
|
69
|
+
"""Register the schema with lacing (once). Returns the URI."""
|
|
70
|
+
global _REGISTERED
|
|
71
|
+
if _REGISTERED:
|
|
72
|
+
return TEXT_OVERLAY_V1
|
|
73
|
+
from lacing import register_body_schema
|
|
74
|
+
|
|
75
|
+
register_body_schema(TEXT_OVERLAY_V1, _model())
|
|
76
|
+
_REGISTERED = True
|
|
77
|
+
return TEXT_OVERLAY_V1
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def body_for(
|
|
81
|
+
layout,
|
|
82
|
+
frame,
|
|
83
|
+
*,
|
|
84
|
+
text: str,
|
|
85
|
+
attribution: str = "",
|
|
86
|
+
kind: str = "caption",
|
|
87
|
+
unlabelled: bool = False,
|
|
88
|
+
) -> dict:
|
|
89
|
+
"""The body dict for a rendered layout — plain data, no lacing needed.
|
|
90
|
+
|
|
91
|
+
>>> from tituli.frame import Frame
|
|
92
|
+
>>> from tituli.compose import caption
|
|
93
|
+
>>> f = Frame.blank((1920, 1080))
|
|
94
|
+
>>> b = body_for(caption("A still", "PD", frame=f), f, text="A still", attribution="PD")
|
|
95
|
+
>>> b["kind"], b["anchor"], len(b["box"])
|
|
96
|
+
('caption', 'bottom-left', 4)
|
|
97
|
+
"""
|
|
98
|
+
bb = layout.bbox()
|
|
99
|
+
ink = None
|
|
100
|
+
if layout.runs:
|
|
101
|
+
r, g, b, _ = layout.runs[0].color
|
|
102
|
+
ink = f"#{r:02x}{g:02x}{b:02x}"
|
|
103
|
+
return {
|
|
104
|
+
"text": text,
|
|
105
|
+
"attribution": attribution,
|
|
106
|
+
"kind": kind,
|
|
107
|
+
"anchor": str(layout.meta.get("anchor", "")),
|
|
108
|
+
"box": tuple(round(v, 4) for v in bb.to_norm(frame.width, frame.height)),
|
|
109
|
+
"ink": ink,
|
|
110
|
+
"scrim": any(p.kind != "box" or p.color[3] < 255 for p in layout.plates)
|
|
111
|
+
or None,
|
|
112
|
+
"reason": layout.meta.get("ink"),
|
|
113
|
+
"style": None,
|
|
114
|
+
"unlabelled": unlabelled,
|
|
115
|
+
"overlay_asset_id": None,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
__all__ = ["TEXT_OVERLAY_V1", "register", "body_for"]
|
tituli/calligram.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Calligrams and concrete poems: text whose shape is part of the meaning.
|
|
2
|
+
|
|
3
|
+
Three treatments, all producing an ordinary :class:`~tituli.layout.Layout`:
|
|
4
|
+
|
|
5
|
+
* :func:`on_path` — glyphs ride a :class:`~tituli.geometry.Path` (any shape:
|
|
6
|
+
circle, wave, Bézier, SVG ``d``), rotated to the tangent or kept upright;
|
|
7
|
+
* :func:`rain` — Apollinaire's *Il pleut*: upright letters stepping down
|
|
8
|
+
fanning streaks (a preset of the same machinery, with the 1918 measurements
|
|
9
|
+
as defaults);
|
|
10
|
+
* :func:`in_shape` — prose poured into a silhouette, the concrete-page case.
|
|
11
|
+
|
|
12
|
+
``shape=`` accepts a named preset, an SVG path string, or a
|
|
13
|
+
:class:`~tituli.geometry.Path`, so a caller can say ``"circle"`` and a
|
|
14
|
+
designer can hand over a traced outline — the same seam.
|
|
15
|
+
|
|
16
|
+
>>> from tituli.frame import Frame
|
|
17
|
+
>>> lay = on_path("round and round", shape="circle", frame=Frame.blank((800, 800)))
|
|
18
|
+
>>> len(lay.runs) == len("round and round".replace(" ", ""))
|
|
19
|
+
True
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Literal, Sequence
|
|
25
|
+
|
|
26
|
+
from tituli.frame import Frame
|
|
27
|
+
from tituli.geometry import Box, Path
|
|
28
|
+
from tituli.layout import Layout, along_path, fill_shape, glyph_columns
|
|
29
|
+
from tituli.style import CALLIGRAM, TextStyle
|
|
30
|
+
|
|
31
|
+
# Measured from the 1918 Mercure de France setting of "Il pleut".
|
|
32
|
+
IL_PLEUT_SLANTS: tuple[float, ...] = (0.186, 0.220, 0.257, 0.298, 0.353)
|
|
33
|
+
IL_PLEUT_HEAD_OFFSETS: tuple[float, ...] = (0.0, 7.0, 11.7, 16.4, 19.3)
|
|
34
|
+
IL_PLEUT_SIZE_RATIO = 0.86
|
|
35
|
+
|
|
36
|
+
_SHAPE_INSET = 0.12 # fraction of the safe box kept clear around a shape
|
|
37
|
+
_WAVE_AMPLITUDE = 0.12 # of the box height
|
|
38
|
+
_ARC_SWEEP = (200.0, 340.0) # a gentle smile-shaped arc, degrees (screen coords)
|
|
39
|
+
|
|
40
|
+
ShapeSpec = "str | Path"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_shape(shape: "str | Path", box: Box) -> Path:
|
|
44
|
+
"""Turn a name, an SVG ``d`` string or a Path into a Path fitted to ``box``.
|
|
45
|
+
|
|
46
|
+
Names: ``"line"``, ``"circle"``, ``"arc"``, ``"wave"``, ``"diagonal"``,
|
|
47
|
+
``"s-curve"``. Anything starting with ``M``/``m`` is parsed as SVG.
|
|
48
|
+
"""
|
|
49
|
+
if isinstance(shape, Path):
|
|
50
|
+
return shape.fit(box)
|
|
51
|
+
name = shape.strip()
|
|
52
|
+
if name[:1] in "Mm" and any(c.isdigit() for c in name):
|
|
53
|
+
return Path.from_svg(name).fit(box)
|
|
54
|
+
cx, cy = box.center
|
|
55
|
+
r = min(box.width, box.height) / 2
|
|
56
|
+
if name == "line":
|
|
57
|
+
return Path.line((box.x0, cy), (box.x1, cy))
|
|
58
|
+
if name == "circle":
|
|
59
|
+
return Path.circle((cx, cy), r)
|
|
60
|
+
if name == "arc":
|
|
61
|
+
return Path.arc((cx, cy + r * 0.6), r * 1.1, *_ARC_SWEEP).fit(box)
|
|
62
|
+
if name == "wave":
|
|
63
|
+
return Path.wave(
|
|
64
|
+
(box.x0, cy),
|
|
65
|
+
(box.x1, cy),
|
|
66
|
+
amplitude=box.height * _WAVE_AMPLITUDE,
|
|
67
|
+
cycles=1.0,
|
|
68
|
+
)
|
|
69
|
+
if name == "diagonal":
|
|
70
|
+
return Path.line((box.x0, box.y0), (box.x1, box.y1))
|
|
71
|
+
if name == "s-curve":
|
|
72
|
+
return Path.bezier(
|
|
73
|
+
(box.x0, box.y1),
|
|
74
|
+
(box.x0 + box.width * 0.9, box.y0 + box.height * 0.9),
|
|
75
|
+
(box.x1 - box.width * 0.9, box.y0 + box.height * 0.1),
|
|
76
|
+
(box.x1, box.y0),
|
|
77
|
+
)
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"unknown shape {shape!r}; use a preset name, an SVG path string, or a Path"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def on_path(
|
|
84
|
+
text: str,
|
|
85
|
+
*,
|
|
86
|
+
shape: "str | Path" = "wave",
|
|
87
|
+
frame: Frame,
|
|
88
|
+
style: TextStyle = CALLIGRAM,
|
|
89
|
+
box: Box | None = None,
|
|
90
|
+
upright: bool = False,
|
|
91
|
+
align: Literal["start", "center", "end"] = "center",
|
|
92
|
+
fit_text: bool = True,
|
|
93
|
+
) -> Layout:
|
|
94
|
+
"""Set ``text`` along a shape inside ``box`` (default: the safe area, inset).
|
|
95
|
+
|
|
96
|
+
With ``fit_text`` the type is shrunk until the whole string fits the path
|
|
97
|
+
length (never clipped); the resulting size is in ``meta["size"]``.
|
|
98
|
+
"""
|
|
99
|
+
box = box or frame.safe.inset(
|
|
100
|
+
frame.safe.width * _SHAPE_INSET, frame.safe.height * _SHAPE_INSET
|
|
101
|
+
)
|
|
102
|
+
path = resolve_shape(shape, box)
|
|
103
|
+
st = style
|
|
104
|
+
lay = along_path(text, path, st, frame, upright=upright, align=align)
|
|
105
|
+
while fit_text and lay.meta.get("overflow", 0) > 0 and st.size > 0.008:
|
|
106
|
+
st = st.with_(size=st.size * 0.92)
|
|
107
|
+
lay = along_path(text, path, st, frame, upright=upright, align=align)
|
|
108
|
+
return Layout(
|
|
109
|
+
lay.runs, lay.plates, {**lay.meta, "size": st.size, "path_length": path.length}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def rain(
|
|
114
|
+
lines: Sequence[str] | str,
|
|
115
|
+
*,
|
|
116
|
+
frame: Frame,
|
|
117
|
+
style: TextStyle = CALLIGRAM,
|
|
118
|
+
box: Box | None = None,
|
|
119
|
+
slants: Sequence[float] = IL_PLEUT_SLANTS,
|
|
120
|
+
head_offsets: Sequence[float] = IL_PLEUT_HEAD_OFFSETS,
|
|
121
|
+
size_ratio: float = IL_PLEUT_SIZE_RATIO,
|
|
122
|
+
) -> Layout:
|
|
123
|
+
"""*Il pleut*: each line a streak of upright letters falling across the frame.
|
|
124
|
+
|
|
125
|
+
Wants a portrait frame. ``slants`` (dx per step down) and ``head_offsets``
|
|
126
|
+
(where each streak starts, in slot units) are shape parameters, not
|
|
127
|
+
coordinates — the defaults are Apollinaire's.
|
|
128
|
+
"""
|
|
129
|
+
if isinstance(lines, str):
|
|
130
|
+
lines = [l for l in lines.split("\n") if l.strip()]
|
|
131
|
+
box = box or frame.safe
|
|
132
|
+
return glyph_columns(
|
|
133
|
+
list(lines),
|
|
134
|
+
style,
|
|
135
|
+
frame,
|
|
136
|
+
box=box,
|
|
137
|
+
slants=slants,
|
|
138
|
+
head_offsets=head_offsets,
|
|
139
|
+
size_ratio=size_ratio,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def in_shape(
|
|
144
|
+
text: str,
|
|
145
|
+
mask,
|
|
146
|
+
*,
|
|
147
|
+
frame: Frame,
|
|
148
|
+
style: TextStyle = CALLIGRAM,
|
|
149
|
+
box: Box | None = None,
|
|
150
|
+
repeat: bool = True,
|
|
151
|
+
) -> Layout:
|
|
152
|
+
"""Pour prose into a silhouette (light = inside). See :func:`tituli.layout.fill_shape`."""
|
|
153
|
+
box = box or frame.safe
|
|
154
|
+
return fill_shape(text, mask, style, frame, box=box, repeat=repeat)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
__all__ = [
|
|
158
|
+
"on_path",
|
|
159
|
+
"rain",
|
|
160
|
+
"in_shape",
|
|
161
|
+
"resolve_shape",
|
|
162
|
+
"IL_PLEUT_SLANTS",
|
|
163
|
+
"IL_PLEUT_HEAD_OFFSETS",
|
|
164
|
+
]
|
tituli/color.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Colours, WCAG contrast, and the ink-for-this-background decision.
|
|
2
|
+
|
|
3
|
+
Everything here is pure arithmetic on ``(r, g, b[, a])`` tuples in 0–255, so it
|
|
4
|
+
costs nothing to import and is easy to test.
|
|
5
|
+
|
|
6
|
+
>>> contrast_ratio((255, 255, 255), (0, 0, 0))
|
|
7
|
+
21.0
|
|
8
|
+
>>> ink_for(luminance=0.9) # light background -> dark ink
|
|
9
|
+
(17, 17, 17)
|
|
10
|
+
>>> ink_for(luminance=0.1) # dark background -> light ink
|
|
11
|
+
(255, 255, 255)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Sequence, Union
|
|
17
|
+
|
|
18
|
+
RGB = tuple[int, int, int]
|
|
19
|
+
RGBA = tuple[int, int, int, int]
|
|
20
|
+
Color = Union[RGB, RGBA, str]
|
|
21
|
+
|
|
22
|
+
WHITE: RGB = (255, 255, 255)
|
|
23
|
+
BLACK: RGB = (0, 0, 0)
|
|
24
|
+
NEAR_BLACK: RGB = (17, 17, 17) # #111 — reads as black without the harshness
|
|
25
|
+
OFF_WHITE: RGB = (245, 245, 243) # a warm paper white for solid title cards
|
|
26
|
+
CREDITS_BLACK: RGB = (8, 8, 10)
|
|
27
|
+
|
|
28
|
+
# WCAG 2.x thresholds (AA): 4.5:1 for normal text, 3:1 for large text
|
|
29
|
+
WCAG_NORMAL_MIN = 4.5
|
|
30
|
+
WCAG_LARGE_MIN = 3.0
|
|
31
|
+
# "Large" text per WCAG is >= 18pt regular or >= 14pt bold; on a 1080p canvas
|
|
32
|
+
# every tituli default is well above that, so 3:1 is the operative floor and
|
|
33
|
+
# 4.5:1 is what we *aim* for.
|
|
34
|
+
|
|
35
|
+
_SRGB_THRESHOLD = 0.03928
|
|
36
|
+
_SRGB_LINEAR_DIVISOR = 12.92
|
|
37
|
+
_SRGB_GAMMA = 2.4
|
|
38
|
+
_SRGB_OFFSET = 0.055
|
|
39
|
+
_SRGB_SCALE = 1.055
|
|
40
|
+
_LUMA_R, _LUMA_G, _LUMA_B = 0.2126, 0.7152, 0.0722
|
|
41
|
+
_CONTRAST_OFFSET = 0.05
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_color(color: Color) -> RGBA:
|
|
45
|
+
"""Accept ``"#rgb"``, ``"#rrggbb"``, ``"#rrggbbaa"``, a Pillow name or a tuple.
|
|
46
|
+
|
|
47
|
+
>>> parse_color("#fff")
|
|
48
|
+
(255, 255, 255, 255)
|
|
49
|
+
>>> parse_color((10, 20, 30))
|
|
50
|
+
(10, 20, 30, 255)
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(color, str):
|
|
53
|
+
s = color.strip()
|
|
54
|
+
if s.startswith("#"):
|
|
55
|
+
h = s[1:]
|
|
56
|
+
if len(h) in (3, 4):
|
|
57
|
+
h = "".join(c * 2 for c in h)
|
|
58
|
+
if len(h) == 6:
|
|
59
|
+
h += "ff"
|
|
60
|
+
if len(h) != 8:
|
|
61
|
+
raise ValueError(f"bad hex colour: {color!r}")
|
|
62
|
+
return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4, 6)) # type: ignore[return-value]
|
|
63
|
+
from PIL import ImageColor
|
|
64
|
+
|
|
65
|
+
return ImageColor.getcolor(s, "RGBA") # type: ignore[return-value]
|
|
66
|
+
t = tuple(int(c) for c in color)
|
|
67
|
+
if len(t) == 3:
|
|
68
|
+
return (*t, 255) # type: ignore[return-value]
|
|
69
|
+
if len(t) == 4:
|
|
70
|
+
return t # type: ignore[return-value]
|
|
71
|
+
raise ValueError(f"bad colour tuple: {color!r}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _channel(c: int) -> float:
|
|
75
|
+
v = c / 255.0
|
|
76
|
+
if v <= _SRGB_THRESHOLD:
|
|
77
|
+
return v / _SRGB_LINEAR_DIVISOR
|
|
78
|
+
return ((v + _SRGB_OFFSET) / _SRGB_SCALE) ** _SRGB_GAMMA
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def relative_luminance(color: Color) -> float:
|
|
82
|
+
"""WCAG relative luminance in 0..1.
|
|
83
|
+
|
|
84
|
+
>>> round(relative_luminance((255, 255, 255)), 3)
|
|
85
|
+
1.0
|
|
86
|
+
>>> relative_luminance((0, 0, 0))
|
|
87
|
+
0.0
|
|
88
|
+
"""
|
|
89
|
+
r, g, b, _ = parse_color(color)
|
|
90
|
+
return _LUMA_R * _channel(r) + _LUMA_G * _channel(g) + _LUMA_B * _channel(b)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def contrast_ratio(a: Color, b: Color) -> float:
|
|
94
|
+
"""WCAG contrast ratio between two colours (1..21).
|
|
95
|
+
|
|
96
|
+
>>> round(contrast_ratio("#777", "#fff"), 2)
|
|
97
|
+
4.48
|
|
98
|
+
"""
|
|
99
|
+
la, lb = relative_luminance(a), relative_luminance(b)
|
|
100
|
+
hi, lo = max(la, lb), min(la, lb)
|
|
101
|
+
return round((hi + _CONTRAST_OFFSET) / (lo + _CONTRAST_OFFSET), 4)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def contrast_from_luminance(ink: Color, luminance: float) -> float:
|
|
105
|
+
"""Contrast of ``ink`` over a background of known relative luminance."""
|
|
106
|
+
li = relative_luminance(ink)
|
|
107
|
+
hi, lo = max(li, luminance), min(li, luminance)
|
|
108
|
+
return (hi + _CONTRAST_OFFSET) / (lo + _CONTRAST_OFFSET)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def ink_for(
|
|
112
|
+
*,
|
|
113
|
+
luminance: float,
|
|
114
|
+
light: RGB = WHITE,
|
|
115
|
+
dark: RGB = NEAR_BLACK,
|
|
116
|
+
) -> RGB:
|
|
117
|
+
"""Pick the ink (light or dark) with more contrast against ``luminance``."""
|
|
118
|
+
if contrast_from_luminance(light, luminance) >= contrast_from_luminance(
|
|
119
|
+
dark, luminance
|
|
120
|
+
):
|
|
121
|
+
return light
|
|
122
|
+
return dark
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def needs_scrim(
|
|
126
|
+
ink: Color, luminance: float, *, minimum: float = WCAG_NORMAL_MIN
|
|
127
|
+
) -> bool:
|
|
128
|
+
"""Whether ``ink`` over that background falls short of ``minimum`` contrast.
|
|
129
|
+
|
|
130
|
+
>>> needs_scrim((255, 255, 255), luminance=0.5)
|
|
131
|
+
True
|
|
132
|
+
>>> needs_scrim((255, 255, 255), luminance=0.02)
|
|
133
|
+
False
|
|
134
|
+
"""
|
|
135
|
+
return contrast_from_luminance(ink, luminance) < minimum
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def mix(a: Color, b: Color, t: float) -> RGBA:
|
|
139
|
+
"""Linear blend, ``t=0`` -> ``a``, ``t=1`` -> ``b``."""
|
|
140
|
+
pa, pb = parse_color(a), parse_color(b)
|
|
141
|
+
return tuple(round(x + (y - x) * t) for x, y in zip(pa, pb)) # type: ignore[return-value]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def with_alpha(color: Color, alpha: float) -> RGBA:
|
|
145
|
+
"""Same colour with opacity ``alpha`` in 0..1.
|
|
146
|
+
|
|
147
|
+
>>> with_alpha("#000", 0.5)
|
|
148
|
+
(0, 0, 0, 128)
|
|
149
|
+
"""
|
|
150
|
+
r, g, b, _ = parse_color(color)
|
|
151
|
+
return (r, g, b, round(255 * alpha))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def mean_luminance(pixels: Sequence[RGB]) -> float:
|
|
155
|
+
"""Mean relative luminance of a pixel sample."""
|
|
156
|
+
if not pixels:
|
|
157
|
+
return 0.0
|
|
158
|
+
return sum(relative_luminance(p) for p in pixels) / len(pixels)
|