最近在使用 Pandoc 将包含复杂数学公式(如 MathJax 或 MathML)的 HTML 转换为 .docx 格式时,生成的文档在 Microsoft Word 中通常能够正常显示,但在 WPS Office 中打开时,公式出现字体正体斜体错误。
问题排查到是pandoc和office都是默认使用Cambria Math作为公式字体,能准确处理数学中的斜体和正体。但是wps中默认不包含这个字体,所以没法正常渲染。wps默认使用DejaVuMathTeXGyre作为公式字体,却没有被包含在pandoc和word中。所以pandoc转化的docx文件要额外处理字体问题。
第一点, Cambria Math字体不在wps,也默认不在系统里,pandoc的docx模板需要自带包含字体,这个问题不大。
第二点,生成的docx包含了字体后,wps还是不能正常识别字体,经过排查, 发现了是 Office 与 wps 在 Office Open XML (OOXML) 规范关于字体渲染实现上的底层差异。
实现了一种无需解压文件、完全基于 Java 内存流的轻量级后处理解决方法。
一、 常见规避方案的局限性分析
在尝试解决公式字体不匹配的问题时,常规的配置手段往往无法达到预期效果:
1. 在 Pandoc 模板(--reference-doc)中内嵌字体
- 局限性:
.docx文件的本质是 ZIP 压缩包。Pandoc 在重构文档时,仅会复制模板中的样式文本描述(styles.xml),而会直接丢弃模板包底层存储二进制字体文件的word/fonts/目录。因此,通过模板无法将字体打包传导至生成的文件中。
2. 修改全局配置 word/settings.xml
- 局限性:微软 Word 在未指定公式字体时,会读取全局配置中的
<m:mathFont m:val="Cambria Math"/>。但 WPS 的公式渲染引擎并不严格遵循该全局标签,若在具体的公式节点上未检测到显式的物理字体声明,系统将直接降级渲染,导致乱码。
二、 核心症结:word/document.xml 的底层结构差异
通过对符合 WPS 渲染标准的文档进行解压分析,发现 WPS 实现完美公式渲染的前提是:每个数学运行块(Math Run)内部必须包含显式的字体属性注入。
符合规范的 OOXML 公式片段结构如下:
<m:oMath>
<m:r>
<m:rPr>
<w:rFonts w:ascii="DejaVuMathTeXGyre" w:hAnsi="DejaVuMathTeXGyre" w:eastAsia="DejaVuMathTeXGyre" w:cs="DejaVuMathTeXGyre" w:hint="default"/>
</m:rPr>
<m:t>x + y = z</m:t>
</m:r>
</m:oMath>
Pandoc 的生成机制问题:Pandoc 生成的 XML 结构高度简化,其公式节点内部不仅缺失 <w:rFonts> 标签,甚至连包裹它的 <m:rPr>(Math Run Properties)节点也被一并省略。这种结构差异直接导致了 WPS 引擎的解析失败。
方案确定为遍历所有公式里的m:r节点,手动写入rFonts,指定字体,方法是蠢了一点,但是确实有效,并且和wps转存之后的文件解压出来文档完全一致,看来wps也是这样蠢的。
三、 基于 Java 内存流的动态注入方案
为了满足高并发 Web 生产环境的要求,本方案避开了效率较低的“本地磁盘解压-修改-重新打包”流程,完全依托 Java 原生的 ZipInputStream 与 DOM 解析器在内存中完成字节数组(byte[])的流式处理。
同时,针对 DocumentBuilder.parse() 会自动关闭底层流从而引发 Stream closed 异常的特性,方案引入了内存缓冲区进行解耦。
工具类核心代码实现
import org.w3c.dom.*;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
/**
* DOCX 办公文档数学公式字体精准修复工具
*/
public class DocxMathFontFixer {
private static final String MATH_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math";
private static final String MAIN_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
private static final String TARGET_FONT = "DejaVuMathTeXGyre";
/**
* 不落盘,直接在内存中修正 docx 二进制流中的公式字体
* @param incomingDocxBytes Pandoc 生成的原始 docx 字节数组
* @return 修复后的 docx 字节数组
*/
public static byte[] fixMathFontsInBytes(byte[] incomingDocxBytes) throws Exception {
ByteArrayOutputStream outBaos = new ByteArrayOutputStream();
try (
ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(incomingDocxBytes));
ZipOutputStream zos = new ZipOutputStream(outBaos)
) {
ZipEntry entry;
byte[] buffer = new byte[4096];
while ((entry = zis.getNextEntry()) != null) {
// 写入同名打包入口
zos.putNextEntry(new ZipEntry(entry.getName()));
if ("word/document.xml".equals(entry.getName())) {
// 规避点:先将条目读入独立内存,阻止 DocumentBuilder 自动关闭全局 ZipInputStream
ByteArrayOutputStream entryBaos = new ByteArrayOutputStream();
int len;
while ((len = zis.read(buffer)) > 0) {
entryBaos.write(buffer, 0, len);
}
// 进行 DOM 树解析与节点注入
byte[] modifiedDocumentXml = modifyDocumentXml(entryBaos.toByteArray());
zos.write(modifiedDocumentXml);
} else {
// 其他无关文件流式直接复制
int len;
while ((len = zis.read(buffer)) > 0) {
zos.write(buffer, 0, len);
}
}
zos.closeEntry();
zis.closeEntry();
}
}
return outBaos.toByteArray();
}
/**
* 利用 DOM 树在内存中精准修复公式节点
*/
private static byte[] modifyDocumentXml(byte[] originalXmlBytes) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true); // 开启命名空间感知
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(new ByteArrayInputStream(originalXmlBytes));
// 精准获取数学公式作用域下的 r 标签 (m:r),避免误伤正文普通的 w:r
NodeList mRNodes = doc.getElementsByTagNameNS(MATH_NS, "r");
for (int i = 0; i < mRNodes.getLength(); i++) {
Element mRElem = (Element) mRNodes.item(i);
// 1. 检查或创建 m:rPr 属性块
NodeList rPrList = mRElem.getElementsByTagNameNS(MATH_NS, "rPr");
Element rPrElem;
if (rPrList.getLength() > 0) {
rPrElem = (Element) rPrList.item(0);
} else {
rPrElem = doc.createElementNS(MATH_NS, "m:rPr");
// OOXML 规范要求:rPr 必须作为 m:r 的第一个子节点插入
mRElem.insertBefore(rPrElem, mRElem.getFirstChild());
}
// 2. 检查或创建 w:rFonts 标签
NodeList rFontsList = rPrElem.getElementsByTagNameNS(MAIN_NS, "rFonts");
Element rFontsElem;
if (rFontsList.getLength() > 0) {
rFontsElem = (Element) rFontsList.item(0);
} else {
rFontsElem = doc.createElementNS(MAIN_NS, "w:rFonts");
rPrElem.appendChild(rFontsElem);
}
// 3. 覆写四向字体属性,确保 WPS 渲染引擎强制生效
rFontsElem.setAttributeNS(MAIN_NS, "w:ascii", TARGET_FONT);
rFontsElem.setAttributeNS(MAIN_NS, "w:hAnsi", TARGET_FONT);
rFontsElem.setAttributeNS(MAIN_NS, "w:eastAsia", TARGET_FONT);
rFontsElem.setAttributeNS(MAIN_NS, "w:cs", TARGET_FONT);
rFontsElem.setAttributeNS(MAIN_NS, "w:hint", "default");
}
// 将修改后的 DOM 树序列化回字节流
ByteArrayOutputStream xmlBaos = new ByteArrayOutputStream();
Transformer transformer = TransformerFactory.newInstance().newTransformer();
transformer.transform(new DOMSource(doc), new StreamResult(xmlBaos));
return xmlBaos.toByteArray();
}
}
四、 方案核心
- 精确的命名空间隔离:在 OOXML 规范中,正文普通文本运行块为
<w:r>,数学公式内部运行块为<m:r>。代码通过getElementsByTagNameNS匹配指定命名空间,实现了对公式节点的精确控制,不会干扰或破坏正文的原有字体设置。 - 完备的结构兼容性:针对 Pandoc 产生的极简 XML 树,代码具备动态补全功能。在
<m:rPr>缺失时能自动创建,并严格遵循 OOXML 节点的顺序定义(rPr必须置于首位),避免引发 Office 组件的文件损坏报错。 - 零磁盘 I/O 损耗:全流程采用流式内存操作(
byte[]级转换),不产生任何临时文件,彻底消除了服务器磁盘空间被写满的风险,适用于对性能要求严苛的高并发文档导出场景。
