C# 约 18 分钟

从零开发 QuickLook markmap 插件:不依赖 WebView2 的双模式预览

目录14

一个 77 KB 的 DLL,接管 QuickLook 的思维导图与 Markdown 文档预览

预览一个 .md 文件需要装浏览器内核吗?QuickLook 的官方答案是需要:上游 QuickLook.Plugin.MarkdownViewer 走的是 WebView2 + markmap-cli + Node,机器上没装 WebView2 运行时,你按下空格看到的就是一句「查看此文件需要安装 Microsoft Edge WebView2」。

这篇文章记录我从零写一个替代品 QuickLook.Plugin.MarkmapViewer 的全过程:怎么摸清 QuickLook 的插件规则、怎么把 markmap 的几何和配色一项项对齐、普通 Markdown 为什么返工了两次、以及一套不装 QuickLook 也能回归的验证工具链。最终产物是一个 77 KB 的 DLL,零额外运行时。

一、需求与目标

用户原话:

给我开发一个不借助 WebView2 的 QuickLook.Plugin.MarkmapViewer,要做到轻量,跟原本的操作逻辑一样。

这句话拆成三条硬约束:

  1. 不借助 WebView2:不允许出现任何浏览器内核、不允许靠 Node 脚本把 Markdown 编译成 HTML;
  2. 轻量:交付物尽量是单个 DLL,不额外安装运行时、不铺开一堆依赖;
  3. 操作逻辑一样:拖动平移、滚轮缩放、点圆点折叠、适应窗口 —— 用户不需要重新学一套手势。

最终交付形态:

项目 结果
目标框架 .NET Framework 4.6.2 + WPF(net462 / UseWPF / AnyCPU)
产物 单个 QuickLook.Plugin.MarkmapViewer.dll,78848 字节(约 77 KB)
安装包 QuickLook.Plugin.MarkmapViewer.qlplugin,33640 字节(约 33 KB)
运行时依赖 无(不装 Node、不装 WebView2、不释放任何文件)
渲染模式 .mm* 走思维导图,.md* 走文档排版

参考的两个仓库:

二、先摸清 QuickLook 是怎么挑插件的

动手写代码之前,先把「为什么我的插件会被选中」搞清楚,否则后面会遇到一堆灵异现象。QuickLook 的加载逻辑在 QuickLook/PluginManager.cs:

// 先扫用户目录,再扫程序目录,递归找 QuickLook.Plugin.*.dll
var plugins = Directory.GetFiles(folder, "QuickLook.Plugin.*.dll", SearchOption.AllDirectories)
                       .OrderByDescending(p => LoadPlugin(p).Priority);

// 预览时取第一个 CanHandle(path) == true 的插件
var plugin = plugins.FirstOrDefault(p => p.CanHandle(path));

三个结论直接决定了实现方式:

  • 插件目录:%APPDATA%\pooi.moe\QuickLook\QuickLook.Plugin\(便携版是程序目录下的 UserData);
  • DLL 文件名必须匹配 QuickLook.Plugin.*.dll;
  • Priority 数值越大越优先,同为 1 时先加载到的赢(不可依赖)。

本机反射读出来的实测值:

插件 Priority 处理的后缀
本插件 1 .mm* + .md*
QuickLook.Plugin.MarkdownViewer 0 .md .markdown .mdown .mkd .mdx ...
QuickLook.Plugin.TextViewer -5 .txt .log ...

所以 Priority => 1 就够了:既压过 WebView2 版接管普通 Markdown,也不至于去抢别人的格式。

插件本体只需要实现 IViewer:

public class Plugin : IViewer
{
    internal static readonly string[] MarkmapExtensions =
        { ".markmap", ".mm", ".mm.md", ".mm.mdown", ".mm.markdown", ".mm.rmd" };

    internal static readonly string[] DocumentExtensions =
        { ".md", ".markdown", ".mdown", ".mkd", ".mdwn", ".mdtxt", ".mdtext", ".mdx", ".mdc" };

    public int Priority => 1;

    public bool CanHandle(string path) =>
        !Directory.Exists(path) && (IsMarkmap(path) || IsDocument(path));

    public void View()
    {
        var content = MarkmapFileReader.Read(_path);
        var title = Path.GetFileNameWithoutExtension(_path);
        var dark = OSThemeHelper.AppsUseDarkTheme();

        if (IsMarkmap(_path))
        {
            var view = new MarkmapView();
            view.ApplyTheme(dark);
            view.Load(content, title);
            context.ViewerContent = view;      // 思维导图
        }
        else
        {
            var view = new MarkdownDocumentView();
            view.ApplyTheme(dark);
            view.Load(content, title, Path.GetDirectoryName(_path));
            context.ViewerContent = view;      // Markdown 文档
        }
    }
}

注意扩展名顺序:.mm.md 这种双扩展名先按 .mm. 匹配,所以 story.mm.md 一定是导图;想让某个 .md 按导图看,改名成 .mm.md 就行。

三、工程骨架:两块互不污染的代码

src/QuickLook.Plugin.MarkmapViewer/
├── Plugin.cs                     # 只此一个文件引用 QuickLook.Common
└── Markmap/
    ├── MarkmapModel.cs           # 节点模型、颜色索引
    ├── MarkmapParser.cs          # Markdown → 树(标题、列表、front matter)
    ├── MarkmapLayout.cs          # tidy tree 布局,与 markmap 数值对齐
    ├── MarkmapText.cs            # 文本度量(含 CJK 宽度)
    ├── MarkmapTheme.cs           # 导图配色
    ├── MarkmapView.cs            # DrawingContext 绘制 + 交互
    ├── MarkdownBlocks.cs         # Markdown 块级解析(文档模式)
    ├── CodeHighlighter.cs        # 行级语法高亮(文档模式)
    └── MarkdownDocumentView.cs   # FlowDocument 排版 + 主题

一条纪律:除了 Plugin.cs,任何文件都不许引用 QuickLook.Common。原因是验证工具链要把渲染器源码单独编译成一个控制台程序跑离屏渲染,一旦某个文件引用了 IViewer,工具链就编译不过。这条纪律从一开始就省下大量时间。

四、导图模式:解析 → 布局 → DrawingContext

1. 解析:只把 markmap 认为的「节点」变成节点

markmap 并不把每个段落都变成节点,官方 selector 是 h1-h6, ul, ol, li, table, pre, p > img:only-child。我们照抄这个语义:标题按层级建树,列表靠缩进决定父子,围栏代码块整块作为一个节点,行内样式(粗体、斜体、代码、链接)解析成带样式的文本片段。

几个容易忽略的细节:

  • front matter 里的 title 优先作为根节点文本,否则用文件名(markmap 会留一个空根节点,这里补上);
  • 没有唯一顶层标题时不能让根节点叫 markmap,否则每个文档长得都一样;
  • 纯 HTML 行、空行要过滤(PruneEmpty),否则树里挂一串空节点;
  • 中文宽度按全角 1em 计算,否则中英混排的换行位置会很怪。

2. 布局:把 markmap 的常量抄准

// 与 markmap 对齐的关键数值
lineWidth        = 1 + 3 / Math.Pow(2, depth);   // 根节点 2.5 px
spacingHorizontal = 80;
spacingVertical   = 5;                            // 同父兄弟之间
nodeHeight        = 20;                           // 行盒 16 px / 行高 20 px

// fit(): 留 5% 余量,且初始缩放不超过 2 倍
scale = Math.Min(W / naturalWidth, H / naturalHeight) * 0.95;
scale = Math.Min(scale, 2.0);

布局本身是一遍自顶向下的 tidy tree:先递归算出每棵子树的宽度,再按 spacingVertical 堆叠兄弟节点,父节点垂直居中于首尾子节点之间。连线用三次贝塞尔,控制点取两端的中点:

M sx,sy C mid,sy mid,ty tx,ty      // mid = (sx + tx) / 2,描边宽度取子节点线宽

3. 交互:跟原版一致的手感

操作 行为 数值
拖动 按住左键平移 无惯性,跟随鼠标
缩放 滚轮,以光标为锚点 每格 2^0.2(与 d3-zoom 一致)
折叠 单击节点文字或右侧圆点 500 ms easeCubicInOut
适应窗口 双击画布 / 右键 fit()

圆点沿用 markmap 原样式:半径 6 px、描边 1.5 px,仅非叶子节点绘制,展开时空心、折叠时填充分支色。

4. 配色:DFS 先序取 schemeCategory10

这里踩过一个理解错误:我一开始以为是「每个一级分支一种颜色」,于是给每个分支算 BranchIndex。实际上 markmap 是按深度优先先序遍历依次取 d3 schemeCategory10 的下一个颜色,根节点拿 0 号色;markmap.colorFreezeLevel: N 时在该层冻结游标,更深的节点继承祖先颜色。

// MarkmapParser.Finalize 里的一行,决定了整张图的颜色
if (freezeLevel <= 0 || node.Depth < freezeLevel)
    node.ColorIndex = colorIndex++;
else
    node.ColorIndex = node.Parent.ColorIndex;

五、怎么证明「跟原版一样」:拿真值样本逐项比对

「看起来差不多」不算对齐。我的做法是先在 docs/markmap-rendering-spec.md 里把上游每条规则连 file:line 一起抄下来,再准备一组可机器比对的真值样本:

  • docs/reference/sample.svg、sample-plain.svg:markmap 真实输出的 SVG;
  • docs/reference/sample-layout.json、sample-tree.json:同一份输入的节点几何与树结构;
  • docs/reference/transform-cases.json:解析层的边界用例。

于是每次改完渲染都能做差分回归:我的 --dump 输出每个节点的 depth/x/y/width/height/radius/folded/color/text,和 JSON 真值逐节点比。对不上的地方必须二选一:改代码,或者写进 README 的「与原版插件的区别」并说明理由(比如「段落节点会被保留」这种有意偏离)。

六、两次返工:普通 .md 到底该画成什么样

这是整个项目里最有价值的两条教训。

第一次:我把 .md 排除在外了

当时的想法是「别抢别人的地盘,只接管 .mm*」,于是扩展名白名单里没有 .md。用户预览 README.md 时,QuickLook 落到了 WebView2 版插件上,弹出「查看此文件需要安装 Microsoft Edge WebView2」,直接来问:

不支持 .md 的 markdown 文本吗

根因判断错了:被替代的那个渲染器在缺 WebView2 时只会报错,不会优雅退化,所以「不接管」不等于「用户看到更好的原版渲染」,而是「用户看到错误页」。修正很简单:扩展名集合与上游对齐,Priority 保持 1。

第二次:接管了,但观感不对

.md 能预览之后,我把它也画成了思维导图 —— 段落成了节点,画布被拉得很宽。用户贴了一张 GitHub 风格网页的截图回来说:

.md 文档不以图的形式展示,将其渲染成 web 形式展示,比如这样

这次不是「参数没调好」,是渲染路线选错了。正确做法是换实现,而不是在导图渲染器上继续微调:

  • .mm* 继续走导图(它本来就是要看结构的);
  • .md* 换成文档排版:块级解析 + WPF FlowDocument 单列居中,标题分六级、列表缩进、引用竖线、表格、语法高亮代码块、图片。

两条教训合起来就是一句话:「能渲染」不等于「该那样渲染」。渲染方式的取舍属于需求,不属于实现细节。

七、文档模式:不用浏览器内核也能像网页

1. 为什么是 FlowDocument

候选方案有三个:

方案 结论
HTML + WebView2 直接违反需求
HTML → XAML 转换 需要一套 HTML 解析 + 转换层,XAML 对 HTML 语义映射很难做全,等于自己再写一个浏览器
自己的块级解析 + FlowDocument 选择项:WPF 原生,选中/复制/滚动全都现成

代价是排版得自己算:FlowDocument 只提供流式段落和表格,没有「代码块」「引用条」这种块,需要自己用 BlockUIContainer、Border、Table 拼出来。

2. 块级解析器

MarkdownBlocks.cs 把文本切成块(约 330 行):标题(含 Setext 的 === / --- 下划线写法)、段落(软换行合并)、引用(连续行合并)、有序/无序/多级列表、任务列表、表格、分隔线、围栏代码块,另外跳过 front matter。

3. 语法高亮:自己写个行级扫描器

不许用浏览器,也不引第三方库,那就自己扫(约 440 行):按行识别注释、字符串、数字、关键字、标签、属性,语言覆盖 C#、JS/TS、Python、Shell、PowerShell、JSON、XML/HTML、CSS、SQL、Go、Rust、Java、C/C++、YAML/INI;未知语言与无语言围栏一律退化成纯文本(GitHub 也是这个行为)。

public static IList<CodeToken> Highlight(string line, string language)
{
    // 关键字表分大小写:SQL、CSS 用 OrdinalIgnoreCase,其余用 Ordinal
    var profile = Profile(language);
    if (profile == null) return new[] { new CodeToken(line, CodeTokenKind.Plain) };

    // 行级状态机:注释 > 字符串 > 数字 > 标识符 > 其它
    return Scan(line, profile);
}

4. 排版规格(最后的实际数值)

项目 值
正文 15 px,行高 24 px
标题 30 / 24 / 19 / 16 / 14.5 / 13.5 px,加粗,h1/h2 带 1 px 底线
内容宽度 最宽 900 px 居中,窗口更窄时左右各留 24 px
代码块 13 px、行高 20 px、圆角 6 px、块内横向滚动
列表 每级缩进 24 px,标记悬挂在左侧
表格 单元格 padding 10x6,列宽按内容分配
浅色 文字 #1f2328、代码底 #f6f8fa、边框 #d1d9e0、链接 #0969da
深色 背景 #27272a、文字 #e4e4e7、代码底 #202024、链接 #58a6ff

5. 三个必须自己处理的排版细节

代码块横向滚动:BlockUIContainer 里放 Border → ScrollViewer(Horizontal=Auto) → TextBlock(NoWrap)。这样长行只在块内滚动,不会把正文撑宽。

表格列宽:FlowDocument 的 Table 不给宽度时会平均分配列宽,而 GitHub 是按内容。做法是用 FormattedText 逐单元格量宽算出 natural[],窗口变化时按 available / total 等比压缩,保证不越界。

图片:独占一段的 ![](相对路径) 或 <img src="..." width="..."> 变成 BlockUIContainer + Image;表格单元格里的图片要把 BlockUIContainer 塞进 TableCell.Blocks,并且给单元格图片限宽 360 px,否则会把列撑爆。远程图片不联网,退化成替代文本。

八、验证工具链:不装 QuickLook 也能回归

这是整个项目里投入产出比最高的部分:一套离屏渲染 + 一个真实插件加载器。

工具 作用 关键参数
tests/RenderHarness 离屏渲染成 PNG,或打印几何/布局 --dump --layout --full --dark --doc --wheel
tests/PluginHarness 加载真实 DLL,跑 CanHandle/Prepare/View +file / -file,--out=png
# 思维导图:出图 / 节点几何 / 折叠深度
RenderHarness tests/samples/showcase.mm out.png 1000 700 --dark
RenderHarness tests/samples/showcase.mm out.png 1000 700 --dump
RenderHarness tests/samples/showcase.mm out.png 1200 800 --fold-depth=3

# 文档模式:出图 / 整页高度 / 每块的坐标与换行
RenderHarness README.md doc.png 1000 720 --doc
RenderHarness README.md doc.png 1000 720 --doc --full
RenderHarness README.md - 1000 720 --doc --layout

# 滚轮路由:光标在代码块/图片上时滚轮是否仍滚动整页
RenderHarness tests/samples/document-showcase.md - 1000 720 --doc --wheel

# 单元测试(解析、高亮、滚轮路由)
RenderHarness --selftest

其中 --layout 是最值回票价的开关:它遍历 FlowDocument.Blocks,打印每个块的类型、Margin、ContentStart/ContentEnd 的字符矩形,并按 Y 分组还原每一行的文本与起始 X。缩进对不对、有没有越界、表格列在哪,一眼就能看出来。

九、踩过的坑,以及怎么爬出来

问题1:程序一崩只有一句「由于 Exception.ToString() 失败」

现象: 离屏渲染器退出码 3762504530(0xE0434352),日志只有「由于 Exception.ToString() 失败,因此无法打印异常字符串」,看起来像渲染器炸了。

原因: 用 csc 直接编译的 exe 没有本地化/资源程序集,任何未处理异常都只能打印这句话。真实原因往往很蠢:我那次是输入路径写错(.tmp/x.md 与 tests/samples/x.md 搞混),文件根本不存在。

解决: 给工具加输入守卫,先验证路径与参数再看实现:

if (!File.Exists(input))
{
    Console.Error.WriteLine("input file not found: " + Path.GetFullPath(input));
    return 3;
}

问题2:覆盖已安装的插件 DLL 报 EBUSY

现象: 每次更新插件都要报「文件正在被另一进程使用」。

原因: QuickLook 常驻进程已经把 DLL 加载了。

解决: 走「停 → 覆盖 → 起」三步,并用哈希确认真的换上了(Copy-Item 会保留源文件时间戳,别拿时间判断新旧):

Stop-Process QuickLook -Force
Copy-Item .\Build\Release\QuickLook.Plugin\QuickLook.Plugin.MarkmapViewer\* `
          "$env:APPDATA\pooi.moe\QuickLook\QuickLook.Plugin\QuickLook.Plugin.MarkmapViewer\" -Force
Start-Process D:\APP\QuickLook\QuickLook.exe

# 校验:构建产物与已安装文件的 SHA256 必须一致
(Get-FileHash ...\QuickLook.Plugin.MarkmapViewer.dll -Algorithm SHA256).Hash

问题3:列表「看起来没缩进」

原因: WPF 的 List 会把标记挂在内容盒左侧,Padding = 0 时标记直接贴到页边,视觉上等于没缩进;ListItem 的构造函数也只接受 Paragraph。

解决: 每层 Padding.Left = 24、MarkerOffset = 10,标记落在正文左侧 15 px、正文每级 +24 px;嵌套列表挂到 item.Blocks,因为 ListItemCollection 不支持索引,层级栈里自己存 LastItem。

问题4:900 px 的最大宽度会失效

现象: 窗口拉宽时正文没有保持在 900 px,而是铺满了整个窗口。

原因: FlowDocument 靠 PagePadding 和 ColumnWidth 一起把内容挤成居中的一列。我只在「内容宽度变化」时更新 PagePadding,当内容宽度已经卡在 900、而左右留白还在变时直接 return 了,于是 WPF 把唯一的那一列拉伸到可用宽度。

解决: 同时记录内容宽度与左右留白,任一变化都重算:

if (Math.Abs(content - _contentWidth) < 0.5 && Math.Abs(side - _sidePadding) < 0.5) return;
_contentWidth = content;
_sidePadding = side;

这个 bug 是靠 --layout 发现的:1400 px 宽时某段的 ContentEnd.X = 1333,而内容列本该在 1150 结束。

问题5:滚轮滑到代码块上,整页就「卡住」

现象: 用户反馈「鼠标滚轮滑动到代码块时不太顺畅」。实际是滚动到代码块上整页停住,指针挪出去才恢复。

原因: 代码块是文档里被托管的 UI(BlockUIContainer → Border → ScrollViewer)。WPF 派发滚轮时先给命中的元素及其祖先,代码块自己的 ScrollViewer 先吃掉事件;它只能横向滚动,滚不动也不上抛,外层 FlowDocumentScrollViewer 永远收不到。图片同理。

解决: 给每个被托管的块挂 PreviewMouseWheel(隧道阶段,早于块内 ScrollViewer),把垂直滚动交还给整页:

private void OnHostedWheel(ScrollViewer blockScroller, MouseWheelEventArgs e)
{
    if (e.Delta == 0) return;
    if ((Keyboard.Modifiers & ModifierKeys.Control) != 0) return;   // Ctrl+滚轮放行给 WPF

    // Shift+滚轮:横向滚动代码块本身
    if (blockScroller != null &&
        (Keyboard.Modifiers & ModifierKeys.Shift) != 0 &&
        blockScroller.ScrollableWidth > 0)
    {
        blockScroller.ScrollToHorizontalOffset(blockScroller.HorizontalOffset - e.Delta / 120.0 * step);
        e.Handled = true;
        return;
    }

    // 普通滚轮:交还给文档,格数按 delta/120 等比换算
    e.Handled = true;
    document.ScrollToVerticalOffset(document.VerticalOffset + lines * 16.0 * Math.Abs(e.Delta) / 120.0);
}

一格(120)走 48 px,和 WPF 对正文的处理完全一致;触控板的小 delta 按比例换算,不会一次跳三行。

问题6:别用「注入真实鼠标」来验证界面

踩坑: 为了复现滚轮问题,我写了个小窗口,用 SetCursorPos + mouse_event 注入真实滚轮消息,再读滚动偏移。结果极不稳定:第一条消息经常丢,同一位置扫一遍得到过「整轮全 0」和「0/16/48 乱跳」,差点得出和事实相反的结论。

正解: 在离屏视图上直接抛 PreviewMouseWheel,然后 UpdateLayout() 再读偏移 —— 确定性、毫秒级、可进单元测试:

var args = new MouseWheelEventArgs(Mouse.PrimaryDevice, Environment.TickCount, -120)
{
    RoutedEvent = UIElement.PreviewMouseWheelEvent,
    Source = codeText
};

codeText.RaiseEvent(args);
view.UpdateLayout();          // 关键:离屏树里偏移要布局提交后才更新
Assert(document.VerticalOffset > before);

同一件事还有两个变体,都是坑:合成冒泡版 MouseWheelEvent 一定会 Handled = true 却不滚动(真正的滚动依赖真实命中状态);ScrollToVerticalOffset/LineDown 之后不 UpdateLayout() 读到的永远是旧偏移。

问题7:写代码时被模板字符串里的三反引号咬到

现象: 用脚本生成含 Markdown 围栏的 C# 文件时,报 Expected ";", "}" or <eof>,而且文件根本没写出来。

原因: 脚本里的模板字符串被正文中的三反引号提前闭合了。

解决: 先写入占位符(例如 @@F@@)再统一替换成三反引号;或者改用单引号字符串数组逐行拼,干脆用 (char)0x60 拼出反引号。改完回读文件确认占位符清零。

十、性能与体积

场景 结果
导图:约 3300 节点的 .mm 布局 + 首帧 1.5 s 内
文档:62 KB / 1113 块的极端 Markdown 构建整页约 0.6 s(含进程启动约 2 s)
常规 README(约 10 KB) 打开无感
交付体积 DLL 77 KB,.qlplugin 33 KB

文档模式的性能瓶颈在 FlowDocument 的构建(每行代码会拆成若干 Run),不在解析;真要优化,先考虑按块懒加载,而不是并行解析。

十一、常用命令速查表

命令 描述
dotnet build -c Release 构建插件 DLL
powershell -File scripts\pack-zip.ps1 打包成 .qlplugin
RenderHarness <file> out.png 1000 720 --dump 输出节点几何 JSON
RenderHarness <file> - 1000 720 --doc --layout 输出块坐标与逐行文本
RenderHarness <file> - 1000 720 --doc --wheel 校验滚轮路由
RenderHarness --selftest 跑单元测试
PluginHarness plugin.dll +file.md 走一遍 CanHandle/Prepare/View
Stop-Process QuickLook 更新插件前先停掉常驻进程
(Get-FileHash x.dll -Algorithm SHA256).Hash 确认安装的确实是新构建产物

十二、最佳实践建议

  1. 先读上游源码,再写代码。 QuickLook 的插件发现规则、markmap 的节点 selector,都能在上游源码里直接找到,猜出来的规则早晚会错。
  2. 把「与原版的差异」写进 README。 有意偏离(比如保留段落节点、MinScale 更小)必须列出理由,否则半年后自己都说不清哪边是 bug。
  3. 验证要能自动化。 用户能看到的都该有对应断言:几何走 --dump、排版走 --layout、事件走 --wheel、单元测试走 --selftest。
  4. 交付闭环到「装进 QuickLook 并重启」。 只构建不安装,等于没验证;安装后别忘了用哈希确认换的是新文件。
  5. 观感被否定时换路线,别微调。 用户说「不像文档」时,正确反应是换渲染方案,而不是再调几个 px。

总结

这个插件最终是一个 77 KB 的 DLL:没有 WebView2、没有 Node、没有临时文件,.mm* 看到的是与 markmap 对齐的思维导图,.md* 看到的是原生排版的网页式文档。

如果只能留下三条经验,我会选:扩展名先分流(同一份 Markdown 有两种合理呈现,别用一个渲染器硬扛)、几何与配色抄真值(有 SVG/JSON 真值就不要靠肉眼)、验证离屏化(能在控制台里跑的断言,不要留给人手去点)。

参考资料:

把 .qlplugin 拖进 QuickLook 预览窗口装好,之后随便挑一个 .mm 和一个 .md 按空格,就能看到这两种模式的区别。

输入关键词,搜索全站文章。

↑↓ 选择 Enter 打开 Esc 关闭