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
- FONT_ZH_BOLD = ["msyhbd.ttc", "simhei.ttf", "msyh.ttc", "simsun.ttc", "NotoSansSC-Bold.otf", "PingFang.ttc"]
153
- FONT_ZH = ["msyh.ttc", "simsun.ttc", "simhei.ttf", "NotoSansSC-Regular.otf", "PingFang.ttc", "STHeiti.ttc"]
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
- def find_font(candidates: list[str], size: int) -> ImageFont.FreeTypeFont:
158
- """Locate a usable font file across Windows, Linux, and macOS directories."""
159
- search_dirs = [
160
- Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts",
161
- Path(os.environ.get("LOCALAPPDATA", "")) / "Microsoft" / "Windows" / "Fonts",
162
- Path("/usr/share/fonts"),
163
- Path("/usr/local/share/fonts"),
164
- Path.home() / ".fonts",
165
- Path.home() / ".local/share/fonts",
166
- Path("/System/Library/Fonts"),
167
- Path("/Library/Fonts"),
168
- ]
169
- for name in candidates:
170
- for sdir in search_dirs:
171
- if not sdir.exists():
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
- p = sdir / name
174
- if p.exists():
175
- try:
176
- return ImageFont.truetype(str(p), size)
177
- except OSError:
178
- continue
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
- return ImageFont.truetype(name, size)
279
+ picked = consider(ImageFont.truetype(path, size))
181
280
  except OSError:
182
- pass
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
@@ -16,7 +16,7 @@ const path = require('path');
16
16
  const os = require('os');
17
17
  const { execSync, spawnSync } = require('child_process');
18
18
 
19
- const VERSION = '1.3.0';
19
+ const VERSION = '1.3.1';
20
20
  const SKILL_NAME = 'trip-moments';
21
21
 
22
22
  const c = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trip-moments-skill",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "AI 朋友圈旅行出图助手 (trip-moments skill) — 工业级全流程旅行摄影智能出图与朋友圈九宫格套件",
5
5
  "main": "bin/install.js",
6
6
  "bin": {