From 1639bde9ccd192c089b14a50b8eabbb300de26ee Mon Sep 17 00:00:00 2001 From: token <61819790+239573049@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:24:30 +0800 Subject: [PATCH] Tests: wait for the text segment in every IncrementalLayoutTests case AppendCost_DoesNotGrowWithParagraphLength failed on main after #2 (Windows, run 39051505730) with "Sequence contains no elements": as the first test in the process, one frame was not enough for the block view to exist. The streamed-break tests already wait; the tail-fade and append-cost cases now do too. Also record the known risk of the per-character fallback cache (it pins whatever Avalonia returned first) and why calling the platform matcher directly is not possible (private API stripped from reference assemblies). Co-Authored-By: Claude Opus 5.5 --- .agents/codemap/modules/avalonia-text-rendering.md | 2 +- .agents/memory/fallback-font-position-history.md | 4 +++- .../Rendering/IncrementalLayoutTests.cs | 6 +++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.agents/codemap/modules/avalonia-text-rendering.md b/.agents/codemap/modules/avalonia-text-rendering.md index 98d9ba7..dd32505 100644 --- a/.agents/codemap/modules/avalonia-text-rendering.md +++ b/.agents/codemap/modules/avalonia-text-rendering.md @@ -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`。 diff --git a/.agents/memory/fallback-font-position-history.md b/.agents/memory/fallback-font-position-history.md index 69b76ff..0eb9523 100644 --- a/.agents/memory/fallback-font-position-history.md +++ b/.agents/memory/fallback-font-position-history.md @@ -2,7 +2,7 @@ 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] --- @@ -10,3 +10,5 @@ alpha.16 引入逐行排版后,`StreamedLineBreaks_MatchFullLayout_AtEveryStep **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,编译不过;按文字类别的确定回退表被搁置,维持现状。 diff --git a/tests/StreamMark.Avalonia.Tests/Rendering/IncrementalLayoutTests.cs b/tests/StreamMark.Avalonia.Tests/Rendering/IncrementalLayoutTests.cs index 9c1fd19..cefe990 100644 --- a/tests/StreamMark.Avalonia.Tests/Rendering/IncrementalLayoutTests.cs +++ b/tests/StreamMark.Avalonia.Tests/Rendering/IncrementalLayoutTests.cs @@ -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); @@ -153,7 +153,7 @@ private static async Task 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(); for (var i = 0; i < 40; i++) { @@ -177,7 +177,7 @@ private static async Task MeasureAppendCost(int length) private static TextSegmentView Segment(Control view) => view.GetVisualDescendants().OfType().Last(); /// - /// 等到文本片段出现再取:进程里第一个测试冷启动时(CI 的 x64 Linux),一帧之内块视图可能还没建好。 + /// 等到文本片段出现再取:进程里第一个测试冷启动时(CI 的 x64 Linux / Windows),一帧之内块视图可能还没建好。 /// private static async Task SettledSegment(Control view) {