Markdown 一行命令变 PPT,拆开 Marp CLI,发现导出的 PPTX 全是截图

发布于 · 2,754 字 · 约 7 分钟#Github 解读#CLI原文链接
Markdown 一行命令变 PPT,拆开 Marp CLI,发现导出的 PPTX 全是截图 封面图
  • Marp CLI 默认导出的 PPTX 每页是一张 2 倍分辨率的 PNG 贴图,仅演讲者备注为真实文本,需可编辑版本须走 LibreOffice 实验性路线
  • 架构为两段式管线:Node 内 Marpit 渲染 Markdown 为 HTML 与 CSS,再交给浏览器输出像素级成品,支持 PDF、PPTX、PNG 等格式
  • 放弃捆绑 Chromium 改用 puppeteer-core,通过三个 finder 自动查找系统浏览器,Chromium 走 CDP、Firefox 走 WebDriver BiDi,代价是渲染质量受浏览器版本绑定
  • 默认以 data URI 方式加载内容并拦截 file:// 请求,防范 HTML 中脚本与本地文件读取组合成的数据外泄通道,需显式开启 --allow-local-files
  • 项目 bus factor 为 1,yhatt 一人贡献 2388 次提交;适合用 Markdown 长期维护幻灯片,追求交互与实时代码演示则更宜选 Slidev

你大概用过那类工具,左边写 Markdown,右边实时出幻灯片,一行命令导出 PPT。体验确实好,直到有一天你打开导出的 PPTX,想改个错别字。

改不了。

每一个字、每一张图、每一条分割线,在 PowerPoint 眼里都是一张贴满整页的 PNG。这份 PPTX 和一本相册没有区别,而生成它的工具对此毫不遮掩,文档里写得清清楚楚。

这就是 Marp CLI,Marp 生态的命令行入口,把 Markdown 幻灯片转成 HTML、PDF、PPTX、PNG、JPEG 和纯文本备注。2018 年 8 月开源,MIT 协议,3801 个 star,194 个 fork,我写这篇的当天它刚发布 v4.5.1。说真的,一个做了八年的幻灯片工具,值得拆开看看它到底怎么想的。

难的不是 Markdown,是像素

把 Markdown 变成 HTML 幻灯片,2018 年就有不少方案了。Marp 自家的 Marpit 框架干的就是这个,按 --- 切页,Markdown 渲染成一段 HTML 加一份 CSS,每页一个 section。这部分没有悬念。

真正难的是后面。幻灯片是像素级排版,你的 CSS 写得再讲究,只有浏览器渲染出来的那层像素是真的。要 PDF,要图片,要 PPTX,都得有人把这份 HTML 真真切切画出来。

业界两条路。一条学 puppeteer,自己捆绑一个 Chromium,npm install 拉一百多 MB,用户忍着。另一条用用户机器上已有的浏览器,体积轻,但得自己解决「找浏览器」和「浏览器各不相同」两个麻烦。

Marp CLI 选了第二条。它的依赖是 puppeteer-core,注意 core 这个后缀,含义就是不带浏览器,自己找。src/browser/finders/ 目录下有 chrome、edge、firefox 三个 finder,每个 finder 知道 macOS、Windows、Linux 的哪些固定路径里躺着浏览器,--browser auto 默认按 chrome、edge、firefox 的顺序挨个试。

找到之后怎么连也有讲究。src/browser/browsers/ 下,Chromium 系走 chrome-cdp.ts,用 Chrome DevTools Protocol,Firefox 走 W3C 标准的 WebDriver BiDi。BrowserManager 统一管理,你偏好的协议找不到可用浏览器时,回落到第一个可用的,不罢工。

你看,光是「把浏览器用起来」这一步,它就铺了一整套抽象,而这只是管线的后半段。

两段式管线

核心架构一句话能讲清。前半段在 Node 进程内,Marpit 引擎把 Markdown 变成 HTML 加 CSS。后半段把 HTML 塞进浏览器,拿回像素级成品。整条链路长这样。

Marp CLI 两段式转换管线
Marp CLI 两段式转换管线

对照图看,src/converter.ts 这个 811 行的文件是全部核心,我把它读了两遍。

前半段有个设计我很喜欢,引擎可以整个换掉。--engine 传一个模块名甚至一个函数都行,传函数时,generateEngine 会用 Object.defineProperty 在参数对象上挂一个 marp getter,你在自己的函数里拿到内置 Marp Core 的实例,use 几个 markdown-it 插件再还回去。几十行代码,就能给幻灯片发明自定义语法。

后半段要 PDF,调 page.pdf,参数就 printBackground、preferCSSPageSize,加上从渲染结果读出的页面宽高。出来之后还没完,pdf-lib 接手做后处理,写 title、author、keywords 元数据。--pdf-notes 的演讲者备注也在这一步塞进去,源码里那个黄色便利贴的 RGB 值 [1, 0.92, 0.42] 和 0.25 的透明度都是手写死的。讲真,看到这段我能想象作者对着 PDF 阅读器一点点调颜色的样子。

有意思的是 PDF 书签的实现。--pdf-outlines 生成大纲时,标题层级来自 Markdown 解析,坐标却要靠 page.evaluate 在真实渲染的页面里逐个量出来,收尾交给 pdf-lib 写成书签。一个功能三个引擎接力,markdown-it 管语义,浏览器管几何,pdf-lib 管格式,谁也不抢谁的活。

截图这条支线全是脏活。convertFileToImage 里,Chromium 系用 deviceScaleFactor 放大视口出高清图,Firefox 不认这套,源码注释里挂着两个上游 issue 编号,只好把视口宽高直接乘以 scale。WebDriver BiDi 协议还只支持 PNG,你要 JPEG,它就先截 PNG,再开个空页面把 PNG 重编码成 JPEG,函数名 png2jpegViaPuppeteer,我看一次乐一次,但人家是认真的。

批量转换也有细节。convertFiles 用一个 queue.shift() 循环的工作池,默认 5 个 worker 并行,--parallel 可调。渲染长页面时它还会等一帧 requestAnimationFrame,注释写着并行渲染时第一帧可能还没画完。

PPTX 的真相与代价

回到开头那个反差,现在可以拆开讲了。

convertFileToPPTX 一共四十来行。先把每页截成 PNG,复用刚才说的截图支线,然后 pptxgenjs 建文档,每页 addSlide,把 PNG 转 base64 塞进 slide.background。演讲者备注倒是真的文本,slide.addNotes 写进去,PowerPoint 的演示者视图能看到。

就这么直接。

所以默认放大倍数写死为 2,converter.ts 里那行 imageScale ?? 2,原因很实际,1 倍截图投到全屏会糊,2 倍是清晰度和文件体积的折中。你拿到的「PPT」是一套 2 倍分辨率的 PNG 贴图,外加每页一段备注文本。

想要能编辑的 PPT?有,--pptx-editable,但走的是另一条世界线,两条线的分野画出来一目了然。

PPTX 两条转换路线对比
PPTX 两条转换路线对比

convertFileToEditablePPTX 先转出一份 PDF,然后调 LibreOffice 的 headless 模式,soffice 带着 impress_pdf_import 导入过滤器,把 PDF 硬转成 PPTX。源码里有条注释特别诚实,soffice 转换失败不返回错误码,只好检查输出文件存不存在来判断成败。文档对这条路线的警告也够直白,复现率低于其他格式,不支持备注,主题复杂时可能直接报错,实验性,不推荐对效果有要求的场景。

我一直觉得这个取舍做得极对。PPTX 背后是 OOXML,形状、文本框、母版层层嵌套,从 Markdown 语义映射过去永远做不完美,做出来也是「差不多先生」。截图路线保住所见即所得,编辑路线外包给打磨了四十年的办公软件老兵,自己的管线一行 OOXML 都不用碰。

默认拒绝本地文件

还有个安全设计值得单独讲。

转换要把你的 HTML 送进浏览器,HTML 里难免引用本地图片。Marp CLI 的默认做法在 usePuppeteer 里,走 data:text/html 加 setContent,浏览器把内容当成远程页面,file:// 协议的请求一律拦截。同时 page.on('requestfailed') 上挂了监听,收集所有失败的本地文件请求,还区分了「文件不存在」和「被安全策略拦下」两种情况,给出不同的警告文案。

真要引用本地文件,--allow-local-files,把 HTML 写进临时文件,用 file: URI 打开,同时每次都警告你这不安全。这个默认值来自 2018 年的一个安全修复,作者当时在 PR 里把默认禁止本地文件写进了代码。你想想看,一个转换工具,默认假设你的 Markdown 不可信,这个姿势比大多数同类正确。

其实吧,转换工具正是攻击面的重灾区,HTML 里的脚本、iframe 加本地文件读取,组合起来就是一条数据外泄通道。默认关死,需要的人显式打开,八年后回看这个决定依然是对的。

塞进一个文件

v4.5.0 开始,独立二进制换成了 Node.js SEA 方案。pkg.config.mjs 里能看到全部心思,Brotli 压缩,optimized 模式裁掉 mathjax-full/es5、其他平台的 prebuilds 和所有 TypeScript 源码。更狠的在 package.json 的 overrides,pptxgenjs 依赖的 image-size 和 @puppeteer/browsers 依赖的 extract-zip,被直接替换成一个叫 dry-uninstall 的空包,占位符换掉真实依赖,二进制里就不会混进一行没用的代码。

他们还 patch 了打包器 @yao-pkg/pkg,暴露 useLocalNode 接口,用裁剪过的官方 Node 二进制做底来缩体积,patches/README.md 里记录了原因。顺带一提,pptxgenjs 和 pdf-lib 在 dependencies 里根本找不到,它们躺在 devDependencies,构建时被 Rollup 打进 lib 产物,所以运行时动态 import 拿到的是本地 chunk,npm 包是预打包好的。

一个 CLI 工具把体积抠到这个程度,不是洁癖,是独立二进制的用户对几十 MB 的下载深恶痛绝。

一个人和 2388 次提交

好话说完了,泼冷水。

贡献者统计里,yhatt 一个人 2388 次提交,排名第二的人类贡献者 15 次,剩下二十几个人是个位数。bus factor 等于 1,整个 Marp 生态的命令行入口,系于一个工程师的业余时间。讽刺的是,勤勉本身也是证据,今年 3 月到 9 月连发 v4.3.1 到 v4.5.1 五个版本,v4.5.1 的发布日期就是我写这篇的当天。

issue 区有几个反复出现的老伤口。#289 从 2020 年 9 月开到现在,watch 模式不监听部分文件,六年没关上。#678 抱怨 Windows 11 上临时文件堆积。#619 最典型,Chrome 130 一升级,text-shadow 的 PDF 渲染直接坏了。「用系统浏览器」的代价在这里显形,渲染质量被浏览器版本绑死,浏览器一个小版本就能让你的幻灯片变样,工具只能被动跟着修。

这些不算致命伤,但你选型时该知道,PPTX 的截图保真和浏览器的版本漂移,是同一枚硬币的两面。

什么时候选它

常被拿来一起比的是 Slidev 和 pandoc。Slidev 面向前端开发者,Vue 组件、热更新、代码演示全家桶,代价是你得吃得动前端那套。pandoc 是万能文档转换器,幻灯片只是它三十多种格式里的一种,保真度凑合。Marp CLI 的哲学相反,Markdown 尽量保持普通,主题 CSS 包揽九成样式,bare 模板甚至零 JS,产物就是一个任何浏览器都能打开的 HTML。

我的选型结论很简单。技术分享、课程、内部文档,想用 Markdown 长期维护一套幻灯片源文件,选 Marp,HTML 直出加 PDF 导出覆盖九成场景。要炫酷交互、要在幻灯片里跑实时代码,选 Slidev。只想从现有文档顺手出一版 slides,pandoc 凑合用。

拆完这个项目,最值得带走的是它的架构姿态,我管它叫「像素终端」模式。业务真相永远留在自己的格式里,在 Marp 这里是 Markdown 和 CSS,浏览器只是一台负责输出像素的终端机,要什么成品就让它打印什么。语义归自己的引擎,几何归浏览器,格式归 pdf-lib、pptxgenjs 这样的专业库,谁也不越界。AI Agent 给网页截图存证、自动化报表出 PDF、批量生成 OG 图,都能套这个模子。

一行命令的背后,是一整套「哪些自己做、哪些外包」的清醒。下次你导出一份改不动的 PPTX 时,至少知道它为什么长这样了。

评论互动

© 2026 王若风的技术博客 · Powered by Astro