用 Rust 复刻了掘金的 Markdown 阅读体验,做了个纯阅读器

37 阅读10分钟

一句话:MD Reader 是一个用 Rust 写的本地 Markdown 阅读器——不做编辑,只把"读"这件事做到舒服。界面版式参考稀土掘金的 Markdown 预览。


一、为什么要写这个

先说我的真实使用场景,可能和你很像。

我平时写文档、记笔记都是 Markdown,但读这些文档的时候一直很别扭:我的做法是把 .md 的内容整段复制到稀土掘金的写文章页面,靠右边那个预览窗口来读。听起来很绕对吧?但那个预览区的排版确实好看——中英文混排舒服、标题层级清晰、代码块有高亮、表格不挤。

我也试过 VS Code。它能读 Markdown,功能也强,但界面是编辑器逻辑:左侧资源管理器、顶部面包屑、行号、源码符号、右侧小地图……这些在"写"的时候是助力,在"读"的时候全是干扰。通读一篇长文档时,我需要的是一块干净的"纸",而不是一个 IDE。

所以需求其实很朴素:

  1. 一个双击就能打开的本地 .md 阅读器;
  2. 排版要像掘金那样好看;
  3. 只做阅读——不要编辑、不要工作区、不要插件;
  4. 用 Rust 写。

最后一项一半是兴趣,一半是认真考虑过:Rust 编译出的是单个 exe,没有运行时依赖,启动快,内存占用小,适合这种常驻后台、偶尔切过去看一眼的小工具。

于是有了 MD Reader。目前 v0.1.0 已经发布,Windows 版是一个 3.9 MB 的单文件 exe,解压即用。


二、它长什么样,能做什么

阅读体验

  • 掘金风格排版:居中的白色"纸张"、灰底背景;标题带分隔线、引用带左侧色条、表格有表头底色、任务列表是可爱的圆角复选框。字体栈针对中英文混排做了处理(PingFang SC / Microsoft YaHei 优先)。
  • 滚动目录:自动抽取 H1–H4,滚动时高亮当前章节,可按标题过滤,点击平滑跳转。
  • 代码高亮:syntect 支持 200+ 语言,亮 / 暗两套配色,可选行号,右上角一键复制。
  • 阅读进度:顶部一条细进度条;每篇文档记住你读到哪,下次打开自动回到那个位置。
  • 专注模式:Ctrl+Shift+F 把顶栏和侧栏全藏起来,屏幕上只剩那张"纸"。
  • 统计信息:标题栏直接显示 792 字 · 约 2 分钟 · 7 节 · 2 段代码 · 1 张图。

文档库:把"读过的东西"管起来

这是我后来才想清楚的一个点。写完第一版后我习惯性地把"最近打开"扔在右上角的下拉菜单里——用起来才发现根本想不起来点。后来看了看 Obsidian、VS Code、Kindle 的做法,它们的共同点是:读过的东西是一个常驻的书架,带进度和时间。

所以第二版把它挪到了侧边栏,做成"文档库":

  • 继续阅读:按最近阅读排序,每条右侧一个 16px 的小圆环显示进度(悬停才变清晰,不抢视线)、相对时间(刚刚 / 3 分钟前 / 2 天前)、预计读完时长
  • 固定:常用文档加星置顶
  • 移除 / 清空:只出库,不动磁盘文件
  • Ctrl+P 快速切换:模糊搜索已读文档 + 当前文件夹里的文档,↑↓ 选择、Enter 打开

输出:写作者最可能用得上

  • Ctrl+Shift+C 复制为富文本:把渲染结果(含代码高亮配色)以 CF_HTML 写进剪贴板,同时写一份 Markdown 源码作为纯文本。直接粘到掘金编辑器里,标题、表格、代码高亮都在——这正好解决了我最初"把 md 复制到掘金"的绕路问题。
  • Ctrl+S 导出自带样式的独立 HTML
  • Ctrl+Shift+P 打印 / 导出 PDF

一些"应该有的"细节

  • 双击打开:设置里一键关联 .md / .markdown(写 HKCU,不需要管理员权限)
  • 单实例:再启动一次会把文件交给已运行的窗口,不会开第二个
  • GBK 兼容:自动识别 UTF-8 / UTF-16 / BOM,UTF-8 解析失败时回退 GBK、Big5——中文 Windows 上的老文档不会整篇乱码
  • 安全:默认开启 HTML 消毒,过滤脚本与事件属性;本地图片只允许加载已打开目录下的文件

三、下载与使用

下载

Windows 用户直接下这个 zip:

https://gitee.com/zlin151/md_reader/releases/download/v0.1.0/md_reader-v0.1.0-windows-x64.zip

解压后双击 md_reader.exe 就能用,不需要安装,也不写注册表(除非你主动点"关联 .md 文件")。

依赖:Windows 10/11 自带 WebView2。如果是较老的 Windows,先装 WebView2 Runtime。

其他平台目前需要自己编译(cargo build --release),CI 里有 macOS 和 Linux 的构建配置,但还没在真机上完整验证过,欢迎试。

打开文档的四种方式

  1. 双击 .md(先关联)→ 直接打开
  2. 把文件拖进窗口
  3. md_reader 文档.md 命令行
  4. Ctrl+O 或 Ctrl+P 快速切换

快捷键

快捷键功能
Ctrl + O打开文件
Ctrl + P快速切换文档
Ctrl + F页内查找
Ctrl + R重新加载(编辑器里改完自动刷新也行)
Ctrl + T切换亮色 / 暗色
Ctrl + B显示 / 隐藏侧边栏
Ctrl + Shift + F专注阅读
Ctrl + Shift + C复制为富文本
Ctrl + S导出 HTML
Ctrl + Shift + P打印 / 导出 PDF
Ctrl + PageUp / PageDown上一篇 / 下一篇(同目录)
Alt + ←返回上一个跳转位置

四、技术选型

领域选择理由
窗口tao跨平台,与 wry 同源
WebViewwry只想要 WebView + IPC,Tauri 整套打包与插件体系太重
Markdownpulldown-cmarkCommonMark 完整,事件流便于中途改写
代码高亮syntectSublime 语法与主题生态,配色质量好
HTML 消毒ammoniaRust 生态最成熟的白名单清洗
文件监听notify跨平台统一
编码encoding_rsFirefox 同款,GBK / Big5 支持完善
剪贴板arboard支持 CF_HTML 富文本
文件对话框rfd系统原生,无 GTK 依赖

一个刻意的决定:前端不引入任何构建工具。整个 UI 就是 index.html / style.css / app.js 三个手写文件,用 include_str! 编译进二进制,运行时通过自定义协议提供给 WebView。没有 npm、没有打包器、没有框架。好处是构建快、产物小、改完即生效;代价是要手写 DOM 操作,不过这个 UI 的复杂度完全撑得住。

最终产物:Rust 侧约 3200 行,前端约 2700 行,单个 exe 3.9 MB。


五、架构

┌──────────────────────────────────────────────┐
│ 前端(assets/,编译进二进制)                 │
│   index.html · style.css · app.js            │
└──────▲───────────────────────────▲───────────┘
       │ ipc.postMessage           │ evaluate_script
┌──────┴───────────────────────────┴───────────┐
│ 宿主(Rust)                                  │
│   main.rs     窗口 / WebView / 协议 / 事件循环 │
│   app.rs      状态机:打开、渲染、设置、导出…  │
│   markdown.rs 渲染引擎                        │
│   config.rs   配置与文档库                    │
│   辅助:encoding / sanitize / scan / instance │
│        / assoc / clipboard / logging / cli    │
└──────────────────────────────────────────────┘

打开一篇文档的完整链路:

双击 / 拖拽 / Ctrl+O
   → UserEvent::OpenPath
   → 路径规范化 → 编码探测(BOM → UTF-8 → GBK → Big5)
   → markdown::render(解析 + 代码高亮 + 目录 + 消毒)
   → 记入文档库 → 监听所在目录
   → evaluate_script("MDReader.renderDoc(...)")
   → 前端注入 HTML、构建目录、恢复上次阅读位置

反向:前端用 window.ipc.postMessage(JSON) 发命令,Rust 端转成 UserEvent::Command 交给 app.handle()。所有逻辑都在事件循环线程上跑,webview 不需要跨线程共享——这一点省掉了大量加锁的麻烦。


六、几个值得说的实现细节

1. 前端资源编译进 exe

assets/ 三个文件用 include_str! 嵌入,运行时由自定义协议 wry://localhost/... 提供。所以发布产物只有一个 exe,不用附带任何文件。

这里踩了个坑,值得记一下:Windows 上 WebView2 不支持自定义 URL scheme,wry 会悄悄把 wry://localhost/index.html 改写成 http://wry.localhost/index.html。我一开始写的导航处理器判断"以 http 开头就是外链",结果把自己的首页当成外链拦掉了——表现是页面白屏,只有 index.html 被请求,CSS 和 JS 都没加载。

排查方式是让程序每隔一段时间用 evaluate_script_with_callback 回传前端状态(后来干脆固化成 MD_READER_PROBE=1 的自检开关)。修法是:

fn is_internal(url: &str) -> bool {
    match url.split_once("://") {
        Some(("wry", _)) => true,
        Some((_, rest)) => rest.starts_with("wry.localhost"),  // Windows 上的改写形态
        None => false,
    }
}

顺带一个推论:文档里生成的本地图片链接不能写死 wry:// 前缀(在 Windows 上命中不了拦截器),统一用相对地址 /img/?p=... 最稳。

2. 渲染引擎:在事件流上做改写

没有直接用 pulldown_cmark::html::push_html 一把梭,而是先把事件收集成 Vec<Event>,再在三个点做改写:

  • 标题:先取出内部文本 → 生成稳定 slug(保留中文,如 标题一)→ 注入 id 与锚点 → 收集进目录
  • 代码块:拦截 CodeBlock 起止事件,把内部文本交给 syntect,包成带语言标签和复制按钮的结构
  • 链接 / 图片:指向本地 .md 的链接改写成内部地址(点击在阅读器里直接打开);本地图片改写成 /img/?p=<绝对路径>

最后按配置决定是否过一遍 ammonia。

3. 高亮结果缓存

主题切换、文件热重载都会触发重新渲染,如果每次都重跑 syntect,几 MB 的文档会明显卡。所以按 (主题, 行号, 代码哈希, 语言) 缓存高亮结果,超过 256 条就清空。切换主题时基本是瞬时的。

4. 编码探测

BOM(UTF-8 / UTF-16LE / UTF-16BE)
  → 严格 UTF-8 能解?
  → 否则 GBK
  → 否则 Big5
  → 最后才 lossy

顺序很重要:GBK 的中文在 UTF-8 下几乎必然解析失败,反之 UTF-8 中文用 GBK 解会变成一堆乱码字。先试严格 UTF-8 能同时兼顾正确性和兼容性。

5. 单实例

%APPDATA%/md_reader/instance.lock 当心跳文件,主实例每 2 秒更新一次时间戳。第二个实例发现心跳存活(6 秒内有更新)就把文件路径追加到 pending_open.txt 然后退出;主实例监听这个文件并打开。锁陈旧则抢占。没有用 socket 或命名管道,因为只需要传一个路径,文件够用了。

6. 图标是代码画出来的

不想引二进制资源文件,就在 src/icon.rs 里用几何运算画了一个圆角卡片 + 三行文本的图标(3×3 超采样抗锯齿),生成 16/32/48/256 四尺寸的 ICO。build.rs 用 include! 复用同一份代码,再调 rc.exe 嵌进 exe 并写入版本信息资源;找不到资源编译器就跳过,不影响正常编译。


七、项目结构

src/
  main.rs        窗口 + WebView + 自定义协议(资源与图片白名单)+ IPC
  app.rs         状态机:打开、渲染、设置、导出、搜索、前后篇
  markdown.rs    渲染引擎:锚点、目录、高亮(缓存)、行号
  config.rs      配置与文档库持久化
  encoding.rs    编码探测     sanitize.rs  HTML 消毒
  scan.rs        文件树与全文搜索
  instance.rs    单实例        assoc.rs    文件关联
  clipboard.rs   富文本复制    logging.rs  日志
  cli.rs         命令行        icon.rs     图标绘制
assets/          index.html / style.css / app.js

数据与配置都放在 %APPDATA%/md_reader/:config.json(含文档库与阅读进度)、logs/app.log、单实例锁文件。


八、还没做的

坦诚列一下当前的限制:

  • 一次只读一篇,没有多标签和并排视图
  • 不支持数学公式与 Mermaid 图
  • Linux 依赖写进了 CI,但没在真机上验证过
  • 自动更新只是打开发行版页面,不做下载安装

排在后面的计划:双列 / 分页阅读、摘录与批注(选中即存、可导出,让"读过的东西"真正沉淀下来)、按标题跳转(上一节 / 下一节)、护眼纸色主题、自动滚动。


九、最后

如果你也有"写好的 Markdown 要找个舒服地方通读一遍"的需求,可以试试:

代码是 MIT 许可,随便看、随便改。有用的话给个 Star,遇到 bug 或者想要什么功能,直接提 Issue 就好——尤其是"排版哪里不好看"这种反馈,我最需要。