第一章:无头模式golang截图模糊/字体缺失问题的现象与影响
在基于 Chrome DevTools Protocol(CDP)或第三方库(如 chromedp、rod)实现的 Go 无头浏览器自动化截图场景中,开发者常遭遇两类典型视觉异常:截图整体呈现明显模糊(边缘发虚、文字锯齿严重),以及中文、特殊符号或自定义字体完全无法渲染(显示为方块、空白或默认英文字体)。这些问题并非随机偶发,而是与无头环境的图形栈配置强相关。
常见现象表现
- 截图中 CSS
font-family指定的「思源黑体」「Noto Sans SC」等中文字体未生效,退化为 Times New Roman 或 Arial; <canvas>或 SVG 渲染内容出现抗锯齿失效,线条毛刺感强烈;- 高 DPI 屏幕(如 macOS Retina)下截图分辨率异常,宽高仅为预期 50%;
- 使用
--force-device-scale-factor=2启动参数后,页面布局错乱且字体仍缺失。
根本原因分析
无头 Chromium 默认禁用字体回退机制与系统字体服务,且不加载 X11/Fontconfig(Linux)、Core Text(macOS)或 GDI+(Windows)的完整字体链。同时,其软件光栅化器(Skia)在无显示设备上下文中跳过子像素抗锯齿与字体微调(hinting)流程。
关键修复步骤
启动浏览器时必须显式注入字体支持与渲染参数:
# Linux 环境:预装字体 + 强制启用字体配置
sudo apt-get install fonts-noto-cjk fonts-liberation
google-chrome-stable \
--headless=new \
--no-sandbox \
--disable-gpu \
--font-render-hinting=medium \
--force-color-profile=srgb \
--disable-font-subpixel-positioning \
--disable-remote-fonts \
--remote-debugging-port=9222
注意:
--disable-remote-fonts可防止网络字体阻塞,而--font-render-hinting=medium激活字形微调,二者缺一不可。macOS 用户需额外执行sudo atsutil databases -enable刷新字体缓存。
| 环境 | 必需操作 |
|---|---|
| Ubuntu 22.04 | 安装 fonts-noto-cjk 并设置 FONTCONFIG_PATH=/etc/fonts |
| Docker | 在 Dockerfile 中 COPY 字体文件至 /usr/share/fonts/ 并运行 fc-cache -fv |
| Windows WSL2 | 安装 libfontconfig1 并挂载 Windows 字体目录 |
上述配置缺失将直接导致截图质量不可用于生产环境——例如生成带中文水印的 PDF 报表时字符丢失,或 UI 自动化测试中 OCR 识别准确率下降超 60%。
第二章:fontconfig缓存机制与Linux容器内污染路径深度解析
2.1 fontconfig配置层级与缓存生成原理(理论)
fontconfig 采用多级配置叠加机制,优先级从高到低依次为:
- 用户级
~/.config/fontconfig/conf.d/(覆盖全局) - 系统级
/etc/fonts/conf.d/(符号链接指向/usr/share/fontconfig/conf.avail/) - 编译内置默认(
/usr/share/fontconfig/fonts.conf)
配置加载顺序
<!-- /etc/fonts/local.conf 示例 -->
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
<dir>/usr/local/share/fonts</dir> <!-- 新增字体目录 -->
<cache>yes</cache> <!-- 启用缓存(实际无效,仅占位) -->
</fontconfig>
<dir> 告知 fontconfig 扫描路径;<cache> 标签在配置中无实际作用——缓存行为由 fc-cache 工具控制,非 XML 指令驱动。
缓存生成流程
graph TD
A[扫描所有 <dir> 目录] --> B[解析每个字体文件元数据]
B --> C[合并所有 conf.d/*.conf 配置]
C --> D[应用匹配规则与替换策略]
D --> E[序列化为二进制 fc-cache 文件]
| 层级 | 路径 | 可写性 | 生效方式 |
|---|---|---|---|
| 用户级 | ~/.config/fontconfig/fonts.conf |
✅ | 登录会话自动重载 |
| 系统级 | /etc/fonts/conf.d/ |
❌(需 root) | fc-cache -fv 强制刷新 |
2.2 容器共享宿主机缓存导致字体映射失效的实证复现(实践)
环境复现步骤
- 启动宿主机 Fontconfig 缓存:
fc-cache -fv - 运行共享
--volume /var/cache/fontconfig:/var/cache/fontconfig的容器 - 在容器内执行
fc-list | grep "Noto",返回空
核心问题定位
宿主机与容器共用同一 fontconfig 缓存目录,但 fc-cache 生成的 fonts.cache-8 文件含绝对路径哈希与UID/GID 元数据,容器内非 root 用户(如 UID 1001)读取时校验失败,跳过缓存直接回退至空字体列表。
关键验证代码
# 宿主机执行(记录原始缓存元数据)
stat -c "%U %G %a %y" /var/cache/fontconfig/fonts.cache-8
# 输出示例:root root 644 2024-04-01 10:23:12.000000000 +0800
逻辑分析:
fontconfigv2.14+ 引入缓存签名机制,校验文件属主、权限及修改时间。容器内进程 UID 不匹配宿主机root,触发缓存拒绝加载,强制重建(失败因无写权限),最终fc-list返回空。
对比验证表
| 场景 | 缓存路径挂载 | fc-list 是否命中 |
原因 |
|---|---|---|---|
| 独立缓存 | 未挂载 | ✅ | 容器内 fc-cache -fv 重建有效缓存 |
| 共享缓存 | /var/cache/fontconfig |
❌ | UID 不匹配导致签名校验失败 |
graph TD
A[容器启动] --> B{检查 /var/cache/fontconfig/fonts.cache-8}
B -->|UID不匹配| C[跳过缓存]
B -->|UID匹配| D[加载字体列表]
C --> E[尝试重建缓存]
E -->|无写权限| F[返回空列表]
2.3 golang chromedp/puppeteer-go在无头Chrome中字体渲染依赖链分析(理论+实践)
无头 Chrome 的字体渲染并非黑盒,而是由操作系统字体配置、Chrome 启动参数、页面 CSS 声明与 Blink 渲染引擎共同构成的四级依赖链。
字体解析关键层级
- OS 层:
/usr/share/fonts/(Linux)、/System/Library/Fonts/(macOS)提供基础字型资源 - Chrome 启动层:需显式挂载
--font-render-hinting=medium与--disable-font-subpixel-positioning - chromedp 层:通过
cdp.NewBrowser()传递ExecPath和Args控制底层行为 - Web 层:
@font-face加载路径、font-family回退链决定最终匹配结果
实践验证代码
opts := []chromedp.ExecAllocatorOption{
chromedp.ExecPath("/usr/bin/chromium-browser"),
chromedp.Flag("font-render-hinting", "medium"),
chromedp.Flag("disable-font-subpixel-positioning", true),
chromedp.Flag("no-sandbox", true),
}
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts)
该配置强制启用 hinting 并禁用亚像素定位,使渲染结果跨环境可复现;no-sandbox 在容器中为必需项,否则字体加载可能因沙箱拦截失败。
依赖关系图
graph TD
A[OS 字体目录] --> B[Chrome 启动参数]
B --> C[chromedp 分配器配置]
C --> D[CSS font-family 回退链]
2.4 多阶段构建中FONTCONFIG_PATH挂载冲突的典型日志取证(实践)
现象复现:构建时字体缓存初始化失败
Docker 构建日志中高频出现以下错误:
Fontconfig error: Cannot load default config file
Failed to load module "pkcs11" /usr/lib/x86_64-linux-gnu/gio/modules/libpkcs11.so: cannot open shared object file
根本诱因分析
多阶段构建中,builder 阶段通过 COPY --from=base /etc/fonts $FONTCONFIG_PATH 挂载字体配置,但 FONTCONFIG_PATH 默认为 /etc/fonts,而运行时镜像已将该路径声明为 volume —— 导致 COPY 覆盖被静默忽略。
关键验证命令
# 检查目标阶段是否实际写入
docker run --rm -it <image> ls -la $FONTCONFIG_PATH/conf.d/
# 输出为空 → 表明挂载覆盖了 COPY 内容
解决方案对比
| 方案 | 是否规避挂载冲突 | 是否需 root 权限 | 推荐度 |
|---|---|---|---|
ENV FONTCONFIG_PATH /tmp/fonts + COPY |
✅ | ❌ | ⭐⭐⭐⭐ |
RUN mkdir -p /etc/fonts && cp -r ... |
✅ | ✅ | ⭐⭐ |
VOLUME [] 显式清空 |
❌(破坏兼容性) | ❌ | ⚠️ |
推荐修复流程
# 在 final 阶段显式重定向配置路径
ENV FONTCONFIG_PATH=/opt/fonts
RUN mkdir -p $FONTCONFIG_PATH/conf.d && \
cp /usr/share/fontconfig/conf.avail/10-hinting-slight.conf $FONTCONFIG_PATH/conf.d/
→ 此方式绕过 /etc/fonts 的 volume 冲突,且无需修改基础镜像结构。
2.5 不同基础镜像(alpine/debian/ubuntu)下fc-cache行为差异对比实验(实践)
实验环境准备
使用相同字体集(NotoSansCJK-Regular.ttc)在三类镜像中执行 fc-cache -fv,观察日志输出、缓存路径及退出码。
关键差异表现
| 镜像 | 默认 fontconfig 版本 | 缓存目录 | fc-cache -fv 是否自动创建 /var/cache/fontconfig |
|---|---|---|---|
| alpine | 2.14.2 | /usr/share/fonts/cache |
否(需手动 mkdir -p,否则静默失败) |
| debian | 2.13.1+dfsg-1 | /var/cache/fontconfig |
是(自动创建,权限为 root:root 755) |
| ubuntu | 2.13.1-0ubuntu3 | /var/cache/fontconfig |
是(但若挂载只读 /var,报错 Permission denied) |
典型错误复现代码
# Alpine 中未预创建缓存目录 → fc-cache 静默跳过扫描
FROM alpine:3.19
COPY NotoSansCJK-Regular.ttc /usr/share/fonts/truetype/
RUN fc-cache -fv # ❌ 无输出,$? = 0,但 /usr/share/fonts/cache/ 为空
逻辑分析:Alpine 的 fontconfig 在
FC_CACHE_DIR未就绪时跳过写入,不报错也不提示;而 Debian/Ubuntu 会尝试mkdir -p并校验写权限。参数-v仅显示扫描路径,不反映写入结果,需配合ls -l /var/cache/fontconfig验证。
行为决策建议
- 生产镜像统一使用
debian:slim,规避 Alpine 的静默失败风险; - 若坚持 Alpine,必须显式初始化缓存目录:
RUN mkdir -p /usr/share/fonts/cache && fc-cache -fv。
第三章:systemd-run –scope隔离方案的设计哲学与内核机制
3.1 cgroup v2 scope单元与命名空间隔离边界的技术本质(理论)
cgroup v2 的 scope 单元是动态生命周期的资源控制边界,由 systemd 自动创建,不依赖用户显式配置,其核心在于与命名空间(尤其是 PID、user、mount)的协同裁剪。
scope 的生成机制
# 创建一个带新 PID 命名空间的 scope
systemd-run --scope --scope-prefix="net-isolate" \
--property=Delegate=yes \
--property=MemoryMax=512M \
unshare -r -p --fork /bin/bash -c 'echo $$; sleep infinity'
--scope触发 runtime scope 创建,绑定当前 shell 进程及其子树Delegate=yes启用 cgroup delegation,允许内部进程管理子 cgroupunshare -r -p创建嵌套 user+PID 命名空间,形成双重隔离锚点
隔离边界的对齐原理
| 维度 | cgroup v2 scope 边界 | 命名空间挂载点 | 对齐方式 |
|---|---|---|---|
| 进程归属 | scope 内所有 PID | 新 PID 命名空间 root | scope root = ns init PID |
| 资源可见性 | /sys/fs/cgroup/.../scope-net-isolate/ |
/proc/[pid]/cgroup 显示该 scope 路径 |
两者路径映射严格一致 |
graph TD
A[systemd-run --scope] --> B[创建 scope.slice 子目录]
B --> C[将调用进程迁移至该 cgroup]
C --> D[unshare 创建新 PID ns]
D --> E[新 PID ns init 进程成为 scope cgroup leader]
E --> F[所有后续 fork 均受 scope 资源策略约束]
scope 的本质是 cgroup v2 层面的“轻量级 service”,其隔离效力仅在与命名空间协同时才完整——缺少 PID 命名空间,scope 无法阻止跨边界进程逃逸;缺少 Delegate,容器内无法再细分资源。
3.2 –scope对fontconfig缓存目录自动隔离的实现原理(理论+实践)
Fontconfig 通过 --scope 参数区分系统级与用户级配置作用域,其缓存路径自动隔离依赖 $FC_CACHE_DIR 环境变量与内部 FcConfigGetCacheDir() 调用链。
缓存路径生成逻辑
- 用户 scope:
~/.cache/fontconfig/cache(默认,受$XDG_CACHE_HOME影响) - System scope:
/var/cache/fontconfig(需 root 权限,跳过$HOME相关路径拼接)
# 查看当前生效的缓存目录(带 scope 标识)
fc-cache --verbose --scope=system 2>&1 | grep "cache directory"
# 输出示例:cache directory "/var/cache/fontconfig"
此命令触发
FcConfigCreateWithCurrentDir()初始化,--scope=system强制绕过getpwuid()查询,直接返回预设系统路径。
关键路径决策流程
graph TD
A[fc-cache --scope=USER/SYSTEM] --> B{Scope == system?}
B -->|Yes| C[/var/cache/fontconfig/]
B -->|No| D[~/.cache/fontconfig/]
| Scope 值 | 权限要求 | 缓存写入位置 | 配置文件优先级 |
|---|---|---|---|
user |
无 | $XDG_CACHE_HOME 或 ~/.cache |
用户配置 > 系统 |
system |
root | /var/cache/fontconfig |
仅系统配置生效 |
3.3 与docker –cgroup-parent、podman systemd集成的兼容性验证(实践)
验证环境准备
- Ubuntu 22.04 LTS(cgroup v2 默认启用)
- Docker 24.0+ 与 Podman 4.6+ 并存
- systemd 版本 ≥ 250(支持
Delegate=yes和Slice=单元属性)
cgroup 层级绑定实测
# 启动容器并显式指定 cgroup 父路径(Docker)
docker run --cgroup-parent="myapp.slice" --rm alpine cat /proc/1/cgroup
逻辑分析:
--cgroup-parent="myapp.slice"将容器进程挂载至 systemd 管理的myapp.slice下,而非默认docker.slice。需确保该 slice 已通过systemctl link /usr/lib/systemd/system/myapp.slice激活,且Delegate=yes已配置,否则 cgroup v2 权限拒绝。
Podman + systemd 单元模板对比
| 工具 | 原生支持 systemd 用户会话 | 自动创建 .slice |
需手动 systemctl daemon-reload |
|---|---|---|---|
podman system service |
✅(--time=0) |
❌ | ❌ |
podman generate systemd |
✅ | ✅ | ✅ |
跨工具一致性流程
graph TD
A[定义 myapp.slice] --> B[Delegate=yes + CPUAccounting=true]
B --> C[Docker: --cgroup-parent=myapp.slice]
B --> D[Podman: generate systemd --new --name myapp]
C & D --> E[统一受 systemctl status myapp.slice 管控]
第四章:生产级修复落地与稳定性保障体系构建
4.1 基于systemd-run –scope封装golang截图服务的Dockerfile改造(实践)
传统 Alpine 镜像中直接运行 chromium --headless 易因缺失 cgroup v1 支持或权限限制崩溃。改用 systemd-run --scope 可在容器内创建轻量资源隔离上下文,规避 PID namespace 冲突。
核心改造点
- 基础镜像切换为
ubuntu:22.04(预装 systemd) - 启动前启用
--privileged或挂载/sys/fs/cgroup - 使用
systemd-run --scope --property=MemoryLimit=512M --property=CPUQuota=80%封装主进程
Dockerfile 关键片段
# 启用 systemd 并禁用默认服务
CMD ["systemd-run", "--scope", \
"--property=MemoryLimit=512M", \
"--property=CPUQuota=80%", \
"--property=TasksMax=256", \
"./screenshot-service"]
--scope动态创建临时 scope unit,避免修改系统 unit 文件;MemoryLimit和CPUQuota实现硬性资源约束,防止 Chromium 内存泄漏拖垮容器。
| 参数 | 作用 | 容器适配要点 |
|---|---|---|
--scope |
创建临时资源组 | 无需 systemd 系统模式 |
TasksMax=256 |
限制线程数 | 防止 Chromium fork 爆炸 |
--property=... |
运行时动态设限 | 替代 cgroup v2 手动挂载 |
graph TD
A[容器启动] --> B[systemd-run --scope]
B --> C[分配独立 cgroup 子树]
C --> D[执行 Go 服务]
D --> E[Chromium 子进程自动纳入同一 scope]
4.2 Fontconfig缓存预热脚本与scope生命周期绑定策略(实践)
Fontconfig 缓存预热需在容器或服务启动早期完成,避免首次文本渲染时的阻塞延迟。推荐将预热逻辑与 scope 生命周期对齐——例如在 systemd service 的 ExecStartPre 阶段执行,或在 Kubernetes Init Container 中触发。
预热脚本示例
#!/bin/sh
# /usr/local/bin/fc-cache-warmup.sh
fc-cache -fv 2>/dev/null | grep -E "^(\/.*\.fonts|cached)" # 强制重建全部缓存并静默输出
-f 强制刷新现有缓存,-v 启用详细日志便于调试;重定向 stderr 可避免干扰主进程日志流。
scope 绑定策略对比
| 绑定方式 | 触发时机 | 缓存可见性范围 |
|---|---|---|
| Init Container | Pod 启动前 | 共享卷内全局有效 |
| ExecStartPre | systemd 服务启动前 | 本服务进程独占生效 |
| postStart hook | 主容器 entrypoint 后 | 容器命名空间内生效 |
执行流程示意
graph TD
A[Service Start] --> B{Scope Ready?}
B -->|Yes| C[Run fc-cache -fv]
B -->|No| D[Wait & Retry]
C --> E[Mark Fontcache Ready]
4.3 Prometheus+Grafana监控fontconfig命中率与截图清晰度指标(实践)
为量化字体渲染质量与截图保真度,需采集两类核心指标:fontconfig_cache_hit_ratio(缓存命中率)和 screenshot_psnr_value(PSNR峰值信噪比)。
数据采集方式
- Fontconfig 命中率通过
fc-match --verbose日志解析 + Exporter 暴露为 Prometheus Gauge; - 截图清晰度由 OpenCV 计算参考图与实际截图的 PSNR,经 HTTP 接口上报。
关键 exporter 配置示例
# fontconfig_exporter 启动命令(含采样间隔与超时控制)
fontconfig_exporter \
--scrape-interval=30s \
--timeout=5s \
--font-dir="/usr/share/fonts" # 指定扫描路径,影响命中率基线
逻辑说明:
--scrape-interval决定指标更新频率,过短易引发 fc-cache 锁竞争;--font-dir必须与应用实际加载路径一致,否则命中率失真。
Grafana 面板关键字段
| 字段名 | 类型 | 说明 |
|---|---|---|
fontconfig_cache_hit_ratio |
Gauge | 范围 0–1, |
screenshot_psnr_value |
Gauge | ≥35dB 为清晰, |
监控链路流程
graph TD
A[FontConfig/OCR服务] -->|日志/HTTP| B(fontconfig_exporter)
A -->|OpenCV PSNR计算| C(psnr_exporter)
B & C --> D[Prometheus Server]
D --> E[Grafana Dashboard]
4.4 CI/CD流水线中无头截图质量门禁的自动化校验方案(实践)
核心校验流程
使用 Puppeteer + Pixelmatch 构建像素级比对门禁,在 PR 构建阶段自动拦截视觉回归缺陷。
# .gitlab-ci.yml 片段:触发截图比对任务
visual-regression-test:
stage: test
image: node:18
script:
- npm ci
- npx jest --testMatch "**/e2e/visual/*.spec.js" --runInBand
该步骤在
test阶段执行,--runInBand确保单线程顺序运行,避免无头浏览器实例竞争;--testMatch精准定位视觉测试用例,提升执行效率与可维护性。
质量门禁判定逻辑
| 指标 | 阈值 | 触发动作 |
|---|---|---|
| 像素差异率 | >0.1% | 构建失败,阻断合并 |
| 差异区域面积(px²) | >50 | 输出高亮差异图 |
| 失败截图数量 | ≥1 | 附带 diff 图链接 |
差异分析流程
graph TD
A[生成基准截图] --> B[渲染待测版本]
B --> C[像素逐帧比对]
C --> D{差异率 ≤0.1%?}
D -->|是| E[通过门禁]
D -->|否| F[生成diff.png + 失败报告]
第五章:从字体渲染到云原生GUI测试范式的演进思考
字体子像素渲染的跨平台一致性挑战
在某金融级桌面应用迁移至 Electron 24 + Chromium 120 的过程中,团队发现 macOS 上启用了 Core Text subpixel positioning,而 Linux(Ubuntu 22.04 + Wayland)默认禁用 LCD 渲染,导致同一 CSS font-smoothing: antialiased 在交易看板按钮文字宽度计算偏差达 1.8px。通过注入 --force-device-scale-factor=1 --disable-lcd-text 启动参数并配合 Puppeteer 的 emulateMediaFeatures 动态覆盖 prefers-reduced-motion 和 color-gamut,最终在 CI 流水线中实现三端文本布局 diff 误差
WebGPU 加速 GUI 的测试断言重构
某工业 HMI 可视化平台采用 WebGPU 渲染三维设备拓扑图,传统基于 DOM 快照的视觉回归测试失效。团队构建了定制化测试代理:在 GPUCommandEncoder 提交前注入 copyExternalImageToTexture 捕获帧缓冲,经 WASM 模块进行 YUV 色彩空间转换后,使用 OpenCV.js 计算结构相似性(SSIM)指标。下表为关键场景的基线对比:
| 场景 | 帧率(FPS) | SSIM 均值 | GPU 内存峰值 |
|---|---|---|---|
| 默认视角渲染 | 59.2 ± 0.7 | 0.992 | 142 MB |
| 100+ 设备高亮 | 41.5 ± 1.3 | 0.986 | 218 MB |
| 网络延迟模拟(200ms) | 38.8 ± 2.1 | 0.979 | 205 MB |
云原生 GUI 测试的容器化执行模型
将 Cypress 测试套件封装为 OCI 镜像时,发现 Chrome 在无特权容器中无法启用 --use-gl=egl。解决方案是构建多阶段镜像:基础层使用 cypress/browsers:node18.17.0-chrome120-ff115,构建层注入 libgbm1 libegl1-mesa-dev 并编译适配 Mesa 23.2.1 的 EGL 驱动,运行层通过 --device=/dev/dri:/dev/dri --cap-add=SYS_ADMIN 启用硬件加速。流水线中每个测试作业独占 GPU 时间片,实测 12 个并发测试容器在 NVIDIA A10 上资源隔离误差
flowchart LR
A[GUI 应用源码] --> B[WebAssembly 模块编译]
B --> C[Chrome Headless 实例]
C --> D[GPU 帧捕获代理]
D --> E[SSIM 特征比对]
E --> F{阈值判定}
F -->|通过| G[标记为 stable-build]
F -->|失败| H[触发 Vulkan 调试器快照]
H --> I[上传 vktrace 文件至 MinIO]
分布式渲染状态同步的测试断点设计
某跨终端协同设计工具需保证 iPad Pro、Windows Surface、Web 端三端画布状态毫秒级一致。测试框架在 WebSocket 协议层注入 X-Test-Trace-ID 头,并在服务端 Kafka 消费者中记录每条渲染指令的 render_timestamp 与 commit_offset。通过比对各客户端本地时间戳与 Kafka 分区水位差,定位出 Surface 设备因 DirectComposition 合成延迟导致的 17ms 状态漂移,最终通过 requestVideoFrameCallback 替代 requestAnimationFrame 解决。
无头浏览器集群的负载感知调度
在 200+ 并发 GUI 测试任务场景下,Selenium Grid 节点出现 GPU 上下文泄漏。改用自研调度器:每个节点上报 nvidia-smi --query-gpu=utilization.gpu,memory.used --format=csv,noheader,nounits 数据,调度器依据 gpu_util * 0.6 + memory_used_MB * 0.002 计算加权负载值,动态分配新任务至负载
字体回退链的自动化验证矩阵
针对中日韩混合文本渲染,构建了包含 127 种语言组合的字体覆盖率测试集。使用 FontKit 解析 .woff2 文件的 cmap 表,生成字符映射热力图;再结合 Playwright 的 page.evaluate 执行 getComputedStyle(el).fontFamily 获取实际生效字体。发现某次 Noto Sans CJK 更新后,越南语重音符号 ơ 在 Ubuntu 上回退至 DejaVu Sans 导致字形错位,该问题在 CI 中通过字符轮廓哈希比对自动拦截。
