"""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 ```` 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 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) -> str: """The value for the title's "Posição" param, as FCP writes it.""" return f"{self.x:g} {self.y: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) -> str: """The value for the title's "Posição" param, as FCP writes it.""" return f"{self.x:g} {self.y: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)) measured = [] for run, look, is_emphasis in lines: text = ' '.join(t for _, t in run) 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 kept = 0 total = 0.0 for line in measured: advance = line['height'] if not kept else line['height'] + gap if kept and total + advance > box.height: break total += advance kept += 1 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, 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. stack_height = ( sum(line['height'] for line in visible) + gap * (len(visible) - 1) ) 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 -= gap 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