trip-moments-skill 1.3.0 → 1.3.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.
|
@@ -8,11 +8,13 @@ from __future__ import annotations
|
|
|
8
8
|
import base64
|
|
9
9
|
import io
|
|
10
10
|
import os
|
|
11
|
+
import re
|
|
11
12
|
import sys
|
|
13
|
+
from functools import lru_cache
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
|
|
14
16
|
import numpy as np
|
|
15
|
-
from PIL import Image, ImageFilter, ImageOps, ImageFont
|
|
17
|
+
from PIL import Image, ImageDraw, ImageFilter, ImageOps, ImageFont
|
|
16
18
|
|
|
17
19
|
if sys.platform == "win32":
|
|
18
20
|
try:
|
|
@@ -149,41 +151,152 @@ def palette(img: Image.Image, n: int = 5) -> list[tuple[int, int, int]]:
|
|
|
149
151
|
|
|
150
152
|
|
|
151
153
|
# --- fonts (Cross-platform font paths; graceful fallback) ---------------------
|
|
152
|
-
|
|
153
|
-
|
|
154
|
+
# The names differ per platform because the *products* differ: Windows/macOS ship
|
|
155
|
+
# their own CJK faces, while Debian/Ubuntu install the Noto **CJK** superfamily
|
|
156
|
+
# (opentype/noto/NotoSansCJK-*.ttc) and Fedora/Arch often the SC subset instead.
|
|
157
|
+
# Listing only the Windows names (plus NotoSansSC) meant a Linux server rendered
|
|
158
|
+
# every Chinese glyph as the same .notdef box — measured in a python:3.11-slim
|
|
159
|
+
# container: 「朋友圈旅行」 and 「敦煌戈壁滩」 produced byte-identical bitmaps both
|
|
160
|
+
# before AND after `apt install fonts-noto-cjk`, because /usr/share/fonts was only
|
|
161
|
+
# searched one level deep and the .ttc name was not a candidate at all.
|
|
162
|
+
FONT_ZH_BOLD = [
|
|
163
|
+
"msyhbd.ttc", "simhei.ttf", "msyh.ttc", "simsun.ttc", "PingFang.ttc",
|
|
164
|
+
"NotoSansCJK-Bold.ttc", "NotoSansCJK-Regular.ttc", "NotoSansCJKsc-Bold.otf",
|
|
165
|
+
"NotoSansSC-Bold.otf", "SourceHanSansSC-Bold.otf", "wqy-zenhei.ttc",
|
|
166
|
+
]
|
|
167
|
+
FONT_ZH = [
|
|
168
|
+
"msyh.ttc", "simsun.ttc", "simhei.ttf", "PingFang.ttc", "STHeiti.ttc",
|
|
169
|
+
"NotoSansCJK-Regular.ttc", "NotoSansCJKsc-Regular.otf",
|
|
170
|
+
"NotoSansSC-Regular.otf", "SourceHanSansSC-Regular.otf", "NotoSerifCJK-Regular.ttc",
|
|
171
|
+
"wqy-zenhei.ttc",
|
|
172
|
+
]
|
|
154
173
|
FONT_EN = ["georgia.ttf", "times.ttf", "arial.ttf", "DejaVuSerif.ttf", "Helvetica.ttc"]
|
|
155
174
|
|
|
175
|
+
# Any font on the box whose name looks CJK-capable is a valid last resort, which
|
|
176
|
+
# is what makes this work on a distro whose naming we never anticipated.
|
|
177
|
+
_CJK_NAME = re.compile(
|
|
178
|
+
r"(noto.{0,12}cjk|notosanssc|notoserifsc|sourcehan|wqy|droid.{0,8}fallback"
|
|
179
|
+
r"|msyh|simhei|simsun|pingfang|heiti|hiragino|uming|ukai)", re.IGNORECASE)
|
|
180
|
+
_FONT_SUFFIXES = {".ttf", ".ttc", ".otf", ".otc"}
|
|
156
181
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
|
|
183
|
+
def _font_search_dirs() -> list[Path]:
|
|
184
|
+
"""Font directories to scan, most specific first.
|
|
185
|
+
|
|
186
|
+
`TRIP_FONT_DIRS` (os.pathsep-separated) REPLACES the list when set: that is
|
|
187
|
+
the seam the selftest uses to isolate a Debian-like layout from whatever
|
|
188
|
+
fonts this machine happens to own. (A prepend would not isolate anything —
|
|
189
|
+
the machine's own CJK font would still win and hide the bug being tested.)
|
|
190
|
+
"""
|
|
191
|
+
override = os.environ.get("TRIP_FONT_DIRS")
|
|
192
|
+
if override:
|
|
193
|
+
return [Path(p.strip()) for p in override.split(os.pathsep) if p.strip()]
|
|
194
|
+
dirs: list[Path] = []
|
|
195
|
+
win = os.environ.get("WINDIR") or "C:/Windows"
|
|
196
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
197
|
+
dirs.append(Path(win) / "Fonts")
|
|
198
|
+
if local:
|
|
199
|
+
dirs.append(Path(local) / "Microsoft" / "Windows" / "Fonts")
|
|
200
|
+
dirs += [Path("/usr/share/fonts"), Path("/usr/local/share/fonts"),
|
|
201
|
+
Path.home() / ".fonts", Path.home() / ".local/share/fonts",
|
|
202
|
+
Path("/System/Library/Fonts"), Path("/Library/Fonts")]
|
|
203
|
+
return dirs
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@lru_cache(maxsize=8)
|
|
207
|
+
def _font_index(dirs: tuple[str, ...]) -> dict[str, str]:
|
|
208
|
+
"""name -> path for every font file under those dirs, searched RECURSIVELY.
|
|
209
|
+
|
|
210
|
+
Recursion is the whole point: Debian keeps Noto at
|
|
211
|
+
/usr/share/fonts/opentype/noto/..., so a one-level lookup finds nothing.
|
|
212
|
+
"""
|
|
213
|
+
index: dict[str, str] = {}
|
|
214
|
+
for entry in dirs:
|
|
215
|
+
root = Path(entry)
|
|
216
|
+
try:
|
|
217
|
+
if not root.is_dir():
|
|
172
218
|
continue
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
219
|
+
for path in root.rglob("*"):
|
|
220
|
+
if path.suffix.lower() in _FONT_SUFFIXES and path.is_file():
|
|
221
|
+
index.setdefault(path.name, str(path))
|
|
222
|
+
except OSError:
|
|
223
|
+
continue
|
|
224
|
+
return index
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _renders_cjk(font) -> bool:
|
|
228
|
+
"""Does this font actually have CJK glyphs, or would it just draw tofu?
|
|
229
|
+
|
|
230
|
+
A font without CJK coverage maps every ideograph to the same .notdef box, so
|
|
231
|
+
two unrelated characters come out byte-identical. That is the check — the
|
|
232
|
+
same one that caught the Linux bug, kept here so it cannot come back.
|
|
233
|
+
"""
|
|
234
|
+
def ink(text: str) -> bytes:
|
|
235
|
+
img = Image.new("L", (72, 72), 255)
|
|
236
|
+
ImageDraw.Draw(img).text((3, 3), text, font=font, fill=0)
|
|
237
|
+
return img.tobytes()
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
return ink("朋") != ink("北")
|
|
241
|
+
except Exception:
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def find_font(candidates: list[str], size: int) -> ImageFont.FreeTypeFont:
|
|
246
|
+
"""Locate a usable font file across Windows, Linux, and macOS directories.
|
|
247
|
+
|
|
248
|
+
Prefers a font that can actually draw Chinese; falls back to a Latin face and
|
|
249
|
+
then to Pillow's own default (which has no CJK at all). The scanned dirs come
|
|
250
|
+
first and bare names are the last resort — bare names still cover setups our
|
|
251
|
+
scan cannot see (Windows' own font dir is on Pillow's search path, and
|
|
252
|
+
fontconfig-only Linux installs may hide faces behind aliases).
|
|
253
|
+
|
|
254
|
+
When `TRIP_FONT_DIRS` is set the search set is EXACTLY those directories and
|
|
255
|
+
no bare-name lookup happens, which is what lets the selftest reproduce a
|
|
256
|
+
Debian-like box instead of this machine's fonts.
|
|
257
|
+
"""
|
|
258
|
+
override = bool(os.environ.get("TRIP_FONT_DIRS"))
|
|
259
|
+
dirs = tuple(str(d) for d in _font_search_dirs())
|
|
260
|
+
index = _font_index(dirs)
|
|
261
|
+
latin_fallback = None
|
|
262
|
+
# Any CJK-looking font present in the search set is a better answer than a
|
|
263
|
+
# Latin fallback, however the distro happens to name it.
|
|
264
|
+
names = list(candidates) + [n for n in index if _CJK_NAME.search(n)]
|
|
265
|
+
|
|
266
|
+
def consider(font):
|
|
267
|
+
nonlocal latin_fallback
|
|
268
|
+
if _renders_cjk(font):
|
|
269
|
+
return font
|
|
270
|
+
if latin_fallback is None:
|
|
271
|
+
latin_fallback = font
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
for name in names: # 1) the scanned dirs
|
|
275
|
+
path = index.get(name)
|
|
276
|
+
if not path:
|
|
277
|
+
continue
|
|
179
278
|
try:
|
|
180
|
-
|
|
279
|
+
picked = consider(ImageFont.truetype(path, size))
|
|
181
280
|
except OSError:
|
|
182
|
-
|
|
281
|
+
continue
|
|
282
|
+
if picked is not None:
|
|
283
|
+
return picked
|
|
284
|
+
|
|
285
|
+
if not override: # 2) Pillow's own search
|
|
286
|
+
for name in names:
|
|
287
|
+
try:
|
|
288
|
+
picked = consider(ImageFont.truetype(name, size))
|
|
289
|
+
except OSError:
|
|
290
|
+
continue
|
|
291
|
+
if picked is not None:
|
|
292
|
+
return picked
|
|
183
293
|
|
|
294
|
+
if latin_fallback is not None: # 3) no CJK anywhere
|
|
295
|
+
return latin_fallback
|
|
184
296
|
for fallback in ["arial.ttf", "DejaVuSans.ttf"]:
|
|
297
|
+
path = index.get(fallback)
|
|
185
298
|
try:
|
|
186
|
-
return ImageFont.truetype(fallback, size)
|
|
299
|
+
return ImageFont.truetype(path or fallback, size)
|
|
187
300
|
except OSError:
|
|
188
301
|
pass
|
|
189
302
|
return ImageFont.load_default()
|
|
@@ -3281,10 +3281,117 @@ def t_tray_gateway_discovery():
|
|
|
3281
3281
|
srv.server_close()
|
|
3282
3282
|
|
|
3283
3283
|
|
|
3284
|
+
def t_font_selection_has_cjk():
|
|
3285
|
+
"""字体选择必须真的能画中文 —— 在服务器(Debian 布局)上曾经画成方块。
|
|
3286
|
+
|
|
3287
|
+
2026-10-04 在 python:3.11-slim 容器里实测:装不装 fonts-noto-cjk,「朋友圈旅行」
|
|
3288
|
+
与「敦煌戈壁滩」都渲染成完全相同的位图(都是 .notdef 方块),因为
|
|
3289
|
+
(a) /usr/share/fonts 只查了一层,而 Debian 把 Noto 放在 opentype/noto/ 下,
|
|
3290
|
+
(b) 候选名单里写的是 NotoSansSC-*,Debian 只提供 NotoSansCJK-*。
|
|
3291
|
+
|
|
3292
|
+
本用例不依赖具体操作系统:用 TRIP_FONT_DIRS 造一个 Debian 式目录,看选择器能不能
|
|
3293
|
+
递归找到它、并宁可选一个没列进名单但带中文的字体,也不返回列表更靠前却画不出中文的。
|
|
3294
|
+
"""
|
|
3295
|
+
import shutil as _sh
|
|
3296
|
+
import tempfile as _tf
|
|
3297
|
+
from pathlib import Path as _P
|
|
3298
|
+
|
|
3299
|
+
import imquality as iq
|
|
3300
|
+
|
|
3301
|
+
dirs = tuple(str(d) for d in iq._font_search_dirs())
|
|
3302
|
+
index = iq._font_index(dirs)
|
|
3303
|
+
cjk_src = next((p for n, p in index.items() if iq._CJK_NAME.search(n)
|
|
3304
|
+
and iq._renders_cjk(iq.ImageFont.truetype(p, 32))), "")
|
|
3305
|
+
latin_src = ""
|
|
3306
|
+
for name in ("arial.ttf", "DejaVuSans.ttf", "georgia.ttf", "times.ttf", "Helvetica.ttc"):
|
|
3307
|
+
path = index.get(name)
|
|
3308
|
+
if not path:
|
|
3309
|
+
continue
|
|
3310
|
+
try:
|
|
3311
|
+
font = iq.ImageFont.truetype(path, 32)
|
|
3312
|
+
except OSError:
|
|
3313
|
+
continue
|
|
3314
|
+
if not iq._renders_cjk(font):
|
|
3315
|
+
latin_src = path
|
|
3316
|
+
break
|
|
3317
|
+
|
|
3318
|
+
# 反方向:缺字检测必须能认出“画不出中文”的字形 —— 用 Pillow 自带嵌入字体,
|
|
3319
|
+
# 它没有 CJK 字形,且任何机器上都在(不让用例依赖本机凑巧装了某种拉丁字体)。
|
|
3320
|
+
assert not iq._renders_cjk(iq.ImageFont.load_default()), \
|
|
3321
|
+
"无 CJK 字形的字体被判为能画中文 —— 缺字检测失效,服务器上会静默画出方块"
|
|
3322
|
+
if cjk_src:
|
|
3323
|
+
assert iq._renders_cjk(iq.ImageFont.truetype(cjk_src, 32)), \
|
|
3324
|
+
f"已知中文字体 {cjk_src} 被判为画不出中文"
|
|
3325
|
+
|
|
3326
|
+
if not cjk_src:
|
|
3327
|
+
# 本机压根没装中文字体:反向判据已验,不再拿找不到的字体硬凑。
|
|
3328
|
+
print(" (本机无中文字体,只验证了缺字检测不误报)")
|
|
3329
|
+
return
|
|
3330
|
+
|
|
3331
|
+
old_env = os.environ.get("TRIP_FONT_DIRS")
|
|
3332
|
+
with _tf.TemporaryDirectory() as tmp:
|
|
3333
|
+
root = _P(tmp)
|
|
3334
|
+
os.environ["TRIP_FONT_DIRS"] = str(root)
|
|
3335
|
+
iq._font_index.cache_clear()
|
|
3336
|
+
try:
|
|
3337
|
+
# Debian 式布局:嵌套两层 + 名单外的名字,必须被递归找到并选中
|
|
3338
|
+
(root / "opentype" / "noto").mkdir(parents=True)
|
|
3339
|
+
wanted = root / "opentype" / "noto" / "NotoSansCJKjp-Light.ttc"
|
|
3340
|
+
_sh.copyfile(cjk_src, wanted)
|
|
3341
|
+
iq._font_index.cache_clear()
|
|
3342
|
+
picked = iq.find_font(iq.FONT_ZH_BOLD, 32)
|
|
3343
|
+
assert iq._renders_cjk(picked), \
|
|
3344
|
+
f"递归目录里明明有中文字体,却选回了画不出中文的:{getattr(picked, 'path', picked)}"
|
|
3345
|
+
assert str(getattr(picked, "path", "")).endswith("NotoSansCJKjp-Light.ttc"), \
|
|
3346
|
+
("应该选嵌套两层、名单外的那个中文字体(这才是「不靠硬编码名字」),"
|
|
3347
|
+
f"实际选了 {getattr(picked, 'path', picked)}")
|
|
3348
|
+
|
|
3349
|
+
# 像素级:两个不同汉字必须画出不同位图
|
|
3350
|
+
def ink(text):
|
|
3351
|
+
img = iq.Image.new("L", (80, 80), 255)
|
|
3352
|
+
iq.ImageDraw.Draw(img).text((3, 3), text, font=picked, fill=0)
|
|
3353
|
+
return img.tobytes()
|
|
3354
|
+
assert ink("朋") != ink("北"), "两个不同汉字画出同一张图 —— 就是缺字方块"
|
|
3355
|
+
|
|
3356
|
+
# 搜索集里只有画不出中文的字体时,宁可降级也不能假装能画。
|
|
3357
|
+
# 必须用一个全新的目录:刚才那个临时目录里已经放了中文字体。
|
|
3358
|
+
if latin_src:
|
|
3359
|
+
os.environ["TRIP_FONT_DIRS"] = str(root / "only-latin")
|
|
3360
|
+
(root / "only-latin").mkdir()
|
|
3361
|
+
_sh.copyfile(latin_src, root / "only-latin" / "latin-only.ttf")
|
|
3362
|
+
iq._font_index.cache_clear()
|
|
3363
|
+
only = iq.find_font(iq.FONT_ZH_BOLD, 32)
|
|
3364
|
+
assert not iq._renders_cjk(only), \
|
|
3365
|
+
"搜索集里只有拉丁字体,却被报告为能画中文(选字逻辑在蒙)"
|
|
3366
|
+
else:
|
|
3367
|
+
print(" (本机无拉丁字体文件,略过“只有拉丁字体”的降级子用例)")
|
|
3368
|
+
finally:
|
|
3369
|
+
iq._font_index.cache_clear()
|
|
3370
|
+
if old_env is None:
|
|
3371
|
+
os.environ.pop("TRIP_FONT_DIRS", None)
|
|
3372
|
+
else:
|
|
3373
|
+
os.environ["TRIP_FONT_DIRS"] = old_env
|
|
3374
|
+
|
|
3375
|
+
|
|
3376
|
+
def t_linux_font_layout_declared():
|
|
3377
|
+
"""font 目录必须递归搜索:Debian 把 Noto 放在 opentype/noto/ 下。
|
|
3378
|
+
|
|
3379
|
+
这条不依赖机器上有没有中文字体,只查源码契约:搜素用 rglob 而不是直接拼一层;
|
|
3380
|
+
候选名单里必须包含各发行版真实使用的 Noto CJK 文件名。
|
|
3381
|
+
"""
|
|
3382
|
+
src = (Path(__file__).parent / "imquality.py").read_text(encoding="utf-8")
|
|
3383
|
+
assert "rglob" in src, "字体目录退回单层扫描 —— Debian 的 opentype/noto/ 会找不到"
|
|
3384
|
+
for name in ("NotoSansCJK-Bold.ttc", "NotoSansCJK-Regular.ttc", "NotoSansCJKsc-Regular.otf"):
|
|
3385
|
+
assert name in src, f"候选名单缺少发行版实际使用的字体名:{name}"
|
|
3386
|
+
assert "_renders_cjk" in src and "load_default" in src, "缺少「画不出中文就继续找」的判据"
|
|
3387
|
+
|
|
3388
|
+
|
|
3284
3389
|
CASES = {
|
|
3285
3390
|
"打包交付与下载(zip/预览/越权拦截)": t_finish_deliverables_and_download,
|
|
3286
3391
|
"AI 结果可见性+三套候选(每步输出框/agent轨道)": t_claw_grade_candidates_and_output_visibility,
|
|
3287
3392
|
"Agent自带模型轨道也给三档候选(预览/采用)": t_agent_model_grade_candidates,
|
|
3393
|
+
"字体选择真的能画中文(递归+缺字检测)": t_font_selection_has_cjk,
|
|
3394
|
+
"字体目录递归与发行版字体名": t_linux_font_layout_declared,
|
|
3288
3395
|
"OpenClaw WSL gateway 发现(端点未启用的诚实报告)": t_tray_gateway_discovery,
|
|
3289
3396
|
"存活探测不误杀冷实例/代理环境": t_liveness_probe_is_cheap_and_proxy_safe,
|
|
3290
3397
|
"审片页 STUDIO 链接跟随在跑实例": t_review_studio_link_follows_instance,
|
package/bin/install.js
CHANGED