ffmpeg-skill 1.15.1 → 1.16.1
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.
- package/README.md +6 -3
- package/SKILL.md +8 -7
- package/docs/contract.md +24 -9
- package/package.json +1 -1
- package/references/scripts.md +128 -11
- package/scripts/_common/__init__.py +21 -4
- package/scripts/_common/decision.py +106 -1
- package/scripts/_common/probe.py +92 -2
- package/scripts/_common/text.py +552 -1
- package/scripts/_contract.py +19 -4
- package/scripts/caption.py +260 -200
- package/scripts/check.py +19 -0
- package/scripts/graphics.py +47 -8
- package/scripts/metadata.py +130 -5
- package/scripts/render.py +33 -6
- package/scripts/scenes.py +3 -61
- package/scripts/silence.py +3 -25
- package/scripts/waveform.py +199 -12
- package/templates/audiogram.json +31 -0
package/scripts/_common/probe.py
CHANGED
|
@@ -10,9 +10,9 @@ import math
|
|
|
10
10
|
import os
|
|
11
11
|
import re
|
|
12
12
|
from fractions import Fraction
|
|
13
|
-
from typing import Any, Dict, List, Optional, Sequence
|
|
13
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
14
14
|
from _common.emit import die
|
|
15
|
-
from _common.runner import STATE, require_tool, run, run_analysis
|
|
15
|
+
from _common.runner import STATE, dry_run_input_pending, require_tool, run, run_analysis
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def fingerprint(path: str) -> Dict[str, Any]:
|
|
@@ -380,3 +380,93 @@ def _aspect_string(w: Optional[int], h: Optional[int]) -> Optional[str]:
|
|
|
380
380
|
return None
|
|
381
381
|
f = Fraction(w, h)
|
|
382
382
|
return f"{f.numerator}:{f.denominator}"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# --------------------------------------------------------------- structure detectors (1.16)
|
|
386
|
+
# silencedetect and scdet, lifted out of silence.py and scenes.py byte-for-byte in 1.16.0 so that
|
|
387
|
+
# metadata.py --auto-chapters can measure structure without importing another tool (no script in
|
|
388
|
+
# scripts/ imports a sibling tool; only the _-prefixed modules are shared). silence.py and
|
|
389
|
+
# scenes.py import them back from here, so their behaviour is unchanged.
|
|
390
|
+
|
|
391
|
+
SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def detect_silences(path: str, threshold: float, min_silence: float) -> "List[Tuple[float, float]]":
|
|
395
|
+
if dry_run_input_pending(path):
|
|
396
|
+
return []
|
|
397
|
+
ffmpeg = require_tool("ffmpeg")
|
|
398
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af",
|
|
399
|
+
f"silencedetect=noise={threshold}dB:d={min_silence}", "-f", "null", "-"]
|
|
400
|
+
proc = run_analysis(cmd, check=False, record=True)
|
|
401
|
+
if proc.returncode != 0:
|
|
402
|
+
die(f"silencedetect failed:\n{proc.stderr.strip()[-800:]}", kind="ffmpeg")
|
|
403
|
+
silences: "List[Tuple[float, float]]" = []
|
|
404
|
+
start = None
|
|
405
|
+
for kind, val in SIL_RE.findall(proc.stderr):
|
|
406
|
+
if kind == "start":
|
|
407
|
+
start = float(val)
|
|
408
|
+
elif start is not None:
|
|
409
|
+
silences.append((start, float(val)))
|
|
410
|
+
start = None
|
|
411
|
+
if start is not None: # silence runs to the end
|
|
412
|
+
silences.append((start, float("inf")))
|
|
413
|
+
return silences
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def detect_scenes(path: str, threshold: float, min_len: float, duration: float, ratio: float = 3.0) -> "List[float]":
|
|
420
|
+
"""Scene cuts = frames whose scdet score is above `threshold` AND stands out from its
|
|
421
|
+
neighbourhood (score > ratio x median of the surrounding +-12 frames). Sustained motion,
|
|
422
|
+
flashes and fast pans raise the score on many consecutive frames and are rejected;
|
|
423
|
+
a real cut is a one-frame spike. On real footage this roughly doubles precision at
|
|
424
|
+
equal recall compared with the raw scdet threshold."""
|
|
425
|
+
ffmpeg = require_tool("ffmpeg")
|
|
426
|
+
proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
|
|
427
|
+
"scale=320:-2,scdet=threshold=0,metadata=print:file=-", "-f", "null", "-"])
|
|
428
|
+
# No `sc_pass=1` on scdet: on FFmpeg 5.x that option means "pass only the frames whose
|
|
429
|
+
# score exceeds the threshold", so every truly static frame (score exactly 0 -- a title
|
|
430
|
+
# card, colour bars) is dropped before metadata=print and the frame numbers are re-counted
|
|
431
|
+
# without them. The +-12-frame neighbourhood around a real cut then fills with the moving
|
|
432
|
+
# segment's scores instead of the still one's zeros, the cut fails the ratio test, and a
|
|
433
|
+
# 4 s smptebars scene made the cuts on both sides of it disappear (found by the 5.1.1 CI
|
|
434
|
+
# job, #146). 6.1+ passes every frame either way. Scores are still indexed by frame number
|
|
435
|
+
# and any frame the filter did not report counts as 0, so a build that drops frames again
|
|
436
|
+
# cannot shift the neighbourhood.
|
|
437
|
+
by_frame: "Dict[int, Tuple[float, float]]" = {}
|
|
438
|
+
cur = None
|
|
439
|
+
for line in proc.stdout.splitlines():
|
|
440
|
+
m = SCORE_RE.match(line)
|
|
441
|
+
if m:
|
|
442
|
+
cur = (int(m.group(1)), float(m.group(2)))
|
|
443
|
+
continue
|
|
444
|
+
if line.startswith("lavfi.scd.score=") and cur is not None:
|
|
445
|
+
try:
|
|
446
|
+
by_frame[cur[0]] = (cur[1], float(line.split("=", 1)[1]))
|
|
447
|
+
except ValueError:
|
|
448
|
+
pass
|
|
449
|
+
cuts = [0.0]
|
|
450
|
+
if not by_frame:
|
|
451
|
+
return cuts
|
|
452
|
+
n_frames = max(by_frame) + 1
|
|
453
|
+
times: "List[float]" = [by_frame[i][0] if i in by_frame else -1.0 for i in range(n_frames)]
|
|
454
|
+
scores: "List[float]" = [by_frame[i][1] if i in by_frame else 0.0 for i in range(n_frames)]
|
|
455
|
+
w = 12
|
|
456
|
+
for i, sc in enumerate(scores):
|
|
457
|
+
if sc < threshold:
|
|
458
|
+
continue
|
|
459
|
+
lo, hi = max(0, i - w), min(len(scores), i + w + 1)
|
|
460
|
+
neigh = sorted(scores[lo:i] + scores[i + 1:hi])
|
|
461
|
+
med = neigh[len(neigh) // 2] if neigh else 0.0
|
|
462
|
+
if sc < ratio * max(med, 0.5):
|
|
463
|
+
continue
|
|
464
|
+
# keep only the local maximum inside +-2 frames
|
|
465
|
+
if any(scores[j] > sc for j in range(max(0, i - 2), min(len(scores), i + 3)) if j != i):
|
|
466
|
+
continue
|
|
467
|
+
t = times[i]
|
|
468
|
+
if t - cuts[-1] >= min_len:
|
|
469
|
+
cuts.append(t)
|
|
470
|
+
if duration - cuts[-1] < min_len and len(cuts) > 1:
|
|
471
|
+
cuts.pop()
|
|
472
|
+
return cuts
|
package/scripts/_common/text.py
CHANGED
|
@@ -440,7 +440,14 @@ ADVANCE_EM = {"ja": 1.0, "zh": 1.0, "ko": 1.0, "th": 1.0, "hi": 0.7, "ar": 0.6,
|
|
|
440
440
|
|
|
441
441
|
|
|
442
442
|
# Scripts written without spaces: a line breaks between any two characters.
|
|
443
|
-
|
|
443
|
+
# Scripts a line may break inside a run of, one character at a time. Thai is deliberately NOT
|
|
444
|
+
# here since 1.16.1: it writes no space inside a phrase, and without a dictionary the wrapper
|
|
445
|
+
# cannot see where one word ends -- every character-level break it took in eval 17 landed inside
|
|
446
|
+
# a word. A Thai run is therefore one atom, broken only at the spaces (or the manual `|`) the
|
|
447
|
+
# writer put there; an over-long run stays long on its own line, the rule long Latin words
|
|
448
|
+
# already follow.
|
|
449
|
+
NO_SPACE_SCRIPTS = ("ja", "zh", "ko")
|
|
450
|
+
NO_BOUNDARY_SCRIPTS = ("th",) # per-character breaking would chop words: keep the run whole
|
|
444
451
|
|
|
445
452
|
|
|
446
453
|
# Per-character Latin advances in em, read off DejaVu Sans (the default caption family, and close
|
|
@@ -978,3 +985,547 @@ def drawtext_text_opts(text: str, tmpdir: "Optional[str]" = None) -> str:
|
|
|
978
985
|
path = os.path.join(tmpdir, name)
|
|
979
986
|
_DRAWTEXT_PENDING[path] = cleaned
|
|
980
987
|
return f"textfile={escape_filter_path(path)}:expansion=none"
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
# --------------------------------------------------------------- caption line breaking (1.16)
|
|
991
|
+
# Lifted out of caption.py in 1.16.0 so graphics.py can wrap the same way (caption.py keeps the
|
|
992
|
+
# names it exported, re-imported from here). The whole breaker is pure: a string in, a list of
|
|
993
|
+
# lines out, no subprocess and no probe, which is what makes the eval regression corpus cheap
|
|
994
|
+
# to lock down in unit tests.
|
|
995
|
+
|
|
996
|
+
# How much of the frame width a caption line may use. libass's own default SRT margins are 10 of a
|
|
997
|
+
# 384-wide script (2.6 % a side); 5 % a side is the safe area every platform check in this repo uses.
|
|
998
|
+
SAFE_WIDTH_FRACTION = 0.9
|
|
999
|
+
# ORPHAN_MIN_EM: one full-width CJK/Thai character plus a hair. A last line narrower than this is a
|
|
1000
|
+
# single stranded character -- eval 14's th1 (a lone 'ล') and dl3 (a lone '行').
|
|
1001
|
+
ORPHAN_MIN_EM = 1.1
|
|
1002
|
+
|
|
1003
|
+
WRAP_MODES = ("phrase", "measured")
|
|
1004
|
+
|
|
1005
|
+
# R3's Japanese preference table. These are *preferences* applied only among positions that
|
|
1006
|
+
# already fit the line, so the table can never make a line too wide or change the line count.
|
|
1007
|
+
#
|
|
1008
|
+
# JA_PARTICLES is a "do not strand at the start of a line" table, which is the direction kinsoku
|
|
1009
|
+
# practice actually goes: a particle is enclitic -- it attaches to the word BEFORE it and marks
|
|
1010
|
+
# that word's role -- so a line beginning with は or が reads as a fragment torn off its phrase.
|
|
1011
|
+
# A break AFTER a particle is therefore preferred (the particle stays with what it marks) and a
|
|
1012
|
+
# break BEFORE one is forbidden. The list is the eight case/topic particles named in the 1.16.0
|
|
1013
|
+
# task brief (は が を に で と の へ) plus も や から まで より, which a reader of Japanese would
|
|
1014
|
+
# add for the same reason. It is a judgement call with no upstream source; treat it as tunable
|
|
1015
|
+
# data, not as grammar.
|
|
1016
|
+
JA_PARTICLES = "はがをにでとのへもや" # a break AFTER one of these is preferred, BEFORE one forbidden
|
|
1017
|
+
# The multi-character members of the same table. They are matched as whole strings against the
|
|
1018
|
+
# text on each side of a candidate break -- putting them in the character string above turned
|
|
1019
|
+
# か, ら, ま, で, よ and り into one-character particles of their own, which none of them is.
|
|
1020
|
+
JA_PARTICLE_WORDS = ("から", "まで", "より")
|
|
1021
|
+
JA_SENTENCE_END = "。、!?」』)" # a break AFTER one of these is preferred
|
|
1022
|
+
# Characters that may never start a line: small kana, the prolonged sound mark, closing brackets
|
|
1023
|
+
# and the Japanese punctuation that hangs on the end of the line before it.
|
|
1024
|
+
JA_NO_LINE_START = "ぁぃぅぇぉっゃゅょァィゥェォッャュョーヽヾゝゞ、。!?)」』】〕》’”%"
|
|
1025
|
+
JA_NO_LINE_END = "(「『【〔《‘“" # ... and the ones that may never end a line
|
|
1026
|
+
|
|
1027
|
+
# R4. Function words belong to the phrase that FOLLOWS them: an article or preposition begins the
|
|
1028
|
+
# noun phrase it governs, so a break before one is the good break (the word opens the next line
|
|
1029
|
+
# with its phrase) and a break after one is the bad break (it is stranded at the end of a line,
|
|
1030
|
+
# away from what it governs). Both directions are scored, which is what makes the rule decide
|
|
1031
|
+
# rather than merely veto. Frozen data, matched case-folded on the atom with its punctuation
|
|
1032
|
+
# stripped; six languages because those are the Latin-script languages the eval corpus covers. A
|
|
1033
|
+
# word in several sets means the same thing structurally in each, so the union is used when no
|
|
1034
|
+
# --lang was given.
|
|
1035
|
+
FUNCTION_WORDS = {
|
|
1036
|
+
"en": {"a", "an", "the", "of", "to", "in", "on", "at", "for", "with", "by", "from", "and",
|
|
1037
|
+
"or", "as", "is", "it", "its", "this", "that", "into", "than", "but", "so"},
|
|
1038
|
+
"es": {"el", "la", "los", "las", "un", "una", "unos", "unas", "de", "del", "al", "en", "con",
|
|
1039
|
+
"por", "para", "y", "o", "que", "su", "sus", "lo", "se", "es"},
|
|
1040
|
+
"pt": {"o", "a", "os", "as", "um", "uma", "de", "do", "da", "dos", "das", "em", "no", "na",
|
|
1041
|
+
"nos", "nas", "com", "por", "para", "e", "que", "se", "ao", "aos"},
|
|
1042
|
+
"fr": {"le", "la", "les", "un", "une", "de", "du", "des", "à", "au", "aux", "en", "dans",
|
|
1043
|
+
"et", "ou", "que", "qui", "ce", "ces", "son", "sa", "ses", "par", "pour", "avec", "sur"},
|
|
1044
|
+
"de": {"der", "die", "das", "ein", "eine", "einen", "einem", "einer", "den", "dem", "des",
|
|
1045
|
+
"zu", "in", "im", "auf", "mit", "und", "oder", "von", "vom", "für", "aus", "an"},
|
|
1046
|
+
"it": {"il", "lo", "la", "i", "gli", "le", "un", "una", "uno", "di", "del", "della", "da",
|
|
1047
|
+
"in", "nel", "con", "per", "e", "che", "su", "al", "ai"},
|
|
1048
|
+
}
|
|
1049
|
+
_FUNCTION_WORDS_ANY = frozenset().union(*FUNCTION_WORDS.values())
|
|
1050
|
+
|
|
1051
|
+
# Penalty scores. Only the ordering matters; 1.0 means "never choose this if anything else fits".
|
|
1052
|
+
PENALTY_FORBIDDEN = 1.0
|
|
1053
|
+
PENALTY_OKURIGANA = 0.9 # between a kanji stem and the hiragana that inflects it
|
|
1054
|
+
PENALTY_FUNCTION_WORD = 0.8 # R4: the line before the break ends in an article/preposition
|
|
1055
|
+
PENALTY_IDEOGRAPHS = 0.6 # between two kanji: no evidence either way, mildly discouraged
|
|
1056
|
+
PENALTY_NEUTRAL = 0.5 # between two content words, or two characters with nothing to say
|
|
1057
|
+
PENALTY_FUNCTION_WORD_START = 0.2 # R4: the next line opens with the article/preposition it governs
|
|
1058
|
+
PENALTY_PARTICLE = 0.2 # R3: after a particle, so the particle stays with the word it marks
|
|
1059
|
+
PENALTY_SENTENCE_END = 0.0 # R3: after 。、!? -- the one break a reader expects
|
|
1060
|
+
|
|
1061
|
+
_HYPHENS = ("-", "‐") # ‑ (non-breaking hyphen) is deliberately NOT here
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _atoms(line: str) -> "List[Tuple[str, bool]]":
|
|
1065
|
+
"""Break a line into the smallest pieces a wrap may separate -- one atom per CJK/Thai
|
|
1066
|
+
character, one per emoji cluster, one per whitespace-delimited word otherwise -- each with
|
|
1067
|
+
whether a space stood before it in the original. The flag is what puts the text back together
|
|
1068
|
+
exactly as written: "Hello 世界" keeps its space, "世界です" gains none."""
|
|
1069
|
+
out: "List[Tuple[str, bool]]" = []
|
|
1070
|
+
word = ""
|
|
1071
|
+
spaced = False # a space stands before the atom being built
|
|
1072
|
+
pending = False # a space stands before the NEXT atom
|
|
1073
|
+
attach_next = False # a leading Thai/Lao vowel is waiting for its base consonant
|
|
1074
|
+
# An emoji cluster is one atom: a wrap must never land inside a ZWJ sequence, a flag pair or
|
|
1075
|
+
# between a base and its skin-tone modifier (the same rule combining marks already follow).
|
|
1076
|
+
clusters = {i: len(cl) for i, cl in emoji_clusters(line)}
|
|
1077
|
+
i = 0
|
|
1078
|
+
while i < len(line):
|
|
1079
|
+
ch = line[i]
|
|
1080
|
+
if i in clusters:
|
|
1081
|
+
cluster = line[i:i + clusters[i]]
|
|
1082
|
+
if word:
|
|
1083
|
+
out.append((word, spaced))
|
|
1084
|
+
word = ""
|
|
1085
|
+
out.append((cluster, pending))
|
|
1086
|
+
pending = False
|
|
1087
|
+
attach_next = False
|
|
1088
|
+
i += clusters[i]
|
|
1089
|
+
continue
|
|
1090
|
+
i += 1
|
|
1091
|
+
if char_script(ch) in NO_SPACE_SCRIPTS:
|
|
1092
|
+
if word:
|
|
1093
|
+
out.append((word, spaced))
|
|
1094
|
+
word = ""
|
|
1095
|
+
if out and not pending and _is_katakana_run(ch) and _is_katakana_run(out[-1][0][-1]):
|
|
1096
|
+
# a katakana word (タイミング, コンピューター) is one atom: eval 17 saw タイ|ミング
|
|
1097
|
+
out[-1] = (out[-1][0] + ch, out[-1][1])
|
|
1098
|
+
elif out and (attach_next or _is_mark(ch)):
|
|
1099
|
+
# never break between a base and the mark (or the leading vowel) that belongs to
|
|
1100
|
+
# it: the line would start with an orphaned tone mark or vowel sign
|
|
1101
|
+
out[-1] = (out[-1][0] + ch, out[-1][1])
|
|
1102
|
+
else:
|
|
1103
|
+
out.append((ch, pending))
|
|
1104
|
+
pending = False
|
|
1105
|
+
attach_next = ord(ch) in LEADING_VOWELS
|
|
1106
|
+
elif ch.isspace():
|
|
1107
|
+
if word:
|
|
1108
|
+
out.append((word, spaced))
|
|
1109
|
+
word = ""
|
|
1110
|
+
pending = True
|
|
1111
|
+
else:
|
|
1112
|
+
if not word:
|
|
1113
|
+
spaced, pending = pending, False
|
|
1114
|
+
word += ch
|
|
1115
|
+
if word:
|
|
1116
|
+
out.append((word, spaced))
|
|
1117
|
+
return out
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def _split_hyphens(atoms: "List[Tuple[str, bool]]") -> "List[Tuple[str, bool]]":
|
|
1121
|
+
"""R1's one addition to the atom list: a hyphenated token may break *after* its hyphen.
|
|
1122
|
+
|
|
1123
|
+
"end-to-end" becomes `end-` / `to-` / `end`, each piece carrying the space flag of the token
|
|
1124
|
+
it came from for the first piece and False for the rest, so _join() puts it back with no space
|
|
1125
|
+
at all. A hyphen that is the first or last character of the token (`-5`, `well-`) is never a
|
|
1126
|
+
break point: the guard is that both sides must be non-empty."""
|
|
1127
|
+
out: "List[Tuple[str, bool]]" = []
|
|
1128
|
+
for atom, spaced in atoms:
|
|
1129
|
+
if len(atom) < 3 or not any(h in atom[1:-1] for h in _HYPHENS):
|
|
1130
|
+
out.append((atom, spaced))
|
|
1131
|
+
continue
|
|
1132
|
+
piece = ""
|
|
1133
|
+
first = True
|
|
1134
|
+
for i, ch in enumerate(atom):
|
|
1135
|
+
piece += ch
|
|
1136
|
+
if ch in _HYPHENS and 0 < i < len(atom) - 1:
|
|
1137
|
+
out.append((piece, spaced if first else False))
|
|
1138
|
+
piece = ""
|
|
1139
|
+
first = False
|
|
1140
|
+
if piece:
|
|
1141
|
+
out.append((piece, spaced if first else False))
|
|
1142
|
+
return out
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def _join(left: str, atom: str, spaced: bool) -> str:
|
|
1146
|
+
"""Put an atom back on a line, restoring the space that stood before it."""
|
|
1147
|
+
if not left:
|
|
1148
|
+
return atom
|
|
1149
|
+
return left + (" " if spaced else "") + atom
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _break_spaced(first: str, second: str) -> bool:
|
|
1153
|
+
"""Did a space stand at the break between these two wrapped lines? Only spaced scripts put one
|
|
1154
|
+
there -- a CJK/Thai break sits between two characters that were written with nothing between
|
|
1155
|
+
them, and re-joining them with a space would insert a character the cue never had."""
|
|
1156
|
+
if not first or not second:
|
|
1157
|
+
return False
|
|
1158
|
+
return char_script(first[-1]) not in NO_SPACE_SCRIPTS and char_script(second[0]) not in NO_SPACE_SCRIPTS \
|
|
1159
|
+
and char_script(first[-1]) != "emoji" and char_script(second[0]) != "emoji"
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _is_kana(ch: str) -> bool:
|
|
1163
|
+
return 0x3040 <= ord(ch) <= 0x30FF
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def _is_katakana_run(ch: str) -> bool:
|
|
1167
|
+
"""Katakana proper plus the prolonged-sound mark: the characters one loan word is made of."""
|
|
1168
|
+
cp = ord(ch)
|
|
1169
|
+
return (0x30A1 <= cp <= 0x30FA) or cp == 0x30FC or (0x31F0 <= cp <= 0x31FF) or (0xFF66 <= cp <= 0xFF9F)
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _is_hiragana(ch: str) -> bool:
|
|
1173
|
+
return 0x3040 <= ord(ch) <= 0x309F
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _is_ideograph(ch: str) -> bool:
|
|
1177
|
+
cp = ord(ch)
|
|
1178
|
+
return 0x3400 <= cp <= 0x4DBF or 0x4E00 <= cp <= 0x9FFF or 0xF900 <= cp <= 0xFAFF
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _is_weak_line(line: str) -> "bool":
|
|
1182
|
+
"""A line no reader should be given on its own (R2).
|
|
1183
|
+
|
|
1184
|
+
1.15 asked only "is the last line one atom narrower than ORPHAN_MIN_EM", which a full-width
|
|
1185
|
+
character passes: dl3 still showed a lone `2` and a stranded `行`. Three cases instead, any of
|
|
1186
|
+
which makes a line too thin to stand alone:
|
|
1187
|
+
- a single character narrower than ORPHAN_MIN_EM (1.15's rule, kept);
|
|
1188
|
+
- nothing but digits, punctuation and symbols, at most two characters ("2", "--");
|
|
1189
|
+
- a single kana, whatever its width -- a kana is a full em and passes the width test, but a
|
|
1190
|
+
line holding one is a syllable, not a word.
|
|
1191
|
+
"""
|
|
1192
|
+
stripped = (line or "").strip()
|
|
1193
|
+
if not stripped:
|
|
1194
|
+
return True
|
|
1195
|
+
if len(stripped) == 1 and text_width_em(stripped) < ORPHAN_MIN_EM:
|
|
1196
|
+
return True
|
|
1197
|
+
if len(stripped) <= 2 and all(unicodedata.category(c)[0] in "NPS" for c in stripped):
|
|
1198
|
+
return True
|
|
1199
|
+
if len(stripped) == 1 and char_script(stripped) == "ja" and _is_kana(stripped):
|
|
1200
|
+
return True
|
|
1201
|
+
return False
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _function_words(lang: "Optional[str]") -> "frozenset":
|
|
1205
|
+
"""R4's table for this language. An unknown or absent language gets the union of the six sets:
|
|
1206
|
+
a token that appears in several of them is the same kind of word in each, which is why the
|
|
1207
|
+
rule is a penalty and not a refusal."""
|
|
1208
|
+
key = (lang or "").strip().lower().split("-")[0]
|
|
1209
|
+
if key in FUNCTION_WORDS:
|
|
1210
|
+
return frozenset(FUNCTION_WORDS[key])
|
|
1211
|
+
return _FUNCTION_WORDS_ANY
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _bare_word(atom: str) -> str:
|
|
1215
|
+
return "".join(c for c in (atom or "") if c.isalpha() or c == "'").strip("'").lower()
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def _particle_starts(text: str) -> bool:
|
|
1219
|
+
"""Does `text` begin with a particle -- one character, or one of the two-character ones?"""
|
|
1220
|
+
if not text:
|
|
1221
|
+
return False
|
|
1222
|
+
return text[0] in JA_PARTICLES or text.startswith(JA_PARTICLE_WORDS)
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _particle_ends(text: str) -> bool:
|
|
1226
|
+
"""Does `text` end with a particle? `から` counts, a bare `ら` does not."""
|
|
1227
|
+
if not text:
|
|
1228
|
+
return False
|
|
1229
|
+
return text[-1] in JA_PARTICLES or text.endswith(JA_PARTICLE_WORDS)
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def break_penalty(prev_char: str, next_char: str, lang: "Optional[str]" = None,
|
|
1233
|
+
before: str = "", after: str = "") -> float:
|
|
1234
|
+
"""How bad a break between these two characters is, 0.0 (preferred) to 1.0 (forbidden).
|
|
1235
|
+
|
|
1236
|
+
Only consulted among break positions that already fit `max_em`, so a preference can never
|
|
1237
|
+
widen a line or change the line count. Japanese gets the particle half of the table -- a break
|
|
1238
|
+
AFTER a particle is preferred and a break BEFORE one forbidden, because a particle attaches to
|
|
1239
|
+
the word before it; Chinese gets only the sentence-end and forbidden halves, because particles
|
|
1240
|
+
are Japanese grammar.
|
|
1241
|
+
|
|
1242
|
+
`before`/`after` are the text on each side of the break when the caller has it, which is what
|
|
1243
|
+
lets the two-character particles (から/まで/より) be matched as words. Without them only the
|
|
1244
|
+
single-character table applies."""
|
|
1245
|
+
if not prev_char or not next_char:
|
|
1246
|
+
return PENALTY_NEUTRAL
|
|
1247
|
+
script = (lang or "").strip().lower().split("-")[0]
|
|
1248
|
+
if script not in ("ja", "zh"):
|
|
1249
|
+
# A kana on either side settles it: only Japanese has them, and char_script() reads a bare
|
|
1250
|
+
# Han character as Chinese, which used to switch the particle rules off for exactly the
|
|
1251
|
+
# break they exist to judge (`...が|決まる` -- kana before, kanji after).
|
|
1252
|
+
if _is_kana(prev_char) or _is_kana(next_char):
|
|
1253
|
+
script = "ja"
|
|
1254
|
+
else:
|
|
1255
|
+
script = char_script(next_char)
|
|
1256
|
+
if script not in ("ja", "zh"):
|
|
1257
|
+
script = char_script(prev_char)
|
|
1258
|
+
if next_char in JA_NO_LINE_START or prev_char in JA_NO_LINE_END or _is_mark(next_char):
|
|
1259
|
+
return PENALTY_FORBIDDEN
|
|
1260
|
+
if script not in ("ja", "zh"):
|
|
1261
|
+
return PENALTY_NEUTRAL
|
|
1262
|
+
if prev_char in JA_SENTENCE_END:
|
|
1263
|
+
return PENALTY_SENTENCE_END
|
|
1264
|
+
if script == "ja" and _particle_starts(after or next_char):
|
|
1265
|
+
# a particle may not open a line: it belongs to the word before it (kinsoku)
|
|
1266
|
+
return PENALTY_FORBIDDEN
|
|
1267
|
+
if script == "ja" and _particle_ends(before or prev_char):
|
|
1268
|
+
return PENALTY_PARTICLE
|
|
1269
|
+
if script == "ja" and _is_ideograph(prev_char) and _is_hiragana(next_char):
|
|
1270
|
+
# okurigana: 決|まる is inside a word even though neither half is a "word" on its own
|
|
1271
|
+
return PENALTY_OKURIGANA
|
|
1272
|
+
if _is_ideograph(prev_char) and _is_ideograph(next_char):
|
|
1273
|
+
return PENALTY_IDEOGRAPHS
|
|
1274
|
+
return PENALTY_NEUTRAL
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _cut_penalty(atoms: "Sequence[Tuple[str, bool]]", cut: int, lang: "Optional[str]") -> float:
|
|
1278
|
+
"""The penalty of breaking `atoms` before index `cut`."""
|
|
1279
|
+
prev_atom = atoms[cut - 1][0]
|
|
1280
|
+
next_atom = atoms[cut][0]
|
|
1281
|
+
if not prev_atom or not next_atom:
|
|
1282
|
+
return PENALTY_NEUTRAL
|
|
1283
|
+
if atoms[cut][1]:
|
|
1284
|
+
# a space stood here: a spaced script, so R4 is the rule that applies, in both directions
|
|
1285
|
+
if all(not ch.isalnum() for ch in next_atom):
|
|
1286
|
+
return PENALTY_FORBIDDEN # never strand punctuation at the start of a line
|
|
1287
|
+
words = _function_words(lang)
|
|
1288
|
+
if _bare_word(prev_atom) in words:
|
|
1289
|
+
return PENALTY_FUNCTION_WORD # stranded at the end of a line, away from its noun
|
|
1290
|
+
if _bare_word(next_atom) in words:
|
|
1291
|
+
return PENALTY_FUNCTION_WORD_START # opens the next line with the phrase it governs
|
|
1292
|
+
return PENALTY_NEUTRAL
|
|
1293
|
+
if prev_atom.endswith(_HYPHENS):
|
|
1294
|
+
return PENALTY_NEUTRAL # R1: a hyphen is a legitimate break point
|
|
1295
|
+
# the text on each side, so a two-character particle (から/まで/より) is seen as one
|
|
1296
|
+
before = "".join(a for a, _sp in atoms[:cut])
|
|
1297
|
+
after = "".join(a for a, _sp in atoms[cut:])
|
|
1298
|
+
return break_penalty(prev_atom[-1], next_atom[0], lang, before=before, after=after)
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def best_break(atoms: "Sequence[Tuple[str, bool]]", max_em: float,
|
|
1302
|
+
lang: "Optional[str]" = None) -> "Optional[int]":
|
|
1303
|
+
"""The index to break `atoms` at so they become two lines, or None when none fits.
|
|
1304
|
+
|
|
1305
|
+
Among every position whose two halves both fit `max_em`, the one minimising
|
|
1306
|
+
(penalty, widest line, |width difference|) wins: R1-R4 choose first, and 1.15's
|
|
1307
|
+
minimise-the-widest-line rule breaks the ties it used to decide alone."""
|
|
1308
|
+
best = None
|
|
1309
|
+
for cut in range(1, len(atoms)):
|
|
1310
|
+
a = b = ""
|
|
1311
|
+
for atom, sp in atoms[:cut]:
|
|
1312
|
+
a = _join(a, atom, sp)
|
|
1313
|
+
for atom, sp in atoms[cut:]:
|
|
1314
|
+
b = _join(b, atom, sp)
|
|
1315
|
+
wa, wb = text_width_em(a), text_width_em(b)
|
|
1316
|
+
if max(wa, wb) > max_em:
|
|
1317
|
+
continue
|
|
1318
|
+
if _is_weak_line(a) or _is_weak_line(b):
|
|
1319
|
+
continue
|
|
1320
|
+
key = (_cut_penalty(atoms, cut, lang), max(wa, wb), abs(wa - wb))
|
|
1321
|
+
if best is None or key < best[0]:
|
|
1322
|
+
best = (key, cut)
|
|
1323
|
+
return None if best is None else best[1]
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def _fix_orphans(lines: "List[str]", max_em: float) -> "List[str]":
|
|
1327
|
+
"""No last line that is a single stranded atom.
|
|
1328
|
+
|
|
1329
|
+
Greedy wrapping leaves one character alone whenever the line before it filled exactly: eval 14
|
|
1330
|
+
produced a Thai cue ending in a lone `ล` and a Japanese one ending in a lone `行`. While the
|
|
1331
|
+
last line is one atom narrower than ORPHAN_MIN_EM, the last atom of the line above moves down
|
|
1332
|
+
onto it -- but only while the result still fits and the line above does not become an orphan
|
|
1333
|
+
itself, so a two-word cue is never made worse."""
|
|
1334
|
+
lines = list(lines)
|
|
1335
|
+
for _ in range(len(lines)):
|
|
1336
|
+
if len(lines) < 2:
|
|
1337
|
+
break
|
|
1338
|
+
tail = _atoms(lines[-1])
|
|
1339
|
+
if len(tail) != 1 or text_width_em(lines[-1]) >= ORPHAN_MIN_EM:
|
|
1340
|
+
break
|
|
1341
|
+
prev = _atoms(lines[-2])
|
|
1342
|
+
if len(prev) < 2:
|
|
1343
|
+
break
|
|
1344
|
+
moved, spaced = prev[-1]
|
|
1345
|
+
new_prev = ""
|
|
1346
|
+
for atom, sp in prev[:-1]:
|
|
1347
|
+
new_prev = _join(new_prev, atom, sp)
|
|
1348
|
+
new_last = _join(moved, tail[0][0], _break_spaced(lines[-2], lines[-1]))
|
|
1349
|
+
if text_width_em(new_last) > max_em or text_width_em(new_prev) < ORPHAN_MIN_EM:
|
|
1350
|
+
break
|
|
1351
|
+
lines[-2], lines[-1] = new_prev, new_last
|
|
1352
|
+
return lines
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
def _fix_weak_lines(lines: "List[str]", max_em: float) -> "List[str]":
|
|
1356
|
+
"""R2, generalised: _fix_orphans run at *every* boundary, against _is_weak_line.
|
|
1357
|
+
|
|
1358
|
+
1.15 only ever looked at the last line, so a stranded digit or kana in the middle of a
|
|
1359
|
+
three-line cue survived. Walking upward from the last line, while a line is weak the last atom
|
|
1360
|
+
of the line above moves down onto it -- with 1.15's two guards intact (the result must still
|
|
1361
|
+
fit, and the line above must not itself become weak), so the line count never changes."""
|
|
1362
|
+
lines = list(lines)
|
|
1363
|
+
for i in range(len(lines) - 1, 0, -1):
|
|
1364
|
+
for _ in range(len(lines)):
|
|
1365
|
+
if not _is_weak_line(lines[i]):
|
|
1366
|
+
break
|
|
1367
|
+
prev = _atoms(lines[i - 1])
|
|
1368
|
+
if len(prev) < 2:
|
|
1369
|
+
break
|
|
1370
|
+
moved, _spaced = prev[-1]
|
|
1371
|
+
new_prev = ""
|
|
1372
|
+
for atom, sp in prev[:-1]:
|
|
1373
|
+
new_prev = _join(new_prev, atom, sp)
|
|
1374
|
+
new_last = _join(moved, lines[i], _break_spaced(lines[i - 1], lines[i]))
|
|
1375
|
+
if text_width_em(new_last) > max_em or _is_weak_line(new_prev) \
|
|
1376
|
+
or text_width_em(new_prev) < ORPHAN_MIN_EM:
|
|
1377
|
+
break
|
|
1378
|
+
lines[i - 1], lines[i] = new_prev, new_last
|
|
1379
|
+
return lines
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def _rebalance(lines: "List[str]", max_em: float) -> "List[str]":
|
|
1383
|
+
"""Move each break to the one that minimises the widest line of the pair, without changing the
|
|
1384
|
+
line count.
|
|
1385
|
+
|
|
1386
|
+
Greedy wrapping fills line 1 to the brim and leaves line 2 short, which is what split eval 14's
|
|
1387
|
+
`"A third line the tool times for me"` mid-phrase. Only spaced scripts are rebalanced: a
|
|
1388
|
+
non-spaced script has no phrase structure in its atom list, so moving the break there only
|
|
1389
|
+
moves the ragged edge. A break is never placed before a punctuation-only atom."""
|
|
1390
|
+
if len(lines) < 2:
|
|
1391
|
+
return lines
|
|
1392
|
+
out = list(lines)
|
|
1393
|
+
for i in range(len(out) - 1):
|
|
1394
|
+
first, second = out[i], out[i + 1]
|
|
1395
|
+
tail_atoms = _atoms(second)
|
|
1396
|
+
if tail_atoms:
|
|
1397
|
+
tail_atoms[0] = (tail_atoms[0][0], _break_spaced(first, second))
|
|
1398
|
+
atoms = _atoms(first) + tail_atoms
|
|
1399
|
+
if not atoms or any(char_script(ch) in NO_SPACE_SCRIPTS for ch in first + second):
|
|
1400
|
+
continue
|
|
1401
|
+
best = None
|
|
1402
|
+
for cut in range(1, len(atoms)):
|
|
1403
|
+
if not atoms[cut][1]:
|
|
1404
|
+
continue # only break where a space stood
|
|
1405
|
+
if all(not ch.isalnum() for ch in atoms[cut][0]):
|
|
1406
|
+
continue # never strand punctuation at the start of a line
|
|
1407
|
+
a = b = ""
|
|
1408
|
+
for atom, sp in atoms[:cut]:
|
|
1409
|
+
a = _join(a, atom, sp)
|
|
1410
|
+
for atom, sp in atoms[cut:]:
|
|
1411
|
+
b = _join(b, atom, sp)
|
|
1412
|
+
wa, wb = text_width_em(a), text_width_em(b)
|
|
1413
|
+
if max(wa, wb) > max_em:
|
|
1414
|
+
continue
|
|
1415
|
+
key = (max(wa, wb), abs(wa - wb))
|
|
1416
|
+
if best is None or key < best[0]:
|
|
1417
|
+
best = (key, a, b)
|
|
1418
|
+
if best is not None:
|
|
1419
|
+
out[i], out[i + 1] = best[1], best[2]
|
|
1420
|
+
return out
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def _rebalance_phrase(lines: "List[str]", max_em: float, lang: "Optional[str]") -> "Tuple[List[str], int]":
|
|
1424
|
+
"""_rebalance with R1-R4 deciding, for every script rather than spaced ones only.
|
|
1425
|
+
|
|
1426
|
+
Returns the new lines and how many breaks a phrase rule moved away from the position 1.15's
|
|
1427
|
+
widest-line rule alone would have chosen -- the `phrase_breaks` count in the result."""
|
|
1428
|
+
if len(lines) < 2:
|
|
1429
|
+
return list(lines), 0
|
|
1430
|
+
out = list(lines)
|
|
1431
|
+
moved = 0
|
|
1432
|
+
for i in range(len(out) - 1):
|
|
1433
|
+
first, second = out[i], out[i + 1]
|
|
1434
|
+
tail_atoms = _atoms(second)
|
|
1435
|
+
if tail_atoms:
|
|
1436
|
+
tail_atoms[0] = (tail_atoms[0][0], _break_spaced(first, second))
|
|
1437
|
+
atoms = _split_hyphens(_atoms(first) + tail_atoms)
|
|
1438
|
+
if len(atoms) < 2:
|
|
1439
|
+
continue
|
|
1440
|
+
cut = best_break(atoms, max_em, lang)
|
|
1441
|
+
if cut is None:
|
|
1442
|
+
continue
|
|
1443
|
+
a = b = ""
|
|
1444
|
+
for atom, sp in atoms[:cut]:
|
|
1445
|
+
a = _join(a, atom, sp)
|
|
1446
|
+
for atom, sp in atoms[cut:]:
|
|
1447
|
+
b = _join(b, atom, sp)
|
|
1448
|
+
if (a, b) != (first, second):
|
|
1449
|
+
moved += 1
|
|
1450
|
+
out[i], out[i + 1] = a, b
|
|
1451
|
+
return out, moved
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _greedy_chunks(raw: str, max_em: float) -> "List[str]":
|
|
1455
|
+
"""The greedy fill on its own: the line count every mode must keep."""
|
|
1456
|
+
current = ""
|
|
1457
|
+
chunk: "List[str]" = []
|
|
1458
|
+
for atom, spaced in _atoms(raw):
|
|
1459
|
+
candidate = _join(current, atom, spaced)
|
|
1460
|
+
if current and text_width_em(candidate) > max_em:
|
|
1461
|
+
chunk.append(current)
|
|
1462
|
+
current = atom
|
|
1463
|
+
else:
|
|
1464
|
+
current = candidate
|
|
1465
|
+
if current:
|
|
1466
|
+
chunk.append(current)
|
|
1467
|
+
return chunk
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def _balance(chunk: "List[str]", max_em: float, mode: str, lang: "Optional[str]") -> "List[str]":
|
|
1471
|
+
"""The post-passes for one greedy chunk, in the mode's own order. Never changes the count:
|
|
1472
|
+
a pass that would is discarded, exactly as 1.15 did."""
|
|
1473
|
+
if len(chunk) < 2:
|
|
1474
|
+
return chunk
|
|
1475
|
+
if mode == "measured":
|
|
1476
|
+
fixed = _fix_orphans(chunk, max_em)
|
|
1477
|
+
rebalanced = _rebalance(fixed, max_em)
|
|
1478
|
+
else:
|
|
1479
|
+
fixed = _fix_weak_lines(_fix_orphans(chunk, max_em), max_em)
|
|
1480
|
+
rebalanced, _moved = _rebalance_phrase(fixed, max_em, lang)
|
|
1481
|
+
rebalanced = _fix_weak_lines(rebalanced, max_em)
|
|
1482
|
+
if len(rebalanced) == len(chunk):
|
|
1483
|
+
return rebalanced
|
|
1484
|
+
return fixed if len(fixed) == len(chunk) else chunk
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
def wrap_text(text: str, max_em: float, *, balance: bool = True, mode: str = "phrase",
|
|
1488
|
+
lang: "Optional[str]" = None) -> "List[str]":
|
|
1489
|
+
"""Wrap `text` to lines no wider than `max_em` em, keeping the manual breaks it already has.
|
|
1490
|
+
|
|
1491
|
+
An atom wider than the whole line (one very long word) is left alone on its line rather than
|
|
1492
|
+
cut mid-word: an over-long line is readable, a chopped word is not.
|
|
1493
|
+
|
|
1494
|
+
`mode="phrase"` (the default since 1.16) then applies the four phrase rules -- never inside a
|
|
1495
|
+
word or across a hyphen's wrong side (R1), no line that is a lone digit, punctuation or kana
|
|
1496
|
+
(R2), Japanese/Chinese breaks preferred at sentence ends and after particles, never before one
|
|
1497
|
+
and never inside a word (R3), and an article or preposition kept with the phrase it governs by
|
|
1498
|
+
preferring the break before it and avoiding the break after it (R4). `mode="measured"` is
|
|
1499
|
+
1.15's behaviour exactly: no one-character orphan line, and a break chosen only to minimise the
|
|
1500
|
+
widest line. Neither mode ever changes the number of lines the greedy fill produced.
|
|
1501
|
+
"""
|
|
1502
|
+
lines: "List[str]" = []
|
|
1503
|
+
for raw in text.split("\n"):
|
|
1504
|
+
if not raw.strip():
|
|
1505
|
+
continue
|
|
1506
|
+
chunk = _greedy_chunks(raw, max_em)
|
|
1507
|
+
lines.extend(_balance(chunk, max_em, mode, lang) if balance else chunk)
|
|
1508
|
+
return lines or [text]
|
|
1509
|
+
def wrap_variants(text: str, max_em: float, *, mode: str = "phrase",
|
|
1510
|
+
lang: "Optional[str]" = None) -> "Tuple[List[str], List[str], List[str]]":
|
|
1511
|
+
"""`(wrapped, greedy, measured)` for one cue from a single greedy fill.
|
|
1512
|
+
|
|
1513
|
+
layout_cues needs all three -- `wrapped` is what is burnt in, `greedy` is what `rebalanced`
|
|
1514
|
+
counts against and `measured` what `phrase_breaks` counts against -- and used to call
|
|
1515
|
+
wrap_text() three times, re-running the atomiser and the greedy fill each time. The fill is
|
|
1516
|
+
the same for every mode, so it is done once here and only the post-passes are repeated.
|
|
1517
|
+
`measured` is the same list object as `wrapped` when that is already the mode.
|
|
1518
|
+
"""
|
|
1519
|
+
wrapped: "List[str]" = []
|
|
1520
|
+
greedy: "List[str]" = []
|
|
1521
|
+
measured: "List[str]" = []
|
|
1522
|
+
for raw in text.split("\n"):
|
|
1523
|
+
if not raw.strip():
|
|
1524
|
+
continue
|
|
1525
|
+
chunk = _greedy_chunks(raw, max_em)
|
|
1526
|
+
greedy.extend(chunk)
|
|
1527
|
+
wrapped.extend(_balance(list(chunk), max_em, mode, lang))
|
|
1528
|
+
measured.extend(chunk if mode == "measured" else _balance(list(chunk), max_em, "measured", None))
|
|
1529
|
+
if not greedy:
|
|
1530
|
+
greedy = [text]
|
|
1531
|
+
return (wrapped or [text], greedy, measured or [text])
|