Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/codemap/modules/avalonia-text-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ updated: 2026-10-16
- `_runCache.InvalidateFrom(i)` 必须传入**差异点所在段落的起点**,不是差异点本身 — 缓存以换行符分隔的段落为单位、按段落起点存放整段的整形结果,从差异点失效会复用旧段落(连同段末换行),流式每次追加都变成一次硬换行;回归测试 `SmoothStreamWrapTests` — see `src/StreamMark.Avalonia/Rendering/TextSegmentView.cs:164`。
- `TextRunCache` 是 Avalonia 12 的 `[Unstable]` 预览 API — 只允许在 `TextSegmentView` 内使用,并用 `#pragma warning disable CS0618` 限定范围;换别的文件用要重新评估(同一快照里 `TextLayout` 也是预览重载)— see `src/StreamMark.Avalonia/Rendering/TextSegmentView.cs:30`。
- 中文粗体的回退字体必须按**本段字的字重 / 字形**自行解析 — Avalonia 为缺字找回退字体时优先复用“上一段”的回退字体,不检查字重,且 `Typeface(..., Bold)` 在只有中粗(600)的字族上会落回常规字重;所以 `CreateCharacters`(:762)先判主字体是否覆盖字符,缺字才 `TryMatchCharacter`,`RealFace` 只在 `FontFamily.FamilyTypefaces` 列出的**真实字面**里选(粗体取离请求最近的 ≥600 字重,其次最粗的 ≥500,再没有就用常规;没有对应斜体字面就用正体)。**不要用 `Typeface(…, 字重).GlyphTypeface` 去试探不存在的字重 / 字形**:Avalonia 12 会合成粗体 / 斜体,合成时把整份字体文件复制三份常驻内存(Apple Color Emoji 约 600MB,回归测试 `InlineStyleProbeTests.FallbackRuns_UseRealFaces_NotSyntheticCopies`)— see `src/StreamMark.Avalonia/Rendering/TextSegmentView.cs:759`。
- 回退字体必须与取 run 的起点和匹配历史无关 — 逐行排版(`SegmentLayout`)从行首重新取 run,所以 `CreateCharacters` 按整段缺字文字的第一个字(向前找到 span / 内嵌对象边界或主字体能覆盖的字为止)匹配回退字体,而不是按行首的字(Windows 上“。”与“首”可能匹配到不同字体);Avalonia 的 `TryMatchCharacter` 又会优先复用之前匹配到的字体(匹配过韩文后“。”会落到 Apple SD Gothic Neo),所以 `MatchFallback` 用静态字典记住每个字第一次的结果。任何一处依赖起点或历史,流式结果都会与整段排版不同(回归测试 `IncrementalLayoutTests.StreamedMixedScripts_MatchFullLayout`)— see `src/StreamMark.Avalonia/Rendering/TextSegmentView.cs:MatchFallback`。
- 回退字体必须与取 run 的起点和匹配历史无关 — 逐行排版(`SegmentLayout`)从行首重新取 run,所以 `CreateCharacters` 按整段缺字文字的第一个字(向前找到 span / 内嵌对象边界或主字体能覆盖的字为止)匹配回退字体,而不是按行首的字(Windows 上“。”与“首”可能匹配到不同字体);Avalonia 的 `TryMatchCharacter` 又会优先复用之前匹配到的字体(匹配过韩文后“。”会落到 Apple SD Gothic Neo),所以 `MatchFallback` 用静态字典记住每个字第一次的结果。任何一处依赖起点或历史,流式结果都会与整段排版不同(回归测试 `IncrementalLayoutTests.StreamedMixedScripts_MatchFullLayout`)— see `src/StreamMark.Avalonia/Rendering/TextSegmentView.cs:MatchFallback`。 已知风险:缓存的是 Avalonia 某一刻的结果,`FontCollectionBase.TryMatchInCache` 会优先返回已加载过的字体,第一次匹配若恰好拿到按字重拆开的字体族(Windows 上的“Yu Gothic UI Semibold”),之后就一直用它,`RealFace` 又会信任该族列出的模拟斜体字面,渲染成粗体 + 合成斜体(2026-10-17 的 CI 上由测试里探测 GlyphTypeface 触发)。不要在渲染路径或测试里按字重探测 `GlyphTypeface`;彻底消除历史依赖需要按文字类别的确定回退表(`IFontManagerImpl` 在引用程序集里不可访问)。
- 删除线自绘,不用 `TextDecorations.Strikethrough` — 部分字体(含无头环境的回退字体)缺少删除线度量,装饰线不显示;`DrawStrikethrough`(:469)按每段 `HitTestTextRange` 在 54% 高度画线 — see `src/StreamMark.Avalonia/Rendering/TextSegmentView.cs:966`。
- 只有文档末尾片段(`IsDocumentTail`,由 `DocumentPresenter.UpdateTail` 或 `TranscriptPanel` 设置)才渐显并绘制光标 — 片段不再是末尾时,已开始的渐显由 `TailFade` 的定时器在结束后清理;`UpdateTail` 每次 `Apply` 对尾部 `InvalidateVisual()` 只重录片段自身的小块绘制(光标在流结束后消失靠它) — see `src/StreamMark.Avalonia/Rendering/DocumentPresenter.cs:102`。
- 渐显层(渲染线程)只能画字形与矩形:内嵌对象、带装饰线(链接下划线)或 RTL 的文字段不参与渐显,渐显起点移到其后(`TailFade.SkipUnfadeable`);行视觉的矩形裁剪与渐显层的批次矩形在同一横坐标衔接,改其中一边要同步另一边 — see `src/StreamMark.Avalonia/Rendering/TailFade.cs`。
Expand Down
4 changes: 3 additions & 1 deletion .agents/memory/fallback-font-position-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
name: fallback-font-position-history
description: Windows CI 上 IncrementalLayoutTests 失败的原因:回退字体曾依赖取 run 的起点与 Avalonia 的匹配历史
type: project
updated: 2026-10-17
updated: 2026-10-18
source: session
evidence: [src/StreamMark.Avalonia/Rendering/TextSegmentView.cs:MatchFallback, tests/StreamMark.Avalonia.Tests/Rendering/IncrementalLayoutTests.cs:StreamedMixedScripts_MatchFullLayout]
---
alpha.16 引入逐行排版后,`StreamedLineBreaks_MatchFullLayout_AtEveryStep(styled: False)` 只在 Windows CI 上失败(2026-10-16 起):整段排版时缺字片段"。禁则…)。"从"。"开始取 run,逐行重排从行首"首"开始取,回退字体按 run 的第一个字匹配,Windows 上两者落到不同字体,字宽不同、断行不同。macOS 上两者都是苹方,测不出来。另外 Avalonia 的 `FontManager.TryMatchCharacter` 会优先复用之前匹配过的回退字体(实测:匹配过"한"之后"。"从 PingFang SC 变成 Apple SD Gothic Neo),结果依赖进程内历史。alpha.19 两处都修了:按缺字片段起点匹配,并用静态字典固定每个字第一次的结果。

**Why:** 只在一个平台出现的断行差异,根因在字体回退而不在 `SegmentLayout`;韩文与汉字连写的文本能在 macOS 上稳定复现,不必等 Windows CI。
**How to apply:** 改动 `InlineTextSource` 的 run 切分或回退字体时,保证同一段文字无论从哪个位置开始取、何时取,都得到同一字体;跑 `IncrementalLayoutTests`。

2026-10-18 补充:按字缓存会固定 Avalonia 某一刻的偶然结果。PR #2 的 CI 上,`InlineStyles_Render` 按字重探测 `GlyphTypeface` 把"Yu Gothic UI Semibold"加载进了 Avalonia 的缓存,之后"斜"被固定到这个族,渲染成 Yu Gothic Bold + 合成斜体(`3b192e6` 去掉了探测)。曾考虑直接调用系统回退(`IFontManagerImpl.TryMatchCharacter`),但 Avalonia 的引用程序集剥掉了 `AvaloniaLocator.Current` 等私有 API,编译不过;按文字类别的确定回退表被搁置,维持现状。
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ await HeadlessApp.Run(async () =>
view.ApplyLatest();
await HeadlessApp.Settle(1);

var segment = Segment(view);
var segment = await SettledSegment(view);
Assert.True(segment.IsDocumentTail);
Assert.NotNull(ElementComposition.GetElementChildVisual(segment));
Assert.Contains(Lines(segment), l => l.ClipRight is not null);
Expand All @@ -153,7 +153,7 @@ private static async Task<double> MeasureAppendCost(int length)
await stream.Document.WhenIdle();
view.ApplyLatest();
await HeadlessApp.Settle(1);
var segment = Segment(view);
var segment = await SettledSegment(view);
var samples = new List<double>();
for (var i = 0; i < 40; i++)
{
Expand All @@ -177,7 +177,7 @@ private static async Task<double> MeasureAppendCost(int length)
private static TextSegmentView Segment(Control view) => view.GetVisualDescendants().OfType<TextSegmentView>().Last();

/// <summary>
/// 等到文本片段出现再取:进程里第一个测试冷启动时(CI 的 x64 Linux),一帧之内块视图可能还没建好。
/// 等到文本片段出现再取:进程里第一个测试冷启动时(CI 的 x64 Linux / Windows),一帧之内块视图可能还没建好。
/// </summary>
private static async Task<TextSegmentView> SettledSegment(Control view)
{
Expand Down
Loading