Files
msime-windows/scripts/check-symbol-rendering.py
Lu Fan(SonnyCalcr) 3cd429b19d feat(server): 加入 S 模式符号输入与符号混输 (#1047)
符号数据改用 rime-wanxiang 的符号表与 typst/codex 符号名,源数据放在
msime-dictionary 的 sources/symbols/,替换原来的 piliapp 符号表。

- 建库:新增 symbols_db.py,生成表情面板用的 symbol_catalog(中文分类)和
  S 模式、符号混输用的 symbol_code;剔除 wanxiang-unrenderable.txt 列出的
  Windows 自带字体显示不了的符号,以及与 ① ❶ 看不出区别的 Dingbats 带圈数字
- 新增 scripts/check-symbol-rendering.py,用 Windows 字体生成上面的剔除清单
- Shift+S 进入符号模式,按万象分组码(xl、sx、szq)、数字读音(yi → 一 ① ⑴)
  或符号名(alpha、infinity)检索
- 符号混输:整串精确等于编码时混入,首个紧跟首选;读得成拼音的编码只混数字
  序号写法;双拼先按方案换算成全拼再查(小鹤 sj → san → ③)
- 表情面板符号页:中文分类,搜索把整词命中的符号列在「最佳匹配」
- 配置:utility.symbol_mode(默认开)、general.symbol_mixed_input(默认关)

Refs #795
2026-10-18 02:08:39 +08:00

145 lines
7.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""用 Windows 自带字体检查 msime-dictionary 的符号源,把显示不了的符号写进 sources/symbols/wanxiang-unrenderable.txt。
build-dictionary.py 构建 others.db 时按这份清单剔除符号,构建本身不读字体,结果与机器无关;
换了符号源或想按新的 Windows 字体重新判定时,在 Windows 上重跑本脚本并评审清单的变化。
判定规则(逐条,命中第一条即记下):
invisible 只有格式字符、空白或组合附加符号,上屏后看不见或浮在前一个字上
private-use 含私用区码位,显示什么取决于碰巧装了哪种字体
no-glyph C:\\Windows\\Fonts 下(不算 Noto 系列这类非系统自带的字体)没有任何字体有它的字形;
emoji 序列改用 Segoe UI Emoji 排版判定
split Segoe UI Emoji 不能把序列合成一个字形(国旗、部分 ZWJ 组合),会显示成几个字符
需要 fonttools 与 uharfbuzz:python -m pip install fonttools uharfbuzz
用法:
python scripts/check-symbol-rendering.py # 默认读写与本仓同级的 msime-dictionary
python scripts/check-symbol-rendering.py --dictionary D:/msime-dictionary
"""
from __future__ import annotations
import argparse
import os
import platform
import subprocess
import sys
import unicodedata
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from dictionary_build import build, symbols_db, textutil # noqa: E402
FONTS = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
# 不随 Windows 安装的字体:只出现在装过它们的机器上,不能拿来证明「Windows 能显示」。
NON_SYSTEM_PREFIXES = ("noto",)
IGNORABLE = {0x200D, 0xFE0E, 0xFE0F}
EMOJI_MARKERS = {0x200D, 0xFE0F, 0x20E3}
def load_fonts():
try:
import uharfbuzz as hb
from fontTools.ttLib import TTCollection, TTFont
except ImportError as error:
raise SystemExit("需要 fonttools 与 uharfbuzz:python -m pip install fonttools uharfbuzz") from error
coverage: set[int] = set()
used = 0
for path in sorted(FONTS.iterdir()):
suffix = path.suffix.lower()
if suffix not in (".ttf", ".otf", ".ttc") or path.name.lower().startswith(NON_SYSTEM_PREFIXES):
continue
try:
fonts = TTCollection(path, lazy=True).fonts if suffix == ".ttc" else [TTFont(path, lazy=True)]
for font in fonts:
coverage.update((font.getBestCmap() or {}).keys())
used += 1
except Exception: # noqa: BLE001 - 坏字体文件跳过,不影响其余判定
continue
emoji_path = FONTS / "seguiemj.ttf"
emoji_ttf = TTFont(emoji_path)
emoji_font = hb.Font(hb.Face(hb.Blob.from_file_path(str(emoji_path))))
version = emoji_ttf["head"].fontRevision
return hb, coverage, used, emoji_ttf.getBestCmap(), emoji_font, f"{version:.2f}"
def classify(symbol: str, hb, coverage: set[int], emoji_cmap: dict, emoji_font) -> str | None:
categories = [unicodedata.category(c) for c in symbol]
if all(cat in ("Cf", "Zs", "Zl", "Zp", "Cc", "Mn", "Me") for cat in categories):
return "invisible"
if "Co" in categories:
return "private-use"
codepoints = [ord(c) for c in symbol]
emoji = any(cp in EMOJI_MARKERS or 0x1F1E6 <= cp <= 0x1F1FF for cp in codepoints) or (
any(cp >= 0x1F000 for cp in codepoints) and all(cp in emoji_cmap or cp in IGNORABLE for cp in codepoints))
if emoji:
buffer = hb.Buffer()
buffer.add_str(symbol)
buffer.guess_segment_properties()
hb.shape(emoji_font, buffer, {})
if any(info.codepoint == 0 for info in buffer.glyph_infos):
return "no-glyph"
visible = sum(1 for position in buffer.glyph_positions if position.x_advance > 0)
return "split" if visible > 1 else None
if any(cp not in coverage and cp not in IGNORABLE for cp in codepoints):
return "no-glyph"
return None
def git_head(path: Path) -> str:
try:
commit = subprocess.run(["git", "-C", str(path), "rev-parse", "HEAD"], capture_output=True, text=True,
check=True).stdout.strip()
dirty = subprocess.run(["git", "-C", str(path), "status", "--porcelain", "--", "scripts"], capture_output=True,
text=True, check=True).stdout.strip()
except (OSError, subprocess.CalledProcessError):
return "unknown"
return commit + ("-dirty" if dirty else "")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--dictionary", type=Path, default=build.ROOT.parent / "msime-dictionary")
args = parser.parse_args()
dictionary = args.dictionary.resolve()
groups = symbols_db.parse_wanxiang(textutil.read(dictionary / symbols_db.WANXIANG))
codex = symbols_db.parse_codex(textutil.read(dictionary / symbols_db.CODEX))
sources: dict[str, list[str]] = {}
for group in groups:
for symbol in group.items:
sources.setdefault(symbol, []).append(f"/{group.code}")
for name, symbol in codex:
sources.setdefault(symbol, []).append(f"codex:{name}")
hb, coverage, used, emoji_cmap, emoji_font, emoji_version = load_fonts()
lines = []
counts: dict[str, int] = {}
for symbol, where in sources.items():
reason = classify(symbol, hb, coverage, emoji_cmap, emoji_font)
if reason is None:
continue
counts[reason] = counts.get(reason, 0) + 1
codepoints = " ".join(f"U+{ord(c):04X}" for c in symbol)
shown = symbol if reason != "invisible" else ""
lines.append(f"{codepoints}\t{shown}\t{reason}\t{' '.join(dict.fromkeys(where))}")
header = [
f"# 由 MSIME-Windows 仓库提交 {git_head(build.ROOT)} 的 scripts/check-symbol-rendering.py 生成,不要手工编辑。",
f"# 判定环境:Windows {platform.version()},{used} 个 C:\\Windows\\Fonts 字体(不含 Noto 系列),Segoe UI Emoji {emoji_version}。",
f"# 检查对象:{symbols_db.WANXIANG} 中入选的符号分组与 {symbols_db.CODEX} 的全部符号,共 {len(sources)} 个不同的符号。",
"# 判定规则见生成脚本的说明:invisible 看不见,private-use 私用区,no-glyph 系统字体没有字形,split 序列不能合成一个字形。",
"# build-dictionary.py 构建 others.db 时剔除这里列出的全部符号,它们不进入表情面板,也不进入 S 模式和符号混输。",
f"# 共 {len(lines)} 个:" + ",".join(f"{name} {count}" for name, count in sorted(counts.items())),
"# 格式:码位<TAB>符号(invisible 留空)<TAB>原因<TAB>出处(万象分组编码或 codex:名称)",
]
target = dictionary / symbols_db.UNRENDERABLE
target.write_bytes(("\n".join(header + lines) + "\n").encode("utf-8"))
print(f"{len(lines)} 个显示不了的符号({counts})写入 {target}")
if __name__ == "__main__":
main()