diff --git a/packages/domain/ass_subtitle_builder.py b/packages/domain/ass_subtitle_builder.py index cacbf1e6d..ed4d2733f 100755 --- a/packages/domain/ass_subtitle_builder.py +++ b/packages/domain/ass_subtitle_builder.py @@ -35,6 +35,17 @@ FONT_NAME_MAP: dict[str, str] = { "华康俪金黑": "Noto Sans SC", } +# ASS Fontsize 是字体 em-square 高度(含 Latin 升降部留白), +# 中文字符实际只占声明字号的约 65%~75%;浏览器 CSS font-size 让中文字符占满声明高度。 +# 为让成片中文字高与前端 CSS 预览一致,写入 ASS 时对字号乘以补偿系数。 +# font_size=89 → ASS Fontsize=round(89*1.35)=120,实际中文字高约 78~85px。 +ASS_FONTSIZE_COMPENSATION = 1.35 + + +def _compensate_ass_fontsize(font_size: int) -> int: + """将 CSS 语义字号换算为 ASS Fontsize,补偿中文字符在 em-square 中的留白。""" + return max(1, round(font_size * ASS_FONTSIZE_COMPENSATION)) + # ── 颜色转换 ────────────────────────────────────────────────────────────────── @@ -137,8 +148,11 @@ def build_ass_style( # Shadow 深度:shadow_offset[1] 作为纵向偏移 shadow_depth = shadow_offset[1] if shadow_blur > 0 else 0 + # 写入 ASS Style 时对字号做补偿,使成片中文字高与前端 CSS 预览一致 + ass_font_size = _compensate_ass_fontsize(font_size) + return ( - f"Style: {style_name},{actual_font},{font_size},{primary_color}," + f"Style: {style_name},{actual_font},{ass_font_size},{primary_color}," f"&H000000FF,{outline_color},{back_color}," f"{bold_val},{italic_val},0,0,100,100,0,0," f"1,{outline_width},{shadow_depth},{alignment}," @@ -210,6 +224,9 @@ def _wrap_title_text( if available_width <= 0: return text + # 换行宽度必须与实际渲染(补偿后的 ASS Fontsize)一致,否则换行位置会错位 + ass_font_size = _compensate_ass_fontsize(font_size) + # 先按已有 \N 分段,每段独立自动换行,最后用 \N 拼回 segments = text.split("\\N") wrapped_segments: list[str] = [] @@ -221,7 +238,7 @@ def _wrap_title_text( for ch in seg: # CJK 字符按全角估算,其他按半角 - char_width = float(font_size) if ord(ch) > 0x2E80 else font_size * 0.55 + char_width = float(ass_font_size) if ord(ch) > 0x2E80 else ass_font_size * 0.55 if current_width + char_width > available_width and current_line: lines.append(current_line) diff --git a/tests/unit/domain/test_ass_subtitle_builder.py b/tests/unit/domain/test_ass_subtitle_builder.py index 1a7913983..a9a9c8e68 100644 --- a/tests/unit/domain/test_ass_subtitle_builder.py +++ b/tests/unit/domain/test_ass_subtitle_builder.py @@ -105,9 +105,9 @@ class TestBuildAssStyle: def test_contains_font_size(self): result = build_ass_style("S1", font_size=36) - # Style行格式:Name, Fontname, Fontsize, ... + # Style行格式:Name, Fontname, Fontsize, ...(36*1.35=48.6→49) parts = result.split(",") - assert parts[2] == "36" + assert parts[2] == "49" def test_bold_true(self): result = build_ass_style("S1", bold=True) @@ -420,7 +420,7 @@ class TestBuildAssContent: for line in result.split("\n"): if line.startswith("Style: TitleStyle"): parts = line.split(",") - assert parts[2] == "72" + assert parts[2] == "97" # 72*1.35=97.2→97 break def test_title_font_size_frontend_field_alias(self): @@ -435,7 +435,7 @@ class TestBuildAssContent: for line in result.split("\n"): if line.startswith("Style: TitleStyle"): parts = line.split(",") - assert parts[2] == "48" + assert parts[2] == "65" # 48*1.35=64.8→65 break def test_title_font_color_frontend_field_alias(self): @@ -462,7 +462,7 @@ class TestBuildAssContent: for line in result.split("\n"): if line.startswith("Style: TitleStyle"): parts = line.split(",") - assert parts[2] == "56" + assert parts[2] == "76" # 56*1.35=75.6→76 break def test_title_bold(self): diff --git a/tests/unit/test_ass_subtitle_builder.py b/tests/unit/test_ass_subtitle_builder.py index dfa65a68f..9483e298c 100755 --- a/tests/unit/test_ass_subtitle_builder.py +++ b/tests/unit/test_ass_subtitle_builder.py @@ -80,11 +80,11 @@ class TestBuildAssStyle: result = build_ass_style("TestStyle") assert result.startswith("Style: TestStyle,") assert "Noto Sans SC" in result - assert ",48," in result + assert ",65," in result # 48*1.35=64.8→65 def test_custom_font_size(self): result = build_ass_style("Title", font_size=64) - assert ",64," in result + assert ",86," in result # 64*1.35=86.4→86 def test_bold_enabled(self): result = build_ass_style("BoldStyle", bold=True) @@ -570,3 +570,35 @@ class TestWrapTitleText: break else: pytest.fail("未找到 TitleStyle Dialogue 行") + + +class TestFontsizeCompensation: + """ASS Fontsize 补偿系数(CSS 字号 → ASS em-square 字号)。""" + + def test_default_48_compensated_to_65(self): + result = build_ass_style("S") + parts = result.split(",") + assert parts[2] == "65" # round(48*1.35)=65 + + def test_89_compensated_to_120(self): + """实测对齐点:font_size=89 → ASS Fontsize=120。""" + result = build_ass_style("S", font_size=89) + parts = result.split(",") + assert parts[2] == "120" + + def test_subtitle_also_compensated(self): + content = build_ass_content( + video_width=1080, + video_height=1920, + video_duration=5.0, + subtitle_text="字幕", + subtitle_config={"size": 24}, + ) + sub_line = [line for line in content.splitlines() if line.startswith("Style: SubtitleStyle")][0] + fields = [f.strip() for f in sub_line.split(",")] + assert fields[2] == "32" # round(24*1.35)=32 + + def test_minimum_fontsize_at_least_one(self): + result = build_ass_style("S", font_size=0) + parts = result.split(",") + assert int(parts[2]) >= 1 diff --git a/tests/unit/test_render_subtitles.py b/tests/unit/test_render_subtitles.py index a445b4130..c0304e986 100755 --- a/tests/unit/test_render_subtitles.py +++ b/tests/unit/test_render_subtitles.py @@ -81,12 +81,12 @@ class TestBuildAssStyle: style = _build_ass_style("Default") assert style.startswith("Style: Default,") assert "Noto Sans SC" in style - assert "48" in style # font_size + assert "65" in style # font_size 48*1.35=65 def test_custom_font(self): style = _build_ass_style("Custom", font_name="Arial", font_size=32) assert "Arial" in style - assert ",32," in style + assert ",43," in style # font_size 32*1.35=43 def test_bold(self): style = _build_ass_style("Bold", bold=True) diff --git a/tests/unit/test_render_subtitles_pure.py b/tests/unit/test_render_subtitles_pure.py index 1322460f7..4f73aac49 100644 --- a/tests/unit/test_render_subtitles_pure.py +++ b/tests/unit/test_render_subtitles_pure.py @@ -85,7 +85,7 @@ class TestBuildAssStyle: line = _build_ass_style("Default") assert line.startswith("Style: Default,") assert "Noto Sans SC" in line - assert "48" in line # font_size + assert "65" in line # font_size 48*1.35=65 def test_bold_enabled(self): """加粗时Bold=-1.""" @@ -112,7 +112,7 @@ class TestBuildAssStyle: """自定义字号.""" line = _build_ass_style("Big", font_size=72) parts = line.split(",") - assert parts[2] == "72" # Fontsize + assert parts[2] == "97" # Fontsize 72*1.35=97 def test_custom_alignment(self): """自定义对齐方式.""" diff --git a/tests/unit/test_title_render_consistency.py b/tests/unit/test_title_render_consistency.py index 246822a8c..99aeaa3c9 100644 --- a/tests/unit/test_title_render_consistency.py +++ b/tests/unit/test_title_render_consistency.py @@ -31,7 +31,7 @@ class TestFontSize: title_text="测试标题", title_config=config, ) - assert ",36," in content, f"默认字号应为36,实际内容: {content}" + assert ",49," in content, f"默认字号36应补偿为49(36*1.35),实际内容: {content}" def test_size_32_preserved(self): """size=32 应原样使用。""" @@ -43,7 +43,7 @@ class TestFontSize: title_text="测试标题", title_config=config, ) - assert ",32," in content + assert ",43," in content # 32*1.35=43 def test_size_60_preserved(self): """size=60 应原样保留(字号上限已移除)。""" @@ -58,7 +58,7 @@ class TestFontSize: style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] font_size = int(fields[2]) - assert font_size == 60, f"字号60应原样保留, 实际={font_size}" + assert font_size == 81, f"字号60应补偿为81(60*1.35), 实际={font_size}" def test_font_size_alias_normalized(self): """前端传 font_size 应归一化为 size。""" @@ -72,7 +72,7 @@ class TestFontSize: ) style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] fields = [f.strip() for f in style_line.split(",")] - assert fields[2] == "52", f"font_size=52 应归一化, 实际={fields[2]}" + assert fields[2] == "70", f"font_size=52 应补偿为70(52*1.35), 实际={fields[2]}" def test_font_color_alias_normalized(self): """前端传 font_color 应归一化为 color。""" @@ -97,7 +97,7 @@ class TestFontSize: title_text="测试标题", title_config=config, ) - assert ",24," in content + assert ",32," in content # 24*1.35=32.4→32 class TestBooleanStrokeNormalization: @@ -231,8 +231,8 @@ class TestFullStyleConsistency: # Fontname assert fields[1] == "Noto Sans SC" - # Fontsize = 28 - assert fields[2] == "28" + # Fontsize = 28*1.35=37.8→38 + assert fields[2] == "38" # Bold = -1 (True) assert fields[7] == "-1" # Outline width = 2 (前端默认 stroke width)