Skip to content

Commit 0e7bc9b

Browse files
committed
Add Arabic subtitle support
Bundle an Arabic font and enable complex text layout so right-to-left subtitles render with joined letters and correct word order. - Add Tajawal Regular/Bold (SIL OFL) to resource/fonts. Tajawal covers Arabic, Latin and both digit sets, so mixed-script subtitles render from a single font. The font picker already discovers .ttf files, so no UI change is needed. - Install libraqm0 in both Docker images. Pillow wheels no longer bundle Raqm but still load it at runtime; without it Pillow falls back to basic layout, which leaves Arabic letters unjoined and in reversed order even when the font has the glyphs. - Floor the subtitle box height at the font's ascent + descent. MoviePy derives text height from the glyph ink box when Pillow's removed _multiline_spacing helper is missing, which clips fonts whose metrics exceed their ink height. - Center the no-background subtitle by its visible pixels, matching what the background branches already did. - Warn in the WebUI and the task log when subtitles contain right-to-left text but no shaping engine is available, since the failure is otherwise silent.
1 parent 1d1d0d2 commit 0e7bc9b

20 files changed

Lines changed: 297 additions & 4 deletions

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ RUN if [ "$DOCKER_BUILD_MIRROR" = "china" ]; then \
2626
echo "Attempt $i: installing system dependencies"; \
2727
apt-get update && apt-get install -y --no-install-recommends \
2828
git \
29+
libraqm0 \
2930
ffmpeg && break || \
3031
echo "Attempt $i failed, retrying..."; \
3132
if [ "$DOCKER_BUILD_MIRROR" = "china" ] && [ $i -eq 3 ]; then \
@@ -35,13 +36,15 @@ RUN if [ "$DOCKER_BUILD_MIRROR" = "china" ]; then \
3536
( \
3637
apt-get update && apt-get install -y --no-install-recommends \
3738
git \
39+
libraqm0 \
3840
ffmpeg || \
3941
( \
4042
echo "Tsinghua mirror failed, switching to default Debian mirror"; \
4143
sed -i 's/mirrors.tuna.tsinghua.edu.cn/deb.debian.org/g' /etc/apt/sources.list && \
4244
sed -i 's/mirrors.tuna.tsinghua.edu.cn\/debian-security/security.debian.org/g' /etc/apt/sources.list; \
4345
apt-get update && apt-get install -y --no-install-recommends \
4446
git \
47+
libraqm0 \
4548
ffmpeg; \
4649
); \
4750
); \

Dockerfile.gpu

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ ENV PYTHONPATH="/MoneyPrinterTurbo"
1414
RUN apt-get update && apt-get install -y --no-install-recommends \
1515
software-properties-common \
1616
git \
17+
libraqm0 \
1718
ffmpeg \
1819
curl \
1920
&& add-apt-repository ppa:deadsnakes/ppa \

README-en.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ Use the local setup or Docker instructions below.
231231

232232
- Local deployment requires Python 3.11 or later
233233
- On Windows, avoid project paths containing non-ASCII characters, special characters, or spaces
234+
- Right-to-left subtitles such as Arabic need libraqm installed on the system (Debian/Ubuntu: `apt install libraqm0`, macOS: `brew install libraqm`). Without it letters are not joined and word order is reversed; the Docker images already include it
234235

235236
#### ① Clone the Project
236237

README-ja.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ GitHub Releases から最新の Windows 用ワンクリックパッケージを
230230

231231
- ローカル環境へのデプロイには Python 3.11 以降が必要です
232232
- Windows では、プロジェクトのパスに非 ASCII 文字、特殊文字、スペースを含めないでください
233+
- アラビア語など右から左に書く字幕には、システムへの libraqm のインストールが必要です(Debian/Ubuntu: `apt install libraqm0`、macOS: `brew install libraqm`)。無い場合は文字が連結されず語順も逆になります。Docker イメージには同梱済みです
233234

234235
#### ① プロジェクトをクローンする
235236

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@
237237

238238
- 本地部署需要 Python 3.11 或更高版本
239239
- Windows 用户建议避免使用包含中文、特殊字符或空格的项目路径
240+
- 阿拉伯语等从右到左的字幕需要系统安装 libraqm(Debian/Ubuntu:`apt install libraqm0`,macOS:`brew install libraqm`)。缺少它时字母不会连写且顺序会颠倒,Docker 镜像已内置
240241

241242
#### ① 克隆代码
242243

app/services/video.py

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
afx,
2424
)
2525
from moviepy.video.tools.subtitles import SubtitlesClip
26-
from PIL import Image, ImageDraw, ImageFont
26+
from PIL import Image, ImageDraw, ImageFont, features
2727

2828
from app.config import config
2929
from app.models import const
@@ -968,6 +968,69 @@ def subtitle_font_supports_text(font_path: str, text: str) -> bool:
968968
return _subtitle_font_supports_sample(font_path, sample)
969969

970970

971+
@lru_cache(maxsize=1)
972+
def text_layout_supports_shaping() -> bool:
973+
"""
974+
判断 Pillow 是否启用了 Raqm 复杂文本排版(HarfBuzz 字形整形 + FriBiDi 双向重排)。
975+
976+
PyPI 的 Pillow wheel 不再内置 Raqm,但运行时会 dlopen 系统的 libraqm。
977+
缺少它时 Pillow 退回 BASIC 排版:阿拉伯语等文种的字母不会连写,也不会
978+
按从右到左的顺序排列,即使字体本身包含字形,字幕依然无法阅读。
979+
"""
980+
try:
981+
return bool(features.check("raqm"))
982+
except Exception as e:
983+
logger.warning(f"failed to detect Pillow raqm support: {e}")
984+
return False
985+
986+
987+
def subtitle_text_needs_shaping(text: str) -> bool:
988+
"""判断文本是否包含需要整形或双向重排的从右到左文字(阿拉伯语、希伯来语等)。"""
989+
return any(
990+
unicodedata.bidirectional(char) in {"R", "AL", "AN"}
991+
for char in str(text or "")
992+
)
993+
994+
995+
def _subtitle_text_block_height(
996+
font_path: str,
997+
font_size: int,
998+
stroke_width: int,
999+
line_count: int,
1000+
ink_height: int,
1001+
) -> int:
1002+
"""
1003+
计算多行字幕文字实际需要的像素高度。
1004+
1005+
MoviePy 2.2.1 依赖 Pillow 的私有方法 `_multiline_spacing` 计算文本高度,
1006+
该方法在新版 Pillow 中已被移除,于是 MoviePy 退回使用字形墨迹包围盒
1007+
(`bottom - top`)。墨迹高度会明显小于字体自身的 ascent + descent,
1008+
而 MoviePy 仍按 ascent 定位基线,导致 ascent/descent 较大的字体(阿拉伯语
1009+
字体尤为典型,也包括已内置的 Charm、微软雅黑)下方被裁切。
1010+
1011+
这里改用 MoviePy 文档中声明的公式 `ascent + descent + 2 * stroke_width`
1012+
作为单行高度下限,无论 MoviePy 走哪个分支都能留出完整的绘制空间;对
1013+
ascent/descent 较小的中文字体则维持原有的墨迹高度,不改变既有观感。
1014+
"""
1015+
try:
1016+
ascent, descent = ImageFont.truetype(font_path, font_size).getmetrics()
1017+
metric_height = (ascent + descent) * max(1, line_count) + 2 * stroke_width
1018+
except Exception as e:
1019+
logger.warning(f"failed to read subtitle font metrics: {font_path}, {e}")
1020+
metric_height = 0
1021+
return int(max(ink_height, metric_height))
1022+
1023+
1024+
@lru_cache(maxsize=1)
1025+
def _warn_missing_text_shaping() -> None:
1026+
"""整段任务只提示一次,避免逐条字幕刷屏。"""
1027+
logger.warning(
1028+
"subtitle contains right-to-left text but Pillow has no Raqm layout engine; "
1029+
"letters will not be joined and word order will be reversed. "
1030+
"install libraqm (Debian/Ubuntu: libraqm0, macOS: brew install libraqm)"
1031+
)
1032+
1033+
9711034
def generate_video(
9721035
video_path: str,
9731036
audio_path: str,
@@ -1019,6 +1082,8 @@ def create_text_clip(subtitle_item):
10191082
params.font_size = int(params.font_size)
10201083
params.stroke_width = int(params.stroke_width)
10211084
phrase = subtitle_item[1]
1085+
if subtitle_text_needs_shaping(phrase) and not text_layout_supports_shaping():
1086+
_warn_missing_text_shaping()
10221087
max_width = video_width * 0.9
10231088
bg_color = resolve_subtitle_background_color()
10241089
rounded_bg_enabled = bool(
@@ -1050,7 +1115,14 @@ def create_text_clip(subtitle_item):
10501115
# 描边或背景色时,容易把最后一行的下半部分裁掉。这里显式传入
10511116
# 一个更保守的高度,把行间距和额外上下留白一并算进去,保证字幕
10521117
# 背景框与文字本身都能完整渲染出来。
1053-
clip_h = int(txt_height + vertical_padding + (interline * line_count))
1118+
text_block_height = _subtitle_text_block_height(
1119+
font_path=font_path,
1120+
font_size=params.font_size,
1121+
stroke_width=params.stroke_width,
1122+
line_count=line_count,
1123+
ink_height=int(txt_height),
1124+
)
1125+
clip_h = int(text_block_height + vertical_padding + (interline * line_count))
10541126

10551127
if rounded_bg_enabled:
10561128
# 圆角背景需要贴合文字宽度,而不是沿用 90% 视频宽度。这里先用
@@ -1131,7 +1203,7 @@ def create_text_clip(subtitle_item):
11311203
int(max_width),
11321204
clip_h,
11331205
)
1134-
_clip = TextClip(
1206+
text_clip = TextClip(
11351207
text=wrapped_txt,
11361208
font=font_path,
11371209
font_size=params.font_size,
@@ -1140,8 +1212,27 @@ def create_text_clip(subtitle_item):
11401212
stroke_color=params.stroke_color,
11411213
stroke_width=params.stroke_width,
11421214
interline=interline,
1143-
size=size,
1215+
size=(int(max_width), None),
11441216
text_align="center",
1217+
margin=(0, text_clip_margin_y),
1218+
)
1219+
size = (size[0], max(size[1], text_clip.h))
1220+
# 无背景字幕同样需要按可见像素居中。MoviePy 先按墨迹高度居中、
1221+
# 再按 ascent 落基线,字形墨迹没有顶到 ascender 时文字就会偏离
1222+
# 画布中心;带背景的分支早已用透明底板 + 可见居中修正,这里沿用
1223+
# 同一套做法,只是底板完全透明,避免阿拉伯语等大 ascent/descent
1224+
# 字体的字幕整体偏上。
1225+
transparent_canvas = _rounded_subtitle_background_clip(
1226+
width=size[0],
1227+
height=size[1],
1228+
color="#000000",
1229+
alpha=0,
1230+
radius=0,
1231+
)
1232+
text_position = _get_visible_center_position(text_clip, size[0], size[1])
1233+
_clip = CompositeVideoClip(
1234+
[transparent_canvas, text_clip.with_position(text_position)],
1235+
size=size,
11451236
)
11461237
duration = subtitle_item[0][1] - subtitle_item[0][0]
11471238
_clip = _clip.with_start(subtitle_item[0][0])

resource/fonts/Tajawal-Bold.ttf

58.6 KB
Binary file not shown.

resource/fonts/Tajawal-OFL.txt

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
Copyright 2018 Boutros International. (http://www.boutrosfonts.com)
2+
3+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
4+
This license is copied below, and is also available with a FAQ at:
5+
http://scripts.sil.org/OFL
6+
7+
8+
-----------------------------------------------------------
9+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10+
-----------------------------------------------------------
11+
12+
PREAMBLE
13+
The goals of the Open Font License (OFL) are to stimulate worldwide
14+
development of collaborative font projects, to support the font creation
15+
efforts of academic and linguistic communities, and to provide a free and
16+
open framework in which fonts may be shared and improved in partnership
17+
with others.
18+
19+
The OFL allows the licensed fonts to be used, studied, modified and
20+
redistributed freely as long as they are not sold by themselves. The
21+
fonts, including any derivative works, can be bundled, embedded,
22+
redistributed and/or sold with any software provided that any reserved
23+
names are not used by derivative works. The fonts and derivatives,
24+
however, cannot be released under any other type of license. The
25+
requirement for fonts to remain under this license does not apply
26+
to any document created using the fonts or their derivatives.
27+
28+
DEFINITIONS
29+
"Font Software" refers to the set of files released by the Copyright
30+
Holder(s) under this license and clearly marked as such. This may
31+
include source files, build scripts and documentation.
32+
33+
"Reserved Font Name" refers to any names specified as such after the
34+
copyright statement(s).
35+
36+
"Original Version" refers to the collection of Font Software components as
37+
distributed by the Copyright Holder(s).
38+
39+
"Modified Version" refers to any derivative made by adding to, deleting,
40+
or substituting -- in part or in whole -- any of the components of the
41+
Original Version, by changing formats or by porting the Font Software to a
42+
new environment.
43+
44+
"Author" refers to any designer, engineer, programmer, technical
45+
writer or other person who contributed to the Font Software.
46+
47+
PERMISSION & CONDITIONS
48+
Permission is hereby granted, free of charge, to any person obtaining
49+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
50+
redistribute, and sell modified and unmodified copies of the Font
51+
Software, subject to the following conditions:
52+
53+
1) Neither the Font Software nor any of its individual components,
54+
in Original or Modified Versions, may be sold by itself.
55+
56+
2) Original or Modified Versions of the Font Software may be bundled,
57+
redistributed and/or sold with any software, provided that each copy
58+
contains the above copyright notice and this license. These can be
59+
included either as stand-alone text files, human-readable headers or
60+
in the appropriate machine-readable metadata fields within text or
61+
binary files as long as those fields can be easily viewed by the user.
62+
63+
3) No Modified Version of the Font Software may use the Reserved Font
64+
Name(s) unless explicit written permission is granted by the corresponding
65+
Copyright Holder. This restriction only applies to the primary font name as
66+
presented to the users.
67+
68+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69+
Software shall not be used to promote, endorse or advertise any
70+
Modified Version, except to acknowledge the contribution(s) of the
71+
Copyright Holder(s) and the Author(s) or with their explicit written
72+
permission.
73+
74+
5) The Font Software, modified or unmodified, in part or in whole,
75+
must be distributed entirely under this license, and must not be
76+
distributed under any other license. The requirement for fonts to
77+
remain under this license does not apply to any document created
78+
using the Font Software.
79+
80+
TERMINATION
81+
This license becomes null and void if any of the above conditions are
82+
not met.
83+
84+
DISCLAIMER
85+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93+
OTHER DEALINGS IN THE FONT SOFTWARE.

resource/fonts/Tajawal-Regular.ttf

58.9 KB
Binary file not shown.

test/services/test_subtitle_background_settings.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import unittest
44

55
import numpy as np
6+
from PIL import ImageFont
67

78
from app.models.schema import SubtitleRequest, VideoParams
89
from app.services import video
@@ -28,6 +29,7 @@ def test_all_locales_include_subtitle_background_labels(self):
2829
"Subtitle Background Color",
2930
"Subtitle Colors Are Indistinguishable",
3031
"Subtitle Font Does Not Support Text",
32+
"Subtitle Text Shaping Unavailable",
3133
"No Voice",
3234
}
3335

@@ -162,3 +164,84 @@ def test_wrap_text_keeps_closing_punctuation_with_text(self):
162164

163165
self.assertNotIn("\n。", wrapped_text)
164166
self.assertIn("挡。", wrapped_text)
167+
168+
def test_bundled_arabic_font_covers_arabic_and_latin(self):
169+
"""
170+
阿拉伯语字幕常混排拉丁字母和数字,因此新增字体必须同时覆盖两种字符,
171+
否则同一条字幕里会出现缺字。
172+
"""
173+
fonts_dir = Path(__file__).parent.parent.parent / "resource" / "fonts"
174+
arabic_font = fonts_dir / "Tajawal-Regular.ttf"
175+
176+
self.assertTrue(arabic_font.exists())
177+
self.assertTrue((fonts_dir / "Tajawal-Bold.ttf").exists())
178+
self.assertTrue(
179+
video.subtitle_font_supports_text(str(arabic_font), "الذكاء الاصطناعي")
180+
)
181+
self.assertTrue(
182+
video.subtitle_font_supports_text(
183+
str(arabic_font), "MoneyPrinterTurbo 2026"
184+
)
185+
)
186+
self.assertFalse(
187+
video.subtitle_font_supports_text(
188+
str(fonts_dir / "MicrosoftYaHeiBold.ttc"), "الذكاء الاصطناعي"
189+
)
190+
)
191+
192+
def test_detects_text_that_requires_bidi_shaping(self):
193+
"""只有从右到左的文字需要 Raqm 整形,中英文不应触发额外提示。"""
194+
self.assertTrue(video.subtitle_text_needs_shaping("الذكاء الاصطناعي"))
195+
self.assertTrue(video.subtitle_text_needs_shaping("مرحبا MoneyPrinterTurbo"))
196+
self.assertFalse(video.subtitle_text_needs_shaping("人工智能改变生活"))
197+
self.assertFalse(video.subtitle_text_needs_shaping("Artificial intelligence"))
198+
self.assertFalse(video.subtitle_text_needs_shaping(""))
199+
200+
def test_text_block_height_reserves_room_for_font_metrics(self):
201+
"""
202+
MoviePy 依赖的 Pillow 私有方法已被移除,导致文本高度退化成墨迹高度,
203+
ascent/descent 较大的字体下方会被裁切。字幕高度必须以字体自身的
204+
ascent + descent 为下限,同时保留墨迹更高时的原有取值。
205+
"""
206+
fonts_dir = Path(__file__).parent.parent.parent / "resource" / "fonts"
207+
arabic_font = str(fonts_dir / "Tajawal-Regular.ttf")
208+
ascent, descent = ImageFont.truetype(arabic_font, 60).getmetrics()
209+
210+
single_line = video._subtitle_text_block_height(
211+
font_path=arabic_font,
212+
font_size=60,
213+
stroke_width=1,
214+
line_count=1,
215+
ink_height=1,
216+
)
217+
two_lines = video._subtitle_text_block_height(
218+
font_path=arabic_font,
219+
font_size=60,
220+
stroke_width=1,
221+
line_count=2,
222+
ink_height=1,
223+
)
224+
ink_taller_than_metrics = video._subtitle_text_block_height(
225+
font_path=arabic_font,
226+
font_size=60,
227+
stroke_width=1,
228+
line_count=1,
229+
ink_height=9999,
230+
)
231+
232+
self.assertEqual(single_line, ascent + descent + 2)
233+
self.assertEqual(two_lines, (ascent + descent) * 2 + 2)
234+
self.assertEqual(ink_taller_than_metrics, 9999)
235+
236+
def test_text_block_height_falls_back_when_font_is_unreadable(self):
237+
"""字体探测失败时不能让字幕高度归零,应至少退回墨迹高度。"""
238+
self.assertEqual(
239+
video._subtitle_text_block_height(
240+
font_path="/nonexistent/font.ttf",
241+
font_size=60,
242+
stroke_width=1,
243+
line_count=1,
244+
ink_height=88,
245+
),
246+
88,
247+
)

0 commit comments

Comments
 (0)