Skip to content

Commit 1ee110a

Browse files
committed
refactor(font): 移除持久化子集缓存并新增 ASS 内嵌字体方案
- 删除 FontSubsetCache(font_cache.py 及全部引用):缓存对子集体积收益有限, 却带来版本失效、磁盘占用与一致性维护成本。font_cache 配置项一并移除, GUI 的“清理字体缓存”入口下线,benchmark 脚本同步更新。 - 新增 ass_font_embedder 模块:按 Aegisub/libass 的 uuencode 方案把子集字体 内嵌进 ASS 的 [Fonts] 段,生成自包含字幕。新增 font.embed_scheme (attachment / ass / both),贯穿配置、覆盖层、muxer 与 GUI,支持沿用附件、 内嵌或两者并存。 - 修复子集字体 name 表处理:不再改写或折叠 name 表,保留原始 name 表,使 GDI 打包的 weight 子族(如 HYXuanSong 65S、FOT-TsukuMin Pr6N E)在 libass 下可被正确匹配;SUBSET_PROFILE_VERSION 由 1 升到 2 以触发旧子集失效。 - 同步更新本地化词条、前端设置与计划预览、集成/单元测试及文档。
1 parent 7d9d891 commit 1ee110a

33 files changed

Lines changed: 591 additions & 698 deletions

plexmuxy/ass_font_embedder.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Self-contained ASS generation with embedded fonts (Aegisub/libass compatible).
2+
3+
This mirrors the scheme used by tools such as assfonts: the subsetted (or full)
4+
font binaries are uuencoded and placed in a ``[Fonts]`` section inserted just
5+
before the ``[events]`` section of the subtitle file. Players built on libass
6+
decode the embedded blobs and register them by the font's real family name, so
7+
the existing style references keep working without any name rewriting.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from pathlib import Path
13+
14+
from fontTools.ttLib import TTFont
15+
16+
_FONT_SUFFIXES = frozenset({".ttf", ".otf", ".ttc", ".otc", ".woff", ".woff2"})
17+
18+
19+
def _font_family_name(path: Path) -> str | None:
20+
"""Best-effort family name used for an embedded font's ``fontname:`` label."""
21+
try:
22+
with TTFont(path) as font:
23+
name = font["name"]
24+
for name_id in (1, 16, 4):
25+
value = name.getDebugName(name_id)
26+
if value:
27+
return value
28+
except Exception:
29+
return None
30+
return None
31+
32+
33+
def _uuencode(data: bytes) -> str:
34+
"""Aegisub/libass uuencode variant.
35+
36+
Each 6-bit group is emitted as ``value + 33`` (``!`` for 0). There is no
37+
per-line length byte; newlines are inserted purely for readability every
38+
80 encoded characters.
39+
"""
40+
out: list[str] = []
41+
written = 0
42+
length = len(data)
43+
for i in range(0, length, 3):
44+
b0 = data[i]
45+
b1 = data[i + 1] if i + 1 < length else 0
46+
b2 = data[i + 2] if i + 2 < length else 0
47+
n = (b0 << 16) | (b1 << 8) | b2
48+
groups = (length - i) if (length - i) < 3 else 3
49+
groups += 1 # 1 byte -> 2 chars, 2 bytes -> 3, 3 bytes -> 4
50+
for shift in (18, 12, 6, 0):
51+
if groups <= 0:
52+
break
53+
out.append(chr(((n >> shift) & 0x3F) + 33))
54+
groups -= 1
55+
written += 1
56+
if written == 80 and i + 3 < length:
57+
out.append("\n")
58+
written = 0
59+
return "".join(out)
60+
61+
62+
def _read_ass_text(path: Path) -> tuple[str, str, bytes]:
63+
"""Return (text, encoding_for_encode, bom_bytes)."""
64+
raw = path.read_bytes()
65+
if raw.startswith(b"\xff\xfe"):
66+
return raw.decode("utf-16-le"), "utf-16-le", b"\xff\xfe"
67+
if raw.startswith(b"\xfe\xff"):
68+
return raw.decode("utf-16-be"), "utf-16-be", b"\xfe\xff"
69+
if raw.startswith(b"\xef\xbb\xbf"):
70+
return raw.decode("utf-8-sig"), "utf-8-sig", b"\xef\xbb\xbf"
71+
try:
72+
return raw.decode("utf-8"), "utf-8", b""
73+
except UnicodeDecodeError:
74+
return raw.decode("utf-8", errors="replace"), "utf-8", b""
75+
76+
77+
def _encode_ass_text(text: str, encoding: str, bom: bytes) -> bytes:
78+
data = text.encode(encoding)
79+
if bom:
80+
return bom + data
81+
return data
82+
83+
84+
def _build_fonts_block(font_paths: list[Path]) -> str:
85+
blocks: list[str] = []
86+
for path in font_paths:
87+
if path.suffix.lower() not in _FONT_SUFFIXES:
88+
continue
89+
if not path.exists():
90+
continue
91+
label = _font_family_name(path) or path.stem
92+
encoded = _uuencode(path.read_bytes())
93+
blocks.append(f"fontname: {label}\n{encoded}")
94+
return "\n".join(blocks)
95+
96+
97+
def _insert_fonts_section(text: str, fonts_block: str) -> str:
98+
"""Insert a ``[Fonts]`` section (containing ``fonts_block``) before ``[events]``."""
99+
lines = text.split("\n")
100+
insert_at = None
101+
for idx, line in enumerate(lines):
102+
if line.strip().lower() == "[events]":
103+
insert_at = idx
104+
break
105+
if insert_at is None:
106+
return text.rstrip("\n") + "\n\n[Fonts]\n\n" + fonts_block + "\n"
107+
return "\n".join(
108+
lines[:insert_at] + ["[Fonts]", fonts_block, ""] + lines[insert_at:]
109+
)
110+
111+
112+
def embed_fonts_into_ass(
113+
subtitle_path: Path,
114+
font_paths: list[Path],
115+
output_path: Path,
116+
) -> Path:
117+
"""Write a self-contained ASS (fonts embedded in ``[Fonts]``) to ``output_path``."""
118+
text, encoding, bom = _read_ass_text(subtitle_path)
119+
fonts_block = _build_fonts_block(font_paths)
120+
new_text = _insert_fonts_section(text, fonts_block)
121+
output_path.write_bytes(_encode_ass_text(new_text, encoding, bom))
122+
return output_path

plexmuxy/config.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
ArchiveLimits,
1818
ConcurrencyConfig,
1919
FfmpegConfig,
20-
FontCacheConfig,
2120
FontConfig,
2221
LanguageProfile,
2322
MatchingConfig,
@@ -188,7 +187,6 @@ def parse_v2_config(data: dict[str, Any]) -> AppConfig:
188187
matching_data = require_mapping(data, "matching", default={})
189188
subtitle_data = require_mapping(data, "subtitle", default={})
190189
font_data = require_mapping(data, "font", default={})
191-
font_cache_data = require_mapping(data, "font_cache", default={})
192190
limit_data = require_mapping(font_data, "archive_limits", default={})
193191
mkvmerge_data = require_mapping(data, "mkvmerge", default={})
194192
ffmpeg_data = require_mapping(data, "ffmpeg", default={})
@@ -211,10 +209,10 @@ def parse_v2_config(data: dict[str, Any]) -> AppConfig:
211209
}, "matching")
212210
reject_unknown(subtitle_data, {"default_language", "show_author_in_track_name", "profiles"}, "subtitle")
213211
reject_unknown(font_data, {
214-
"delete_fonts_after_mux", "unrar_path", "mode", "mime_mode", "missing_font_action",
212+
"delete_fonts_after_mux", "unrar_path", "mode", "mime_mode", "embed_scheme",
213+
"missing_font_action",
215214
"subset_failure_action", "archive_limits",
216215
}, "font")
217-
reject_unknown(font_cache_data, {"enabled", "max_size_mb", "max_age_days"}, "font_cache")
218216
reject_unknown(limit_data, {
219217
"max_archive_size", "max_files", "max_total_size", "max_file_size", "max_depth",
220218
"allow_uninspected_archives",
@@ -286,6 +284,11 @@ def parse_v2_config(data: dict[str, Any]) -> AppConfig:
286284
unrar_path=str(font_data.get("unrar_path", "")),
287285
mode=choice(font_data.get("mode", "all"), {"all", "referenced", "subset"}, "font.mode"),
288286
mime_mode=choice(font_data.get("mime_mode", "legacy"), {"legacy", "modern"}, "font.mime_mode"),
287+
embed_scheme=choice(
288+
font_data.get("embed_scheme", "attachment"),
289+
{"attachment", "ass", "both"},
290+
"font.embed_scheme",
291+
),
289292
missing_font_action=choice(font_data.get("missing_font_action", "warn"), {"warn", "skip-video", "fail-job", "fallback-all"}, "font.missing_font_action"),
290293
subset_failure_action=choice(
291294
font_data.get("subset_failure_action", "fallback-full"),
@@ -294,11 +297,6 @@ def parse_v2_config(data: dict[str, Any]) -> AppConfig:
294297
),
295298
archive_limits=limits,
296299
)
297-
font_cache = FontCacheConfig(
298-
enabled=bool_value(font_cache_data.get("enabled", True), "font_cache.enabled"),
299-
max_size_mb=positive_int(font_cache_data.get("max_size_mb", 2048), "font_cache.max_size_mb"),
300-
max_age_days=positive_int(font_cache_data.get("max_age_days", 90), "font_cache.max_age_days"),
301-
)
302300
using_legacy_thread_count = "max_parallel_mux_jobs" not in concurrency_data and "thread_count" in concurrency_data
303301
raw_parallel = concurrency_data.get("max_parallel_mux_jobs", concurrency_data.get("thread_count", 1))
304302
if raw_parallel == "auto":
@@ -335,7 +333,7 @@ def parse_v2_config(data: dict[str, Any]) -> AppConfig:
335333
path_mappings.append(PlexPathMapping(local_root=local_root, server_root=server_root))
336334
return AppConfig(
337335
config_version=CURRENT_CONFIG_VERSION, media=media, task=task, matching=matching,
338-
subtitle=subtitle, font=font, font_cache=font_cache,
336+
subtitle=subtitle, font=font,
339337
mkvmerge=MkvMergeConfig(path=str(mkvmerge_data.get("path", ""))),
340338
ffmpeg=FfmpegConfig(path=str(ffmpeg_data.get("path", ""))),
341339
notifications=NotificationConfig(

0 commit comments

Comments
 (0)