纯浏览器端 PPT 渲染引擎:.pptx / .ppt → 统一 JSON Schema → SVG。
零服务端依赖、零框架依赖(原生 TypeScript,可被 React / Vue 直接封装),唯一运行时依赖是 fflate。
▶ 在线 Demo —— 拖一个自己的文件进去,解析与渲染全在本机,文件不出浏览器。
| 包 | 作用 | 依赖 | 体积 (gzip) |
|---|---|---|---|
@web-ppt/core |
解析 / 渲染 / 导出,无框架无 DOM 依赖 | fflate | 84KB |
@web-ppt/viewer-core |
导航 / 缩放 / 搜索 / 动画批次 | @web-ppt/core |
6.8KB |
npm i @web-ppt/core @web-ppt/viewer-coreimport { parse, slideToSvgFile, presentationToPrintableHtml } from '@web-ppt/core';
import { Viewer } from '@web-ppt/viewer-core';
const pres = await parse(file); // File | Blob | ArrayBuffer | Uint8Array
const viewer = new Viewer(container, pres, { animate: true, autoAdvance: true });
viewer.next(); // 有待播动画时先播动画,否则翻页
viewer.playNextAnimation(); // 单独推进一批动画
viewer.finishAnimations(); // 跳到本页动画终态
viewer.setZoom(1.5);
viewer.search('关键词'); // → 命中的页索引数组
await viewer.exportPng(2); // → Blob
// 想让视频 / 音频真的能播(会引入 foreignObject,仅屏幕预览可用)
const v2 = new Viewer(container, pres, { media: 'player' });
const svg = await slideToSvgFile(pres, pres.slides[0]);
const html = await presentationToPrintableHtml(pres); // 浏览器打印即得 PDF
// 有动画的页按点击批次展开成多页,逐步揭示的结构不会被压平
const stepped = await presentationToPrintableHtml(pres, { animationSteps: true });Viewer 只是 PresentationState 之上 24 行 DOM 绑定。要接 React / Vue,直接驱动状态机:
import { PresentationState, playGroup, playTransition } from '@web-ppt/viewer-core';
const st = new PresentationState(pres, { animate: true, skipHidden: true });
st.subscribe((c) => {
if (c.type === 'slide') setIndex(c.index); // c.transition 非空时该播切换
if (c.type === 'animation' && c.group) playGroup(el, c.group);
});
st.next(); // 有待播动画先播动画,否则翻页
st.hiddenElementIds; // 当前批次下应隐藏的元素 id
st.search('关键词'); // → 命中的页索引数组图元文件解码器(约 15KB gzip)默认已接入;若要裁剪体积,可移除 src/index.ts 里的 setMetafileDecoder 调用。
| 能力 | .pptx | .ppt |
|---|---|---|
| 预设几何 | ✅ 187 个预设(ECMA-376 全集) | ✅ MSOSPT 全表映射 |
| 自定义几何 | ✅ custGeom + gdLst 公式求值 + arcTo | ✅ pVertices / pSegmentInfo |
| 填充 | ✅ 纯色 / 线性 / 径向渐变 / 图片 / 平铺 / 图案 / 主题色变换 | ✅ 纯色 / 渐变 / 图片 |
| 描边 | ✅ 虚线 / 线端箭头 / 端点 / 连接样式 | ✅ 虚线 / 箭头 |
| 效果 | ✅ 外阴影 / 内阴影 / 发光 / 柔化边缘 / 倒影 | |
| 立体(3D) | ✅ 挤出 / 斜角 / 轮廓 / 材质 / 视角 | |
| 主题样式引用 | ✅ fillRef / lnRef / effectRef + phClr | — |
| 文本 | ✅ 完整(见下)+ 艺术字变形 | ✅ 字号 / 颜色 / 粗斜下划线 / 对齐 / 项目符号 |
| 样式继承 | ✅ 母版 → 版式 → 占位符 → 段落 → run | ✅ 母版 TxMasterStyle → 形状 |
| 图片 | ✅ 裁剪(含形状填充)/ 裁进形状 / 透明度 / 灰度 | ✅ Pictures 流 + DEFLATE 解压 |
| EMF / WMF / PICT | ✅ 解码为 SVG | ✅ 解码为 SVG(PICT 为 Mac 版所存) |
| 表格 | ✅ tableStyles / 条纹 / 合并 / 边框 / 垂直对齐 | ✅ 表格属性 + 网格启发式还原 |
| 图表 | ✅ 柱/条/堆叠/折线/面积/饼/环/散点/雷达/气泡/股价/复合饼/曲面 · 次坐标轴 · 3D | ✅ 经内嵌 EMF 预览渲染 |
| 媒体 · 墨迹 · 评论 · 节 | ✅ 封面帧+播放标识 / InkML 笔迹 / 结构化评论 / 分节 | ❌ |
| SmartArt | ✅ 缓存 drawing part,无缓存时按数据模型自行排布(6 种布局族) | ❌ |
| 组合 | ✅ 嵌套 + 子坐标系缩放 | ✅ 展平 + 坐标映射 |
| 切换效果 | ✅ 20 种(淡入/推进/擦除/覆盖/分割/缩放…) | ✅ 经 SSSlideInfoAtom,实测 6 种 |
| 元素动画 | ✅ 入场 / 退场 / 强调 / 运动路径,按点击分批 | ✅ 入场 / 退场 / 强调,实测 5 步 |
| 演讲者备注 · 超链接 | ✅ | ✅ |
| OLE 嵌入对象 | ✅ 内嵌 p:pic 预览 / 旧式 VML 快照(含 PICT) | ❌ |
| 加密文档 | ✅ 标准(AES-ECB)/ 敏捷(AES-CBC 分段) | ✅ RC4 CryptoAPI(40/56/128 位) |
| 数学公式 OMML | ✅ 分式 / 根式 / 上下标 / 大算符 / 矩阵 / 定界符 / 重音 / 极限 | ❌ |
| 隐藏页 | ✅ sld@show="0" |
✅ SSSlideInfoAtom F_HIDDEN |
文本细项(pptx):字号 / 字体 / 粗斜体 / 下划线 / 删除线 / 上下标 / 字间距 / 大小写 / 描边 / 渐变填充 / 高亮 / 竖排 / 分栏 / 自动缩放 / 字符与图片项目符号 / 自动编号 / 超链接 / 页码页脚域 / RTL / 15 种艺术字变形预设。
flowchart LR
A[".pptx<br/>Zip + OOXML"] -->|"fflate + DOMParser"| S["统一 Schema<br/>src/types.ts"]
B[".ppt<br/>CFB + OfficeArt"] -->|"自研 CFB + Escher 解析"| S
M["EMF / WMF<br/>图元文件"] -->|"自研 GDI 解释器"| S
S --> H["HTML 文本渲染<br/>foreignObject"]
S --> V["SVG 文本渲染<br/>自实现断行"]
H --> P["屏幕预览<br/>可选中 · 动画 · 切换"]
V --> E["PNG / SVG / PDF 导出"]
| 设计 | 理由 |
|---|---|
| 解析层与渲染层完全解耦 | 渲染层只依赖 src/types.ts,加新输入格式不动它一行;格式按魔数识别(PK → pptx,D0CF11E0 → ppt),不看扩展名 |
| 两条文本渲染路径 | foreignObject + HTML 排版给屏幕预览与 PNG 导出(文本可选中、支持分栏);原生 <text> + 自实现测量断行给独立 SVG 与打印 HTML——foreignObject 只有浏览器认,Inkscape / librsvg / 设计工具打开会整块丢失文本,交出去的文件必须自包含 |
| Safari 运行时探测 | WebKit 15 年未修的老 bug 不给 foreignObject 里的 HTML 应用外层 SVG 缩放;中招就整页切到 <text> 路径 |
| 图表 / 图元解码器经 hook 注入 | 可按需 tree-shake。注意 chart/ 是第四条解析链路而非渲染插件——它读 ppt/charts/chart1.xml(本身即 OOXML/DrawingML)并产出 SlideElement[];hook 只为打破 pptx/parser → chart → pptx/color 的模块环 |
浏览器实测,210 页 / 11280 元素:
| 指标 | 数值 |
|---|---|
| 惰性首屏(解析 + 第 1 页 + 渲染) | 42ms |
| 全量解析 | 376ms(1.8ms/页) |
| 单页渲染 | 0.09ms |
| 缓存命中(重复访问同页) | 0ms |
| JS 堆 | 40 MB(0.19 MB/页) |
| 优化 | 效果 |
|---|---|
| 惰性解析(默认开启) | 每页首次访问时才解析,首屏 376ms → 42ms(9×) |
| Worker 解析 | 主线程零阻塞;实测与主线程忙循环并发 573ms vs 串行 942ms |
| 缩略图虚拟化 | 210 页初始只渲染 7 个,滚动时按需补 |
耗时分布(浏览器):XML 解析 30%(原生 DOMParser)、Schema 构建 63%、解压 7%、渲染 <1%。
WebAssembly 在这条路径上帮不上——XML 解析已是原生 C++,Schema 构建是 DOM 遍历 + 字符串 + 建对象,全是 WASM 的弱项,且跨边界封送成本会吃掉收益。
import { parseInWorker } from '@web-ppt/core';
const worker = new Worker(new URL('@web-ppt/core/worker', import.meta.url), { type: 'module' });
const pres = await parseInWorker(worker, bytes); // 主线程零阻塞Worker 里没有 DOMParser(Window-only API),因此 parseXml 会自动回退到自带的 xml-lite
——纯 JS,实测约为原生的 1.8×,与原生结构等价(测试逐节点比对了全部 slide XML)。
图片不能跨线程传 blob URL,Worker 输出 asset:N 令牌 + 原始字节,主线程兑现成真实 URL。
渲染保真度不靠"看着差不多"判断,而是拿 LibreOffice 的实际渲染做 ground truth 逐档比对。例如主题色的 shade/tint:
| 档位 | LibreOffice | sRGB 直乘(旧) | 线性 RGB(现) |
|---|---|---|---|
| shade 20% | rgb(33,56,97) | rgb(14,23,39) Δ69 | rgb(28,51,93) Δ8 |
| tint 60% | rgb(176,187,222) | rgb(143,170,220) Δ37 | rgb(176,188,222) Δ1 |
npm run compare <file> 可对任意文件生成并排/叠加对照页。
| 项 | 说明 |
|---|---|
| .ppt 的发光 / 柔化 / 倒影 | 格式本身没有这些属性——它们是 DrawingML(2007+) 的概念,OfficeArt 二进制里无从表达(外阴影已支持) |
| .ppt 的 3D | OfficeArt 有挤出属性(c3DExtrude*/c3DBooleans),但缺可信样本:LibreOffice 转换会把 3D 烘进 cube 预设几何又保留 3D 属性,照此实现会双重叠加 |
| .ppt SmartArt | 未实现(自动编号与嵌套组均已支持) |
| OMML 公式 | 只取线性文本,不做 MathML 排版 |
| 艺术字包络型预设 | textPath 只能弯曲基线,textInflate 等不会按位置缩放字形 |
| 3D | 等轴测近似,非真实投影;大角度视角不切换俯视 |
| EMF+ | 不处理。实测手上全部图元文件都是双模式——GDI 记录已承载完整绘制(sample-metafile.pptx 里 16125 条 GDI 记录 vs 3 条 EMF+ 注释),走 GDI 路径即可。只有纯 EMF+ 文件才需要,尚无样本 |
| 光栅操作码 | SVG/CSS 没有 XOR/AND 位运算混合,mix-blend-mode 不等价 |
| chartex 新图表 | 树状图 / 旭日 / 直方图 / 箱线 / 瀑布 / 漏斗 / 地图(Office 2016+ 的 cx:chartSpace)整条链路未实现。经典 16 种图表已全支持 |
| Region 的 OR / XOR / DIFF 组合 | 需要区域布尔运算,SVG 裁剪表达不了;COPY 与 AND 已支持 |
| 嵌入字体 | 注入 @font-face,但部分文件的字体数据浏览器不接受 |
| 字体缺失导致的断行差异 | 断行由实际字体的度量决定:PPT 指定的字体本机没有时回退到别的字体,字宽不同,换行位置就会与 PowerPoint 不一致。这不是解析问题,装上原字体或用嵌入字体即可对齐 |
| 加密文件 | 设了打开密码的文件无法解析,会明确报「该文件已加密」 |
| OLE 嵌入对象 | 渲染 PowerPoint 存的预览图(经 VML 部件解析),不解析内部文档;预览为 PICT 等无法解码的格式时退回占位框 |
| 命令 | 说明 |
|---|---|
npm run dev |
启动 viewer(?file=/showcase.pptx 指定文件) |
npm run dev:site |
启动官网(含浏览器内实时 Demo) |
npm test |
全部测试(核心 + 图元文件) |
npm run test:core |
核心解析 / 渲染,1689 项断言 + 158 个渲染快照 |
npm run test:metafile |
EMF / WMF / PICT 解码器,130 项断言 + 模糊测试 |
npm run fixtures |
重新生成全部测试文件(确定性输出) |
npm run check |
TypeScript 类型检查 |
npm run build |
构建 @web-ppt/core + @web-ppt/viewer-core |
npm run build:site |
构建官网静态产物 |
npm run compare public/showcase.pptx |
用 LibreOffice 生成参考图做并排/叠加对比 |
npm run ppt-samples |
用 LibreOffice 把 pptx 测试文件转成 .ppt 样本(pptx fixture 变更后需重跑) |
npm run bench |
大文件性能基准 |
约定、架构约束与已知陷阱见 AGENTS.md(对编码代理同样适用)。
web-ppt/ npm workspaces monorepo
├── packages/
│ ├── core/ @web-ppt/core —— 解析 / 渲染 / 导出,无框架无 DOM 依赖
│ ├── viewer-core/ @web-ppt/viewer-core —— headless 状态机 + 播放层
│ ├── viewer/ @web-ppt/viewer —— 开箱即用查看器,纯原生 TS
│ └── site/ @web-ppt/site —— 官网,含浏览器内实时 Demo
├── fixtures/ 测试用 pptx / ppt 样本(脚本生成,确定性)
├── tooling/ 测试框架 / fixture 生成 / LibreOffice 对照 / 性能基准
└── test/snapshots/ 158 个渲染快照基线
packages/viewer 与 packages/site 都通过包名消费上游,与外部用户走同一条路径——
边界一旦被破坏,它们立刻编译失败。将来的编辑器作为 apps/editor 加入,
Cordis 之类的应用框架只出现在那一层,不下沉到 core。
为什么 viewer-core 是独立包:Viewer 里真正耦合 DOM 的只有约 24 行(塞 SVG、设可见性、调播放),其余 200 多行是纯状态推进。拆开后 React / Vue / Svelte 可直接驱动 PresentationState 不必等官方封装;状态逻辑不再需要 jsdom 就能在 Node 里测;@web-ppt/core 完全不碰 document,Worker 里可整包运行。
抽出来的当天就测出一个 939 项断言从没碰到的真 bug:
skipHidden下若后续全是隐藏页,next()会落在最后一张隐藏页上。它能活那么久,是因为当时 9 个 fixture 里没有一张隐藏页——快照测试挡得住「变了」,挡不住「一开始就没测过」。现已补上sample-hidden.pptx/.ppt。
在 Node 里用 jsdom 提供 DOM,esbuild 把 src/ 打成 ESM 后跑真实解析与渲染——不 mock 任何解析逻辑。
| 层次 | 覆盖 |
|---|---|
| 结构断言 | 几何(54 形状 × 5 组调节值 + 648 例模糊输入)、颜色、文本继承链、动画/切换、播放引擎、表格还原、图表、文本提取 |
| 不变量 | 每个元素包围盒有限、路径无 NaN、Schema 必填字段齐全、SVG 结构合法、无悬空 url(#id)、无重复 id、导出路径无 foreignObject |
| 渲染快照 | 16 个测试文件 × 全部页 × 两条文本路径 = 158 个归一化 SVG 基线,逐字节比对 |
| 回归锚点 | 针对已修复的真实 bug 写死断言:.ppt 字号错位、动画时长取错节点、飞入方向映射反、BLIP 未解压 |
| 健壮性 | 70 例畸形输入——截断(5%~95%)、随机字节破坏、空文件、假魔数、全零;要求要么正常解析、要么抛可读 Error,不得崩溃或吐半成品。单个形状解析失败只降级为占位,不连累整页 |
| 查看器交互 | 超链接分流(内部跳页 vs 外链回调)、索引夹紧、destroy 清理 |
快照会归一化 blob URL、data URI(转摘要)与 defs id,因此跨机器稳定。渲染有意改动时:
UPDATE_SNAPSHOTS=1 npm run test:core然后 git diff test/snapshots/ 逐行确认改动符合预期再提交。
测试套件本身经过变异验证:把已修复的 bug 逐个改回去,确认能被抓到。
| 变异 | 捕获 |
|---|---|
填充规则 nonzero → evenodd |
40 项 |
.ppt 段落字段表插入错位字段 |
33 项 |
| 关闭几何安全网 | 47 例模糊输入越界 |
单元格边框标签拼回 lnLeft |
2 项 |
shade 退回 sRGB 空间 |
3 项 |
cs 字体不进字体栈 |
1 项 |
快照只能发现「变化」,发现不了「一开始就是错的」——单元格边框那个 bug 就是被外部 ground truth 对照抓出来的,而非测试套件。两者互补,缺一不可。
npm run fixtures 生成,字节确定性:
| 文件 | 覆盖内容 |
|---|---|
showcase.pptx |
120 形状 / 效果 / 填充 / 线条箭头 / 文字特性 / 表格 / 图片 / custGeom / 嵌套组合 / 3D / 动画 / 7 种切换 |
sample-chart.pptx |
14 个图表:柱 / 条 / 堆叠 / 折线 / 面积 / 饼 / 环 / 散点 / 次坐标轴组合 / 3D 柱 / 3D 饼 |
sample-effects.pptx |
内/外阴影 / 发光 / 柔化 / 倒影 / 15 种艺术字变形 / RTL |
sample-media.pptx |
视频/音频封面 / 墨迹 / 评论 / 分节 / 气泡·股价·复合饼·曲面图 |
sample-metafile.pptx |
内嵌 EMF 与 WMF |
sample.pptx · sample.ppt |
母版继承 / 最小合法 CFB |
sample-hidden.pptx · .ppt |
隐藏页导航:可见 · 隐 · 隐 · 可见 · 隐(pptx 走 sld@show,ppt 走 F_HIDDEN) |
sample-autofit.pptx |
文本自动缩放五种情形:溢出/放得下 × 裸 normAutofit、无 autofit、显式 fontScale、缩到 25% 下限 |
sample-placeholder.pptx |
占位符几何继承:图片占位符空 spPr / 图片自带 xfrm / 形状占位符 |
sample-ole.pptx |
OLE 预览图:可解码格式渲染成图片 / 认不出的格式退回占位框 |
.ppt 样本可用 LibreOffice 从 pptx 转换生成:npm run ppt-samples,或 soffice --headless --convert-to ppt <file>。
| 页面 | 用途 |
|---|---|
/ |
查看器:缩略图(虚拟化)/ 缩放 / 搜索 / 备注 / 演示模式 / 导出 |
/shapes.html |
几何调试:全部预设形状实时渲染,可调调节值与宽高比 |
演示模式(工具栏「演示」或 F)下才播放切换与动画:→ 依次推进动画批次,播完再翻页;Esc 退出。
| 渠道 | 地址 |
|---|---|
| 问题反馈 / 需求 | GitHub Issues |
| 微信交流群 | 置顶 Issue 里的二维码 |
微信群二维码 7 天失效,所以放在 Issue 里而不是直接贴进 README—— 换码只需编辑那条 Issue,README 和已发布的 npm 包都不用动。