Skip to content

Commit d2ea627

Browse files
Masterain98claude
andcommitted
fix(font): 允许缺失渲染器可替换的空白/格式码点,避免误判缺失字形
之前 ASS 字幕中的 \h(U+00A0 不换行空格)等空白码点若在某字体的 cmap 中缺失,会被判定为「缺失字形」,导致整族字体回退为完整字体、 子集化被完全跳过。许多 CJK 字体本就省略这些码点(渲染器用普通空格 替代绘制),不应阻断匹配与子集化。 - font_catalog: 新增 is_optional_codepoint,识别渲染器可替代的空白与 格式码点(NBSP/FIGURE SPACE/NARROW NBSP/WORD JOINER/BOM 及 Zs/Zl/Zp/ Cc/Cf 类别),其缺失不算字形缺失。 - font_matching: 匹配时只把「必需缺失」码点计入 missing_codepoints, 使含 \h 的中文字幕仍能被子集化而非回退完整字体。 - font_subset: subset_font_face 遇到仅缺失可选码点时,从请求中剔除后 继续子集化,而非整体抛出 FontSubsetError 回退完整字体;请求清空时 给出明确的空子集错误。 fix(gui): 在结果卡片中渲染可读的警告正文 之前混流结果只显示警告数量(如「4 个警告」),用户无法得知具体 内容。现在结果卡片可展开显示逐条警告的人类可读文案,并补充 en/zh-CN 的 result.warnings 与 warning.* 文案及对应样式。 fix(diagnostics): 优先采用绝对路径的 input_dir 提取媒体根目录 media 根目录提取原先在 report/job 任一命中即返回,且相对路径会静默 解析到进程工作目录(泄露应用运行时路径)。现改为优先返回绝对路径, 相对路径仅作为最后兜底解析。 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 81b408f commit d2ea627

8 files changed

Lines changed: 153 additions & 10 deletions

File tree

plexmuxy/diagnostics.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,19 +45,34 @@ def _extract_media_root(job_context: dict | None) -> Path | None:
4545
4646
Must run *before* path redaction: the job context is otherwise scrubbed to
4747
``<PATH>/name`` so troubleshooting agents can no longer resolve the original
48-
media resources. Prefers ``report.input_dir`` over ``job.input_dir``.
48+
media resources.
49+
50+
Candidates are collected from both ``report.input_dir`` and
51+
``job.input_dir``; the persisted job record reflects the directory the user
52+
actually selected for the task, while the report reflects what was actually
53+
processed. An absolute path is always preferred so a relative or stale value
54+
can never be silently resolved against the process working directory (which
55+
would surface the application's own runtime path in the diagnostics).
4956
"""
5057
if not isinstance(job_context, dict):
5158
return None
59+
candidates: list[Path] = []
5260
for key in ("report", "job"):
5361
candidate = job_context.get(key)
5462
if isinstance(candidate, dict):
5563
root = candidate.get("input_dir")
5664
if isinstance(root, (str, Path)) and str(root).strip():
5765
try:
58-
return Path(root).expanduser().resolve()
66+
candidates.append(Path(root).expanduser())
5967
except (OSError, RuntimeError):
60-
return Path(root)
68+
candidates.append(Path(str(root)))
69+
absolute = [candidate for candidate in candidates if candidate.is_absolute()]
70+
if absolute:
71+
return absolute[0]
72+
if candidates:
73+
# Only relative values remain; resolve against the working directory as
74+
# a last resort rather than reporting a bare relative fragment.
75+
return candidates[-1].resolve()
6176
return None
6277

6378

plexmuxy/font_catalog.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,37 @@ def normalize_font_name(value: str) -> str:
3636
return " ".join(value.split()).casefold()
3737

3838

39+
# Whitespace/format codepoints a renderer resolves without a dedicated glyph
40+
# (e.g. libass draws a NO-BREAK SPACE using the regular space advance). Many CJK
41+
# fonts omit these from their cmap even though the glyph is effectively present.
42+
# Treating a missing entry here as a fatal "missing glyph" would force an entire
43+
# family back to a full-font attachment, defeating font subsetting for scripts
44+
# that merely contain a ``\h`` (U+00A0).
45+
_RENDERER_SUBSTITUTED_CODEPOINTS = frozenset({
46+
0x00A0, # NO-BREAK SPACE (ASS \h)
47+
0x2007, # FIGURE SPACE
48+
0x202F, # NARROW NO-BREAK SPACE
49+
0x2060, # WORD JOINER
50+
0xFEFF, # ZERO WIDTH NO-BREAK SPACE / BOM
51+
})
52+
53+
54+
def is_optional_codepoint(codepoint: int) -> bool:
55+
"""Return True when a font need not provide a glyph for ``codepoint``.
56+
57+
Whitespace separators, control and format characters are rendered via
58+
substitution rather than an outline, so their absence must never block font
59+
matching or subsetting.
60+
"""
61+
if codepoint in _RENDERER_SUBSTITUTED_CODEPOINTS:
62+
return True
63+
try:
64+
category = unicodedata.category(chr(codepoint))
65+
except (ValueError, OverflowError):
66+
return False
67+
return category in {"Zs", "Zl", "Zp", "Cc", "Cf"}
68+
69+
3970
def build_font_catalog(
4071
font_paths: Iterable[Path],
4172
*,

plexmuxy/font_matching.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from pathlib import Path
55
from typing import Literal
66

7-
from .font_catalog import normalize_font_name
7+
from .font_catalog import is_optional_codepoint, normalize_font_name
88
from .models import FontFaceRef, FontUsage
99

1010
FontMatchStatus = Literal["matched", "missing", "ambiguous", "missing-glyphs"]
@@ -53,9 +53,13 @@ def match_font_usage(usage: FontUsage, catalog: list[FontFaceRef]) -> FontMatchR
5353
)
5454
face = next(iter(unique.values()))
5555
available = set(face.unicode_codepoints)
56-
missing = tuple(sorted(set(usage.codepoints) - available))
57-
if missing:
58-
return FontMatchResult(usage, "missing-glyphs", face=face, missing_codepoints=missing)
56+
missing = set(usage.codepoints) - available
57+
# Whitespace/format codepoints the renderer substitutes (e.g. NBSP) must not
58+
# count as missing glyphs; otherwise a single ``\h`` would fail the match and
59+
# force the whole family to a full-font fallback instead of subsetting.
60+
mandatory_missing = tuple(sorted(cp for cp in missing if not is_optional_codepoint(cp)))
61+
if mandatory_missing:
62+
return FontMatchResult(usage, "missing-glyphs", face=face, missing_codepoints=mandatory_missing)
5963
return FontMatchResult(usage, "matched", face=face)
6064

6165

plexmuxy/font_subset.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from fontTools import subset
1010
from fontTools.ttLib import TTFont
1111

12-
from .font_catalog import normalize_font_name
12+
from .font_catalog import is_optional_codepoint, normalize_font_name
1313
from .models import FontFaceRef, FontMimeMode, font_mime_type_for_outline
1414

1515
SUBSET_PROFILE_VERSION = 1
@@ -79,10 +79,21 @@ def subset_font_face(
7979
font = TTFont(source, fontNumber=face.face_index, lazy=False, recalcTimestamp=False)
8080
original_modified = int(getattr(font.get("head"), "modified", 0) or 0)
8181
available = set((font.getBestCmap() or {}).keys())
82-
missing = sorted(requested - available)
82+
missing = requested - available
8383
if missing:
84+
mandatory_missing = sorted(cp for cp in missing if not is_optional_codepoint(cp))
85+
if mandatory_missing:
86+
font.close()
87+
raise FontSubsetError(
88+
f"Source font is missing requested codepoints: {_format_codepoints(mandatory_missing)}"
89+
)
90+
# The only absentees are whitespace/format codepoints the renderer
91+
# substitutes (e.g. NBSP). Drop them from the request so the family can
92+
# still be subset instead of falling back to the full source font.
93+
requested -= missing
94+
if not requested:
8495
font.close()
85-
raise FontSubsetError(f"Source font is missing requested codepoints: {_format_codepoints(missing)}")
96+
raise FontSubsetError("Cannot create an empty font subset")
8697

8798
options = subset.Options()
8899
options.layout_features = ["*"]

plexmuxy_gui/static/app.css

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,6 +1459,34 @@ button.plan-save-fab:hover:not(:disabled) { background: var(--color-brand); filt
14591459
contain-intrinsic-size: auto 240px;
14601460
}
14611461

1462+
/* Collapsible, human-readable warning list on a finished job card. The UI used
1463+
to show only a warning *count*; now the actual messages are revealed here. */
1464+
.result-warnings {
1465+
border: 1px solid color-mix(in srgb, var(--color-warn, #b7791f) 45%, var(--color-border));
1466+
border-radius: var(--radius-sm);
1467+
background: color-mix(in srgb, var(--color-warn, #b7791f) 10%, var(--color-canvas));
1468+
padding: 8px 12px;
1469+
}
1470+
.result-warnings > summary {
1471+
cursor: pointer;
1472+
font-weight: 600;
1473+
color: var(--color-fg-strong, #1a202c);
1474+
list-style: none;
1475+
}
1476+
.result-warnings > summary::-webkit-details-marker { display: none; }
1477+
.result-warnings > summary::before {
1478+
content: "▸ ";
1479+
color: var(--color-warn, #b7791f);
1480+
}
1481+
.result-warnings[open] > summary::before { content: "▾ "; }
1482+
.result-warnings .warning-list {
1483+
margin: 8px 0 2px;
1484+
padding-left: 18px;
1485+
display: grid;
1486+
gap: 4px;
1487+
}
1488+
.result-warnings .warning-item { font-size: 13px; line-height: 1.5; color: var(--color-fg); }
1489+
14621490
.plan-card.has-edits { border-color: color-mix(in srgb, var(--color-brand) 55%, var(--color-border)); }
14631491
.plan-enabled { flex-wrap: wrap; justify-content: flex-end; }
14641492

plexmuxy_gui/static/js/results.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,51 @@ function renderResultCard(result) {
2020
const heading = itemNode(result.output_name, result.output_path); heading.firstChild && (heading.firstChild.className = "");
2121
title.append(heading, badge(result.success ? t("result.success") : t("result.failed"), result.success ? "ok" : "danger")); card.append(title);
2222
const counts = element("div", "count-row"); counts.append(badge(result.verified ? t("result.verified") : t("result.notVerified"), result.verified ? "ok" : "warn"), badge(countText(result.warnings.length, "count.warning.one", "count.warning.other"), result.warnings.length ? "warn" : "info")); card.append(counts);
23+
const warnNode = renderWarnings(result.warnings);
24+
if (warnNode) card.append(warnNode);
2325
if (result.error) card.append(element("div", "inline-error", `${result.error_code ? `[${result.error_code}] ` : ""}${result.error}`)); return card;
2426
}
2527

28+
// Render a collapsible, human-readable list of job warnings. Previously the UI
29+
// only showed the warning *count*, so users could not tell what the warnings
30+
// actually meant.
31+
function renderWarnings(warnings) {
32+
if (!warnings || !warnings.length) return null;
33+
const details = element("details", "result-warnings");
34+
details.append(element("summary", "", t("result.warnings")));
35+
const list = element("ul", "warning-list");
36+
for (const warning of warnings) {
37+
list.append(element("li", "warning-item", humanizeWarning(warning)));
38+
}
39+
details.append(list);
40+
return details;
41+
}
42+
43+
// Turn machine-readable warning codes (e.g. "font_codepoints_missing:...")
44+
// into text a user can understand.
45+
function humanizeWarning(text) {
46+
if (!text) return "";
47+
const idx = text.indexOf(":");
48+
const code = idx === -1 ? text.trim() : text.slice(0, idx);
49+
const rest = idx === -1 ? "" : text.slice(idx + 1).trim();
50+
switch (code) {
51+
case "font_codepoints_missing": {
52+
const m = rest.match(/:\s*(.+)$/);
53+
return t("warning.fontCodepointsMissing", { font: m ? m[1].trim() : rest });
54+
}
55+
case "font_subset_fallback_all":
56+
return t("warning.fontSubsetFallbackAll");
57+
case "subset_fallback_full_font":
58+
return t("warning.subsetFallbackFullFont", { detail: rest });
59+
case "font_family_missing":
60+
return t("warning.fontFamilyMissing", { family: rest });
61+
case "font_match_ambiguous":
62+
return t("warning.fontMatchAmbiguous", { family: rest });
63+
default:
64+
return text;
65+
}
66+
}
67+
2668

2769

2870
function localizeEnum(prefix, value) {

plexmuxy_gui/static/locales/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,12 @@
285285
"result.ok": "ok",
286286
"result.verified": "verified",
287287
"result.notVerified": "not verified",
288+
"result.warnings": "Warnings",
289+
"warning.fontCodepointsMissing": "Font \"{font}\" is missing required glyphs; a fallback font was used.",
290+
"warning.fontSubsetFallbackAll": "Font subsetting was skipped and the full original fonts were attached.",
291+
"warning.subsetFallbackFullFont": "Subsetting failed for a font; the full original font was attached instead: {detail}",
292+
"warning.fontFamilyMissing": "No matching installed font was found for \"{family}\".",
293+
"warning.fontMatchAmbiguous": "Multiple fonts matched \"{family}\"; one was chosen automatically.",
288294
"progress.running": "Mux job in progress",
289295
"progress.aria": "Mux job progress",
290296
"progress.preparing": "Preparing next file",

plexmuxy_gui/static/locales/zh-CN.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,12 @@
285285
"result.ok": "正常",
286286
"result.verified": "已验证",
287287
"result.notVerified": "未验证",
288+
"result.warnings": "警告",
289+
"warning.fontCodepointsMissing": "字体「{font}」缺少所需字形,已改用回退字体。",
290+
"warning.fontSubsetFallbackAll": "已跳过字体子集化,直接附加了原始完整字体。",
291+
"warning.subsetFallbackFullFont": "某字体子集化失败,已改用完整原始字体:{detail}",
292+
"warning.fontFamilyMissing": "未找到与「{family}」匹配的已安装字体。",
293+
"warning.fontMatchAmbiguous": "有多个字体匹配「{family}」,已自动选用其中之一。",
288294
"progress.running": "封装任务进行中",
289295
"progress.aria": "封装任务进度",
290296
"progress.preparing": "正在准备下一个文件",

0 commit comments

Comments
 (0)