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(); + }); + } +}