Skip to content

fix(render): align text rasterization with upstream CATextLayer#214

Open
appergb wants to merge 1 commit into
mainfrom
fix/text-raster-alignment
Open

fix(render): align text rasterization with upstream CATextLayer#214
appergb wants to merge 1 commit into
mainfrom
fix/text-raster-alignment

Conversation

@appergb

@appergb appergb commented Jul 7, 2026

Copy link
Copy Markdown
Owner

背景 (PR-8 / 痛点 4)

crates/opentake-render/src/gpu/text_engine.rs 用 cosmic-text + swash + 自写 alpha-over / box-blur 栅格化文字,但从未与上游 CoreText / CATextLayer 渲染结果做像素对齐验证。本 PR 对齐字体度量、字距/基线、阴影模糊,并增强测试覆盖。

改动

A. 字体度量对齐 (SPEC §4.2)

  • 确认 CANVAS_BASIS_HEIGHT = 1080.0 对齐上游 referenceCanvasHeight (TextLayout.swift L7, TextLayerController.swift L150)
  • 确认 字号缩放 font_px = font_size * font_scale * (canvas.h / 1080) 对齐 L155/L165
  • 澄清 渲染器 box 来自 clip.transform(对齐上游 layer.frame L157-163),不是 TextLayout.naturalSize——shadow padding 12*2+4 slack 属于 placement 度量,不在渲染器 box 里。在 box_pixels 加注释说明

B. 字距/基线对齐

  • B.2 family 解析:任务原要求用 Style::Specific 精确匹配,但 fontdb 0.16 (cosmic-text 0.12) 没有 Style::Specific 变体(只有 Normal/Italic/Oblique),且 Family::Name 文档明确要求传不带 Bold/Italic 后缀的 Typographic Family。所以保持 split('-') 取 family(fontdb 推荐用法),但扩展 weight 识别(Thin/ExtraLight/Light/Medium/Semibold/Bold/ExtraBold/Black)+ Italic/Oblique 检测,逼近上游 NSFont(name:size:) 的语义
  • B.3 基线:cosmic-text 默认 top-origin(Y 向下)== 上游 textRoot.isGeometryFlipped = true 后的 Y 向下。补充注释说明 shadow offset_y 取负的等价性

C. box_blur 改进

  • 当前 1-pass separable box blur → 3-pass box blur 近似 Gaussian(标准 "3-box ≈ 1-Gaussian" 结论,匹配 CoreImage 的 Gaussian shadow)
  • radius = shadow.blur * scale(去掉 /2),对齐上游 layer.shadowRadius = max(0, blur * scale) (L183)
  • 提取 blur_axis 单 pass 辅助函数,3 次水平 + 3 次垂直

D. 测试增强 (tests/gpu_text.rs +6 测试)

全部不依赖 GPU(直接测 CosmicTextRasterizer::rasterize 返回的 DecodedFrame),无系统字体时优雅跳过:

  • font_size_scales_with_canvas_height — 540/1080/2160p 字号按比例缩放
  • shadow_paints_pixels_outside_glyph_footprint — 阴影开启后非零像素多于仅文字
  • alignment_shifts_glyph_x_centroid — left < center < right 重心 x 递增
  • long_text_wraps_in_narrow_box — 长文字 y 跨度 > 单行
  • rasterize_is_deterministic_ssim_one — 同输入 bytes 完全相等(SSIM=1.0 的强形式)
  • natural_size_shadow_padding_matches_upstreamTextLayout shadow padding = 24, REFERENCE_CANVAS_HEIGHT = 1080

E. 复杂样式 TODO

模块注释加 future-work:rich text (Buffer::set_rich_text)、emoji/CJK 回退(cosmic-text 0.12 无 family_emoji/family_asian,需 0.14+)、垂直排版。

验证

cargo fmt --all --check                              ✓
cargo clippy --workspace --all-targets -- -D warnings   ✓ (0 warning)
cargo test --workspace                                  ✓ (252+209+140+... 全过, 0 failed)
cargo clippy -p opentake-tauri --no-default-features --all-targets -- -D warnings  ✓
cargo test -p opentake-render                           ✓ (gpu_text 7/7, 含 6 新增)

web (pnpm build/pnpm test) 与本改动无关,留 CI 验证。

发现的度量偏差

  1. shadow radius 原 (blur*scale)/2 偏小:上游 shadowRadius = blur*scale(无 /2)。已修正——但 box-blur 的 "radius" 是半窗口,与 CA shadowRadius(Gaussian sigma 语义)不完全等价;3-pass box 近似 Gaussian 后数值上对齐,视觉上仍可能有微小差异(SPEC §6.2 已对文字区放宽到 SSIM ≥ 0.98)
  2. Style::Specific 不存在:任务 spec 假设的精确匹配 API 在 cosmic-text 0.12/fontdb 0.16 不可用,已用扩展 weight 识别替代
  3. box 来源澄清:渲染器 box = box_norm × canvas(来自 clip.transform),非 naturalSize——这是正确的(对齐上游 layer.frame),无需在渲染器加 shadow padding

文件

  • crates/opentake-render/src/gpu/text_engine.rs +93/-37
  • crates/opentake-render/tests/gpu_text.rs +263/-0

不合并 PR,等 review。

- font metrics: canvasScale=canvasHeight/1080, shadowPadding=12*2, +4px margin
  (confirmed against TextLayout.swift L6/L28; rasterizer box comes from
  clip.transform like upstream layer.frame L157-163, not naturalSize)
- font family: keep split('-') family + Weight/Style inference (fontdb
  Family::Name rejects _Bold_/_Italic_ suffixes; no Style::Specific in 0.12);
  expand weight recognition (Thin..Black) + Italic/Oblique detection
- blur: 3-pass separable box blur approximates Gaussian (matches CoreImage
  shadow); radius = shadow.blur * scale (was /2), aligning shadowRadius L183
- baseline: document isGeometryFlipped equivalence (cosmic-text top-origin ==
  flipped CALayer; shadow offset_y negated to image rows)
- tests: font scaling (540/1080/2160), shadow pixel spread, alignment x-centroid
  ordering, word-wrap y-span, deterministic SSIM=1.0, natural_size shadow padding
- future-work TODOs: rich text (set_rich_text), emoji/CJK fallback (needs
  cosmic-text 0.14+ family_emoji/family_asian), vertical text
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant