DOCX 和 PPTX 本质上是包含 XML Part、媒体文件与关系图的 ZIP 包,所以浏览器无需安装 Microsoft Office 也能合并。但这也意味着,只拼接 document.xml 或复制 slide XML 远远不够。
正确合并必须解压每个包、保留顺序、分配新关系 ID、递归复制图片和依赖 Part、重写相对 target、解决文件名冲突、更新 Content Types,再用原始 Office MIME 重新打包。
把 Office 文件理解成包关系图
Open XML Part 通过 .rels 文件连接。Word 正文可能引用图片、超链接、编号、页眉或嵌入对象;PowerPoint 幻灯片可能引用 layout、master、theme、chart、notes 和媒体。关系 target 都相对于拥有它的 Part。
包根目录还有 [Content_Types].xml,用来声明扩展名和独立 Part 的内容类型。每个复制到目标包的新 Part 都必须获得正确 default 或 override。
| 包结构 | Word 示例 | PowerPoint 示例 |
|---|---|---|
| 主内容 | word/document.xml | ppt/presentation.xml |
| 有序单元 | 正文块 | p:sldId 列表 |
| 关系文件 | word/_rels/document.xml.rels | ppt/_rels/presentation.xml.rels |
| 依赖内容 | 图片、页眉、样式 | 幻灯片、布局、主题、媒体 |
实现可复用的递归 Part 复制器
给定源 Part 与目标路径,复制字节和 Content Type,解析关系文件,再递归复制全部内部 target。外部链接继续保持外部。如果目标路径已经存在,就分配 merged 后缀,并相对新的拥有者重写关系 target。
一次导入期间缓存源到目标映射,避免共享 layout 或媒体重复复制。used-path 集合则防止多个来源争用同一目标。
function copyPart(sourcePath: string, destinationPath: string) {
destinationZip[destinationPath] = sourceZip[sourcePath];
copyContentType(sourcePath, destinationPath);
for (const relationship of readRelationships(sourcePath)) {
if (relationship.targetMode === 'External') continue;
const sourceTarget = resolveTarget(sourcePath, relationship.target);
const destinationTarget = uniquePartPath(sourceTarget);
copyPart(sourceTarget, destinationTarget);
relationship.target = relativePath(destinationPath, destinationTarget);
}
writeRelationships(destinationPath, relationships);
}合并 Word 正文并重写关系 ID
使用第一个 DOCX 作为基础包,从 document XML 中拆出 w:body,移除中间正文的 sectPr,并在每个导入文档前插入明确分页符,最终保留基础文档末尾的 section properties。
对源文档每条 relationship,在基础列表中分配新 rId,复制目标关系图,再替换导入正文里的 r:id、r:embed、r:link 或 o:relid。缺少这一步时,图片可能指向基础文档里完全无关的关系。
const base = unzipSync(await bytes(files[0]));
const bodyParts = [withoutSectionProperties(readWordBody(base))];
for (const file of files.slice(1)) {
const source = unzipSync(await bytes(file));
let body = withoutSectionProperties(readWordBody(source));
for (const relationship of readDocumentRelationships(source)) {
const nextId = getNextRelationshipId(baseRelationships);
copyRelatedPartRecursively(relationship);
body = replaceRelationshipReferences(body, relationship.id, nextId);
}
bodyParts.push(pageBreakXml(), body);
}
writeWordBody(base, bodyParts.join(''));
return zipSync(base);PowerPoint 按幻灯片顺序合并,而不是按文件名
slide 文件名不是权威顺序。应读取 presentation.xml 的 p:sldId,通过 presentation relationships 解析 r:id;只有顺序列表不可用时,才退回按数字 slide 文件名排序。
为每张导入幻灯片分配新 part index,递归复制它与全部依赖,创建新的 presentation relationship,再追加一个未使用 numeric ID 的 p:sldId。这样既保留源顺序,又不会撞上已有幻灯片。
- 通过关系递归复制 layout、master、theme、chart、notes 和媒体。
- 外部超链接保留为外部,不尝试把目标打进包里。
- 所有 Part 复制后更新 [Content_Types].xml。
- 报告成功前,从重建包重新统计幻灯片数量。
正则只适合受控 XML 边界
当前实现针对已知 Open XML 结构进行有限提取与属性替换,体积小、适合浏览器,但正则不是通用 XML Parser。命名空间变化、特殊格式、损坏包、宏、签名和高级 Office 功能都可能超出假设。
修改前先验证主 Part,只进行狭窄转换,并用多个 Office 查看器打开测试产物。要求企业级高保真时,应改用完整 Open XML 库或带 Office 级工具的服务端环境。
安全限制与结果验证
ZIP 解压体积可能远大于压缩文件。应限制文件数、压缩字节与解压条目数,防范 zip bomb;合并过程中不要执行宏,也不要请求外部 relationship。
zip 成功并不代表 Office 会接受文件。要验证必需 Part、关系 target、Content Type、正文或幻灯片数量,再用 Word、PowerPoint、LibreOffice 和网页查看器打开代表性结果。
总结
浏览器能合并 Office 文件,是因为 DOCX 与 PPTX 是包关系图而非不透明二进制。可复用核心是支持路径冲突和 Content Type 的递归关系复制;Word 再合并正文与 rId,PowerPoint 则追加有序 slide relationship。输出验证也是算法的一部分。