From 2804f5ba7a0d9c0a99030a44336b29e0c7c46348 Mon Sep 17 00:00:00 2001 From: token <61819790+239573049@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:14:47 +0800 Subject: [PATCH] Mermaid: reproduce mermaid.js themes, flatten translucent host colors, fit tall diagrams by width Diagrams were unreadable in hosts whose theme tokens are translucent (AtomUI dark: text = 85% white, code background = 4% white). DiagramBlockView passed those colors on and MermaidDiagramRenderer dropped the alpha, giving Bg = Fg = #F0F0F0 (white on white; black on black in light themes). Tall flowcharts were also squeezed to a 900px height cap, leaving ~3px text. - DiagramBlockView composites foreground / surface / accent onto the real backdrop (nearest opaque ancestor background) before calling the renderer, renders only once attached, uses an opaque fullscreen background, and keys the diagram cache by the colors, font and renderer instance actually used. - StreamMark.Mermaid: new MermaidTheme (MermaidJs default, MermaidJsNeutral, Native). The MermaidJs palettes are the real mermaid 12.2 dark / default / neutral styles, applied through Mermaider's Surface / Border / Line options plus an SVG repaint of subgraphs, edge labels and derived node shapes (stadium, cylinder, decision). A neutral DataPalette is used for structural diagrams only; charts keep categorical colors. - Diagram height is no longer capped at 900px: fit by width, 6000px safety cap. - Tests (theme palettes, node text contrast, translucent host colors, cache key, height fit), codemap, docs and repo memory updated. Packed as 0.1.0-alpha.21 for TKCode. Co-Authored-By: Claude Sonnet 5.5 --- .../codemap/modules/avalonia-extensions.md | 13 +- .../modules/avalonia-text-rendering.md | 4 +- .agents/memory/MEMORY.md | 1 + .agents/memory/mermaid-theme-mermaidjs.md | 17 + README.md | 4 + docs/architecture.md | 2 +- docs/decisions.md | 1 + .../Extensions/IDiagramRenderer.cs | 30 +- .../Rendering/DiagramBlockView.cs | 134 +++++- .../MermaidDiagramRenderer.cs | 93 ++-- src/StreamMark.Mermaid/MermaidPalette.cs | 69 +++ src/StreamMark.Mermaid/MermaidTheme.cs | 20 + src/StreamMark.Mermaid/MermaidThemer.cs | 236 ++++++++++ .../Mermaid/MermaidThemeTests.cs | 413 ++++++++++++++++++ .../Rendering/DiagramBlockViewTests.cs | 166 +++++++ 15 files changed, 1154 insertions(+), 49 deletions(-) create mode 100644 .agents/memory/mermaid-theme-mermaidjs.md create mode 100644 src/StreamMark.Mermaid/MermaidPalette.cs create mode 100644 src/StreamMark.Mermaid/MermaidTheme.cs create mode 100644 src/StreamMark.Mermaid/MermaidThemer.cs create mode 100644 tests/StreamMark.Avalonia.Tests/Mermaid/MermaidThemeTests.cs create mode 100644 tests/StreamMark.Avalonia.Tests/Rendering/DiagramBlockViewTests.cs diff --git a/.agents/codemap/modules/avalonia-extensions.md b/.agents/codemap/modules/avalonia-extensions.md index 9bc4060..6e40367 100644 --- a/.agents/codemap/modules/avalonia-extensions.md +++ b/.agents/codemap/modules/avalonia-extensions.md @@ -2,7 +2,7 @@ module: avalonia-extensions sources: ["src/StreamMark.Highlighting.TextMate/**", "src/StreamMark.RaTeX/**", "src/StreamMark.Mermaid/**"] commit: e01b99a -updated: 2026-10-16 +updated: 2026-10-18 --- # avalonia-extensions(三个渲染扩展包:TextMate 代码高亮 / RaTeX 公式 / Mermaid 图表) @@ -18,7 +18,8 @@ updated: 2026-10-16 | `TextMatePlugin` | `src/StreamMark.Highlighting.TextMate/TextMatePlugin.cs:6` | `Name => "textmate"`;`UseHighlighter(Highlighter)`(第 12 行);构造参数 `prewarm` 会同步调 `Highlighter.Prewarm`(第 14-20 行) | | `TextMateSyntaxHighlighter` | `src/StreamMark.Highlighting.TextMate/TextMateSyntaxHighlighter.cs:17` | `ISyntaxHighlighter` 实现;`SupportsLanguage` / `CreateSession` / `GetThemeColors` / `Prewarm` / `PrewarmAsync` / `ResolveScope` | | `MermaidPlugin` | `src/StreamMark.Mermaid/MermaidPlugin.cs:6` | `Name => "mermaid"`;`builder.AddDiagramRenderer(renderer ?? new MermaidDiagramRenderer())`(第 10 行,**每次插件实例新建渲染器**,没有 `Shared`) | -| `MermaidDiagramRenderer` | `src/StreamMark.Mermaid/MermaidDiagramRenderer.cs:15` | `IDiagramRenderer` 实现;`CanRender` 只认 `mermaid`(第 27 行);`Render`(第 44 行)/ `RenderAsync`(第 31 行)/ 静态 `ResolveSvg`(第 126 行)/ `CreateOptions`(第 129 行) | +| `MermaidDiagramRenderer` | `src/StreamMark.Mermaid/MermaidDiagramRenderer.cs` | `IDiagramRenderer` 实现;`CanRender` 只认 `mermaid`;`Theme`(`MermaidTheme`,默认 `MermaidJs`);`Render` / `RenderAsync` / 内部 `BuildSvg`(Mermaider → 按配色方案重涂,测试直接看最终 SVG)/ 静态 `ResolveSvg` / `CreateOptions(context)`(Native 映射)与 `CreateOptions(context, theme)` | +| `MermaidTheme` / `MermaidPalette` / `MermaidThemer` | `src/StreamMark.Mermaid/MermaidTheme.cs`、`MermaidPalette.cs`、`MermaidThemer.cs` | 配色方案:公开枚举 `MermaidJs`(暗色 dark / 亮色 default)、`MermaidJsNeutral`(亮色 neutral)、`Native`;内部 `MermaidPalette` 存 mermaid 12.2 三个主题在流程图上的实际颜色;`MermaidThemer` 负责 `Detect`(按首条语句判断图表类别)、`Apply`(用 `Surface`/`Border`/`Line`/`Default` 钉住节点连线,`Gradient=false`、`Elevation=0`)、`PaintSvg`(用 `XDocument` 按语义类重涂子图与连线标签) | | `SvgImageRasterizer` | `src/StreamMark.Mermaid/SvgImageRasterizer.cs:13` | `ISvgRasterizer` 实现(给 `ImagePipelineOptions.SvgRasterizer` 用),与 `IDiagramRenderer` 无关 | ## Key types and functions @@ -53,7 +54,7 @@ updated: 2026-10-16 - **RaTeX 排版缓存(连错误一起缓存)**:`RaTeXMathRenderer._map` + `_lru`(`RaTeXMathRenderer.cs:15-16`,键 `(latex, display)`,条目里同时存 `Layout` 与 `Error`,第 88 行);命中失败结果同样更新 LRU(第 43-49 行);排版在锁外执行,偶发重复排版被判为无害(第 52 行注释);`Clear`(第 79 行)与 `CacheCount`(第 26 行)。 - **RaTeX 的字体缓存**:KaTeX TTF 按 fontId 缓存 `GlyphTypeface`(`KaTeXFonts.Typefaces`,第 22 行),并通过私有的 `KaTeXFontCollection`(第 113 行,不注册到 `FontManager`)持有内存流;系统回退字形按**码点**缓存(`SystemFallback`,第 23、69 行)。字体对象因此比单个公式活得久。 - **RaTeX 每个 `RaTeXMathLayout` 最多缓存 4 个字号的可绘制图元**:`_plans`(`MaxCachedSizes = 4`,`RaTeXMathLayout.cs:18-20`,超出时丢最早的一个,第 93-96 行)。`GlyphRun` 在首次按某字号绘制时构建 —— 即首次绘制发生在界面线程。 -- **Mermaid 没有渲染器内部缓存**:`MermaidDiagramRenderer` 无状态(只保留 `ConfigureOptions` / `ErrorReported` 两个回调,第 21、24 行);缓存由视图侧的静态 `DiagramBlockView.DiagramCache` 持有(键 `语言\n明暗\n代码`,容量 32,`Rendering/DiagramBlockView.cs:72,250-281`),RaTeX 的缓存也在渲染器里而不是视图里 —— 两者位置不一致。 +- **Mermaid 没有渲染器内部缓存**:`MermaidDiagramRenderer` 无状态(只保留 `ConfigureOptions` / `ErrorReported` 两个回调,第 21、24 行);缓存由视图侧的静态 `DiagramBlockView.DiagramCache` 持有(键 = 渲染器身份 + 语言 + 明暗 + 实际传给渲染器的前景 / 底色 / 强调色 + 字体 + 字号 + 代码,容量 32,`Rendering/DiagramBlockView.cs` 的 `RenderKey`),RaTeX 的缓存也在渲染器里而不是视图里 —— 两者位置不一致。 - **进程级共享的默认渲染器**:只有 RaTeX 有 `RaTeXMathRenderer.Shared`(`RaTeXMathRenderer.cs:12`,`RaTeXPlugin` 默认用它);`MermaidPlugin` 每次 `new MermaidDiagramRenderer()`(`MermaidPlugin.cs:10`),`TextMatePlugin` 每次 `new TextMateSyntaxHighlighter()`(`TextMatePlugin.cs:10`)。 ## Contracts it exposes @@ -69,7 +70,7 @@ updated: 2026-10-16 - To add 一种代码语言 / 别名:先在 `LanguageAliases.Aliases` 里加"围栏名 → TextMateSharp language id"(`LanguageAliases.cs:11`,表里没有的名字会被当成 language id 或扩展名再去查一次,`TextMateSyntaxHighlighter.cs:140`)→ 再确认 `TextMateSharp.Grammars` 2.0.4 真的有这门语法(`Directory.Packages.props:18-19`);能力边界写在 `docs/decisions.md:32`:**Kotlin 在 Grammars 包里不存在**,TOML 被有意映射到 INI(`LanguageAliases.cs:33`)→ 最后在宿主注册时预热,`new TextMatePlugin(prewarm: ["csharp", "rust"])`(`TextMatePlugin.cs:6,14`)或 `highlighter.PrewarmAsync(theme, …)`(`TextMateSyntaxHighlighter.cs:76`)。漏掉预热的症状是首个代码块首次 `Update` 卡数百毫秒(注释 `TextMateSyntaxHighlighter.cs:14-15`);语言不存在时的症状是 `SupportsLanguage == false`,代码块按纯文本显示且完全不加载语法(`TextMateSyntaxHighlighter.cs:46-56`,测试 `TextMateHighlighterTests.cs:225` 用 `kotlin` 断言 false)。 - To add 一种图表语言:这一步**不需要改视图**——`BlockViewFactory` 只要 `FindDiagramRenderer(language)` 返回非空就把闭合的代码块换成 `DiagramBlockView`(`BlockViewFactory.cs:95`)。所以只要写一个 `IDiagramRenderer`(`CanRender` 认自己的语言名、`RenderAsync` 在后台跑、失败返回 `null`)→ `builder.AddDiagramRenderer(...)`(`Plugins/IMarkdownPlugin.cs:56`)→ 在宿主 `MarkdownServices { Plugins = [...] }` 注册(`README.md:33`)。漏掉注册的症状是代码块保持普通代码显示,不会有任何图表(`BlockViewFactory.cs:96`);把 `CanRender` 写成大小写敏感会比较少见的围栏写法漏掉(Mermaid 自己做了 `Trim()` + `OrdinalIgnoreCase`,`MermaidDiagramRenderer.cs:27`)。若要改 Mermaid 的配色 / 字体,优先用 `MermaidDiagramRenderer.ConfigureOptions`(`MermaidDiagramRenderer.cs:21`)而不是改 `CreateOptions`(第 129 行)。 - To add 一种公式语法 / 命令(例如新的 `\xxx`、新的字体):C# 侧**不需要改**——能力全在原生引擎里,`RaTeXEngine.Layout` 只是通道(`RaTeXEngine.cs:22-52`)。只有当原生返回的显示列表出现**新的条目类型或字段**时才需要在 C# 侧配合:在 `DisplayList` / `DisplayItem` 加字段(`DisplayList.cs:18`)并在 `RaTeXMathLayout.BuildPlan` 的 `switch (item.Type)` 加分支(`RaTeXMathLayout.cs:102`)。漏掉分支的症状是静默不画——`BuildPlan` 没有 `default` 分支,未知 `Type` 被直接忽略(协议本身就允许忽略未来类型,`DisplayList.cs:20`)。若要新增宿主主题化的默认颜色语义,走哨兵色(`RaTeXMathLayout.cs:16`)而不是给每条图元染色。 -- To add 主题 / 配色:Mermaid 走 `CreateOptions`(`MermaidDiagramRenderer.cs:129`,`Fg`/`Bg`/`Accent`/`FontSize`/`Font`/`Tint`);TextMate 走构造函数的两上主题名(`TextMateSyntaxHighlighter.cs:29`)。两条路径都不会让已经缓存的结果失效(见下方陷阱)。 +- To add 主题 / 配色:Mermaid 先选 `MermaidDiagramRenderer.Theme`(`MermaidJs` 默认 / `MermaidJsNeutral` / `Native`);要新增一套固定配色就在 `MermaidPalette.For` 加一个方案(颜色取自 mermaid.js 对应主题的实际样式表),宿主侧细调用 `ConfigureOptions`;TextMate 走构造函数的两个主题名(`TextMateSyntaxHighlighter.cs:29`)。图表缓存键包含颜色,换色会重画;渲染器自身的配置(`Theme`、`ConfigureOptions`)变化靠键里的渲染器身份区分,所以换配置要换渲染器实例。 ## Invariants and traps - **TextMateSharp 2.0.4 不是线程安全的**:`Registry` 用普通 `Dictionary` 存语法、`Grammar` 分词时惰性编译规则并写内部表、主题匹配缓存也会在分词中被写入 —— 因此对 Registry / Grammar / Theme 的**每一次**访问都必须持有 `ThemeContext.Gate`(注释 `ThemeContext.cs:13-18`),落地在第 44 行的 `Gate`、`TextMateSyntaxHighlighter.cs:91-95` 的预热锁与 `TextMateHighlightSession.cs:97` 的会话锁。**每个主题各一把锁**(明暗互不阻塞),共享锁或去掉锁的表现是随机的颜色错乱 / 崩溃而不是异常。 @@ -81,7 +82,9 @@ updated: 2026-10-16 - **CJK / emoji 不是 KaTeX 字体能的**:字体名以 `CJK-` / `Emoji-` 开头时直接跳过 KaTeX 字体走系统回退(`KaTeXFonts.cs:49-50`);系统回退先试 `FontFamily.Default` 再 `FontManager.TryMatchCharacter`(第 71-82 行),结果(含 `null`)按码点缓存,字形索引 0 视为缺字(第 52-61 行),缺字就直接跳过不画以免出豆腐块(第 25 行)。所有这些都要求 Avalonia 平台已初始化 —— 而图元只在 `Draw` 里构建(`RaTeXMathLayout.cs:56,81`),也就是界面线程首次绘制时,所以**不能**在后台线程构造 `GlyphRun`。 - **Mermaid 的 SVG 必须先展开 CSS 函数再交给 Svg.Skia**:Mermaider 0.15.1 的输出确实依赖 `color-mix(`(测试里对此有断言,`MermaidRendererTests.cs:114`),而 Svg.Skia 不支持 `color-mix()`,会画成黑色填充并丢连线(`CssColorResolver.cs:8-10`)——所以 `Render` 的顺序是 `MermaidRenderer.RenderSvg` → `ResolveSvg` → `SKSvg.FromSvg`(`MermaidDiagramRenderer.cs:67,83,89`),并且 `ResolveSvg` 之后整份文档里不应再出现 `var(` / `color-mix(`(测试 `MermaidRendererTests.cs:111`、`CssColorResolverTests.cs:36`)。混合规则:百分比归一化、预乘 alpha 在 sRGB 中插值、两者之和 <100% 时按比例降 alpha、非 sRGB 空间按 sRGB 近似(`CssColorResolver.cs:261-264,306-319`);循环变量靠 `MaxDepth = 32` 终止(第 18、77、217 行)。变量来源只解析根 `` 与 ``, ``, ``, ``, ``, ``, ``. diff --git a/docs/architecture.md b/docs/architecture.md index 47b298d..45b7005 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,7 +80,7 @@ StreamMark 是面向 AI 对话场景的高性能流式 Markdown 渲染库:Aval |---|---| | `StreamMark.RaTeX` | `RaTeXMathRenderer`:C ABI 排版(线程安全、LRU 缓存含错误),显示列表在界面线程转为 `GlyphRun` + 几何;KaTeX 字体从嵌入资源加载为私有 `GlyphTypeface`;默认颜色用哨兵色区分,保留 `\color`;CJK / emoji 回退系统字体 | | `StreamMark.Highlighting.TextMate` | `TextMateSyntaxHighlighter`:每个明暗主题一个 Registry + 锁(TextMateSharp 2.0.4 非线程安全);会话按行缓存语法状态,追加只着色新行,中间修改在结束状态一致处停止;冷启动超时行重试 | -| `StreamMark.Mermaid` | `MermaidDiagramRenderer`:Mermaider → SVG → 解析 `var()` / `color-mix()` 为字面颜色 → Svg.Skia `SKPicture` → 自定义绘制操作在 Avalonia 的 Skia 画布上矢量回放 | +| `StreamMark.Mermaid` | `MermaidDiagramRenderer`:Mermaider → SVG → 按配色方案(默认复刻 mermaid.js 官方主题)重涂子图与连线标签 → 解析 `var()` / `color-mix()` 为字面颜色 → Svg.Skia `SKPicture` → 自定义绘制操作在 Avalonia 的 Skia 画布上矢量回放;传入的前景 / 底色先由 `DiagramBlockView` 叠到真实底色上 | | `StreamMark.Theme.AtomUI` | `AtomUIMarkdownTheme`:`Md.*` → `MarkdownResourceAlias(SharedTokenKind.*)`,从控件本身解析 AtomUI token | ### 主题解析 diff --git a/docs/decisions.md b/docs/decisions.md index 3ca7d49..066f878 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -27,6 +27,7 @@ - **AtomUI 主题用资源别名而不是画刷**:资源字典中画刷上的动态资源从字典所在位置向上查找,找不到 AtomUI 注册在窗口 / 主题作用域内的 token(文字变成透明)。别名由 StreamMark 从 Markdown 控件本身解析。 - **字体默认继承宿主**:不设置 `Md.FontFamily` / `Md.FontSize` 时沿用控件继承的字体,宿主(如 AtomUI 设在窗口上的字体)无需额外配置。 - **删除线自绘**:部分字体(含无头环境的回退字体)缺少删除线度量,`TextDecorations.Strikethrough` 不显示。 +- **Mermaid 配色默认复刻 mermaid.js 官方主题(2026-10-18)**:暗色 = dark、亮色 = default(可选 neutral),`MermaidTheme.Native` 保留 Mermaider 原生风格。起因是 AtomUI 的半透明 token 被丢掉 alpha 后图表成了白底白字;修复后与浏览器里的 mermaid.js 对照,原生风格(子图按色板着色、描边偏淡)和 Web 观感差距明显,所以改为默认复刻。颜色取自 mermaid 12.2 在流程图上实际输出的样式表(`MermaidPalette`);只用 Mermaider 自带的 `Surface` / `Border` / `Line` 等选项,加上对子图与连线标签的 SVG 重涂,不换渲染引擎(merman 能做到像素级一致,但需要自建 Rust 原生库,已评估过、不采用)。图表高度不再封顶 900px(高流程图文字只有约 3px,浏览器里是约 12px),改为只按宽度适配,6000px 为安全上限。 - **Svg.Skia 固定 5.1.1,托管依赖(Svg.Model / Svg.Custom / ShimSkiaSharp / Svg.SceneGraph)覆盖到 5.2.3(Svg.Animation 例外:5.2.x 依赖 SkiaSharp 4,保持 5.1.1;CI 的 Linux 任务发现了这一点)**:5.1.1 与 Avalonia 12.1 使用相同的 SkiaSharp 3 原生库;5.2.x 的 Svg.Skia 需要 SkiaSharp 4。Svg.Model 5.1.1 内的 `TypeDescriptor.GetConverter` 会产生 2 个 IL2026,5.2.3 已修复。Mermaid 测试(真实渲染)与 Gallery AOT 冒烟均通过。Avalonia 升级到 SkiaSharp 4 后统一升到 5.2+。 - **RaTeX 本地补丁** `native/patches/0001-enclose-border-uses-current-color.patch`:`\fbox` / `\boxed` 边框原本固定黑色,改为当前颜色;`native/build.sh` 构建前自动应用。应提交上游。 - **TextMateSharp 不支持 Kotlin**(Grammars 包无该语法);TOML 映射到 INI 语法。 diff --git a/src/StreamMark.Avalonia/Extensions/IDiagramRenderer.cs b/src/StreamMark.Avalonia/Extensions/IDiagramRenderer.cs index 24e70bd..ea6eb24 100644 --- a/src/StreamMark.Avalonia/Extensions/IDiagramRenderer.cs +++ b/src/StreamMark.Avalonia/Extensions/IDiagramRenderer.cs @@ -3,8 +3,34 @@ namespace StreamMark.Avalonia.Extensions; -/// 图表渲染的环境信息。 -public sealed record DiagramRenderContext(bool IsDark, Color Foreground, Color Background, Color Accent, string? FontFamily, double FontSize); +/// +/// 图表渲染的环境信息。 传入的颜色已经是叠到真实底色上的不透明色; +/// 其它调用方传入的颜色可以带透明度(宿主主题常用半透明的文字 / 填充),渲染器需要不透明色时用 。 +/// +public sealed record DiagramRenderContext(bool IsDark, Color Foreground, Color Background, Color Accent, string? FontFamily, double FontSize) +{ + /// + /// 把带透明度的 叠到 上,得到视觉上等效的不透明色。 + /// 完全透明时就是底色; 的透明度被忽略。 + /// + public static Color Flatten(Color color, Color backdrop) + { + if (color.A == byte.MaxValue) + { + return color; + } + + if (color.A == 0) + { + return Color.FromRgb(backdrop.R, backdrop.G, backdrop.B); + } + + var alpha = color.A / 255.0; + return Color.FromRgb(Mix(color.R, backdrop.R), Mix(color.G, backdrop.G), Mix(color.B, backdrop.B)); + + byte Mix(byte top, byte bottom) => (byte)Math.Round(top * alpha + bottom * (1 - alpha)); + } +} /// /// 代码块图表渲染器(如 Mermaid)。代码块闭合后才会调用; 可在后台线程执行。 diff --git a/src/StreamMark.Avalonia/Rendering/DiagramBlockView.cs b/src/StreamMark.Avalonia/Rendering/DiagramBlockView.cs index 992a4a6..27650d5 100644 --- a/src/StreamMark.Avalonia/Rendering/DiagramBlockView.cs +++ b/src/StreamMark.Avalonia/Rendering/DiagramBlockView.cs @@ -1,9 +1,14 @@ +using System.Runtime.CompilerServices; using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; using Avalonia.Input.Platform; using Avalonia.Media; +using Avalonia.Media.Immutable; using Avalonia.Media.TextFormatting; using Avalonia.Threading; +using Avalonia.VisualTree; using StreamMark.Avalonia.Extensions; using StreamMark.Avalonia.Selection; using StreamMark.Core.Model; @@ -12,13 +17,19 @@ namespace StreamMark.Avalonia.Rendering; /// /// 图表代码块(如 Mermaid):代码块闭合后在后台渲染;渲染完成前和失败时显示源码。 -/// 头部可在"图表 / 源码"之间切换。渲染结果按 (语言, 代码, 明暗) 缓存,视图回收后再出现不会重复渲染。 +/// 头部可在"图表 / 源码"之间切换。渲染结果按 (语言, 代码, 明暗, 实际用到的颜色与字体) 缓存,视图回收后再出现不会重复渲染。 /// public sealed class DiagramBlockView : BlockView { private const double HeaderHeight = 34; private const double Padding = 14; + /// + /// 图表高度的安全上限(设备无关像素)。图表按宽度适配、不因为高而缩小——和浏览器里 max-width: 100% 的 SVG 一致, + /// 否则几千像素高的流程图会被压到看不清文字;只有超过这个上限的极端图表才会继续缩小。 + /// + internal const double MaxDiagramHeight = 6000; + private readonly IconButton _toggle = new() { Icon = MarkdownIcons.Code }; private readonly IconButton _copy = new() { Icon = MarkdownIcons.Copy }; private readonly IconButton _fullscreen = new() { Icon = MarkdownIcons.Expand }; @@ -27,6 +38,7 @@ public sealed class DiagramBlockView : BlockView private string? _error; private bool _showCode; private string _renderKey = ""; + private Color _surface; private CancellationTokenSource? _cts; private TextLayout? _title; private TextLayout? _status; @@ -72,16 +84,110 @@ protected override void OnUpdate(RenderContext context, MarkdownBlock? previous, } _title = new TextLayout(code.Language ?? "diagram", new Typeface(style.MonospaceFontFamily), style.CodeFontSize * 0.92, style.SecondaryForeground); - var key = $"{code.Language}\n{style.IsDark}\n{code.Code}"; - if (key != _renderKey) + Refresh(context, code); + } + + /// + /// 按当前主题与底色计算渲染环境,环境或代码变了就重新渲染。 + /// 底色取决于祖先的背景,没挂上可视树时算不准(还会白白渲染一次),所以挂上之后才开始渲染; + /// 先更新、后挂上的情况由 补上。 + /// + private void Refresh(RenderContext context, CodeBlock code) + { + if (this.IsAttachedToVisualTree()) { - _renderKey = key; - StartRender(context, code); + var environment = ResolveEnvironment(context.Style); + var renderer = context.Services.FindDiagramRenderer(code.Language); + _surface = environment.Background; + var key = RenderKey(code, environment, renderer); + if (key != _renderKey) + { + _renderKey = key; + StartRender(code, environment, renderer); + } } UpdateVisibility(); } - private void StartRender(RenderContext context, CodeBlock code) + /// + /// 缓存键:除了语言与代码,还要包含渲染实际用到的明暗、颜色与字体,以及渲染器本身。 + /// 否则宿主换强调色、换字号、底色变化后,已渲染(含静态缓存里)的图表不会重画; + /// 同一进程里配置不同的两个渲染器(例如不同配色方案)也会互相命中对方的缓存。 + /// + private static string RenderKey(CodeBlock code, DiagramRenderContext environment, IDiagramRenderer? renderer) => FormattableString.Invariant( + $"{RuntimeHelpers.GetHashCode(renderer)}\n{code.Language}\n{environment.IsDark}\n{environment.Foreground}\n{environment.Background}\n{environment.Accent}\n{environment.FontFamily}\n{environment.FontSize}\n{code.Code}"); + + /// + /// 渲染环境:宿主主题里的文字 / 填充色常常带透明度(例如 AtomUI 暗色的代码底是 4% 的白、文字是 85% 的白), + /// 直接丢掉透明度会得到「白底白字」。这里把它们叠到块背后真实的底色上,传给渲染器的都是不透明色。 + /// + private DiagramRenderContext ResolveEnvironment(MarkdownStyle style) + { + var surface = DiagramRenderContext.Flatten(ColorOf(style.CodeBackground), BackdropColor(style.IsDark)); + return new DiagramRenderContext( + style.IsDark, + DiagramRenderContext.Flatten(ColorOf(style.Foreground), surface), + surface, + DiagramRenderContext.Flatten(ColorOf(style.LinkForeground), surface), + style.FontFamily.Name, + style.FontSize); + } + + /// + /// 块背后的不透明底色:沿可视树向上叠加各祖先的纯色背景,直到遇到不透明的一层; + /// 没有祖先背景(或还没挂上可视树)时按明暗取一个默认底色。 + /// + private Color BackdropColor(bool isDark) + { + // 由内向外收集;最外一层不透明就作为底,否则底用默认色 + var layers = new List(); + for (var parent = this.GetVisualParent(); parent is not null; parent = parent.GetVisualParent()) + { + if (BackgroundOf(parent) is { A: > 0 } color) + { + layers.Add(color); + if (color.A == byte.MaxValue) + { + break; + } + } + } + + var result = isDark ? Color.FromRgb(0x1E, 0x1E, 0x1E) : Colors.White; + var top = layers.Count; + if (top > 0 && layers[^1].A == byte.MaxValue) + { + result = layers[^1]; + top--; + } + for (var i = top - 1; i >= 0; i--) + { + result = DiagramRenderContext.Flatten(layers[i], result); + } + return result; + } + + private static Color? BackgroundOf(Visual visual) => visual switch + { + Border border => SolidColorOf(border.Background), + Panel panel => SolidColorOf(panel.Background), + ContentPresenter presenter => SolidColorOf(presenter.Background), + TemplatedControl templated => SolidColorOf(templated.Background), + _ => null, + }; + + private static Color? SolidColorOf(IBrush? brush) => brush is ISolidColorBrush ? ColorOf(brush) : null; + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + if (Context is { } context && Block is CodeBlock code) + { + Refresh(context, code); + } + } + + private void StartRender(CodeBlock code, DiagramRenderContext renderContext, IDiagramRenderer? renderer) { _cts?.Cancel(); _cts = new CancellationTokenSource(); @@ -92,7 +198,6 @@ private void StartRender(RenderContext context, CodeBlock code) _error = null; return; } - var renderer = context.Services.FindDiagramRenderer(code.Language); if (renderer is null) { return; @@ -102,9 +207,6 @@ private void StartRender(RenderContext context, CodeBlock code) _statusText = "Rendering…"; _statusIsError = false; _status = null; - var style = context.Style; - var renderContext = new DiagramRenderContext(style.IsDark, ColorOf(style.Foreground), ColorOf(style.CodeBackground), ColorOf(style.LinkForeground), - style.FontFamily.Name, style.FontSize); var key = _renderKey; var language = code.Language ?? ""; var source = code.Code; @@ -148,7 +250,10 @@ await Dispatcher.UIThread.InvokeAsync(() => }, token); } - private static Color ColorOf(IBrush brush) => brush is ISolidColorBrush solid ? solid.Color : Colors.Gray; + /// 纯色画刷的颜色(含画刷自身的不透明度);非纯色画刷取灰色。 + private static Color ColorOf(IBrush brush) => brush is ISolidColorBrush solid + ? Color.FromArgb((byte)Math.Round(solid.Color.A * solid.Opacity), solid.Color.R, solid.Color.G, solid.Color.B) + : Colors.Gray; private bool DiagramVisible => _diagram is not null && !_showCode; @@ -159,7 +264,8 @@ await Dispatcher.UIThread.InvokeAsync(() => { return null; } - return ImageViewer.Show(this, diagram.Size, (context, rect) => diagram.Draw(context, rect), Context.Style.CodeBackground); + // 全屏的深色遮罩上要用不透明的底色:主题的代码底常是几乎透明的填充色,图表又是按真实底色配的色 + return ImageViewer.Show(this, diagram.Size, (context, rect) => diagram.Draw(context, rect), new ImmutableSolidColorBrush(_surface)); } /// 当前显示的是渲染好的图表(而不是源码)。 @@ -204,9 +310,9 @@ private Size FitDiagram(double width) { return default; } + // 按宽度适配,不放大;高度只受安全上限约束(见 MaxDiagramHeight) var scale = Math.Min(1, width / natural.Width); - // 过高的图表限制高度 - scale = Math.Min(scale, 900 / natural.Height); + scale = Math.Min(scale, MaxDiagramHeight / natural.Height); return new Size(natural.Width * scale, natural.Height * scale); } diff --git a/src/StreamMark.Mermaid/MermaidDiagramRenderer.cs b/src/StreamMark.Mermaid/MermaidDiagramRenderer.cs index 5628e1c..adfd221 100644 --- a/src/StreamMark.Mermaid/MermaidDiagramRenderer.cs +++ b/src/StreamMark.Mermaid/MermaidDiagramRenderer.cs @@ -14,6 +14,12 @@ namespace StreamMark.Mermaid; /// public sealed class MermaidDiagramRenderer : IDiagramRenderer { + /// + /// 配色方案。默认 :复刻 mermaid.js 官方主题(暗色 dark、亮色 default), + /// 和浏览器里的效果一致; 则完全由宿主的前景 / 背景 / 强调色推导。 + /// + public MermaidTheme Theme { get; init; } = MermaidTheme.MermaidJs; + /// /// 在主题映射之后进一步定制 Mermaider 的渲染选项(样式预设、间距、调色板等)。 /// 返回值作为最终选项;可用 options with { ... }。 @@ -45,26 +51,10 @@ public bool CanRender(string? language) => { cancellationToken.ThrowIfCancellationRequested(); string svg; - List? violations = null; + var violations = new List(); try { - var options = CreateOptions(context) with - { - OnSanitized = list => - { - violations ??= []; - foreach (var v in list) - { - violations.Add(v.ToString() ?? string.Empty); - } - }, - }; - if (ConfigureOptions is { } configure) - { - options = configure(options, context); - } - - svg = MermaidRenderer.RenderSvg(code, options); + svg = BuildSvg(code, context, violations); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -102,7 +92,7 @@ public bool CanRender(string? language) => return null; } - var warning = violations is { Count: > 0 } ? "Sanitized: " + string.Join("; ", violations) : null; + var warning = violations.Count > 0 ? "Sanitized: " + string.Join("; ", violations) : null; return new SkiaPictureDiagram(picture, skSvg, new Size(cull.Right, cull.Bottom), warning); } catch @@ -122,15 +112,51 @@ public bool CanRender(string? language) => } } + /// + /// Mermaider 渲染并按配色方案重涂后的 SVG(仍含 var()/color-mix(),交给 展开)。 + /// 被 Mermaider 清理掉的内容写进 。 + /// + internal string BuildSvg(string code, DiagramRenderContext context, List violations) + { + var kind = MermaidThemer.Detect(code); + var options = CreateOptions(context, Theme, kind) with + { + OnSanitized = list => + { + foreach (var v in list) + { + violations.Add(v.ToString() ?? string.Empty); + } + }, + }; + if (ConfigureOptions is { } configure) + { + options = configure(options, context); + } + + var svg = MermaidRenderer.RenderSvg(code, options); + return MermaidPalette.For(Theme, context.IsDark) is { } palette + ? MermaidThemer.PaintSvg(svg, palette, OpaqueBackground(context), kind) + : svg; + } + /// Mermaider SVG → 不含 var()/color-mix() 的 SVG(Svg.Skia 可完整识别)。 public static string ResolveSvg(string svg) => CssColorResolver.Resolve(svg); - /// 把宿主主题映射为 Mermaider 选项。 + /// + /// 把宿主主题映射为 Mermaider 选项(:颜色全部由前景 / 背景 / 强调色推导)。 + /// 带透明度的颜色会叠到不透明的底色上,而不是丢掉透明度。 + /// public static RenderOptions CreateOptions(DiagramRenderContext context) { ArgumentNullException.ThrowIfNull(context); - var bg = Opaque(context.Background, context.IsDark ? Color.FromRgb(0x1E, 0x1E, 0x1E) : Colors.White); - var fg = Opaque(context.Foreground, context.IsDark ? Color.FromRgb(0xD4, 0xD4, 0xD4) : Color.FromRgb(0x27, 0x27, 0x2A)); + var bg = OpaqueBackground(context); + var fg = DiagramRenderContext.Flatten(context.Foreground, bg); + if (context.Foreground.A == 0) + { + fg = context.IsDark ? Color.FromRgb(0xD4, 0xD4, 0xD4) : Color.FromRgb(0x27, 0x27, 0x2A); + } + var options = new RenderOptions { Bg = CssColorResolver.Format(bg), @@ -142,7 +168,7 @@ public static RenderOptions CreateOptions(DiagramRenderContext context) if (context.Accent.A > 0) { - options = options with { Accent = CssColorResolver.Format(Opaque(context.Accent, context.Accent)) }; + options = options with { Accent = CssColorResolver.Format(DiagramRenderContext.Flatten(context.Accent, bg)) }; } if (context.FontSize > 0 && double.IsFinite(context.FontSize)) @@ -158,8 +184,25 @@ public static RenderOptions CreateOptions(DiagramRenderContext context) return options; } - private static Color Opaque(Color color, Color fallback) => - color.A == 0 ? fallback : Color.FromRgb(color.R, color.G, color.B); + /// + /// 按配色方案把宿主主题映射为 Mermaider 选项( 等同于 )。 + /// 不含按图表类别做的色板调整(流程图等把色板换成中性灰)——渲染器渲染时会根据围栏源码补上。 + /// + public static RenderOptions CreateOptions(DiagramRenderContext context, MermaidTheme theme) => + CreateOptions(context, theme, DiagramKind.Other); + + private static RenderOptions CreateOptions(DiagramRenderContext context, MermaidTheme theme, DiagramKind kind) + { + var options = CreateOptions(context); + return MermaidPalette.For(theme, context.IsDark) is { } palette ? MermaidThemer.Apply(options, palette, kind) : options; + } + + /// 图表背后的不透明底色:带透明度的背景叠到默认底色上,完全透明时就用默认底色。 + private static Color OpaqueBackground(DiagramRenderContext context) + { + var fallback = context.IsDark ? Color.FromRgb(0x1E, 0x1E, 0x1E) : Colors.White; + return DiagramRenderContext.Flatten(context.Background, fallback); + } /// /// Avalonia 字体族字符串(逗号分隔,可能含 avares://…#Name 或 $Default)→ 第一个可用的系统字体名。 diff --git a/src/StreamMark.Mermaid/MermaidPalette.cs b/src/StreamMark.Mermaid/MermaidPalette.cs new file mode 100644 index 0000000..4e78c0c --- /dev/null +++ b/src/StreamMark.Mermaid/MermaidPalette.cs @@ -0,0 +1,69 @@ +using Avalonia.Media; +using StreamMark.Avalonia.Extensions; + +namespace StreamMark.Mermaid; + +/// +/// mermaid.js 官方主题里决定观感的几个颜色。数值取自 mermaid 12.2 在流程图上实际输出的样式表 +/// (.node rect、.cluster rect、.edgePaths .path、.edgeLabel、.label text): +/// dark 来自 theme-dark,default 来自 theme-default,neutral 来自 theme-neutral。 +/// +internal sealed record MermaidPalette( + Color Text, + Color Line, + Color NodeFill, + Color NodeBorder, + Color ClusterFill, + Color ClusterBorder, + Color EdgeLabel, + Color Neutral) +{ + /// 方案对应的配色; 没有固定配色,返回 null。 + public static MermaidPalette? For(MermaidTheme theme, bool isDark) + { + if (theme == MermaidTheme.Native) + { + return null; + } + + if (isDark) + { + return DarkPalette; + } + + return theme == MermaidTheme.MermaidJsNeutral ? NeutralPalette : DefaultPalette; + } + + // dark:节点 #1f2020 / #ccc,文字 #ccc,连线 lightgrey,子图 hsl(180,1.6%,28.4%) + 25% 白描边,连线标签 #585858 @ 50% + private static readonly MermaidPalette DarkPalette = new( + Text: Color.FromRgb(0xCC, 0xCC, 0xCC), + Line: Color.FromRgb(0xD3, 0xD3, 0xD3), + NodeFill: Color.FromRgb(0x1F, 0x20, 0x20), + NodeBorder: Color.FromRgb(0xCC, 0xCC, 0xCC), + ClusterFill: Color.FromRgb(0x47, 0x49, 0x49), + ClusterBorder: DiagramRenderContext.Flatten(Color.FromArgb(0x40, 0xFF, 0xFF, 0xFF), Color.FromRgb(0x47, 0x49, 0x49)), + EdgeLabel: Color.FromArgb(0x80, 0x58, 0x58, 0x58), + Neutral: Color.FromRgb(0x9A, 0x9A, 0x9A)); + + // default:节点 #ECECFF / #9370DB,文字与连线 #333,子图 #ffffde / #aaaa33,连线标签 rgba(232,232,232,.8) + private static readonly MermaidPalette DefaultPalette = new( + Text: Color.FromRgb(0x33, 0x33, 0x33), + Line: Color.FromRgb(0x33, 0x33, 0x33), + NodeFill: Color.FromRgb(0xEC, 0xEC, 0xFF), + NodeBorder: Color.FromRgb(0x93, 0x70, 0xDB), + ClusterFill: Color.FromRgb(0xFF, 0xFF, 0xDE), + ClusterBorder: Color.FromRgb(0xAA, 0xAA, 0x33), + EdgeLabel: Color.FromArgb(0xCC, 0xE8, 0xE8, 0xE8), + Neutral: Color.FromRgb(0x77, 0x77, 0x77)); + + // neutral:节点 #eee / #999,文字 #000,连线 #666,子图 #fcfcfc / #707070,连线标签白底 + private static readonly MermaidPalette NeutralPalette = new( + Text: Color.FromRgb(0x00, 0x00, 0x00), + Line: Color.FromRgb(0x66, 0x66, 0x66), + NodeFill: Color.FromRgb(0xEE, 0xEE, 0xEE), + NodeBorder: Color.FromRgb(0x99, 0x99, 0x99), + ClusterFill: Color.FromRgb(0xFC, 0xFC, 0xFC), + ClusterBorder: Color.FromRgb(0x70, 0x70, 0x70), + EdgeLabel: Colors.White, + Neutral: Color.FromRgb(0x77, 0x77, 0x77)); +} diff --git a/src/StreamMark.Mermaid/MermaidTheme.cs b/src/StreamMark.Mermaid/MermaidTheme.cs new file mode 100644 index 0000000..a8ac37b --- /dev/null +++ b/src/StreamMark.Mermaid/MermaidTheme.cs @@ -0,0 +1,20 @@ +namespace StreamMark.Mermaid; + +/// Mermaid 图表的配色方案(见 )。 +public enum MermaidTheme +{ + /// + /// 复刻 mermaid.js 官方主题,和浏览器里的效果一致:暗色用 dark(深色节点、浅色描边、灰色子图), + /// 亮色用 default(淡紫节点、淡黄子图)。明暗跟随宿主主题。这是默认值。 + /// + MermaidJs, + + /// 复刻 mermaid.js 官方主题:暗色用 dark,亮色用 neutral(灰阶,和黑白灰的界面更协调)。 + MermaidJsNeutral, + + /// + /// Mermaider 原生风格:节点、连线、文字的颜色全部由宿主的前景 / 背景 / 强调色推导, + /// 子图按色板自动着色,描边偏淡。 + /// + Native, +} diff --git a/src/StreamMark.Mermaid/MermaidThemer.cs b/src/StreamMark.Mermaid/MermaidThemer.cs new file mode 100644 index 0000000..65c86e9 --- /dev/null +++ b/src/StreamMark.Mermaid/MermaidThemer.cs @@ -0,0 +1,236 @@ +using System.Xml; +using System.Xml.Linq; +using Avalonia.Media; +using StreamMark.Avalonia.Extensions; +using RenderOptions = Mermaider.Models.RenderOptions; + +namespace StreamMark.Mermaid; + +/// Mermaid 图表的大类:决定色板是否中性化、是否重绘子图。 +internal enum DiagramKind +{ + /// 饼图、甘特图、思维导图等:色板是分类色,必须保留。 + Other, + Flowchart, + State, + Class, + Sequence, + Block, +} + +/// +/// 把 (mermaid.js 官方主题的颜色)落到 Mermaider 的渲染选项和它输出的 SVG 上。 +/// 节点、连线、文字用 Mermaider 自带的 Surface / Border / Line 等选项钉住; +/// 子图和连线标签的涂法是 Mermaider 的设计(按色板着色的渐变面板、胶囊标签),选项改不了,所以在 SVG 上按语义类重涂。 +/// +internal static class MermaidThemer +{ + private const int NeutralPaletteSize = 12; + + /// + /// 从围栏源码判断图表类别:跳过空行、YAML 头(---)、注释和 %%{init}%% 指令,取第一条语句的关键字。 + /// + public static DiagramKind Detect(string code) + { + var inFrontMatter = false; + foreach (var raw in code.AsSpan().EnumerateLines()) + { + var line = raw.Trim(); + if (line.IsEmpty) + { + continue; + } + + if (line.SequenceEqual("---")) + { + inFrontMatter = !inFrontMatter; + continue; + } + + if (inFrontMatter || line.StartsWith("%%", StringComparison.Ordinal)) + { + continue; + } + + if (line.StartsWith("flowchart", StringComparison.Ordinal) || line.StartsWith("graph", StringComparison.Ordinal)) + { + return DiagramKind.Flowchart; + } + + if (line.StartsWith("stateDiagram", StringComparison.Ordinal)) + { + return DiagramKind.State; + } + + if (line.StartsWith("classDiagram", StringComparison.Ordinal)) + { + return DiagramKind.Class; + } + + if (line.StartsWith("sequenceDiagram", StringComparison.Ordinal)) + { + return DiagramKind.Sequence; + } + + return line.StartsWith("block", StringComparison.Ordinal) ? DiagramKind.Block : DiagramKind.Other; + } + + return DiagramKind.Other; + } + + /// 用固定配色覆盖选项里由宿主颜色推导的部分。字体、字号、画布底色保持不变。 + public static RenderOptions Apply(RenderOptions options, MermaidPalette palette, DiagramKind kind) + { + var text = CssColorResolver.Format(palette.Text); + var border = CssColorResolver.Format(palette.NodeBorder); + options = options with + { + Fg = text, + Muted = text, + Line = CssColorResolver.Format(palette.Line), + // 决定节点(菱形)用强调色,保持和普通节点同一色系 + Accent = border, + Surface = CssColorResolver.Format(palette.NodeFill), + Border = border, + Default = border, + // mermaid.js 的图是平涂、没有阴影 + Gradient = false, + Elevation = 0, + Tint = null, + }; + + if (kind != DiagramKind.Other) + { + // 结构类图表的色板只用来给容器(子图、命名空间、循环框、block 节点)自动着色:用同一个中性灰,标题等派生色才不会各有各的色相。 + // 图表类(饼图、甘特图等)的色板是分类色,不能动。 + var neutral = CssColorResolver.Format(palette.Neutral); + options = options with { DataPalette = Enumerable.Repeat(neutral, NeutralPaletteSize).ToArray() }; + } + + return options; + } + + /// + /// 重涂 SVG 里 Mermaider 的涂法改不了的部分:子图(面板色、描边,去掉标题条)、连线标签(矩形底,无描边) + /// 与推导色的节点形状(胶囊、圆柱、菱形)。 + /// 是图表背后的不透明底色,半透明的标签底色叠到它上面,免得连线从标签文字后面透出来。 + /// 解析失败时原样返回。 + /// + public static string PaintSvg(string svg, MermaidPalette palette, Color backdrop, DiagramKind kind) + { + XDocument document; + try + { + var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }; + using var reader = XmlReader.Create(new StringReader(svg), settings); + document = XDocument.Load(reader, LoadOptions.PreserveWhitespace); + } + catch (XmlException) + { + return svg; + } + + var paintClusters = kind is DiagramKind.Flowchart or DiagramKind.State or DiagramKind.Class; + var paintNodes = kind is DiagramKind.Flowchart or DiagramKind.State; + var clusterFill = CssColorResolver.Format(palette.ClusterFill); + var clusterBorder = CssColorResolver.Format(palette.ClusterBorder); + var nodeFill = CssColorResolver.Format(palette.NodeFill); + var nodeBorder = CssColorResolver.Format(palette.NodeBorder); + var labelFill = CssColorResolver.Format(DiagramRenderContext.Flatten(palette.EdgeLabel, backdrop)); + + var changed = false; + foreach (var group in document.Descendants().Where(e => e.Name.LocalName == "g").ToList()) + { + var classes = (string?)group.Attribute("class"); + if (classes is null) + { + continue; + } + + if (paintClusters && HasClass(classes, "subgraph")) + { + PaintCluster(group, clusterFill, clusterBorder); + changed = true; + } + else if (HasClass(classes, "edge-label")) + { + PaintEdgeLabel(group, labelFill); + changed = true; + } + else if (paintNodes && HasClass(classes, "node")) + { + PaintNode(group, nodeFill, nodeBorder); + changed = true; + } + } + + return changed ? document.ToString(SaveOptions.DisableFormatting) : svg; + } + + private static bool HasClass(string classes, string name) + { + foreach (var part in classes.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + if (part == name) + { + return true; + } + } + + return false; + } + + private static void PaintCluster(XElement group, string fill, string border) + { + foreach (var element in group.Elements().ToList()) + { + switch (element.Name.LocalName) + { + case "rect": + element.SetAttributeValue("fill", fill); + element.SetAttributeValue("stroke", border); + element.SetAttributeValue("stroke-width", "1"); + break; + case "path": // 标题条(mermaid.js 的子图没有) + case "line": // 标题条下沿的分隔线 + element.Remove(); + break; + } + } + } + + /// + /// 胶囊(terminal)、圆柱(data store)和菱形(decision)的填充与描边是 Mermaider 用连线色 / 强调色经 color-mix() 推导的, + /// 选项钉不住;mermaid.js 里所有形状同色,所以把这些推导出来的颜色换成节点色。 + /// 作者用 style / classDef 指定的颜色是字面量、状态图的起止标记用 var(),都不受影响。 + /// + private static void PaintNode(XElement group, string fill, string border) + { + foreach (var element in group.Elements()) + { + var isShape = element.Name.LocalName is "rect" or "polygon" or "circle" or "ellipse" or "path"; + if (isShape && IsDerivedPaint((string?)element.Attribute("fill"))) + { + element.SetAttributeValue("fill", fill); + } + + if (IsDerivedPaint((string?)element.Attribute("stroke"))) + { + element.SetAttributeValue("stroke", border); + } + } + } + + private static bool IsDerivedPaint(string? paint) => paint is not null && paint.StartsWith("color-mix(", StringComparison.Ordinal); + + private static void PaintEdgeLabel(XElement group, string fill) + { + foreach (var rect in group.Elements().Where(e => e.Name.LocalName == "rect")) + { + rect.SetAttributeValue("fill", fill); + rect.SetAttributeValue("stroke", "none"); + rect.SetAttributeValue("rx", "4"); + rect.SetAttributeValue("ry", "4"); + } + } +} diff --git a/tests/StreamMark.Avalonia.Tests/Mermaid/MermaidThemeTests.cs b/tests/StreamMark.Avalonia.Tests/Mermaid/MermaidThemeTests.cs new file mode 100644 index 0000000..e0e3508 --- /dev/null +++ b/tests/StreamMark.Avalonia.Tests/Mermaid/MermaidThemeTests.cs @@ -0,0 +1,413 @@ +using System.Xml.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using StreamMark.Avalonia.Extensions; +using StreamMark.Mermaid; +using Xunit; + +namespace StreamMark.Avalonia.Tests.Mermaid; + +/// +/// Mermaid 配色:复刻 mermaid.js 官方主题(dark / default / neutral),以及宿主主题里半透明颜色的处理。 +/// 回归点:AtomUI 的文字 / 填充 token 是半透明的,丢掉透明度会得到「白底白字」(亮色下是黑底黑字)。 +/// +public sealed class MermaidThemeTests(ITestOutputHelper output) +{ + private const string Clustered = """ + flowchart LR + top[Outside] --> A1 + subgraph a[Group A] + A1[One] --> A2[Two] + end + subgraph b[Group B] + B1{Choice} -->|yes| B2[Three] + end + A2 --> B1 + """; + + private const string Shapes = """ + flowchart LR + A[Rect] --> B(Round) + B --> C([Stadium]) + C --> D[[Subroutine]] + D --> E[(Database)] + E --> F((Circle)) + F --> G{Decision} + G --> H{{Hexagon}} + L[Custom] --> A + style L fill:#f9f,stroke:#333,stroke-width:2px + """; + + private const string StateMarkers = """ + stateDiagram-v2 + [*] --> Idle + Idle --> Done + Done --> [*] + """; + + /// AtomUI 主题的实际取值:暗色文字是 85% 的白、代码底是 4% 的白;亮色文字是 88% 的黑、代码底是 2% 的黑。 + internal static DiagramRenderContext AtomUiLike(bool dark) => dark + ? new DiagramRenderContext(true, Color.FromArgb(0xD8, 0xF0, 0xF0, 0xF0), Color.FromArgb(0x0A, 0xF0, 0xF0, 0xF0), Color.FromRgb(0x9C, 0x9C, 0x9C), null, 13) + : new DiagramRenderContext(false, Color.FromArgb(0xE0, 0x1A, 0x1A, 0x1A), Color.FromArgb(0x05, 0x1A, 0x1A, 0x1A), Color.FromRgb(0x4A, 0x4A, 0x4A), null, 13); + + private static string ResolvedSvg(string code, DiagramRenderContext context, MermaidTheme theme) + { + var renderer = new MermaidDiagramRenderer { Theme = theme }; + return MermaidDiagramRenderer.ResolveSvg(renderer.BuildSvg(code, context, [])); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SemiTransparentHostColors_AreFlattenedOntoTheBackdrop_NotStripped(bool dark) + { + var options = MermaidDiagramRenderer.CreateOptions(AtomUiLike(dark)); + + Assert.True(CssColorResolver.TryParseColor(options.Bg!, out var bg)); + Assert.True(CssColorResolver.TryParseColor(options.Fg!, out var fg)); + var contrast = Contrast(ToColor(bg), ToColor(fg)); + output.WriteLine($"dark={dark} bg={options.Bg} fg={options.Fg} contrast={contrast:F1}"); + + // 丢掉透明度时前景与背景是同一个颜色(对比度 1) + Assert.NotEqual(options.Bg, options.Fg); + Assert.True(contrast > 7, $"foreground is not readable on the background: {contrast:F2}"); + // 底色保持明暗方向:暗色仍是深色,亮色仍是浅色 + Assert.Equal(dark, RelativeLuminance(ToColor(bg)) < 0.2); + } + + [Theory] + [InlineData(MermaidTheme.MermaidJs, true, "#1f2020", "#cccccc", "#d3d3d3", "#cccccc")] + [InlineData(MermaidTheme.MermaidJs, false, "#ececff", "#9370db", "#333333", "#333333")] + [InlineData(MermaidTheme.MermaidJsNeutral, true, "#1f2020", "#cccccc", "#d3d3d3", "#cccccc")] + [InlineData(MermaidTheme.MermaidJsNeutral, false, "#eeeeee", "#999999", "#666666", "#000000")] + public void MermaidJsThemes_UseTheOfficialPalette(MermaidTheme theme, bool dark, string surface, string border, string line, string text) + { + var options = MermaidDiagramRenderer.CreateOptions(AtomUiLike(dark), theme); + + Assert.Equal(surface, options.Surface); + Assert.Equal(border, options.Border); + Assert.Equal(line, options.Line); + Assert.Equal(text, options.Fg); + // mermaid.js 的图是平涂、没有阴影 + Assert.False(options.Gradient); + Assert.Equal(0, options.Elevation); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void NativeTheme_KeepsTheHostDerivedOptions(bool dark) + { + var context = AtomUiLike(dark); + + Assert.Equal(MermaidDiagramRenderer.CreateOptions(context), MermaidDiagramRenderer.CreateOptions(context, MermaidTheme.Native)); + Assert.Equal(MermaidTheme.MermaidJs, new MermaidDiagramRenderer().Theme); + } + + public static TheoryData ReadableCases() + { + var data = new TheoryData(); + foreach (var name in new[] { "flowchart", "sequence", "class", "state" }) + { + foreach (var theme in new[] { MermaidTheme.MermaidJs, MermaidTheme.MermaidJsNeutral, MermaidTheme.Native }) + { + data.Add(name, theme, true); + data.Add(name, theme, false); + } + } + + data.Add("clustered", MermaidTheme.MermaidJs, true); + data.Add("clustered", MermaidTheme.MermaidJs, false); + return data; + } + + [Theory] + [MemberData(nameof(ReadableCases))] + public void NodeText_IsReadableOnTheNodeFill(string name, MermaidTheme theme, bool dark) + { + var code = name == "clustered" ? Clustered : MermaidRendererTests.Samples[name]; + var svg = ResolvedSvg(code, AtomUiLike(dark), theme); + var backdrop = dark ? Color.FromRgb(0x1D, 0x1D, 0x1D) : Color.FromRgb(0xFA, 0xFA, 0xFA); + + var pairs = NodePairs(svg, backdrop).ToList(); + Assert.NotEmpty(pairs); + foreach (var (fill, text) in pairs) + { + var contrast = Contrast(fill, text); + Assert.True(contrast >= 4.5, $"{name}/{theme}/dark={dark}: text {text} on fill {fill} has contrast {contrast:F2}"); + } + } + + [Fact] + public void MermaidJsDark_PaintsSubgraphsAsFlatGrayPanels_WithoutTheTitleStrip() + { + var svg = ResolvedSvg(Clustered, AtomUiLike(true), MermaidTheme.MermaidJs); + var groups = Subgraphs(XDocument.Parse(svg)); + + Assert.Equal(2, groups.Count); + foreach (var group in groups) + { + var rect = Assert.Single(group.Elements(), e => e.Name.LocalName == "rect"); + Assert.Equal("#474949", (string?)rect.Attribute("fill")); + Assert.DoesNotContain(group.Elements(), e => e.Name.LocalName is "path" or "line"); + } + + // Mermaider 原本按色板给子图着色(橙 / 青 / 紫…),复刻 mermaid.js 时所有子图同色 + Assert.DoesNotContain("#f28e2b", svg); + Assert.DoesNotContain("#76b7b2", svg); + } + + [Fact] + public void MermaidJsLight_PaintsSubgraphsPaleYellow() + { + var svg = ResolvedSvg(Clustered, AtomUiLike(false), MermaidTheme.MermaidJs); + + foreach (var group in Subgraphs(XDocument.Parse(svg))) + { + var rect = Assert.Single(group.Elements(), e => e.Name.LocalName == "rect"); + Assert.Equal("#ffffde", (string?)rect.Attribute("fill")); + Assert.Equal("#aaaa33", (string?)rect.Attribute("stroke")); + } + } + + [Fact] + public void NativeTheme_LeavesSubgraphsToMermaider() + { + var svg = ResolvedSvg(Clustered, AtomUiLike(true), MermaidTheme.Native); + + foreach (var group in Subgraphs(XDocument.Parse(svg))) + { + Assert.Contains(group.Elements(), e => e.Name.LocalName == "path"); // 标题条 + } + } + + [Theory] + [InlineData(true, "#1f2020", "#cccccc")] + [InlineData(false, "#ececff", "#9370db")] + public void NodeShapes_ShareTheNodeColors_ButKeepAuthorStyles(bool dark, string fill, string border) + { + var document = XDocument.Parse(ResolvedSvg(Shapes, AtomUiLike(dark), MermaidTheme.MermaidJs)); + var nodes = document.Descendants().Where(e => e.Name.LocalName == "g" && HasClass(e, "node")).ToList(); + + Assert.Equal(9, nodes.Count); + foreach (var node in nodes) + { + var id = (string?)node.Attribute("data-id"); + foreach (var shape in node.Elements().Where(e => e.Name.LocalName is "rect" or "polygon" or "circle" or "ellipse" or "path")) + { + var shapeFill = (string?)shape.Attribute("fill"); + var shapeStroke = (string?)shape.Attribute("stroke"); + if (id == "L") + { + // 作者用 style 指定的颜色保持原样 + Assert.Equal("#f9f", shapeFill); + Assert.Equal("#333", shapeStroke); + continue; + } + + // 胶囊、圆柱、菱形原本是 Mermaider 推导的颜色,现在和普通节点一致 + Assert.Equal(fill, shapeFill); + Assert.True(shapeStroke is "none" || shapeStroke == border, $"{id}: stroke {shapeStroke}"); + } + } + } + + [Theory] + [InlineData(true, "#cccccc")] + [InlineData(false, "#9370db")] + public void StateMarkers_KeepTheAccentColor(bool dark, string accent) + { + var document = XDocument.Parse(ResolvedSvg(StateMarkers, AtomUiLike(dark), MermaidTheme.MermaidJs)); + var start = document.Descendants().Single(e => e.Name.LocalName == "g" && (string?)e.Attribute("data-shape") == "statestart"); + + Assert.Equal(accent, (string?)start.Elements().Single(e => e.Name.LocalName == "circle").Attribute("fill")); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void EdgeLabels_AreRectanglesWithoutOutline(bool dark) + { + var svg = ResolvedSvg(Clustered, AtomUiLike(dark), MermaidTheme.MermaidJs); + var labels = XDocument.Parse(svg).Descendants().Where(e => e.Name.LocalName == "g" && HasClass(e, "edge-label")).ToList(); + + Assert.NotEmpty(labels); + foreach (var rect in labels.SelectMany(g => g.Elements()).Where(e => e.Name.LocalName == "rect")) + { + Assert.Equal("none", (string?)rect.Attribute("stroke")); + Assert.Equal("4", (string?)rect.Attribute("rx")); + // 不透明:连线不会从标签文字后面透出来 + Assert.True(CssColorResolver.TryParseColor((string?)rect.Attribute("fill")!, out var fill)); + Assert.True(fill.A > 0.999); + } + } + + [Fact] + public void ChartDiagrams_KeepTheirCategoricalColors() + { + var svg = ResolvedSvg(MermaidRendererTests.Samples["pie"], AtomUiLike(true), MermaidTheme.MermaidJs); + + var slices = XDocument.Parse(svg).Descendants().Where(e => HasClass(e, "pie-slice")).ToList(); + var fills = slices.SelectMany(s => s.DescendantsAndSelf()).Select(e => (string?)e.Attribute("fill")).Where(f => f is { Length: > 0 } && f != "none").Distinct().ToList(); + output.WriteLine(string.Join(", ", fills)); + Assert.True(fills.Count >= 3, $"pie slices collapsed into {fills.Count} color(s)"); + } + + [Theory] + [InlineData("flowchart TD\n A-->B", "Flowchart")] + [InlineData("graph LR\n A-->B", "Flowchart")] + [InlineData("\n\n flowchart-elk TD\n", "Flowchart")] + [InlineData("%% 注释\n%%{init: {'theme': 'dark'}}%%\nsequenceDiagram\n A->>B: hi", "Sequence")] + [InlineData("---\ntitle: Demo\n---\nstateDiagram-v2\n [*] --> A", "State")] + [InlineData("classDiagram\n class A", "Class")] + [InlineData("block-beta\n columns 2", "Block")] + [InlineData("pie title Pets\n \"Dogs\" : 1", "Other")] + [InlineData("gantt\n title X", "Other")] + [InlineData("", "Other")] + public void Detect_ReadsTheFirstStatement(string code, string expected) + { + // DiagramKind 是内部类型,测试方法签名里不能出现 + Assert.Equal(Enum.Parse(expected), MermaidThemer.Detect(code)); + } + + [Theory] + [InlineData("flowchart")] + [InlineData("clustered")] + [InlineData("sequence")] + [InlineData("class")] + [InlineData("state")] + [InlineData("pie")] + public async Task Renders_ThemedSnapshots(string name) + { + var code = name == "clustered" ? Clustered : MermaidRendererTests.Samples[name]; + foreach (var theme in new[] { MermaidTheme.MermaidJs, MermaidTheme.MermaidJsNeutral, MermaidTheme.Native }) + { + foreach (var dark in new[] { true, false }) + { + var context = AtomUiLike(dark); + var backdrop = dark ? Color.FromRgb(0x1D, 0x1D, 0x1D) : Color.FromRgb(0xFA, 0xFA, 0xFA); + string? error = null; + var renderer = new MermaidDiagramRenderer { Theme = theme, ErrorReported = (m, _) => error = m }; + using var result = await renderer.RenderAsync("mermaid", code, context, CancellationToken.None); + Assert.True(result is not null, error); + + var stats = await HeadlessApp.Session.Dispatch(() => + { + const double scale = 1.5; + var control = new DiagramTestControl(result) { Width = result.Size.Width * scale, Height = result.Size.Height * scale }; + var window = new Window { Content = control, Background = new SolidColorBrush(backdrop), SizeToContent = SizeToContent.WidthAndHeight }; + window.Show(); + HeadlessApp.Save(window, $"mermaid-theme-{name}-{theme}-{(dark ? "dark" : "light")}"); + using var frame = HeadlessApp.Capture(window); + var s = PixelStats.Measure(frame, backdrop); + window.Close(); + return Task.FromResult(s); + }, CancellationToken.None); + + output.WriteLine($"{name}/{theme}/dark={dark}: {stats}"); + Assert.True(stats.NonBackgroundRatio > 0.02, $"almost nothing drawn: {stats}"); + // color-mix 没展开时节点填充会变成纯黑;neutral 亮色的文字本身就是纯黑(mermaid.js 如此),放宽到只拦大面积黑块 + var maxBlack = theme == MermaidTheme.MermaidJsNeutral && !dark ? 0.03 : 0.005; + Assert.True(stats.BlackRatio < maxBlack, $"black fills detected: {stats}"); + Assert.True(stats.ForegroundLikeRatio > 0.001, $"no readable text / edges: {stats}"); + } + } + } + + // ---- 辅助:从最终 SVG 里取节点填充与文字颜色,按 WCAG 计算对比度 ---- + + private static bool HasClass(XElement element, string name) => + ((string?)element.Attribute("class"))?.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains(name) == true; + + private static List Subgraphs(XDocument document) => + document.Descendants().Where(e => e.Name.LocalName == "g" && HasClass(e, "subgraph")).ToList(); + + private static IEnumerable<(Color Fill, Color Text)> NodePairs(string svg, Color backdrop) + { + var document = XDocument.Parse(svg); + foreach (var node in document.Descendants().Where(e => e.Name.LocalName == "g" && (HasClass(e, "node") || HasClass(e, "actor") || HasClass(e, "class-node")))) + { + var shape = node.Elements().FirstOrDefault(e => e.Name.LocalName is "rect" or "polygon" or "circle" or "ellipse" or "path"); + var text = node.Descendants().FirstOrDefault(e => e.Name.LocalName == "text"); + if (shape is null || text is null) + { + continue; + } + + foreach (var fill in Fills(document, (string?)shape.Attribute("fill"), backdrop)) + { + if (TryColor((string?)text.Attribute("fill"), fill, out var foreground)) + { + yield return (fill, foreground); + } + } + } + } + + /// 纯色填充就是它自己;渐变填充(Mermaider 原生风格)取每个色标,逐个检查对比度。 + private static IEnumerable Fills(XDocument document, string? fill, Color backdrop) + { + if (fill is null) + { + yield break; + } + + if (!fill.StartsWith("url(#", StringComparison.Ordinal)) + { + if (TryColor(fill, backdrop, out var color)) + { + yield return color; + } + + yield break; + } + + var id = fill["url(#".Length..fill.IndexOf(')')]; + var gradient = document.Descendants().FirstOrDefault(e => (string?)e.Attribute("id") == id); + foreach (var stop in gradient?.Elements().Where(e => e.Name.LocalName == "stop") ?? []) + { + if (TryColor((string?)stop.Attribute("stop-color"), backdrop, out var color)) + { + yield return color; + } + } + } + + private static bool TryColor(string? value, Color backdrop, out Color color) + { + color = default; + if (value is null || !CssColorResolver.TryParseColor(value, out var parsed)) + { + return false; + } + + color = DiagramRenderContext.Flatten(ToColor(parsed), backdrop); + return true; + } + + private static Color ToColor(RgbaColor c) => + Color.FromArgb((byte)Math.Round(c.A * 255), (byte)Math.Round(c.R * 255), (byte)Math.Round(c.G * 255), (byte)Math.Round(c.B * 255)); + + internal static double RelativeLuminance(Color color) + { + static double Linear(byte channel) + { + var c = channel / 255.0; + return c <= 0.03928 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4); + } + + return 0.2126 * Linear(color.R) + 0.7152 * Linear(color.G) + 0.0722 * Linear(color.B); + } + + internal static double Contrast(Color a, Color b) + { + var (hi, lo) = (RelativeLuminance(a), RelativeLuminance(b)); + if (hi < lo) + { + (hi, lo) = (lo, hi); + } + + return (hi + 0.05) / (lo + 0.05); + } +} diff --git a/tests/StreamMark.Avalonia.Tests/Rendering/DiagramBlockViewTests.cs b/tests/StreamMark.Avalonia.Tests/Rendering/DiagramBlockViewTests.cs new file mode 100644 index 0000000..073c369 --- /dev/null +++ b/tests/StreamMark.Avalonia.Tests/Rendering/DiagramBlockViewTests.cs @@ -0,0 +1,166 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.VisualTree; +using StreamMark.Avalonia.Extensions; +using StreamMark.Avalonia.Rendering; +using Xunit; + +namespace StreamMark.Avalonia.Tests.Rendering; + +/// 图表块:传给渲染器的颜色要叠到真实底色上、缓存键含颜色、高图只按宽度适配。 +public sealed class DiagramBlockViewTests +{ + private const string Fence = "```mermaid\nflowchart TD\n A-->B\n```\n"; + + /// 记录收到的渲染环境,返回指定大小的假图表。 + private sealed class RecordingRenderer(Size size) : IDiagramRenderer + { + private readonly List _contexts = []; + + public IReadOnlyList Contexts + { + get + { + lock (_contexts) + { + return [.. _contexts]; + } + } + } + + public bool CanRender(string? language) => language == "mermaid"; + + public Task RenderAsync(string language, string code, DiagramRenderContext context, CancellationToken cancellationToken) + { + lock (_contexts) + { + _contexts.Add(context); + } + + return Task.FromResult(new FakeDiagram(size)); + } + } + + private sealed class FakeDiagram(Size size) : DiagramResult + { + public override Size Size => size; + + public override void Draw(DrawingContext context, Rect destination) => + context.DrawRectangle(Brushes.SteelBlue, null, destination); + } + + private static async Task<(Window Window, DiagramBlockView Block)> Show(RecordingRenderer renderer, Action? configure = null, Control? host = null) + { + var view = new MarkdownView(); + MarkdownServices.SetServices(view, new MarkdownServices { Diagrams = [renderer] }); + view.Markdown = Fence; + Control content = view; + if (host is Border border) + { + border.Child = view; + content = border; + } + + var window = new Window + { + Width = 1000, + Height = 600, + Content = content, + Background = new SolidColorBrush(Color.FromRgb(0x14, 0x14, 0x14)), + }; + configure?.Invoke(window); + window.Show(); + await HeadlessApp.Settle(); + view.ApplyLatest(); + var block = await WaitFor(window, b => b.IsShowingDiagram); + return (window, block); + } + + private static async Task WaitFor(Window window, Func condition) + { + var deadline = DateTime.UtcNow.AddSeconds(15); + while (DateTime.UtcNow < deadline) + { + await HeadlessApp.Settle(2); + if (window.GetVisualDescendants().OfType().FirstOrDefault(condition) is { } block) + { + return block; + } + } + + throw new TimeoutException("diagram block did not reach the expected state"); + } + + [Fact] + public async Task SemiTransparentThemeColors_AreFlattenedOntoTheRealBackdrop() + { + await HeadlessApp.Run(async () => + { + var renderer = new RecordingRenderer(new Size(400, 200)); + // AtomUI 暗色的取值:文字 85% 的白、代码底 4% 的白;窗口底 #141414,中间还有一层 5% 的白卡片 + var foreground = Color.FromArgb(0xD8, 0xF0, 0xF0, 0xF0); + var codeBackground = Color.FromArgb(0x0A, 0xF0, 0xF0, 0xF0); + var card = Color.FromArgb(0x0D, 0xFF, 0xFF, 0xFF); + var (window, _) = await Show(renderer, + w => + { + w.Resources[MarkdownResourceKeys.Foreground] = new SolidColorBrush(foreground); + w.Resources[MarkdownResourceKeys.CodeBackground] = new SolidColorBrush(codeBackground); + }, + new Border { Background = new SolidColorBrush(card) }); + + var context = Assert.Single(renderer.Contexts); + var backdrop = DiagramRenderContext.Flatten(card, Color.FromRgb(0x14, 0x14, 0x14)); + var surface = DiagramRenderContext.Flatten(codeBackground, backdrop); + Assert.Equal(surface, context.Background); + Assert.Equal(DiagramRenderContext.Flatten(foreground, surface), context.Foreground); + Assert.Equal(byte.MaxValue, context.Background.A); + Assert.Equal(byte.MaxValue, context.Foreground.A); + Assert.Equal(byte.MaxValue, context.Accent.A); + // 丢掉透明度时前景和背景都是 #F0F0F0 + Assert.NotEqual(context.Background, context.Foreground); + Assert.True(Mermaid.MermaidThemeTests.Contrast(context.Background, context.Foreground) > 7); + window.Close(); + }); + } + + [Fact] + public async Task ThemeColorChange_RendersAgain_NotServedFromTheCache() + { + await HeadlessApp.Run(async () => + { + var renderer = new RecordingRenderer(new Size(400, 200)); + var (window, _) = await Show(renderer, w => w.Resources[MarkdownResourceKeys.LinkForeground] = new SolidColorBrush(Color.FromRgb(0x11, 0x88, 0xFF))); + Assert.Single(renderer.Contexts); + + window.Resources[MarkdownResourceKeys.LinkForeground] = new SolidColorBrush(Color.FromRgb(0xFF, 0x88, 0x11)); + var deadline = DateTime.UtcNow.AddSeconds(15); + while (renderer.Contexts.Count < 2 && DateTime.UtcNow < deadline) + { + await HeadlessApp.Settle(2); + } + + Assert.Equal(2, renderer.Contexts.Count); + Assert.NotEqual(renderer.Contexts[0].Accent, renderer.Contexts[1].Accent); + window.Close(); + }); + } + + [Theory] + [InlineData(800, 4000, 4000)] // 窄而高:原来被压到 900 高,现在保持自然大小 + [InlineData(2000, 400, 194.4)] // 宽图:按宽度适配(972 / 2000) + [InlineData(800, 20000, 6000)] // 极端高图:只受 6000 的安全上限约束 + public async Task DiagramHeight_FollowsWidthFit_NotAFixedCap(double width, double height, double expectedDiagramHeight) + { + await HeadlessApp.Run(async () => + { + var renderer = new RecordingRenderer(new Size(width, height)); + var (window, block) = await Show(renderer); + + // 头部 34 + 图表 + 底部内边距 14(布局取整最多差 1) + Assert.Equal(34 + expectedDiagramHeight + 14, block.DesiredSize.Height, 1.0); + window.Close(); + }); + } +}