Word 里的「WMF」转不了图?一次把扩展名、魔数和错误日志一起拆开的排障
现象很典型:导入 docx 明显变慢了(像是在转图),前端却始终显示不出图片。日志还一本正经地写着「Inkscape 未安装」。真相比表面说法有意思得多。
背景
做 Canvas / 协同文档渲染时,浏览器只能稳定解码 PNG、JPEG、GIF、WebP 等常见位图。Word 里大量图表预览、旧式矢量插图却是 WMF / EMF (Windows Metafile / Enhanced Metafile)。前端 drawImage 碰到 data:image/wmf 基本是死路一条。
常见做法是:
- 服务端导入 docx 时,把 metafile 转成 PNG;
- 展示用 PNG(
src); - 原件另存(如
originalSrc),导出时再写回,避免把预览 PNG 永久替换进文档。
转换引擎我们选了 Inkscape(sharp/libvips 本身就不吃 WMF),本地自造的标准 WMF 也能转成功。直到碰上真实业务文档------导入耗时飙到近 10 秒,日志刷屏失败,前端一片空白。
现象
某次导入日志大致是:
text
Inkscape 转换 canvas-metafile.wmf (WMF) 失败: inkscape 未安装或不在 PATH 中,尝试 sharp 兜底
图片转换失败: ... Inkscape 和 sharp 均无法处理,已跳过
Canvas metafile 转换完成: converted=0 failed=52
POST /office/canvas 200 9352ms
几个容易误判的点:
- 变慢 ≠ 转换成功。变慢只说明「尝试过」;
- HTTP 200 ≠ 图片可用 。接口成功返回了 IR,但
src仍是浏览器解不了的 WMF; - 「未安装」很有迷惑性 。容器里明明有
/usr/bin/inkscape。
前端侧其实是对的:对 data:image/(wmf|emf) 直接标 broken,避免解码失败拖垮整页绘制。问题在上游没交出 PNG。
先说清楚:文中的「魔数」是什么
魔数(magic number / magic bytes) 不是加密、也不是业务自己再封的一层包,而是 文件格式规范写死在文件开头的那几字节固定十六进制 。读这段内容,就能认出「我到底是什么格式」,比看 .doc / .docx / .wmf 这种扩展名靠谱得多------因为扩展名可以被随便改名,头部约定一般改不了(改了就不成合法文件了)。
可以把它理解成格式的「身份证」:
| 格式 | 典型文件头(十六进制) | 怎么记 |
|---|---|---|
| DOC(老 Word,OLE 复合文档) | D0 CF 11 E0 A1 B1 1A E1 |
OLE / CFB 容器签名 |
| DOCX(新 Word,本质是 ZIP) | 50 4B 03 04(即 ASCII PK\x03\x04) |
ZIP local file header |
| PNG | 89 50 4E 47 0D 0A 1A 0A |
含可见字符 PNG |
| JPEG | FF D8 FF ... |
SOI 标记 |
| Placeable WMF | D7 CD C6 9A |
Aldus placeable header |
| 标准 WMF | 01 00 09 00 ... |
METAHEADER(mtType=1, mtHeaderSize=9) |
| EMF | 01 00 00 00 ... (常接 6C 00 00 00) |
EMR_HEADER,记录类型为 uint32 的 1 |
DOC 和 DOCX 差得特别远,正好说明「只看扩展名有多危险」:
.doc→ 二进制 OLE 容器;.docx→ 换皮 ZIP,里面是word/document.xml等 XML 部件。
很多人把文件改名为 .doc 或 .docx,外壳程序若只信后缀就会走错解析器。
LibreOffice 是不是也靠魔数区分 DOC / DOCX?
大体是的:先按内容认容器,而不是只信扩展名。 LibreOffice 的过滤器探测(filter detect)大致是:
- 看流能不能当 ZIP 打开(DOCX/OOXML 这条路,文件头常见就是
PK\x03\x04);确认是 ZIP 后,再读包内的[Content_Types].xml、_rels/.rels等,判断到底是 Word / Excel / PPT 的哪种 OOXML; - 否则按 OLE 复合文档 打开(DOC 这条路,文件头常见
D0 CF 11 E0 ...);再检查里面有没有WordDocument等流,确认是不是 Word 97--2003 二进制格式。
所以 LibreOffice 并不是「只比对四个十六进制就完事」,而是:
魔数 / 容器形态先做大类分流(ZIP vs OLE),再深入包内结构做细分类。
这和我们修 WMF/EMF 的思路一致:扩展名可以撒谎,内容(至少文件头)更诚实。
复现:从真实 docx 抽出一张「.wmf」
拿问题文档解包后,word/media/ 里有大量 imageN.wmf。先看文件头(前几个字节):
text
01 00 00 00 6C 00 00 00 ...
对照上一节表格:这是 EMF 的 EMR_HEADER,不是经典 WMF。
也就是说:文件名叫 .wmf,内容却是 EMF。
Word 很爱这么干------扩展名不可信,魔数才可信。
对照实验:同一份字节,换个后缀就通了
把抽出的文件分别按 .wmf / .emf 丢给 Inkscape:
bash
# 按 .wmf ------ 失败,无输出
inkscape sample.wmf --export-type=png --export-filename=out.png
# stderr 大意:
# parser error : Start tag expected, '<' not found
# ink_file_open: '...wmf' cannot be opened!
# 改名为 .emf ------ 成功
cp sample.wmf sample.emf
inkscape sample.emf --export-type=png --export-filename=out.png
# → 有效 PNG
多张业务图(小图、大图)都是同一结论:
- 按 WMF 扩展名:失败
- 按 EMF 扩展名:成功
现有转换函数若按 MIME/contentType 决定临时文件后缀(.wmf 来自路径推断),也会完整复现「FAIL」;强制 'image/emf' 则立刻 OK。
那条「Inkscape 未安装」是怎么来的?
转换代码逻辑大致是:
- 写临时输入文件(后缀由类型决定);
execFile('inkscape', ...);fs.readFile(outputPath)读 PNG;- 校验 PNG 魔数;失败再走 sharp。
坑在两处叠加:
第一,Inkscape 对打不开的文件仍可能 exit 0,且不写输出文件。
于是第 3 步 readFile 抛出 ENOENT。
第二,错误归类把所有 ENOENT 都当成「找不到 inkscape 可执行文件」。
js
const hint = e.code === 'ENOENT'
? 'inkscape 未安装或不在 PATH 中'
: e.message
在 Node 里,execFile 找不到二进制是 ENOENT;读不存在的输出文件也是 ENOENT。两种完全不同的失败,被写成了同一句话。排障时就会在「装没装 Inkscape」这条死路上空转。
sharp 兜底对 WMF/EMF 基本无效,于是最终:converted=0,src 原样保留,前端无法展示。
根因一句话
Word 把 EMF 存成了
.wmf;转换链路只信扩展名,Inkscape 按错格式解析失败;失败检测又把「没有输出文件」误报成「Inkscape 未安装」。
不是「Inkscape 方案整体不可用」,而是:
- 类型识别不可靠(扩展名 / MIME 优先于文件内容);
- 失败可观测性很差(exit code、stderr、输出是否存在没有分开处理)。
自造标准 WMF 能转成功,更容易让人误判成「引擎没问题,一定是环境坏了」。真实 docx 一上来就打脸。
我们怎么修的
结论很简单:不必换掉 Inkscape ,只要在喂给它之前,按文件内容(文件头魔数)选对临时文件后缀------EMF 用 .emf,WMF 用 .wmf。这里的「魔数」就是上一节说的那几字节固定十六进制,和 LibreOffice 认 DOC/DOCX 时先看 OLE/ZIP 容器是同一类思路。
改动落在协同服务 src/docx/zip-extractor.mjs 的 convertWmfEmfToPng(Word HTML 导入与 Canvas IR metafile 转换共用这一条)。
1. 按魔数识别真实格式
新增 sniffMetafileExt(buffer),优先看文件头,而不是 zip 路径或 MIME:
js
export function sniffMetafileExt(buffer) {
if (!buffer || buffer.length < 4) return null
// EMF: EMR_HEADER,iType 为 uint32 LE = 1 → 01 00 00 00
if (buffer[0] === 0x01 && buffer[1] === 0x00
&& buffer[2] === 0x00 && buffer[3] === 0x00) {
return '.emf'
}
// Placeable WMF: Aldus key D7 CD C6 9A
if (buffer[0] === 0xd7 && buffer[1] === 0xcd
&& buffer[2] === 0xc6 && buffer[3] === 0x9a) {
return '.wmf'
}
// 标准 WMF METAHEADER: mtType=1, mtHeaderSize=9 → 01 00 09 00
if (buffer[0] === 0x01 && buffer[1] === 0x00
&& buffer[2] === 0x09 && buffer[3] === 0x00) {
return '.wmf'
}
return null
}
注意 EMF 与标准 WMF 都可能以 01 00 开头,但:
- EMF 的记录类型是 4 字节
01 00 00 00 - 标准 WMF 的
mtType+mtHeaderSize是01 00 09 00
靠这一点就能把 Word 里「假 WMF」认回 EMF。
写临时文件时:
js
const ext = sniffMetafileExt(buffer)
?? (contentType?.includes('emf') ? '.emf' : '.wmf')
魔数认不出来时,再回退到原来的 MIME/扩展名启发式。若声称类型与魔数不一致,打一条 info,方便以后审计:
text
metafile 扩展名与内容不符,按魔数处理: image4.wmf (claimed=.wmf, sniffed=.emf)
2. 顺手修掉误导日志
- 只有
e.code === 'ENOENT' && e.syscall === 'spawn'才报「inkscape 未安装或不在 PATH」 - Inkscape 跑完但没有
output.png时,单独报「未生成 PNG」,并把 stderr 尾部带上 - 避免再把
readFile的ENOENT当成环境故障
3. 用问题文档验过
从业务 docx(如 D019.docx)抽出 image4.wmf(文件头实为 EMF),仍然传入 contentType: 'image/wmf':
text
sniff .emf
metafile 扩展名与内容不符,按魔数处理: image4.wmf (claimed=.wmf, sniffed=.emf)
图片转换成功: image4.wmf (EMF→PNG, 9KB, Inkscape)
修复前:同参调用返回 null,日志还说没装 Inkscape。
修复后:无需改调用方、无需改前端,Canvas / Word 两条导入链路一起受益。
4. 刻意没动的部分
-
不换引擎:Inkscape 对真 WMF / 真 EMF 都可用,问题在后缀
-
不改
originalSrc的 MIME 标签:导出仍按文档原始 data URL 写回,尽量保持与 Word 包内命名一致 -
sharp 仍作兜底:对 metafile 基本无效,但留下不影响主路径
-
不用 LibreOffice 直接转图 :理论上可以用
soffice把含图文档或单图再导出一遍,但 LibreOffice 体积大、冷启动慢、依赖重,只为 WMF/EMF→PNG 拉一整套办公套件太不划算。Inkscape 已经在镜像里、进程轻、专做矢量/metafile 导出,修对后缀后就够用了。体积体感大致是:组件 体量(量级) 说明 LibreOffice / soffice约 0.7 GB+(完整安装目录常见更大) 整套办公套件:Writer/Calc/UI/语言包等 Inkscape 约几十 MB (如容器内 /usr/share/inkscape≈ 36MB,二进制本身更小)专做矢量读写与导出 差一个数量级以上。为偶尔出现的 metafile 转 PNG 引入 LibreOffice,镜像和运维成本都不划算。
部署时注意:改的是 Node 源码,需要重启协同服务或重建镜像 后才会生效;只 docker cp 进容器、不重启进程的话,老模块仍在内存里。
排查清单(下次可直接套)
- 看
converted/failed计数,别只看接口 200 和耗时; - 容器里确认
inkscape --version,排除真环境问题; - 从 docx 抽出一张失败图,
xxd/ 看文件头,区分 WMF vs EMF; - 强制改后缀再转一次------成本最低的对照实验;
- 核对代码:临时文件后缀是跟扩展名走,还是跟魔数走;
- 核对错误处理:
ENOENT到底来自 spawn 还是readFile。
一点感想
这类 bug 的共性是:中间层用了「方便的启发式」(文件名、MIME、exit code),而真实世界(尤其是 Office 生态)长期不遵守这些启发式。
日志若再把不同失败折叠成一句正确率很低的「人话」,排障时间会被成倍放大------你会去重装镜像、查 PATH、怀疑 Docker 基础镜像,却迟迟打不开「扩展名撒谎」这扇门。
一句话收束:
转换引擎可能没问题;先问清楚你喂给它的,到底是什么格式。
我们最终的做法也印证了这句话:加一层魔数判断,让 EMF 走 .emf、WMF 走 .wmf,Inkscape 方案不用推倒重来。
本文基于一次真实 Canvas/docx 导入排障与修复整理:业务文档中的「.wmf」实为 EMF;按魔数选择临时后缀后转换恢复;并修正了将缺失输出误报为「Inkscape 未安装」的日志。