Word 里的 .wmf 其实是 EMF:一次 metafile 转 PNG 排障

Word 里的「WMF」转不了图?一次把扩展名、魔数和错误日志一起拆开的排障

现象很典型:导入 docx 明显变慢了(像是在转图),前端却始终显示不出图片。日志还一本正经地写着「Inkscape 未安装」。真相比表面说法有意思得多。

背景

做 Canvas / 协同文档渲染时,浏览器只能稳定解码 PNG、JPEG、GIF、WebP 等常见位图。Word 里大量图表预览、旧式矢量插图却是 WMF / EMF (Windows Metafile / Enhanced Metafile)。前端 drawImage 碰到 data:image/wmf 基本是死路一条。

常见做法是:

  1. 服务端导入 docx 时,把 metafile 转成 PNG;
  2. 展示用 PNG(src);
  3. 原件另存(如 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)大致是:

  1. 看流能不能当 ZIP 打开(DOCX/OOXML 这条路,文件头常见就是 PK\x03\x04);确认是 ZIP 后,再读包内的 [Content_Types].xml_rels/.rels 等,判断到底是 Word / Excel / PPT 的哪种 OOXML;
  2. 否则按 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 未安装」是怎么来的?

转换代码逻辑大致是:

  1. 写临时输入文件(后缀由类型决定);
  2. execFile('inkscape', ...)
  3. fs.readFile(outputPath) 读 PNG;
  4. 校验 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=0src 原样保留,前端无法展示。

根因一句话

Word 把 EMF 存成了 .wmf;转换链路只信扩展名,Inkscape 按错格式解析失败;失败检测又把「没有输出文件」误报成「Inkscape 未安装」。

不是「Inkscape 方案整体不可用」,而是:

  1. 类型识别不可靠(扩展名 / MIME 优先于文件内容);
  2. 失败可观测性很差(exit code、stderr、输出是否存在没有分开处理)。

自造标准 WMF 能转成功,更容易让人误判成「引擎没问题,一定是环境坏了」。真实 docx 一上来就打脸。

我们怎么修的

结论很简单:不必换掉 Inkscape ,只要在喂给它之前,按文件内容(文件头魔数)选对临时文件后缀------EMF 用 .emf,WMF 用 .wmf。这里的「魔数」就是上一节说的那几字节固定十六进制,和 LibreOffice 认 DOC/DOCX 时先看 OLE/ZIP 容器是同一类思路。

改动落在协同服务 src/docx/zip-extractor.mjsconvertWmfEmfToPng(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 + mtHeaderSize01 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 尾部带上
  • 避免再把 readFileENOENT 当成环境故障

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 进容器、不重启进程的话,老模块仍在内存里。

排查清单(下次可直接套)

  1. converted / failed 计数,别只看接口 200 和耗时;
  2. 容器里确认 inkscape --version,排除真环境问题;
  3. 从 docx 抽出一张失败图,xxd / 看文件头,区分 WMF vs EMF;
  4. 强制改后缀再转一次------成本最低的对照实验;
  5. 核对代码:临时文件后缀是跟扩展名走,还是跟魔数走;
  6. 核对错误处理:ENOENT 到底来自 spawn 还是 readFile

一点感想

这类 bug 的共性是:中间层用了「方便的启发式」(文件名、MIME、exit code),而真实世界(尤其是 Office 生态)长期不遵守这些启发式。

日志若再把不同失败折叠成一句正确率很低的「人话」,排障时间会被成倍放大------你会去重装镜像、查 PATH、怀疑 Docker 基础镜像,却迟迟打不开「扩展名撒谎」这扇门。

一句话收束:

转换引擎可能没问题;先问清楚你喂给它的,到底是什么格式。

我们最终的做法也印证了这句话:加一层魔数判断,让 EMF 走 .emf、WMF 走 .wmf,Inkscape 方案不用推倒重来。


本文基于一次真实 Canvas/docx 导入排障与修复整理:业务文档中的「.wmf」实为 EMF;按魔数选择临时后缀后转换恢复;并修正了将缺失输出误报为「Inkscape 未安装」的日志。

相关推荐
mayaairi1 小时前
Vue2 组件通讯(二):ref、自定义事件与provide/inject实战
前端·javascript·vue.js
爱读源码的大都督1 小时前
DeepSeek面试官问:生产RAG系统回答不准确,该如何定位和优化?这样回答,能让面试官当场给你Offer!
java·后端·python
开开心心就好1 小时前
PDF图片去水印软件,支持批量处理页面
前端·javascript·人工智能·智能手机·pdf·语音识别
爱敲代码的小杨.1 小时前
【Spring】Spring Web MVC
前端·spring·mvc
杨运交2 小时前
[069][公共模块]Spring Boot 全局异常处理与参数校验实战(下):校验异常精细化处理与 WebFlux 适配
java·spring boot·后端
是立不是利2 小时前
深入理解JavaScript事件委托
开发语言·前端·javascript
晴天162 小时前
React 版本迭代解析:从15到19 核心差异-Day40
前端·react.js·前端框架
Andya_net2 小时前
Spring Boot | 条件注解完全指南:从 @Conditional 到 @ConditionalOnExpression 的原理、实践与避坑
spring boot·后端·python
APItesterCris2 小时前
告别人工盯品!借助 Open‑Claw 快速搭建电商商品全自动监控与数据分析系统(完整实操代码)
java·大数据·前端·数据库