Files

902 lines
35 KiB
Python

"""Text measurement and block layout for kinetic-typography subtitles.
Lays a sentence's words out as a compact block — words packed onto lines,
lines stacked and centered — so each word can be emitted as its own positioned
``<title>`` without ever overlapping a sibling.
CALIBRATION
-----------
Every constant here is derived from a real Final Cut export the user built by
hand and sent back ("Exemplo Letra.fcpxmld", project 2160x3840, template
"Essencial - Título"). The five hand-placed words of its first sentence:
word position fontSize kerning
Toda -251.828 -109 170 2.72
a 2.236 -101.135 128 —
minha 267.319 -95 151 2.416
vida, 71.0395 -233.65 128 2.048
assim, 210.88 -100 128 2.048
Three facts fall out of those numbers, and they are why this module can place
words at all:
1. **Position uses the same unit as fontSize.** Center-to-center distances on
line 1 are 254.06 (Toda→a) and 265.08 (a→minha). Half-width sums from the
Helvetica metrics below at those sizes give 245.1 and 265.0 — matching to
within a few units, which is the slop of dragging by hand. A different unit
would have shown up as a constant ratio; there is none.
2. **The canvas is 1080x1920 points** — half of the 2160x3840 frame, because
Final Cut positions in points over 2x media. Line 1 spans -460.3 to +495.7,
which fills that width with small side margins, exactly as the reference
frame looks.
3. **y grows upward.** "vida," (line 2) sits at -233.65 against line 1's ~-101.
Widths are still ESTIMATES — Final Cut renders the real glyphs — so they are
computed generously. Overestimating costs a little empty space; underestimating
makes two words collide, which is the one failure this module exists to
prevent.
"""
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Sequence
from .font_metrics import METRICS, VERTICAL_METRICS
# Fallback advance widths (standard Helvetica AFM), used only for a font the
# embedded metrics do not cover. Real per-variant metrics live in
# font_metrics.METRICS and are preferred — see measure_text.
_HELVETICA_WIDTHS = {
' ': 0.278, '!': 0.278, '"': 0.355, '#': 0.556, '$': 0.556, '%': 0.889,
'&': 0.667, "'": 0.191, '(': 0.333, ')': 0.333, '*': 0.389, '+': 0.584,
',': 0.278, '-': 0.333, '.': 0.278, '/': 0.278, ':': 0.278, ';': 0.278,
'<': 0.584, '=': 0.584, '>': 0.584, '?': 0.556, '@': 1.015, '[': 0.278,
'\\': 0.278, ']': 0.278, '^': 0.469, '_': 0.556, '`': 0.333, '{': 0.334,
'|': 0.260, '}': 0.334, '~': 0.584,
'A': 0.667, 'B': 0.667, 'C': 0.722, 'D': 0.722, 'E': 0.667, 'F': 0.611,
'G': 0.778, 'H': 0.722, 'I': 0.278, 'J': 0.500, 'K': 0.667, 'L': 0.556,
'M': 0.833, 'N': 0.722, 'O': 0.778, 'P': 0.667, 'Q': 0.778, 'R': 0.722,
'S': 0.667, 'T': 0.611, 'U': 0.722, 'V': 0.667, 'W': 0.944, 'X': 0.667,
'Y': 0.667, 'Z': 0.611,
'a': 0.556, 'b': 0.556, 'c': 0.500, 'd': 0.556, 'e': 0.556, 'f': 0.278,
'g': 0.556, 'h': 0.556, 'i': 0.222, 'j': 0.222, 'k': 0.500, 'l': 0.222,
'm': 0.833, 'n': 0.556, 'o': 0.556, 'p': 0.556, 'q': 0.556, 'r': 0.333,
's': 0.500, 't': 0.278, 'u': 0.556, 'v': 0.500, 'w': 0.722, 'x': 0.500,
'y': 0.500, 'z': 0.500,
}
_FALLBACK_WIDTH = 0.556
# Accented Portuguese letters advance like their base letter.
_ACCENT_BASE = str.maketrans(
'áàâãäéèêëíìîïóòôõöúùûüçñÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇÑ',
'aaaaaeeeeiiiiooooouuuucnAAAAAEEEEIIIIOOOOOUUUUCN',
)
# Bold thickens every stem. Only applied on the fallback path — the embedded
# metrics already carry the bold variant's own advances.
_BOLD_FACTOR = 1.06
# Cushion over the computed advance. Small, because the embedded metrics are
# exact: it covers the renderer's own rounding and any glyph outside the table,
# nothing more.
_SAFETY_MARGIN = 1.02
# The frame is 2160x3840 but Final Cut positions in points over 2x media, so
# the coordinate canvas is half that. See the calibration note above.
POINT_SCALE = 0.5
# The canvas the reference sizes were chosen against: 3840px tall at 2x. Other
# formats scale off this, so a 170pt word keeps the same share of frame height
# (8.9%) on a landscape timeline as it has on the user's vertical one.
REFERENCE_CANVAS_HEIGHT = 3840.0 * POINT_SCALE
# Reference values read off the calibration export.
REFERENCE_FONT_SIZE_LARGE = 170
REFERENCE_FONT_SIZE_MEDIUM = 128
REFERENCE_FONT_SIZE_ALT = 151
REFERENCE_KERNING = 2.048
# Center of the hand-placed block: line 1 at y≈-101, line 2 at y≈-233.65.
REFERENCE_BLOCK_CENTER_Y = -167.0
# Gap between words on a line, as a fraction of the larger neighbour's font
# size. The reference export's own gaps work out to ~10-12 points at 170pt
# (0.06), but that leaves only a few points of slack once glyph-metric error is
# accounted for — one bad estimate and two words touch. This is deliberately
# roomier: still a tight typographic block, with margin that survives the
# estimate being off.
REFERENCE_WORD_GAP_RATIO = 0.14
# Text occupies roughly cap-height, not the full em box, so a line's visual
# height is well under its font size. The reference export puts line 1 (max
# 170pt) and line 2 (128pt) 132.65 apart; with this ratio their half-heights
# sum to 111.75, leaving the ~20pt of breathing room below. Using the full em
# box instead would space the lines ~40% further apart than the user did.
_CAP_HEIGHT_RATIO = 0.75
REFERENCE_LINE_GAP = 20.0
# Progressive composition sets its lines much tighter than the word-by-word
# block: in the reference the small grotesque lines almost touch the display
# italic between them. Small but never negative — overlapping boxes is the one
# failure this module exists to prevent.
REFERENCE_BLOCK_LINE_GAP = 8.0
# How far a body line slides toward its side of the emphasis line, as a
# fraction of the slack between the two widths. 1.0 would flush it against the
# emphasis line's edge; the reference leaves a little air.
REFERENCE_STAGGER_RATIO = 0.8
# Extra gap, as a fraction of the emphasis line's font size, added only to
# the boundary right below it. The display italic's slant leans its stems
# past the vertical ink box the metrics measure, so a body line directly
# under the emphasis line reads tighter than the same nominal gap anywhere
# else in the stack — this cushion (~14pt at the 230pt reference size)
# closes that optical gap without touching the user's `line_gap` elsewhere.
_EMPHASIS_ITALIC_CUSHION_RATIO = 0.06
# The numbers above were read off a hand export that used the "Essencial -
# Título" template. That template never rendered when we generated it (see
# Engine/docs/05_EXPERIENCIAS.md, 2026-08-17), so the writer switched to FCP's
# own "Basic Text > Text" (Text.moti) — whose coordinate space is the FRAME
# ITSELF (2160x3840), not the half-scale point canvas the numbers above were
# measured in. Everything the template reads is in that space: fontSize,
# kerning AND Position alike.
#
# Getting this half-right is worse than getting it wrong. Scaling only the type
# left the block at the old spread with twice the type in it, so the lines
# collided; scaling only the positions would spread a block of half-size type
# across the frame. The layout keeps measuring in canvas points — every
# constant above depends on that — and this single factor converts the whole
# result on the way out, which is the only way the two stay in step.
#
# Exposed as `text_scale` on DynamicSubtitleConfig for a template authored
# against a different space.
TEXT_TEMPLATE_FONT_SCALE = 2.0
def metrics_for(font: Optional[str], face: Optional[str] = None) -> Optional[Dict]:
"""The embedded advance table for *font*/*face*, or None if uncovered.
Falls back from the exact "family-face" key to the bare family, so an
unknown face still measures against the right family rather than a
generic table.
"""
if not font:
return None
family = font.strip().lower()
if face:
exact = METRICS.get(f"{family}-{face.strip().lower()}")
if exact:
return exact
return METRICS.get(family)
def char_width_ratio(ch: str, table: Optional[Dict] = None) -> float:
"""Return *ch*'s advance width as a fraction of the font size."""
if table is not None and ch in table:
return table[ch]
base = ch.translate(_ACCENT_BASE)
if table is not None and base in table:
return table[base]
return _HELVETICA_WIDTHS.get(base, _FALLBACK_WIDTH)
def measure_text(
text: str,
font_size: float,
*,
bold: bool = False,
kerning: float = REFERENCE_KERNING,
font: Optional[str] = None,
face: Optional[str] = None,
) -> float:
"""Width of *text* rendered at *font_size*, in canvas points.
Measured against the real advance widths of the macOS font when *font*
names one this module carries metrics for, which is the case for every
font the subtitle rhythm uses. Otherwise falls back to generic Helvetica
advances, which is an estimate.
``kerning`` is Final Cut's per-character tracking, in the same unit as
font size — the calibration export carries 2.048 to 2.72.
"""
if not text:
return 0.0
table = metrics_for(font, face)
width = sum(char_width_ratio(ch, table) for ch in text) * font_size
width += kerning * len(text)
if bold and table is None:
# The embedded tables already carry the bold variant's own advances;
# only the generic fallback needs a correction factor.
width *= _BOLD_FACTOR
return width * _SAFETY_MARGIN
# Glyph classes for the vertical ink extent of a line. A line's real top and
# bottom depend on WHICH characters it contains: "sua legenda" reaches the
# x-height and dips to the descender of its g; "que vão" adds the tilde above.
# Measuring the class actually present keeps the stack as tight as the
# reference without ever letting two lines touch.
_ACCENTED_UPPER = set('ÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÑÇ')
_ACCENTED_LOWER = set('áàâãäéèêëíìîïóòôõöúùûüñ')
_ASCENDERS = set('bdfhklt')
_DESCENDERS = set('gjpqyçÇ')
_CAPS = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
# Used when a font carries no measured vertical metrics: Helvetica's, which
# are typical for a grotesque and tighter than a display serif's, plus a
# cushion so an unmeasured display face still clears its neighbour.
_FALLBACK_VERTICAL = {
'ascent': 0.98, 'descent': -0.25, 'cap': 0.72, 'x_height': 0.53,
'ascender': 0.73, 'accent_upper': 0.95, 'accent_lower': 0.80,
'descender': -0.22,
}
_UNMEASURED_VERTICAL_CUSHION = 1.08
def vertical_metrics_for(font: Optional[str], face: Optional[str] = None) -> tuple:
"""``(metrics, measured)`` for *font*/*face* — the same lookup as metrics_for."""
family = (font or '').strip().lower()
if face:
exact = VERTICAL_METRICS.get(f"{family}-{face.strip().lower()}")
if exact:
return exact, True
table = VERTICAL_METRICS.get(family)
if table:
return table, True
return _FALLBACK_VERTICAL, False
def ink_extent(
text: str,
font_size: float,
*,
font: Optional[str] = None,
face: Optional[str] = None,
) -> tuple:
"""``(top, bottom)`` of the rendered ink, in points around the title's y.
Final Cut centres the LINE BOX — ascent to descent — on the title's
Position when vertical alignment is Middle, so the ink sits off-centre by
however asymmetric the font is. Both values are returned relative to that
centre: positive up, negative down.
"""
metrics, measured = vertical_metrics_for(font, face)
baseline = -(metrics['ascent'] + metrics['descent']) / 2
top = metrics['x_height']
bottom = 0.0
for ch in text:
if ch in _ACCENTED_UPPER:
top = max(top, metrics['accent_upper'])
elif ch in _ACCENTED_LOWER:
top = max(top, metrics['accent_lower'])
elif ch in _CAPS:
top = max(top, metrics['cap'])
elif ch in _ASCENDERS:
top = max(top, metrics['ascender'])
if ch in _DESCENDERS:
bottom = min(bottom, metrics['descender'])
if not measured:
top *= _UNMEASURED_VERTICAL_CUSHION
bottom *= _UNMEASURED_VERTICAL_CUSHION
return (baseline + top) * font_size, (baseline + bottom) * font_size
@dataclass
class PlacedWord:
"""One word positioned inside a laid-out block.
``x``/``y`` are the word's CENTER in canvas points, origin at frame center,
y growing upward — the convention Final Cut's position param uses, and the
value that goes straight into the title's "Posição" param.
"""
text: str
font_size: float
italic: bool
x: float
y: float
width: float
height: float
line_index: int
source: Optional[Dict] = None
look: Optional[object] = None # the WordLook this word was set with
kerning: float = REFERENCE_KERNING # scaled to the frame, as font_size is
@property
def left(self) -> float:
return self.x - self.width / 2
@property
def right(self) -> float:
return self.x + self.width / 2
@property
def bottom(self) -> float:
return self.y - self.height / 2
@property
def top(self) -> float:
return self.y + self.height / 2
def overlaps(self, other: 'PlacedWord') -> bool:
"""True if this word's box intersects *other*'s."""
return (
self.left < other.right
and other.left < self.right
and self.bottom < other.top
and other.bottom < self.top
)
def position_param(self, scale: float = 1.0) -> str:
"""The value for the title's "Posição" param, as FCP writes it.
*scale* converts from canvas points to the template's own space; see
TEXT_TEMPLATE_FONT_SCALE. It must be the same factor the emitted
fontSize uses, or the type and the spacing drift apart.
"""
return f"{self.x * scale:g} {self.y * scale:g}"
# The rest of this block is the interface a placed unit shares with
# PlacedBlock, so the writer emits titles from either without caring
# whether the composition is per word or per phrase.
@property
def words(self) -> List[Dict]:
return [self.source] if self.source else []
@property
def start(self) -> float:
return float(self.source.get('start', 0.0)) if self.source else 0.0
@property
def end(self) -> float:
return float(self.source.get('end', 0.0)) if self.source else 0.0
@property
def font(self) -> str:
return getattr(self.look, 'font', None) or 'Helvetica Neue'
@property
def face(self) -> Optional[str]:
return getattr(self.look, 'face', None)
@property
def color(self) -> str:
return getattr(self.look, 'color', '1 1 1 1')
@dataclass
class BlockLayout:
"""A laid-out group of words, plus whatever did not fit."""
placed: List[PlacedWord] = field(default_factory=list)
overflow: List[Dict] = field(default_factory=list)
@property
def fitted_count(self) -> int:
return len(self.placed)
@dataclass
class LayoutBox:
"""The usable area words may occupy, in canvas points.
Defaults reproduce the calibration export: a block centered slightly below
frame center, spanning most of the width of a 2160x3840 vertical frame.
"""
width: float = 1080.0 * 0.92
height: float = 1920.0 * 0.22
center_y: float = REFERENCE_BLOCK_CENTER_Y
font_scale: float = 1.0
@classmethod
def for_frame(
cls,
frame_width: float,
frame_height: float,
*,
side_margin: float = 0.04,
band_height: float = 0.22,
center_y: Optional[float] = None,
) -> 'LayoutBox':
"""Build a box for a frame of *frame_width* x *frame_height* pixels.
Pixels are converted to canvas points via ``POINT_SCALE``. Type sizes
and the block's height scale with the frame, so the same rhythm reads
proportionally on any format rather than overflowing a shorter one.
``center_y`` defaults to the calibration export's height, scaled.
"""
w = frame_width * POINT_SCALE
h = frame_height * POINT_SCALE
scale = h / REFERENCE_CANVAS_HEIGHT
return cls(
width=w * (1.0 - 2 * side_margin),
height=h * band_height,
center_y=(
REFERENCE_BLOCK_CENTER_Y * scale if center_y is None else center_y
),
font_scale=scale,
)
def look_for(index: int, style):
"""The look for the word at *index* within its sentence.
Prefers ``style.look_for`` (WordStyle's rhythm of WordLook entries, which
carries size, colour, font and face together). Falls back to the older
parallel-pattern attributes so a bare stand-in style still lays out.
"""
resolver = getattr(style, 'look_for', None)
if callable(resolver):
return resolver(index)
class _Fallback:
pass
look = _Fallback()
sizes = getattr(style, 'size_pattern', None)
look.font_size = (
float(sizes[index % len(sizes)]) if sizes
else float(getattr(style, 'font_size', REFERENCE_FONT_SIZE_MEDIUM))
)
italics = getattr(style, 'italic_pattern', None)
look.italic = bool(italics[index % len(italics)]) if italics else False
look.font = getattr(style, 'font', 'Helvetica Neue')
look.face = None
look.color = getattr(style, 'active_color', '1 1 1 1')
look.kerning = float(getattr(style, 'kerning', REFERENCE_KERNING))
return look
def rhythm_font_size(index: int, style) -> float:
"""Font size for the word at *index* within its sentence."""
return float(look_for(index, style).font_size)
def rhythm_italic(index: int, style) -> bool:
"""Whether the word at *index* within its sentence is italic."""
return bool(look_for(index, style).italic)
def layout_sentence(
words: Sequence[Dict],
style,
box: Optional[LayoutBox] = None,
*,
line_gap: float = REFERENCE_LINE_GAP,
word_gap_ratio: float = REFERENCE_WORD_GAP_RATIO,
) -> BlockLayout:
"""Lay *words* out as a centered, line-wrapped block inside *box*.
Words are packed left to right until the line no longer fits ``box.width``,
then a new line opens. Lines are stacked, the stack centered on
``box.center_y``, and every line centered horizontally — a compact block
with no word ever overlapping another.
Words that would push the block past ``box.height`` come back in
``BlockLayout.overflow`` instead of being placed. The caller starts a fresh
block with them, which is what keeps a long sentence from spilling off
screen.
``word_gap_ratio`` sizes the gap between neighbours off the larger of the
two font sizes, so a 170pt word is not separated by the same sliver as a
128pt one.
"""
if box is None:
box = LayoutBox()
bold = bool(getattr(style, 'bold', False))
default_kerning = float(getattr(style, 'kerning', REFERENCE_KERNING))
# Type, spacing and gaps all scale together, or a shorter frame would get
# reference-sized words that never fit.
scale = float(getattr(box, 'font_scale', 1.0)) or 1.0
line_gap *= scale
def gap_between(left: Dict, right: Dict) -> float:
"""Space between two neighbouring words, off the larger of the two."""
return max(left['font_size'], right['font_size']) * word_gap_ratio
tokens = []
for w in words:
text = str(w.get('word', '')).strip()
if not text:
continue
look = look_for(len(tokens), style)
size = float(look.font_size) * scale
kerning = float(getattr(look, 'kerning', default_kerning)) * scale
tokens.append({
'source': w,
'text': text,
'look': look,
'kerning': kerning,
'font_size': size,
'italic': bool(look.italic),
'width': measure_text(
text, size, bold=bold, kerning=kerning,
font=getattr(look, 'font', None) or getattr(style, 'font', None),
face=getattr(look, 'face', None),
),
'height': size * _CAP_HEIGHT_RATIO,
})
if not tokens:
return BlockLayout()
# Pack into lines. A word wider than the whole box still gets its own line
# rather than being dropped — losing a spoken word is worse than one line
# running wide.
lines: List[List[Dict]] = []
current: List[Dict] = []
current_width = 0.0
for tok in tokens:
gap = gap_between(current[-1], tok) if current else 0.0
projected = current_width + gap + tok['width']
if current and projected > box.width:
lines.append(current)
current = [tok]
current_width = tok['width']
else:
current.append(tok)
current_width = projected
if current:
lines.append(current)
# Keep the leading lines that fit the band; the rest overflow into a new
# block. Consecutive lines are half-height + gap + half-height apart, so a
# tall word only costs what it actually occupies.
line_heights = [max(t['height'] for t in line) for line in lines]
kept = 0
total_height = 0.0
for i, h in enumerate(line_heights):
advance = h if not kept else (line_heights[i - 1] + h) / 2 + line_gap
if kept and total_height + advance > box.height:
break
total_height += advance
kept += 1
kept = max(kept, 1) # always place one line, or the caller never advances
result = BlockLayout()
for line in lines[kept:]:
result.overflow.extend(tok['source'] for tok in line)
# Center the stack: the first line's center sits half the total span above
# box.center_y, measuring the span between line CENTERS.
span = sum(
(line_heights[i - 1] + line_heights[i]) / 2 + line_gap
for i in range(1, kept)
)
cursor_y = box.center_y + span / 2
for line_index, line in enumerate(lines[:kept]):
if line_index:
cursor_y -= (
(line_heights[line_index - 1] + line_heights[line_index]) / 2
+ line_gap
)
gaps = [gap_between(a, b) for a, b in zip(line, line[1:])]
line_width = sum(tok['width'] for tok in line) + sum(gaps)
cursor_x = -line_width / 2
for position, tok in enumerate(line):
if position:
cursor_x += gaps[position - 1]
result.placed.append(PlacedWord(
text=tok['text'],
font_size=tok['font_size'],
italic=tok['italic'],
x=cursor_x + tok['width'] / 2,
y=cursor_y,
width=tok['width'],
height=tok['height'],
line_index=line_index,
source=tok['source'],
look=tok['look'],
kerning=tok['kerning'],
))
cursor_x += tok['width']
return result
# ---------------------------------------------------------------------------
# PROGRESSIVE COMPOSITION (block-per-phrase)
# ---------------------------------------------------------------------------
# The look the user asked for (reference: fernandoluz.d reel, 2026-08-17):
#
# [ que vão ]
# [ melhorar ]
# [ sua legenda ]
#
# One title per BLOCK, not per word. Supporting words are set small in a
# grotesque; the sentence's key word is set large in a display italic, on its
# own line. Blocks appear as their first word is spoken and stay on screen, so
# the sentence assembles itself; they all clear together.
# Function words are never the emphasis — "que", "de", "uma" set 2.5x larger
# than the rest reads as a mistake, not as a design.
STOPWORDS_PT = frozenset("""
a as o os um uma uns umas de do da dos das em no na nos nas por para pra pro
com sem sob sobre e ou mas que se ao aos à às pelo pela pelos pelas num numa
eu tu ele ela nos vos eles elas me te lhe nos vos lhes meu minha seu sua teu
tua nosso nossa este esta esse essa aquele aquela isso isto aquilo já não sim
muito mais menos tão como quando onde quem qual quais é foi ser estar tem ter
vai vou vão era são está estão dos aqui ali lá então porque assim
""".split())
def pick_emphasis_index(texts: Sequence[str]) -> int:
"""Index of the word to set as the block's emphasis.
The longest content word, since length is the best proxy available for
"the word this sentence is about" without a language model. Ties break
toward the middle of the sentence, which is where a designer puts the
hero word. A sentence of nothing but function words emphasises its
longest word anyway rather than emphasising nothing.
"""
if not texts:
return 0
cleaned = [t.strip(".,!?;:…\"'()").lower() for t in texts]
middle = (len(texts) - 1) / 2
content = [i for i, t in enumerate(cleaned) if t and t not in STOPWORDS_PT]
pool = content or list(range(len(texts)))
return max(pool, key=lambda i: (len(cleaned[i]), -abs(i - middle)))
@dataclass
class PlacedBlock:
"""One title's worth of text, positioned as a line of the composition."""
text: str
words: List[Dict]
font: str
face: Optional[str]
font_size: float
color: str
kerning: float
x: float
y: float
width: float
height: float
line_index: int
emphasis: bool = False
# Where the rendered ink actually reaches, relative to y (see ink_extent).
ink_top: float = 0.0
ink_bottom: float = 0.0
@property
def left(self) -> float:
return self.x - self.width / 2
@property
def right(self) -> float:
return self.x + self.width / 2
@property
def bottom(self) -> float:
return self.y + self.ink_bottom
@property
def top(self) -> float:
return self.y + self.ink_top
@property
def start(self) -> float:
"""When this block is spoken — its first word's start, in seconds."""
return min(float(w.get('start', 0.0)) for w in self.words)
@property
def end(self) -> float:
"""When this block finishes being spoken, in seconds."""
return max(float(w.get('end', 0.0)) for w in self.words)
def position_param(self, scale: float = 1.0) -> str:
"""The value for the title's "Posição" param, as FCP writes it.
*scale* converts from canvas points to the template's own space; see
TEXT_TEMPLATE_FONT_SCALE. It must be the same factor the emitted
fontSize uses, or the type and the spacing drift apart.
"""
return f"{self.x * scale:g} {self.y * scale:g}"
def overlaps(self, other: 'PlacedBlock') -> bool:
return (
self.left < other.right
and other.left < self.right
and self.bottom < other.top
and other.bottom < self.top
)
@dataclass
class Composition:
"""A laid-out group of blocks, plus whatever did not fit."""
blocks: List[PlacedBlock] = field(default_factory=list)
overflow: List[Dict] = field(default_factory=list)
def compose_sentence(
words: Sequence[Dict],
style,
box: Optional[LayoutBox] = None,
*,
line_gap: float = REFERENCE_BLOCK_LINE_GAP,
stagger_ratio: float = REFERENCE_STAGGER_RATIO,
) -> Composition:
"""Lay a sentence out as stacked blocks, one title per line.
The emphasis word takes a line of its own, set in the display face; the
words before and after it fill the lines above and below, wrapped at
``box.width`` and set in the body face. Body lines are staggered — pushed
toward opposite edges of the emphasis line — which is what makes the
composition read as diagrammed rather than as a centred caption.
Lines that would push the stack past ``box.height`` come back in
``Composition.overflow`` for the caller to place as the next composition.
"""
if box is None:
box = LayoutBox()
scale = float(getattr(box, 'font_scale', 1.0)) or 1.0
entries = [
(w, str(w.get('word', '')).strip())
for w in words
if str(w.get('word', '')).strip()
]
if not entries:
return Composition()
texts = [t for _, t in entries]
emphasis_index = pick_emphasis_index(texts)
emphasis_look = style.look_for_emphasis()
body_look = style.look_for_body()
def measure(text: str, look) -> tuple:
size = float(look.font_size) * scale
kerning = float(getattr(look, 'kerning', REFERENCE_KERNING)) * scale
width = measure_text(
text, size,
bold=bool(getattr(style, 'bold', False)),
kerning=kerning,
font=getattr(look, 'font', None),
face=getattr(look, 'face', None),
)
return size, kerning, width
# Split into lines: everything before the emphasis, the emphasis alone,
# everything after. Body runs wrap at the box width so a long lead-in
# becomes two lines instead of running off frame.
def body_lines(run: List[tuple]) -> List[List[tuple]]:
out: List[List[tuple]] = []
current: List[tuple] = []
for item in run:
trial = current + [item]
text = ' '.join(t for _, t in trial)
if current and measure(text, body_look)[2] > box.width:
out.append(current)
current = [item]
else:
current = trial
if current:
out.append(current)
return out
lines: List[tuple] = [] # (run, look, is_emphasis)
for run in body_lines(entries[:emphasis_index]):
lines.append((run, body_look, False))
lines.append(([entries[emphasis_index]], emphasis_look, True))
for run in body_lines(entries[emphasis_index + 1:]):
lines.append((run, body_look, False))
# A body run wraps onto a new line when it doesn't fit — but the
# emphasis line is always exactly one word, so it can't wrap, and
# nothing capped its size against the box. A long or all-caps word (an
# emphasis pass sometimes upper-cases its pick) could run past both
# edges of the frame — found on real footage, wide enough to spill off
# BOTH sides while centred. Shrinking it back to the box scales its
# font_size and kerning by the same factor, so the ink height used for
# stacking below shrinks with it too — restoring the vertical
# non-overlap the rest of this function already guarantees by
# construction. Never shrunk below the body size: emphasis smaller
# than body text isn't emphasis anymore, it's just a different font.
def fit_emphasis(text: str, look) -> tuple:
size, kerning, width = measure(text, look)
if width <= box.width:
return size, kerning, width
floor = float(body_look.font_size) * scale
fit = max(box.width / width, floor / size) if size > 0 else 1.0
fit = min(fit, 1.0)
return size * fit, kerning * fit, width * fit
measured = []
for run, look, is_emphasis in lines:
text = ' '.join(t for _, t in run)
if is_emphasis:
size, kerning, width = fit_emphasis(text, look)
else:
size, kerning, width = measure(text, look)
# Stack on the real ink each line contains, not on a nominal
# cap-height: the display italic's accents and descenders run well
# past it, and a nominal box lets them collide with the neighbour.
top, bottom = ink_extent(
text, size,
font=getattr(look, 'font', None), face=getattr(look, 'face', None),
)
measured.append({
'run': run, 'look': look, 'emphasis': is_emphasis, 'text': text,
'font_size': size, 'kerning': kerning, 'width': width,
'ink_top': top, 'ink_bottom': bottom, 'height': top - bottom,
})
# Keep the leading lines that fit the band; the rest become the next
# composition. The emphasis line must survive — a block of only body text
# loses the whole point of the look — so if it does not fit, everything
# from the emphasis on overflows together.
gap = line_gap * scale
# The emphasis line's italic slant carries visual weight below its own
# ink box — Playfair's stems lean past what the vertical metrics measure
# — so a body line sitting right under it reads tighter than the same
# nominal gap elsewhere, even though the ink boxes themselves never
# touch. Add a size-proportional cushion only to the boundary right
# after the emphasis line; every other pair keeps exactly the caller's
# ``line_gap``.
def pair_gap(prev_line: dict) -> float:
if prev_line['emphasis']:
return gap + _EMPHASIS_ITALIC_CUSHION_RATIO * prev_line['font_size']
return gap
kept = 0
total = 0.0
prev = None
for line in measured:
advance = line['height'] if prev is None else line['height'] + pair_gap(prev)
if prev is not None and total + advance > box.height:
break
total += advance
kept += 1
prev = line
kept = max(kept, 1)
if not any(line['emphasis'] for line in measured[:kept]):
kept = min(kept, next(
i for i, line in enumerate(measured) if line['emphasis']
)) or 1
result = Composition()
for line in measured[kept:]:
result.overflow.extend(w for w, _ in line['run'])
visible = measured[:kept]
# Stack the ink boxes edge to edge with exactly *gap* between them (plus
# the emphasis cushion where it applies), then centre the whole stack on
# the band. Because the boxes are the real ink, "no overlap" is a
# property of the arithmetic, not of a safety factor.
gaps = [pair_gap(visible[i - 1]) for i in range(1, len(visible))]
stack_height = sum(line['height'] for line in visible) + sum(gaps)
edge = box.center_y + stack_height / 2
# Body lines hang off the emphasis line's edges, alternating sides in
# reading order — the first body line to the left, the next to the right.
anchor = max(line['width'] for line in visible)
side = -1
for index, line in enumerate(visible):
if index:
edge -= gaps[index - 1]
cursor_y = edge - line['ink_top']
edge = cursor_y + line['ink_bottom']
if line['emphasis']:
x = 0.0
else:
x = side * (anchor - line['width']) / 2 * stagger_ratio
side = -side
look = line['look']
result.blocks.append(PlacedBlock(
text=line['text'],
words=[w for w, _ in line['run']],
font=getattr(look, 'font', 'Helvetica Neue'),
face=getattr(look, 'face', None),
font_size=line['font_size'],
color=getattr(look, 'color', '1 1 1 1'),
kerning=line['kerning'],
x=x,
y=cursor_y,
width=line['width'],
height=line['height'],
ink_top=line['ink_top'],
ink_bottom=line['ink_bottom'],
line_index=index,
emphasis=line['emphasis'],
))
return result