ffmpeg-skill 1.17.3 → 1.18.0

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.
@@ -0,0 +1,778 @@
1
+ """Text measurement, caption line breaking and the size that fits the cue: the per-character
2
+ advance table, the phrase/measured wrapper and fit_size(). Pure -- no subprocess, no probe.
3
+ Split out of _common.text in the refactor after 1.17.3; every body is byte-identical.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import unicodedata
8
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
9
+ from _common.emoji import emoji_clusters
10
+ from _common.fonts import char_script
11
+
12
+
13
+ # --------------------------------------------------------------------------- text measurement (1.12)
14
+ # Moved here in 1.15 so graphics.py's ASS route and the emoji placement share caption.py's table.
15
+ # Average advance width per character, in em (a fraction of the font size). Proportional Latin text
16
+ # averages a bit over half an em; CJK and Thai are drawn on a full-width grid; Arabic/Hebrew and
17
+ # Devanagari sit in between. These are deliberately averages, not per-glyph metrics: measuring the
18
+ # real advance needs a font parser (no stdlib one) and would still be wrong for libass's own
19
+ # shaping, while a cue wrapped from an average is right to within a character on every line.
20
+ # (Latin is measured per character from LATIN_EM below, not from this average.)
21
+ ADVANCE_EM = {"ja": 1.0, "zh": 1.0, "ko": 1.0, "th": 1.0, "hi": 0.7, "ar": 0.6, "he": 0.6,
22
+ "ru": 0.55, "el": 0.55, "latin": 0.55}
23
+
24
+
25
+ # Scripts written without spaces: a line breaks between any two characters.
26
+ # Scripts a line may break inside a run of, one character at a time. Thai is deliberately NOT
27
+ # here since 1.16.1: it writes no space inside a phrase, and without a dictionary the wrapper
28
+ # cannot see where one word ends -- every character-level break it took in eval 17 landed inside
29
+ # a word. A Thai run is therefore one atom, broken only at the spaces (or the manual `|`) the
30
+ # writer put there; an over-long run stays long on its own line, the rule long Latin words
31
+ # already follow.
32
+ NO_SPACE_SCRIPTS = ("ja", "zh", "ko")
33
+ NO_BOUNDARY_SCRIPTS = ("th",) # per-character breaking would chop words: keep the run whole
34
+
35
+
36
+ # Per-character Latin advances in em, read off DejaVu Sans (the default caption family, and close
37
+ # enough to any other proportional sans for a wrap) and rounded UP: a capital runs 0.56-0.99 em
38
+ # against the single 0.55 average that used to stand for all of Latin, so an all-caps caption --
39
+ # the style most burn-ins use -- overflowed the safe area and was silently re-wrapped by libass
40
+ # past --max-lines. Rounding up is the safe direction: libass re-wraps a too-long line, it never
41
+ # un-wraps a short one. Characters outside the table fall back by class (0.7 uppercase/digit,
42
+ # 0.57 lowercase and anything else Latin-ish).
43
+ LATIN_EM = {
44
+ ' ': 0.32, '!': 0.41, '"': 0.46, '#': 0.84, '$': 0.64, '%': 0.96, '&': 0.78, "'": 0.28,
45
+ '(': 0.4, ')': 0.4, '*': 0.5, '+': 0.84, ',': 0.32, '-': 0.37, '.': 0.32, '/': 0.34, '0': 0.64,
46
+ '1': 0.64, '2': 0.64, '3': 0.64, '4': 0.64, '5': 0.64, '6': 0.64, '7': 0.64, '8': 0.64,
47
+ '9': 0.64, ':': 0.34, ';': 0.34, '<': 0.84, '=': 0.84, '>': 0.84, '?': 0.54, '@': 1.0,
48
+ 'A': 0.69, 'B': 0.69, 'C': 0.7, 'D': 0.78, 'E': 0.64, 'F': 0.58, 'G': 0.78, 'H': 0.76,
49
+ 'I': 0.3, 'J': 0.3, 'K': 0.66, 'L': 0.56, 'M': 0.87, 'N': 0.75, 'O': 0.79, 'P': 0.61,
50
+ 'Q': 0.79, 'R': 0.7, 'S': 0.64, 'T': 0.62, 'U': 0.74, 'V': 0.69, 'W': 0.99, 'X': 0.69,
51
+ 'Y': 0.62, 'Z': 0.69, '[': 0.4, '\\': 0.34, ']': 0.4, '^': 0.84, '_': 0.5, '`': 0.5, 'a': 0.62,
52
+ 'b': 0.64, 'c': 0.55, 'd': 0.64, 'e': 0.62, 'f': 0.36, 'g': 0.64, 'h': 0.64, 'i': 0.28,
53
+ 'j': 0.28, 'k': 0.58, 'l': 0.28, 'm': 0.98, 'n': 0.64, 'o': 0.62, 'p': 0.64, 'q': 0.64,
54
+ 'r': 0.42, 's': 0.53, 't': 0.4, 'u': 0.64, 'v': 0.6, 'w': 0.82, 'x': 0.6, 'y': 0.6, 'z': 0.53,
55
+ '{': 0.64, '|': 0.34, '}': 0.64, '~': 0.84
56
+ }
57
+
58
+
59
+ # Thai and Lao write some vowels BEFORE the consonant they belong to: the break must not land
60
+ # between them and the base that follows.
61
+ LEADING_VOWELS = set(range(0x0E40, 0x0E45)) | set(range(0x0EC0, 0x0EC5))
62
+
63
+
64
+ def _is_mark(ch: str) -> bool:
65
+ """A character that hangs off the one before it: a combining mark (any script) or one of the
66
+ Thai/Lao vowel signs and tone marks, which are Mn/Mc but carry no combining class."""
67
+ return unicodedata.combining(ch) != 0 or unicodedata.category(ch) in ("Mn", "Mc")
68
+
69
+
70
+ def _char_em(ch: str) -> float:
71
+ # CJK punctuation and the fullwidth forms (、。,!? and U+FF01-FF60) are drawn on the same
72
+ # full-width grid as the ideographs they sit between, even though they are not "Han" to a
73
+ # script detector -- measuring them as Latin under-counts a wrapped CJK line by a character.
74
+ cp = ord(ch)
75
+ # A combining mark is drawn on top of (or under) its base and advances the pen by nothing:
76
+ # charging it a full em wrapped Thai and Devanagari lines far shorter than they needed to be.
77
+ if unicodedata.combining(ch) != 0 or unicodedata.category(ch) in ("Mn", "Cf"):
78
+ # "Cf" catches ZWJ/ZWNJ: an Indic joiner is orthography, and it advances the pen by
79
+ # nothing -- charging it a full em (it used to count as "emoji") shrank a Hindi line.
80
+ return 0.0
81
+ if 0x3000 <= cp <= 0x303F or 0xFF01 <= cp <= 0xFF60 or 0xFFE0 <= cp <= 0xFFE6:
82
+ return 1.0
83
+ script = char_script(ch)
84
+ if script == "emoji":
85
+ # 1.15: an emoji is drawn (or reserved) at a full em box, not at Latin's 0.57 -- counting
86
+ # it as Latin overflowed the safe area on an emoji-heavy line.
87
+ return 1.0
88
+ if script == "latin":
89
+ if ch in LATIN_EM:
90
+ return LATIN_EM[ch]
91
+ if ch.isupper() or ch.isdigit():
92
+ return 0.7
93
+ return 0.57
94
+ return ADVANCE_EM.get(script, 0.55)
95
+
96
+
97
+ def text_width_em(text: str, emoji_em: float = 1.0) -> float:
98
+ """Width of `text` in em, from the per-script average advance table. `emoji_em` is what one
99
+ emoji cluster costs (--emoji-scale), so a wrap counts the box that will actually be drawn."""
100
+ total = 0.0
101
+ spans = {i: len(c) for i, c in emoji_clusters(text)}
102
+ i = 0
103
+ while i < len(text):
104
+ if i in spans:
105
+ total += emoji_em
106
+ i += spans[i]
107
+ continue
108
+ total += _char_em(text[i])
109
+ i += 1
110
+ return total
111
+
112
+
113
+ # --------------------------------------------------------------- caption line breaking (1.16)
114
+ # Lifted out of caption.py in 1.16.0 so graphics.py can wrap the same way (caption.py keeps the
115
+ # names it exported, re-imported from here). The whole breaker is pure: a string in, a list of
116
+ # lines out, no subprocess and no probe, which is what makes the eval regression corpus cheap
117
+ # to lock down in unit tests.
118
+
119
+ # How much of the frame width a caption line may use. libass's own default SRT margins are 10 of a
120
+ # 384-wide script (2.6 % a side); 5 % a side is the safe area every platform check in this repo uses.
121
+ SAFE_WIDTH_FRACTION = 0.9
122
+ # ORPHAN_MIN_EM: one full-width CJK/Thai character plus a hair. A last line narrower than this is a
123
+ # single stranded character -- eval 14's th1 (a lone 'ล') and dl3 (a lone '行').
124
+ ORPHAN_MIN_EM = 1.1
125
+
126
+ WRAP_MODES = ("phrase", "measured")
127
+
128
+ # R3's Japanese preference table. These are *preferences* applied only among positions that
129
+ # already fit the line, so the table can never make a line too wide or change the line count.
130
+ #
131
+ # JA_PARTICLES is a "do not strand at the start of a line" table, which is the direction kinsoku
132
+ # practice actually goes: a particle is enclitic -- it attaches to the word BEFORE it and marks
133
+ # that word's role -- so a line beginning with は or が reads as a fragment torn off its phrase.
134
+ # A break AFTER a particle is therefore preferred (the particle stays with what it marks) and a
135
+ # break BEFORE one is forbidden. The list is the eight case/topic particles named in the 1.16.0
136
+ # task brief (は が を に で と の へ) plus も や から まで より, which a reader of Japanese would
137
+ # add for the same reason. It is a judgement call with no upstream source; treat it as tunable
138
+ # data, not as grammar.
139
+ JA_PARTICLES = "はがをにでとのへもや" # a break AFTER one of these is preferred, BEFORE one forbidden
140
+ # The multi-character members of the same table. They are matched as whole strings against the
141
+ # text on each side of a candidate break -- putting them in the character string above turned
142
+ # か, ら, ま, で, よ and り into one-character particles of their own, which none of them is.
143
+ JA_PARTICLE_WORDS = ("から", "まで", "より")
144
+ JA_SENTENCE_END = "。、!?」』)" # a break AFTER one of these is preferred
145
+ # Characters that may never start a line: small kana, the prolonged sound mark, closing brackets
146
+ # and the Japanese punctuation that hangs on the end of the line before it.
147
+ JA_NO_LINE_START = "ぁぃぅぇぉっゃゅょァィゥェォッャュョーヽヾゝゞ、。!?)」』】〕》’”%"
148
+ JA_NO_LINE_END = "(「『【〔《‘“" # ... and the ones that may never end a line
149
+
150
+ # R4. Function words belong to the phrase that FOLLOWS them: an article or preposition begins the
151
+ # noun phrase it governs, so a break before one is the good break (the word opens the next line
152
+ # with its phrase) and a break after one is the bad break (it is stranded at the end of a line,
153
+ # away from what it governs). Both directions are scored, which is what makes the rule decide
154
+ # rather than merely veto. Frozen data, matched case-folded on the atom with its punctuation
155
+ # stripped; six languages because those are the Latin-script languages the eval corpus covers. A
156
+ # word in several sets means the same thing structurally in each, so the union is used when no
157
+ # --lang was given.
158
+ FUNCTION_WORDS = {
159
+ "en": {"a", "an", "the", "of", "to", "in", "on", "at", "for", "with", "by", "from", "and",
160
+ "or", "as", "is", "it", "its", "this", "that", "into", "than", "but", "so"},
161
+ "es": {"el", "la", "los", "las", "un", "una", "unos", "unas", "de", "del", "al", "en", "con",
162
+ "por", "para", "y", "o", "que", "su", "sus", "lo", "se", "es"},
163
+ "pt": {"o", "a", "os", "as", "um", "uma", "de", "do", "da", "dos", "das", "em", "no", "na",
164
+ "nos", "nas", "com", "por", "para", "e", "que", "se", "ao", "aos"},
165
+ "fr": {"le", "la", "les", "un", "une", "de", "du", "des", "à", "au", "aux", "en", "dans",
166
+ "et", "ou", "que", "qui", "ce", "ces", "son", "sa", "ses", "par", "pour", "avec", "sur"},
167
+ "de": {"der", "die", "das", "ein", "eine", "einen", "einem", "einer", "den", "dem", "des",
168
+ "zu", "in", "im", "auf", "mit", "und", "oder", "von", "vom", "für", "aus", "an"},
169
+ "it": {"il", "lo", "la", "i", "gli", "le", "un", "una", "uno", "di", "del", "della", "da",
170
+ "in", "nel", "con", "per", "e", "che", "su", "al", "ai"},
171
+ }
172
+ _FUNCTION_WORDS_ANY = frozenset().union(*FUNCTION_WORDS.values())
173
+
174
+ # Penalty scores. Only the ordering matters; 1.0 means "never choose this if anything else fits".
175
+ PENALTY_FORBIDDEN = 1.0
176
+ PENALTY_OKURIGANA = 0.9 # between a kanji stem and the hiragana that inflects it
177
+ PENALTY_FUNCTION_WORD = 0.8 # R4: the line before the break ends in an article/preposition
178
+ PENALTY_IDEOGRAPHS = 0.6 # between two kanji: no evidence either way, mildly discouraged
179
+ PENALTY_NEUTRAL = 0.5 # between two content words, or two characters with nothing to say
180
+ PENALTY_FUNCTION_WORD_START = 0.2 # R4: the next line opens with the article/preposition it governs
181
+ PENALTY_PARTICLE = 0.2 # R3: after a particle, so the particle stays with the word it marks
182
+ PENALTY_SENTENCE_END = 0.0 # R3: after 。、!? -- the one break a reader expects
183
+
184
+ _HYPHENS = ("-", "‐") # ‑ (non-breaking hyphen) is deliberately NOT here
185
+
186
+
187
+ def _atoms(line: str) -> "List[Tuple[str, bool]]":
188
+ """Break a line into the smallest pieces a wrap may separate -- one atom per CJK/Thai
189
+ character, one per emoji cluster, one per whitespace-delimited word otherwise -- each with
190
+ whether a space stood before it in the original. The flag is what puts the text back together
191
+ exactly as written: "Hello 世界" keeps its space, "世界です" gains none."""
192
+ out: "List[Tuple[str, bool]]" = []
193
+ word = ""
194
+ spaced = False # a space stands before the atom being built
195
+ pending = False # a space stands before the NEXT atom
196
+ attach_next = False # a leading Thai/Lao vowel is waiting for its base consonant
197
+ # An emoji cluster is one atom: a wrap must never land inside a ZWJ sequence, a flag pair or
198
+ # between a base and its skin-tone modifier (the same rule combining marks already follow).
199
+ clusters = {i: len(cl) for i, cl in emoji_clusters(line)}
200
+ i = 0
201
+ while i < len(line):
202
+ ch = line[i]
203
+ if i in clusters:
204
+ cluster = line[i:i + clusters[i]]
205
+ if word:
206
+ out.append((word, spaced))
207
+ word = ""
208
+ out.append((cluster, pending))
209
+ pending = False
210
+ attach_next = False
211
+ i += clusters[i]
212
+ continue
213
+ i += 1
214
+ if char_script(ch) in NO_SPACE_SCRIPTS:
215
+ if word:
216
+ out.append((word, spaced))
217
+ word = ""
218
+ if out and not pending and _is_katakana_run(ch) and _is_katakana_run(out[-1][0][-1]):
219
+ # a katakana word (タイミング, コンピューター) is one atom: eval 17 saw タイ|ミング
220
+ out[-1] = (out[-1][0] + ch, out[-1][1])
221
+ elif out and (attach_next or _is_mark(ch)):
222
+ # never break between a base and the mark (or the leading vowel) that belongs to
223
+ # it: the line would start with an orphaned tone mark or vowel sign
224
+ out[-1] = (out[-1][0] + ch, out[-1][1])
225
+ else:
226
+ out.append((ch, pending))
227
+ pending = False
228
+ attach_next = ord(ch) in LEADING_VOWELS
229
+ elif ch.isspace():
230
+ if word:
231
+ out.append((word, spaced))
232
+ word = ""
233
+ pending = True
234
+ else:
235
+ if not word:
236
+ spaced, pending = pending, False
237
+ word += ch
238
+ if word:
239
+ out.append((word, spaced))
240
+ return out
241
+
242
+
243
+ def _split_hyphens(atoms: "List[Tuple[str, bool]]") -> "List[Tuple[str, bool]]":
244
+ """R1's one addition to the atom list: a hyphenated token may break *after* its hyphen.
245
+
246
+ "end-to-end" becomes `end-` / `to-` / `end`, each piece carrying the space flag of the token
247
+ it came from for the first piece and False for the rest, so _join() puts it back with no space
248
+ at all. A hyphen that is the first or last character of the token (`-5`, `well-`) is never a
249
+ break point: the guard is that both sides must be non-empty."""
250
+ out: "List[Tuple[str, bool]]" = []
251
+ for atom, spaced in atoms:
252
+ if len(atom) < 3 or not any(h in atom[1:-1] for h in _HYPHENS):
253
+ out.append((atom, spaced))
254
+ continue
255
+ piece = ""
256
+ first = True
257
+ for i, ch in enumerate(atom):
258
+ piece += ch
259
+ if ch in _HYPHENS and 0 < i < len(atom) - 1:
260
+ out.append((piece, spaced if first else False))
261
+ piece = ""
262
+ first = False
263
+ if piece:
264
+ out.append((piece, spaced if first else False))
265
+ return out
266
+
267
+
268
+ def _join(left: str, atom: str, spaced: bool) -> str:
269
+ """Put an atom back on a line, restoring the space that stood before it."""
270
+ if not left:
271
+ return atom
272
+ return left + (" " if spaced else "") + atom
273
+
274
+
275
+ def _break_spaced(first: str, second: str) -> bool:
276
+ """Did a space stand at the break between these two wrapped lines? Only spaced scripts put one
277
+ there -- a CJK/Thai break sits between two characters that were written with nothing between
278
+ them, and re-joining them with a space would insert a character the cue never had."""
279
+ if not first or not second:
280
+ return False
281
+ return char_script(first[-1]) not in NO_SPACE_SCRIPTS and char_script(second[0]) not in NO_SPACE_SCRIPTS \
282
+ and char_script(first[-1]) != "emoji" and char_script(second[0]) != "emoji"
283
+
284
+
285
+ def _is_kana(ch: str) -> bool:
286
+ return 0x3040 <= ord(ch) <= 0x30FF
287
+
288
+
289
+ def _is_katakana_run(ch: str) -> bool:
290
+ """Katakana proper plus the prolonged-sound mark: the characters one loan word is made of."""
291
+ cp = ord(ch)
292
+ return (0x30A1 <= cp <= 0x30FA) or cp == 0x30FC or (0x31F0 <= cp <= 0x31FF) or (0xFF66 <= cp <= 0xFF9F)
293
+
294
+
295
+ def _is_hiragana(ch: str) -> bool:
296
+ return 0x3040 <= ord(ch) <= 0x309F
297
+
298
+
299
+ def _is_ideograph(ch: str) -> bool:
300
+ cp = ord(ch)
301
+ return 0x3400 <= cp <= 0x4DBF or 0x4E00 <= cp <= 0x9FFF or 0xF900 <= cp <= 0xFAFF
302
+
303
+
304
+ def _is_weak_line(line: str) -> "bool":
305
+ """A line no reader should be given on its own (R2).
306
+
307
+ 1.15 asked only "is the last line one atom narrower than ORPHAN_MIN_EM", which a full-width
308
+ character passes: dl3 still showed a lone `2` and a stranded `行`. Three cases instead, any of
309
+ which makes a line too thin to stand alone:
310
+ - a single character narrower than ORPHAN_MIN_EM (1.15's rule, kept);
311
+ - nothing but digits, punctuation and symbols, at most two characters ("2", "--");
312
+ - a single kana, whatever its width -- a kana is a full em and passes the width test, but a
313
+ line holding one is a syllable, not a word.
314
+ """
315
+ stripped = (line or "").strip()
316
+ if not stripped:
317
+ return True
318
+ if len(stripped) == 1 and text_width_em(stripped) < ORPHAN_MIN_EM:
319
+ return True
320
+ if len(stripped) <= 2 and all(unicodedata.category(c)[0] in "NPS" for c in stripped):
321
+ return True
322
+ if len(stripped) == 1 and char_script(stripped) == "ja" and _is_kana(stripped):
323
+ return True
324
+ return False
325
+
326
+
327
+ def _function_words(lang: "Optional[str]") -> "frozenset":
328
+ """R4's table for this language. An unknown or absent language gets the union of the six sets:
329
+ a token that appears in several of them is the same kind of word in each, which is why the
330
+ rule is a penalty and not a refusal."""
331
+ key = (lang or "").strip().lower().split("-")[0]
332
+ if key in FUNCTION_WORDS:
333
+ return frozenset(FUNCTION_WORDS[key])
334
+ return _FUNCTION_WORDS_ANY
335
+
336
+
337
+ def _bare_word(atom: str) -> str:
338
+ return "".join(c for c in (atom or "") if c.isalpha() or c == "'").strip("'").lower()
339
+
340
+
341
+ def _particle_starts(text: str) -> bool:
342
+ """Does `text` begin with a particle -- one character, or one of the two-character ones?"""
343
+ if not text:
344
+ return False
345
+ return text[0] in JA_PARTICLES or text.startswith(JA_PARTICLE_WORDS)
346
+
347
+
348
+ def _particle_ends(text: str) -> bool:
349
+ """Does `text` end with a particle? `から` counts, a bare `ら` does not."""
350
+ if not text:
351
+ return False
352
+ return text[-1] in JA_PARTICLES or text.endswith(JA_PARTICLE_WORDS)
353
+
354
+
355
+ def break_penalty(prev_char: str, next_char: str, lang: "Optional[str]" = None,
356
+ before: str = "", after: str = "") -> float:
357
+ """How bad a break between these two characters is, 0.0 (preferred) to 1.0 (forbidden).
358
+
359
+ Only consulted among break positions that already fit `max_em`, so a preference can never
360
+ widen a line or change the line count. Japanese gets the particle half of the table -- a break
361
+ AFTER a particle is preferred and a break BEFORE one forbidden, because a particle attaches to
362
+ the word before it; Chinese gets only the sentence-end and forbidden halves, because particles
363
+ are Japanese grammar.
364
+
365
+ `before`/`after` are the text on each side of the break when the caller has it, which is what
366
+ lets the two-character particles (から/まで/より) be matched as words. Without them only the
367
+ single-character table applies."""
368
+ if not prev_char or not next_char:
369
+ return PENALTY_NEUTRAL
370
+ script = (lang or "").strip().lower().split("-")[0]
371
+ if script not in ("ja", "zh"):
372
+ # A kana on either side settles it: only Japanese has them, and char_script() reads a bare
373
+ # Han character as Chinese, which used to switch the particle rules off for exactly the
374
+ # break they exist to judge (`...が|決まる` -- kana before, kanji after).
375
+ if _is_kana(prev_char) or _is_kana(next_char):
376
+ script = "ja"
377
+ else:
378
+ script = char_script(next_char)
379
+ if script not in ("ja", "zh"):
380
+ script = char_script(prev_char)
381
+ if next_char in JA_NO_LINE_START or prev_char in JA_NO_LINE_END or _is_mark(next_char):
382
+ return PENALTY_FORBIDDEN
383
+ if script not in ("ja", "zh"):
384
+ return PENALTY_NEUTRAL
385
+ if prev_char in JA_SENTENCE_END:
386
+ return PENALTY_SENTENCE_END
387
+ if script == "ja" and _particle_starts(after or next_char):
388
+ # a particle may not open a line: it belongs to the word before it (kinsoku)
389
+ return PENALTY_FORBIDDEN
390
+ if script == "ja" and _particle_ends(before or prev_char):
391
+ return PENALTY_PARTICLE
392
+ if script == "ja" and _is_ideograph(prev_char) and _is_hiragana(next_char):
393
+ # okurigana: 決|まる is inside a word even though neither half is a "word" on its own
394
+ return PENALTY_OKURIGANA
395
+ if _is_ideograph(prev_char) and _is_ideograph(next_char):
396
+ return PENALTY_IDEOGRAPHS
397
+ return PENALTY_NEUTRAL
398
+
399
+
400
+ def _cut_penalty(atoms: "Sequence[Tuple[str, bool]]", cut: int, lang: "Optional[str]") -> float:
401
+ """The penalty of breaking `atoms` before index `cut`."""
402
+ prev_atom = atoms[cut - 1][0]
403
+ next_atom = atoms[cut][0]
404
+ if not prev_atom or not next_atom:
405
+ return PENALTY_NEUTRAL
406
+ if atoms[cut][1]:
407
+ # a space stood here: a spaced script, so R4 is the rule that applies, in both directions
408
+ if all(not ch.isalnum() for ch in next_atom):
409
+ return PENALTY_FORBIDDEN # never strand punctuation at the start of a line
410
+ words = _function_words(lang)
411
+ if _bare_word(prev_atom) in words:
412
+ return PENALTY_FUNCTION_WORD # stranded at the end of a line, away from its noun
413
+ if _bare_word(next_atom) in words:
414
+ return PENALTY_FUNCTION_WORD_START # opens the next line with the phrase it governs
415
+ return PENALTY_NEUTRAL
416
+ if prev_atom.endswith(_HYPHENS):
417
+ return PENALTY_NEUTRAL # R1: a hyphen is a legitimate break point
418
+ # the text on each side, so a two-character particle (から/まで/より) is seen as one
419
+ before = "".join(a for a, _sp in atoms[:cut])
420
+ after = "".join(a for a, _sp in atoms[cut:])
421
+ return break_penalty(prev_atom[-1], next_atom[0], lang, before=before, after=after)
422
+
423
+
424
+ def best_break(atoms: "Sequence[Tuple[str, bool]]", max_em: float,
425
+ lang: "Optional[str]" = None) -> "Optional[int]":
426
+ """The index to break `atoms` at so they become two lines, or None when none fits.
427
+
428
+ Among every position whose two halves both fit `max_em`, the one minimising
429
+ (penalty, widest line, |width difference|) wins: R1-R4 choose first, and 1.15's
430
+ minimise-the-widest-line rule breaks the ties it used to decide alone."""
431
+ best = None
432
+ for cut in range(1, len(atoms)):
433
+ a = b = ""
434
+ for atom, sp in atoms[:cut]:
435
+ a = _join(a, atom, sp)
436
+ for atom, sp in atoms[cut:]:
437
+ b = _join(b, atom, sp)
438
+ wa, wb = text_width_em(a), text_width_em(b)
439
+ if max(wa, wb) > max_em:
440
+ continue
441
+ if _is_weak_line(a) or _is_weak_line(b):
442
+ continue
443
+ key = (_cut_penalty(atoms, cut, lang), max(wa, wb), abs(wa - wb))
444
+ if best is None or key < best[0]:
445
+ best = (key, cut)
446
+ return None if best is None else best[1]
447
+
448
+
449
+ def _fix_orphans(lines: "List[str]", max_em: float) -> "List[str]":
450
+ """No last line that is a single stranded atom.
451
+
452
+ Greedy wrapping leaves one character alone whenever the line before it filled exactly: eval 14
453
+ produced a Thai cue ending in a lone `ล` and a Japanese one ending in a lone `行`. While the
454
+ last line is one atom narrower than ORPHAN_MIN_EM, the last atom of the line above moves down
455
+ onto it -- but only while the result still fits and the line above does not become an orphan
456
+ itself, so a two-word cue is never made worse."""
457
+ lines = list(lines)
458
+ for _ in range(len(lines)):
459
+ if len(lines) < 2:
460
+ break
461
+ tail = _atoms(lines[-1])
462
+ if len(tail) != 1 or text_width_em(lines[-1]) >= ORPHAN_MIN_EM:
463
+ break
464
+ prev = _atoms(lines[-2])
465
+ if len(prev) < 2:
466
+ break
467
+ moved, spaced = prev[-1]
468
+ new_prev = ""
469
+ for atom, sp in prev[:-1]:
470
+ new_prev = _join(new_prev, atom, sp)
471
+ new_last = _join(moved, tail[0][0], _break_spaced(lines[-2], lines[-1]))
472
+ if text_width_em(new_last) > max_em or text_width_em(new_prev) < ORPHAN_MIN_EM:
473
+ break
474
+ lines[-2], lines[-1] = new_prev, new_last
475
+ return lines
476
+
477
+
478
+ def _fix_weak_lines(lines: "List[str]", max_em: float) -> "List[str]":
479
+ """R2, generalised: _fix_orphans run at *every* boundary, against _is_weak_line.
480
+
481
+ 1.15 only ever looked at the last line, so a stranded digit or kana in the middle of a
482
+ three-line cue survived. Walking upward from the last line, while a line is weak the last atom
483
+ of the line above moves down onto it -- with 1.15's two guards intact (the result must still
484
+ fit, and the line above must not itself become weak), so the line count never changes."""
485
+ lines = list(lines)
486
+ for i in range(len(lines) - 1, 0, -1):
487
+ for _ in range(len(lines)):
488
+ if not _is_weak_line(lines[i]):
489
+ break
490
+ prev = _atoms(lines[i - 1])
491
+ if len(prev) < 2:
492
+ break
493
+ moved, _spaced = prev[-1]
494
+ new_prev = ""
495
+ for atom, sp in prev[:-1]:
496
+ new_prev = _join(new_prev, atom, sp)
497
+ new_last = _join(moved, lines[i], _break_spaced(lines[i - 1], lines[i]))
498
+ if text_width_em(new_last) > max_em or _is_weak_line(new_prev) \
499
+ or text_width_em(new_prev) < ORPHAN_MIN_EM:
500
+ break
501
+ lines[i - 1], lines[i] = new_prev, new_last
502
+ return lines
503
+
504
+
505
+ def _rebalance(lines: "List[str]", max_em: float) -> "List[str]":
506
+ """Move each break to the one that minimises the widest line of the pair, without changing the
507
+ line count.
508
+
509
+ Greedy wrapping fills line 1 to the brim and leaves line 2 short, which is what split eval 14's
510
+ `"A third line the tool times for me"` mid-phrase. Only spaced scripts are rebalanced: a
511
+ non-spaced script has no phrase structure in its atom list, so moving the break there only
512
+ moves the ragged edge. A break is never placed before a punctuation-only atom."""
513
+ if len(lines) < 2:
514
+ return lines
515
+ out = list(lines)
516
+ for i in range(len(out) - 1):
517
+ first, second = out[i], out[i + 1]
518
+ tail_atoms = _atoms(second)
519
+ if tail_atoms:
520
+ tail_atoms[0] = (tail_atoms[0][0], _break_spaced(first, second))
521
+ atoms = _atoms(first) + tail_atoms
522
+ if not atoms or any(char_script(ch) in NO_SPACE_SCRIPTS for ch in first + second):
523
+ continue
524
+ best = None
525
+ for cut in range(1, len(atoms)):
526
+ if not atoms[cut][1]:
527
+ continue # only break where a space stood
528
+ if all(not ch.isalnum() for ch in atoms[cut][0]):
529
+ continue # never strand punctuation at the start of a line
530
+ a = b = ""
531
+ for atom, sp in atoms[:cut]:
532
+ a = _join(a, atom, sp)
533
+ for atom, sp in atoms[cut:]:
534
+ b = _join(b, atom, sp)
535
+ wa, wb = text_width_em(a), text_width_em(b)
536
+ if max(wa, wb) > max_em:
537
+ continue
538
+ key = (max(wa, wb), abs(wa - wb))
539
+ if best is None or key < best[0]:
540
+ best = (key, a, b)
541
+ if best is not None:
542
+ out[i], out[i + 1] = best[1], best[2]
543
+ return out
544
+
545
+
546
+ def _rebalance_phrase(lines: "List[str]", max_em: float, lang: "Optional[str]") -> "Tuple[List[str], int]":
547
+ """_rebalance with R1-R4 deciding, for every script rather than spaced ones only.
548
+
549
+ Returns the new lines and how many breaks a phrase rule moved away from the position 1.15's
550
+ widest-line rule alone would have chosen -- the `phrase_breaks` count in the result."""
551
+ if len(lines) < 2:
552
+ return list(lines), 0
553
+ out = list(lines)
554
+ moved = 0
555
+ for i in range(len(out) - 1):
556
+ first, second = out[i], out[i + 1]
557
+ tail_atoms = _atoms(second)
558
+ if tail_atoms:
559
+ tail_atoms[0] = (tail_atoms[0][0], _break_spaced(first, second))
560
+ atoms = _split_hyphens(_atoms(first) + tail_atoms)
561
+ if len(atoms) < 2:
562
+ continue
563
+ cut = best_break(atoms, max_em, lang)
564
+ if cut is None:
565
+ continue
566
+ a = b = ""
567
+ for atom, sp in atoms[:cut]:
568
+ a = _join(a, atom, sp)
569
+ for atom, sp in atoms[cut:]:
570
+ b = _join(b, atom, sp)
571
+ if (a, b) != (first, second):
572
+ moved += 1
573
+ out[i], out[i + 1] = a, b
574
+ return out, moved
575
+
576
+
577
+ def _greedy_chunks(raw: str, max_em: float) -> "List[str]":
578
+ """The greedy fill on its own: the line count every mode must keep."""
579
+ current = ""
580
+ chunk: "List[str]" = []
581
+ for atom, spaced in _atoms(raw):
582
+ candidate = _join(current, atom, spaced)
583
+ if current and text_width_em(candidate) > max_em:
584
+ chunk.append(current)
585
+ current = atom
586
+ else:
587
+ current = candidate
588
+ if current:
589
+ chunk.append(current)
590
+ return chunk
591
+
592
+
593
+ def _balance(chunk: "List[str]", max_em: float, mode: str, lang: "Optional[str]") -> "List[str]":
594
+ """The post-passes for one greedy chunk, in the mode's own order. Never changes the count:
595
+ a pass that would is discarded, exactly as 1.15 did."""
596
+ if len(chunk) < 2:
597
+ return chunk
598
+ if mode == "measured":
599
+ fixed = _fix_orphans(chunk, max_em)
600
+ rebalanced = _rebalance(fixed, max_em)
601
+ else:
602
+ fixed = _fix_weak_lines(_fix_orphans(chunk, max_em), max_em)
603
+ rebalanced, _moved = _rebalance_phrase(fixed, max_em, lang)
604
+ rebalanced = _fix_weak_lines(rebalanced, max_em)
605
+ if len(rebalanced) == len(chunk):
606
+ return rebalanced
607
+ return fixed if len(fixed) == len(chunk) else chunk
608
+
609
+
610
+ def wrap_text(text: str, max_em: float, *, balance: bool = True, mode: str = "phrase",
611
+ lang: "Optional[str]" = None) -> "List[str]":
612
+ """Wrap `text` to lines no wider than `max_em` em, keeping the manual breaks it already has.
613
+
614
+ An atom wider than the whole line (one very long word) is left alone on its line rather than
615
+ cut mid-word: an over-long line is readable, a chopped word is not.
616
+
617
+ `mode="phrase"` (the default since 1.16) then applies the four phrase rules -- never inside a
618
+ word or across a hyphen's wrong side (R1), no line that is a lone digit, punctuation or kana
619
+ (R2), Japanese/Chinese breaks preferred at sentence ends and after particles, never before one
620
+ and never inside a word (R3), and an article or preposition kept with the phrase it governs by
621
+ preferring the break before it and avoiding the break after it (R4). `mode="measured"` is
622
+ 1.15's behaviour exactly: no one-character orphan line, and a break chosen only to minimise the
623
+ widest line. Neither mode ever changes the number of lines the greedy fill produced.
624
+ """
625
+ lines: "List[str]" = []
626
+ for raw in text.split("\n"):
627
+ if not raw.strip():
628
+ continue
629
+ chunk = _greedy_chunks(raw, max_em)
630
+ lines.extend(_balance(chunk, max_em, mode, lang) if balance else chunk)
631
+ return lines or [text]
632
+ def wrap_variants(text: str, max_em: float, *, mode: str = "phrase",
633
+ lang: "Optional[str]" = None) -> "Tuple[List[str], List[str], List[str]]":
634
+ """`(wrapped, greedy, measured)` for one cue from a single greedy fill.
635
+
636
+ layout_cues needs all three -- `wrapped` is what is burnt in, `greedy` is what `rebalanced`
637
+ counts against and `measured` what `phrase_breaks` counts against -- and used to call
638
+ wrap_text() three times, re-running the atomiser and the greedy fill each time. The fill is
639
+ the same for every mode, so it is done once here and only the post-passes are repeated.
640
+ `measured` is the same list object as `wrapped` when that is already the mode.
641
+ """
642
+ wrapped: "List[str]" = []
643
+ greedy: "List[str]" = []
644
+ measured: "List[str]" = []
645
+ for raw in text.split("\n"):
646
+ if not raw.strip():
647
+ continue
648
+ chunk = _greedy_chunks(raw, max_em)
649
+ greedy.extend(chunk)
650
+ wrapped.extend(_balance(list(chunk), max_em, mode, lang))
651
+ measured.extend(chunk if mode == "measured" else _balance(list(chunk), max_em, "measured", None))
652
+ if not greedy:
653
+ greedy = [text]
654
+ return (wrapped or [text], greedy, measured or [text])
655
+
656
+
657
+ # --- caption size that fits the cue (1.17) -------------------------------------------------
658
+ # The legibility floor: 4.5 % of the frame height, ass_units(0.045) = 13 against the 288-line
659
+ # ASS script grid. One floor for every destination -- 87 px of type on a 1920-tall frame, above
660
+ # the ~3.5 % where mobile legibility bottoms out and where the platforms' own caption UIs sit.
661
+ # Nothing per-platform is measured, so nothing per-platform is claimed. (The eval-17 cues happen
662
+ # to land exactly on it: 13 is the smallest size at which every one of them fits two lines.)
663
+ MIN_CAPTION_FRACTION = 0.045
664
+ ASS_SCRIPT_HEIGHT = 288 # caption.py's --size/--margin reference grid; mirrors _platforms
665
+
666
+
667
+ def line_em_for_size(size: float, play_w: "Optional[int]", play_h: "Optional[int]", *,
668
+ safe_fraction: float = SAFE_WIDTH_FRACTION,
669
+ script_height: int = ASS_SCRIPT_HEIGHT) -> "Optional[float]":
670
+ """How many em fit on one caption line at `size`, or None without geometry.
671
+
672
+ `size` is in ASS points against a `script_height`-line script (what libass's force_style
673
+ uses), so the rendered pixel size is size * play_h / script_height. This is the one width
674
+ formula: caption.py::max_line_em and fit_size() both call it.
675
+ """
676
+ if not play_w or not play_h or not size:
677
+ return None
678
+ size_px = size * play_h / float(script_height)
679
+ if size_px <= 0:
680
+ return None
681
+ return (play_w * safe_fraction) / size_px
682
+
683
+
684
+ def fit_size(cues, *, size: int, min_size: "Optional[int]" = None, max_lines: int = 2,
685
+ play_w: "Optional[int]" = None, play_h: "Optional[int]" = None,
686
+ safe_fraction: float = SAFE_WIDTH_FRACTION, mode: str = "phrase",
687
+ lang: "Optional[str]" = None, script_height: int = ASS_SCRIPT_HEIGHT,
688
+ step: int = 1, scope: str = "file") -> "Dict[str, Any]":
689
+ """The largest size in [min_size, size] at which every cue wraps to <= max_lines lines.
690
+
691
+ Pure: strings and integers in, a dict out. No ffmpeg, no ffprobe, no I/O -- the caption size
692
+ is a text-measurement decision, and measuring it must not need a subprocess.
693
+
694
+ `cues` is an iterable of cue texts (or of (start, end, text) tuples, as caption.py holds
695
+ them before layout). Returns
696
+ {"size", "floor", "requested", "scope", "shrunk", "fits", "per_cue", "max_em", "steps"}.
697
+
698
+ The search is a linear walk downwards, not a bisection, and deliberately so:
699
+ len(wrap_text(t, max_em)) is NOT guaranteed monotone in max_em under the phrase rules -- a
700
+ rebalance that is discarded at one width can be applied at the next -- and a non-monotone
701
+ predicate breaks bisection. 24 -> 13 is at most twelve iterations of pure string work.
702
+
703
+ `scope="cue"` returns one size per cue index in `per_cue`, with `size` the minimum of them;
704
+ the caller writes a per-cue {\\fsN} override. The default is `scope="file"`: a caption track
705
+ whose type size changes from cue to cue reads as a mistake, and one measured line width per
706
+ file is what makes the wrap behaviour reproducible.
707
+ """
708
+ # `texts` stays parallel to `cues`: a blank cue becomes None rather than being dropped, so
709
+ # per_cue[i] always refers to the caller's cue i. caption.py indexes layout by these keys.
710
+ texts: "List[Optional[str]]" = []
711
+ for cue in cues or []:
712
+ if isinstance(cue, (tuple, list)):
713
+ raw = cue[2] if len(cue) > 2 else cue[-1]
714
+ else:
715
+ raw = cue
716
+ texts.append(raw if raw and str(raw).strip() else None)
717
+ measurable = [t for t in texts if t is not None]
718
+ requested = int(size)
719
+ floor = int(min_size) if min_size is not None else ass_units_local(MIN_CAPTION_FRACTION,
720
+ script_height)
721
+ floor = max(1, min(floor, requested))
722
+ step = max(1, int(step))
723
+ result: "Dict[str, Any]" = {"size": requested, "floor": floor, "requested": requested,
724
+ "scope": scope, "shrunk": 0, "fits": True, "per_cue": {},
725
+ "max_em": None, "steps": 0}
726
+ em_at = lambda sz: line_em_for_size(sz, play_w, play_h, safe_fraction=safe_fraction,
727
+ script_height=script_height)
728
+ base_em = em_at(requested)
729
+ result["max_em"] = base_em
730
+ if not measurable or base_em is None or max_lines < 1:
731
+ # No geometry means no measurable width: leave the size exactly as asked.
732
+ return result
733
+
734
+ def lines_at(text: str, sz: int) -> int:
735
+ em = em_at(sz)
736
+ if em is None:
737
+ return 1
738
+ return len(wrap_text(text, em, mode=mode, lang=lang))
739
+
740
+ over_at_requested = [t for t in measurable if lines_at(t, requested) > max_lines]
741
+ result["shrunk"] = len(over_at_requested)
742
+
743
+ def best_for(subset) -> "Tuple[int, bool]":
744
+ """(largest size in [floor, requested] fitting every text in `subset`, did it fit)."""
745
+ sz = requested
746
+ while sz >= floor:
747
+ result["steps"] += 1
748
+ if all(lines_at(t, sz) <= max_lines for t in subset):
749
+ return sz, True
750
+ sz -= step
751
+ return floor, all(lines_at(t, floor) <= max_lines for t in subset)
752
+
753
+ if scope == "cue":
754
+ per_cue = {}
755
+ fits_all = True
756
+ for i, t in enumerate(texts):
757
+ if t is None:
758
+ per_cue[i] = requested # a blank cue draws nothing; it constrains nothing
759
+ continue
760
+ sz, ok = best_for([t])
761
+ per_cue[i] = sz
762
+ fits_all = fits_all and ok
763
+ result["per_cue"] = per_cue
764
+ sized = [v for i, v in per_cue.items() if texts[i] is not None]
765
+ result["size"] = min(sized) if sized else requested
766
+ result["fits"] = fits_all
767
+ else:
768
+ sz, ok = best_for(measurable)
769
+ result["size"] = sz
770
+ result["fits"] = ok
771
+ result["max_em"] = em_at(result["size"])
772
+ return result
773
+
774
+
775
+ def ass_units_local(fraction: float, script_height: int = ASS_SCRIPT_HEIGHT) -> int:
776
+ """`fraction` of the frame height in ASS units. Mirrors _platforms.ass_units, kept here so
777
+ _common.text stays importable without the scripts/ top level on sys.path."""
778
+ return int(round(fraction * script_height))