自包含Markdown编辑器:图片样式脚本全内嵌,一个文件搞定文档交付
写 Markdown 也有七八年了从最早的 Sublime 插件一路用到 Typora、Obsidian工具换了一茬又一茬但有一个问题始终绕不开文档里插了几张图、调了几处样式回头要发给别人或者换个设备打开不是图片裂了就是样式全没。这也是我做Markdown这个自包含 Markdown 编辑器的直接原因——目标很简单就是让一篇文档变成一个文件图片、样式、甚至脚本全都装进同一个文件里拿出去就能看怎么传都不碎。这篇文章就把这个项目的设计思路、核心实现和实操过程完整拆开讲一遍适合被“资源丢失”坑过的 Markdown 深度用户也适合想自己写一个自包含文档工具的同学参考。1. 自包含的核心价值从“一堆文件”到“一个文件”1.1 传统 Markdown 工作流的隐患用过 Markdown 的人都知道它本质上是纯文本图片、附件统统靠路径引用。本地写的时候一切正常因为图片就在你电脑那个相对路径下躺着。可一旦你想把文档发给别人、上传到内部 Wiki或者拷贝到另一台机器上继续编辑问题就来了图片路径是./images/xxx.png对方打开文档时图片根本不存在直接显示一个破裂的图片图标。在线图床的链接有时能打开但万一图床域名变更、图片被删文档就永久残缺了。引用的是本地绝对路径/Users/me/Pictures/xxx.png换台电脑直接失效这几乎是跨设备协作的噩梦。我统计过自己过去三年的 Markdown 笔记带图片的文档占比超过六成。也就是说如果不做自包含处理我写的每三篇技术文档里就有两篇在“离开我的电脑”后会缺胳膊少腿。这个问题对程序员来说尤其致命——技术方案的 Markdown 文档经常要贴架构图、流程图、运行截图图一丢整篇文档的可信度就没了。1.2 自包含解决的到底是什么自包含self-contained这个概念简单说就是文档不再依赖任何外部资源。图片、样式、脚本全部以数据形式内嵌进文档内部最终产出的是一个独立的、完整的文件实体。这个思路其实在 Web 领域早就有了最常见的就是将一个 HTML 页面连同 CSS 和 JavaScript 全部内联到一个.html文件里。打开这个文件哪怕是在完全离线的环境页面照样能完整渲染。Markdown做的就是把这件事搬到 Markdown 文档工作流里图片不再引用外部路径而是以 Base64 编码直接嵌入文档。样式不再依赖外部 CSS全部以style标签内联。渲染逻辑所需的 JavaScript 一并打进最终文件。整个文档从“多文件项目”变成“单文件产品”。这么做带来的收益是肉眼可见的一是分享变得极其简单一个文件通过 IM、邮件、U盘拷来拷去永远不缺资源二是离线可用性大幅提升不需要联网加载 CDN 资源三是长期存档的安全感强只要文件还在文档就永远是完整的。2. 从标题拆解需求Markdown 到底要做什么2.1 “自包含”不是新概念难在怎么做得顺自包含文档不是没人做过Pandoc 的--self-contained参数就能把 Markdown 转成单文件 HTML静态站点生成器也可以把样式内联。但这些工具解决的都是“发布”场景对于写作过程中的体验帮助有限。你写文档的时候还是得维护一堆图片文件还是得操心资源引用路径到了最后导出环节才会把资源内嵌进去。而Markdown的设计目标是把“自包含”这个概念延伸到整个编辑过程里。它不是一个单纯的导出发布工具而是一个完整的 Markdown 编辑器——你在里面写文章、插图片、调样式编辑过程中所有资源就已经自动内嵌到文档内部了。写完即得一个自包含的文件不需要额外的“导出”心智负担。这个“编辑器原生的自包含”是我最看重的点。就好比你在 Word 里截图粘贴图片图片就直接成为文档的一部分你怎么转发都不会丢。Markdown 生态里一直缺少这种体验大家默认接受了“图片需要单独存”的设定甚至用各种图床、同步盘来弥补。实话实说这套方案我用了两年遇到图床失效的次数至少五次每次都要花半天去抢救文档。自包含编辑器能从根本上杜绝这种问题。2.2 目标用户与典型使用场景Markdown 适合以下几类场景技术文档撰写者编写的架构文档、接口文档经常包含大量架构图和算法流程图需要分享给团队成员或外部协作方保证图片不丢是最基本的需求。知识管理爱好者本地笔记里嵌套了大量截图、照片和剪藏内容希望以一篇文章为单位打包存档而不是依赖某个特定软件的私有格式。企业内部文档协作很多企业内部不能用公网图床文档要完整传递要么走内部图片服务器要么就用单文件自包含方案后者的维护成本显然低得多。说白了只要你有“写完一篇 Markdown 之后希望干干净净地把它交付出去”的需求Markdown 的思路就适合你。3. 核心实现拆解一个文件是怎么装下整篇文档的3.1 图片资源的内嵌Base64 的取舍图片是 Markdown 文档里最常见的资源类型所以先说图片。把图片内嵌进文件通常的做法是把二进制数据转成 Base64 编码然后以 data URI 的形式嵌入到 HTML 或者 Markdown 引用里。一个基础的数据形态长这样img srcdata:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg这个方案的优点很直接标准、通用、任何浏览器和 Markdown 渲染引擎都认识 data URI不需要额外写解析逻辑。缺点也很明显——Base64 编码会让图片体积膨胀约 33%一张 1MB 的 PNG 在文档里会变成 1.33MB 的文本。如果文档里嵌了二三十张大图整个文件会迅速膨胀到几十 MB这对轻量级 Markdown 的定位是个考验。我实测过一个包含 12 张高清截图的文档纯 Markdown 源文件只有 18KB自包含后的 HTML 文件达到了 9.6MB。说实话这个体积还能接受毕竟分享起来就是一封邮件的事但如果你经常处理很大的图片就有必要在嵌入前做一次压缩。Markdown 的自动驾驶方案是超过 500KB 的图片自动调用 Canvas 进行尺寸缩放和 WebP 重编码合并压缩率通常在 60% 以上实测能把上面的 9.6MB 压到 2.3MB画质肉眼看不太出来。3.2 非图片资源的处理策略文档里除了图片往往还有样式和脚本资源这两类资源刚好能被 HTML 的style和script标签原生承载所以处理起来更直接。Markdown 渲染后的 HTML 里会引用 CSS 文件比如代码高亮主题、JavaScript 文件比如公式渲染引擎、流程图绘制脚本要让文档自包含就得把这些外部文件的内容读出来写入内联标签。这里有一个关键的工程决策是“全量内联”还是“按需内联”最笨的方案是把所有可能用到的 CSS 和 JS 一股脑内联进去这样写起来省事但代价是文件冗余度极高。比如你根本没写代码块却把一整套代码高亮主题的 100KB CSS 带上了。我的做法是写一个简单的静态分析器先扫描 Markdown 渲染产物里实际用到的元素类型和类名再只内联对应的样式规则。公式和代码高亮的触发条件是扫描到$...$或标记流程图则是检测到graph TD之类的关键字命中才注入对应渲染脚本。这套逻辑听起来复杂写起来大概也就两百行代码。3.3 最终产物的结构解剖一个由 Markdown 生成的自包含文档内部结构其实非常清晰打开开发者工具可以看到它的骨架!DOCTYPE html html langzh-CN head meta charsetUTF-8 title我的技术方案文档/title style /* 所有样式内联在此处包括主题、代码高亮、打印媒体查询 */ /style /head body article classmarkdown-body !-- Markdown 渲染产出的 HTML 内容 -- p这是一段示例文本。/p img srcdata:image/webp;base64,UklGR... alt系统架构图 precode classlanguage-js const result await api.fetchData(); /code/pre /article script /* 按需注入的功能脚本例如流程图渲染、公式渲染、代码高亮激活 */ /script /body /html这里面有一个值得注意的设计细节Markdown 正文内容全部放在article标签里和外层页面的 head、script 数据结构上就隔离了。这样做一个好处是当你需要把这个自包含文档再次导入 Markdown 的编辑器时程序可以很准确地定位到正文区域把article内的 HTML 逆向还原成 Markdown同时也保留了整个文件作为一个独立网页直接打开的能力。读写循环是闭环的这一点对编辑器体验来说很重要。3.4 编辑器形态为什么不像 Typora 那样沉浸式我第一版的原型长得很像 Typora也就是文档和渲染结果使用同一块区域渲染后直接编辑。但做到一半我遇到了一个很麻烦的问题当图片被转换成 Base64 后如果编辑时加载的是经过内嵌处理的 HTML下次保存时又要重新解析一次article的内容渲染流程绕了一圈状态管理和撤销重做的复杂度直接翻倍。最终我用了更保守但也更实用的方案——左右分栏。左侧是纯 Markdown 源码编辑区右侧是实时预览区。这样做的好处是用户随时能看到 Markdown 源码里被内嵌后的图片那一大串 Base64 长文本意识到资源确实在文件里同时右侧的预览效果和最终发布的单文件 HTML 产物完全一致不会出现所见非所得的情况。分栏式还有一个隐藏优势调试方便。预览区渲染出错时可以直接在控制台检查 HTML 结构定位是 Markdown 解析的问题还是资源内嵌的问题这个在后来的问题排查阶段帮了我很大的忙。4. 实操过程从原型到可用的自包含编辑器4.1 技术栈选型Markdown 的前端整体是基于 Electron Vue 3 构建的核心模块主要负责三件事Markdown 解析、编辑器状态管理、自包含资源的内嵌和还原。下面逐个说这些模块是怎么落地的。Markdown 解析我选的是markdown-it没有自己造轮子。它插件体系成熟扩展语法表格、任务列表、删除线开箱即用渲染速度也足够满足打字时的毫秒级预览需求。代码高亮用了highlight.js公式渲染接了KaTeX流程图和时序图用mermaid.js渲染成 SVG 后内嵌。这些在知名的 Markdown 编辑器里几乎是标配了这次没有重新发明。Electron 的选择是考虑到文件系统的读写能力和本地资源访问权限。浏览器里受沙箱限制读不了本地图片这套工具做纯 Web 版会有很多阻塞问题但是 Electron 加一层 Node 的桥接之后图片读取和 Base64 转换就是几行代码的事。当然这也带来了安装包偏大、内存占用偏高的代价权衡下来编辑器的便利性更重要。4.2 核心步骤图片从路径到内嵌的完整实现图片内嵌是实现自包含的关键链路我把完整的处理流程拆成五步这一步一步看下来应该是比较清晰的第一步捕获图片引用。在 Markdown 源码中匹配图片语法和 HTML 形式的img srcurl。这里要注意Markdown 的图片语法可能会被markdown-it的插件以不同方式渲染所以最稳的方式是在编辑原始文本上做一次预处理而不是等渲染完再解析。第二步判断资源类型。拿到 URL 后先判断它是网链、本地相对路径、绝对路径还是已经是 data URI。已经以data:开头的直接跳过本地路径和相对路径做后续处理网链则根据用户在设置里的偏好决定是下载内嵌还是保守保留外链。我在默认设置里是建议保留网链的——如果你引用的是一张外网公开图下载内嵌会增大文件体积也没有必要网链失效时再手动处理反而更灵活。第三步读取二进制内容。Node 环境下用fs.readFile把图片文件读成 Bufferimport fs from fs/promises; async function encodeImage(filePath) { const fileBuffer await fs.readFile(filePath); return data:image/${getMimeType(filePath)};base64,${fileBuffer.toString(base64)}; }第四步体积控制与压缩。拿到 Buffer 后判断大小超过阈值的图片先在 Electron 的离屏窗口中用 Canvas 解码后缩放再重编码。这里有一个关键点解码超大图片时 Canvas 有宽度和高度的上限不同的 Chromium 版本上限不同一般在 16384 像素左右所以要在加载图片前先读一下尺寸信息超了直接降采样再送入 Canvas。第五步替换引用并更新时间戳。将原文档里的路径字符串替换成生成的 data URI同时记录这份图片资源的元信息原路径、最终大小、压缩标记方便编辑器在“反向导入”时可以识别出哪些图片是被内嵌的避免重复嵌入导致文件继续膨胀。这五步走完之后一篇带图的 Markdown 文档就从“文本 多个图片文件”变成了“一份自带完整资源的文本”。4.3 样式与脚本内联的实现细节样式内联的思路和图片类似但有一个坑特别容易被忽略CSS 里可能还引用着字体文件或背景图片。如果你只是把 CSS 文本直接塞进style标签CSS 里的url()引用的字体、背景图依然指向外部路径文档仍然不是真正自包含的。所以在处理外部 CSS 文件时我额外写了一个扫描器把 CSS 里所有的url(...)取出来同样做一次 Base64 内嵌。对于字体文件我会用font-face加一个data:font/woff2;base64,...的 src 替换掉原来指向磁盘的路径对于背景图直接用图片内嵌流程处理就行。这套逻辑跑完后整个 CSS 就是一个没有外部依赖的纯文本可以安全地内联进 HTML。脚本内联的处理相对简单因为脚本通常没有额外资源引用。但有一个小细节需要注意如果内联的脚本里包含/script字符串HTML 解析器会直接把它当作标签结束符导致脚本被截断。处理方法是把转义成\u003c这一步一定不能忘。我第一次实测 mermaid 脚本内联时就因为这个原因踩了坑折腾了半天才反应过来是转义没做全。4.4 反向导入让单文件文档还能再编辑自包含文档的一个隐含需求是拿到了一个.html格式的自包含文档能不能把它再导回 Markdown 继续编辑这个流程我花了不少心思设计。反向导入的逻辑是读取 HTML 文件定位article标签的位置。把article内的 HTML 内容交给一个内联的 HTML 转 Markdown 工具我用的是turndown还原成 Markdown 文本。遍历还原后的文本扫描srcdata:image/...的引用为这些图片生成内部资源 ID。把资源 ID 对应的图片以临时文件形式存到当前编辑会话的资源目录里同时在 Markdown 里引用临时文件名保证编辑时能正常预览。这个设计的意义在于自包含文档不是“一次性导出”的死文件它是一个可以反复打开、编辑、再保存为新的自包含文件的活文档。我自己用这套流程处理过一批从旧系统迁移过来的技术文档几千字的方案文稿带十几张架构图整个过程不到三分钟就全部流转完成效率和完整度都可以接受。5. 常见问题与排查技巧实录5.1 生成的文档文件过大怎么办这是自包含方案被问得最多的问题。文件过大的根因基本都在图片上三条路可以选一是开启图片自动压缩让 Markdown 在嵌入前对超过阈值的图片做 WebP 重编码二是手动设置一个图片最大宽度超出宽度的图片在嵌入前自动等比缩放三是用“延迟嵌入”模式即编辑时保留图片路径引用只有执行“导出自包含文档”时才做内嵌。前两种方案适合日常写作第三种适合需要频繁编辑文档草稿的场景。我自己的推荐是组合使用日常编辑开启自动压缩导入图片时就把图片压到 1920px 宽度以内超过 600KB 的重编码为 WebP最后需要交付时再做一次总检查。这样一套组合拳下来含 20 张图的文档基本能控制在 5MB 以内分享完全没有压力。5.2 图片路径含中文或特殊字符怎么办这可能是 Windows 用户最容易踩的坑。Markdown 源码里的图片路径如果写的是在某些渲染引擎里中文字符没有做 URL 编码导致路径匹配混乱。我的解决方案是读取图片路径时统一走 Node 的path.resolve做绝对路径归一化然后调用encodeURI编码非 ASCII 字符再传给 fs API 读取。这样处理之后中文文件名、带空格的文件名、带#和?的文件名都不会再导致资源读取失败。5.3 为什么我转出来的 HTML 打开后样式全丢了排查路径很简单按顺序检查三件事。第一是不是在生成自包含文档后又手动修改了 HTML 的style标签部分破坏了内联样式和正文内容的匹配关系。第二Markdown 源码里是不是写了裸 HTML 标签并且这些标签强制引用了外部 CSS 文件。第三是不是代码高亮插件没有在导出时被正确内联——这种情况往往是因为页面加载时机的问题如果文档是在浏览器里直接打开而不是通过 Markdown 内置的预览窗口打开的某些脚本内联后没有正确执行。面向这类问题我习惯的做法是在 Markdown 里增加一个“文档健康检查”功能扫描自包含文件中的所有src、href、url()引用检测是否存在尚未内嵌的外部资源。健康检查通过后这个文件才算真正达到了自包含标准。养成每次导出后都跑一次健康检查的习惯基本能避免绝大多数交付事故。5.4 常见问题速查表问题现象可能原因解决方案导出的 HTML 图片打不开图片未被正确内嵌仍引用原始路径检查图片路径是否含特殊字符重新导入并执行内嵌文件体积过大图片未压缩或内嵌了过多大尺寸原图开启自动压缩限制图片宽度为 1920px使用 WebP 格式代码块没有高亮highlight.js 的 CSS 未内联或 JS 未执行检查文档是否包含标记重新导出并验证内联结果公式显示为原始代码KaTeX 脚本未注入或加载时机不对确认在导出时检测到$...$标记刷新预览后重新导出中文路径图片导入失败URL 编码未处理或被系统 locale 影响统一使用 Node path 处理路径对非 ASCII 字符执行 encodeURI反向导入后格式错乱turndown 对部分复杂表格转换失败检查是否为嵌套表格或合并单元格手动微调 Markdown 源码6. 写完项目后我的几点个人体会做 Markdown 的过程中我自己也被反向教育了一次。过去我总觉得 Markdown 的优势就是“纯文本 轻量”文件夹组织是最自然的状态。但实际做下来才发现对大多数用户来说文档的完整性和可分享性远比格式的灵活性重要。很多人其实根本不在乎源文件是不是纯文本他们在乎的是这篇文档能不能点开就能看、转了之后还是完整的。自包含理念本质上是一种“文档即产品”的思路它跳过了技术细节把交付体验放在第一位。现在我自己写技术方案、做知识梳理默认就开 Markdown。每次看到一篇带着十几张截图、完整排版的文档最终落成一个 3MB 的 HTML 文件发出去对方双击就能看到所有内容时还是会觉得这个项目做得值。如果你也受够了图片失效、路径断链的折磨不妨试试这个思路——不一定要用我的工具你也可以在现有的 Markdown 编辑器里加上一层自包含导出逻辑。工具是死的思路是活的让文档变成真正可以独当一面的产品这种体验真的会改变写作习惯。