Mobile wallpaper 1Mobile wallpaper 2Mobile wallpaper 3Mobile wallpaper 4
1731 字
9 分钟
AI 改写简历后,Word 格式没崩——折腾了 3 种方案才搞定

一个看起来很简单的问题#

面面通的简历优化流程是:用户上传简历 DOCX → AI 分析 → AI 按岗位改写 → 导出优化后的 Word。

看起来很简单对不对?“替换文本”而已。

但真的试了 3 种方案才搞定。


方案一:全文替换(失败)#

最天真的想法:把原 DOCX 当成文本文件,把旧段落替换成新段落。

// 方案一:简单粗暴
String content = readDocxAsText(docxFile);
content = content.replace(oldText, newText);
writeDocxFromText(docxFile, content);

问题在于 DOCX 不是文本文件,它是一个 ZIP 包,里面是 OOXML 标记语言。直接替换字符串?表格、图片、样式全丢了。

更致命的是,很多简历模板的文本不是连续存储的——“精通 Java”在 XML 里可能是 <w:r><w:t>精通</w:t></w:r><w:r><w:t> Java</w:t></w:r>,按段落替换会把格式覆盖掉。

结果:输出的 Word 能打开,但模板完全变了,连字体的颜色都不对。


方案二:POI 段落替换(部分失败)#

Apache POI 能按段落读取 DOCX,也能替换段落文本:

// 方案二:POI 段落替换
XWPFDocument doc = new XWPFDocument(fileStream);
for (XWPFParagraph para : doc.getParagraphs()) {
String text = para.getText();
if (text.equals(oldText)) {
// 直接删除所有 run 重建
for (int i = para.getRuns().size() - 1; i >= 0; i--) {
para.removeRun(i);
}
para.createRun().setText(newText);
}
}
doc.write(outputStream);

这个方案有两个问题:

1. 格式丢失:删掉所有 run 重建后,新的 run 用默认字体——用户的楷体变宋体、加粗变正常、字号全跑了。简历模板里”求职意向”四个字通常是加大加粗的,重建后就变成了普通正文。

2. 表格和文本框不命中:很多简历模板把关键信息放在表格里或文本框里,POI 的 doc.getParagraphs() 拿不到这些段落。

测试了一轮,大约 40% 的段落格式不对。


方案三:格式化替换(成功)#

最终方案的核心思想是:不动段落的 XML 结构,只替换 run 里面的文本内容。

完整的流程分三步走:

DOCX 的 OOXML 替换路径

第一步:解析(DocxTextLocator)#

读取原始 DOCX,遍历所有段落(正文、表格、页眉、页脚),记录每个段落的路径文本内容

DocxTextLocator.java
public List<ParagraphProfile> locate(byte[] docxBytes) {
try (XWPFDocument doc = new XWPFDocument(new ByteArrayInputStream(docxBytes))) {
// 正文段落
for (int i = 0; i < doc.getParagraphs().size(); i++) {
XWPFParagraph para = doc.getParagraphs().get(i);
ParagraphProfile profile = tryExtract(
profiles.size(), para, DocxPath.body(i), MIN_LENGTH
);
if (profile != null) profiles.add(profile);
}
// 表格段落
for (int ti = 0; ti < doc.getTables().size(); ti++) {
for (int ri = 0; ri < tables.get(ti).getRows().size(); ri++) {
for (int ci = 0; ci < rows.get(ri).getTableCells().size(); ci++) {
// 表格单元格内的段落也要解析
}
}
}
// 文本框段落(通过 DOM 回退读取)
// ...
}
}

每个段落的路径是稳定的:body.p[0] 表示正文第 1 段,body.table[1].row[0].cell[0].p[2] 表示第 2 个表格的第 1 行第 1 个单元格的第 3 个段落。

第二步:对照#

AI 改写完后,把改写结果和原始段落做对照。因为改写前后段落数量和顺序应该一致(改写内容而不是重写),可以直接按 index 匹配。

如果 index 匹配不上,用文本相似度(BEFORE_SIMILARITY_THRESHOLD = 0.5)做 fallback:

// 文本相似度 >= 0.5 就认为是同一段
if (similarity(originalText, optimizedText) >= 0.5) {
// 匹配成功,生成 patch
}

第三步:替换(DocxPatchApplier + DocxDocumentXmlPatcher)#

这是最核心也最 tricky 的一步。

关键操作是:在 OOXML 中找到对应段落,定位到第一个 <w:r><w:t> 元素,替换它的文本内容,其他所有属性保持不变。

// DocxDocumentXmlPatcher — 在 OOXML 层面操作
// 定位到 body.p[3] → 找到 <w:r><w:t> 节点 → 修改文本内容

这里避开了 XWPFDocument.write(),因为 POI 的序列化会重建整个 DOCX——样式、图片、文本框、页眉页脚配置都可能被冲掉。

改用直接操作 ZIP 包里的 word/document.xml

// 直接修改 ZIP 中的 XML 文件
ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(originalDocx));
ZipOutputStream zos = new ZipOutputStream(outputStream);
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.getName().equals("word/document.xml")) {
// 读取 XML,应用 patch,写回
byte[] patchedXml = applyPatchesToXml(readEntry(zis), patches);
zos.putNextEntry(new ZipEntry(entry.getName()));
zos.write(patchedXml);
} else {
// 其他文件(图片、样式、主题等)原样复制
zos.putNextEntry(new ZipEntry(entry.getName()));
zis.transferTo(zos);
}
}

这样除了被改写的文本,DOCX 里的所有其他内容——图片、主题颜色、页眉页脚、段落样式、字体嵌入——都保持原样。


文本框的特殊处理#

大部分的模板格式问题都出在文本框

简历模板经常用文本框来放”求职意向”、“技能标签”这类关键信息。POI 读不到文本框里的文本,但它们在 OOXML 里其实存在——藏在 <mc:AlternateContent> 或者 <wps:wsp> 节点里。

解决方案是在 DocxTextLocator 里增加一个 DOM fallback 路径:

// 当 POI 拿不到段落时,用 DOM 解析 raw XML
Document dom = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(new ByteArrayInputStream(xmlBytes));
// 搜索 wps:wsp 标签内的文本
// 这些就是 POI 读不到的文本框段落

这个 DOM fallback 能覆盖大约 95% 的文本框场景。还有 5% 是嵌套太深的复杂模板——人工微调就好。


降级策略#

所有替换操作都是可逆的。如果某个段落匹配失败,不会让整个导出崩掉:

  1. 匹配成功的段落 → 用改写文本替换
  2. 匹配失败的段落 → 保留原文
  3. 全部失败 → 走全文本提取回退(extractFallbackText
// TemplatePreservingExportService
public byte[] exportWithPreservedFormat(byte[] originalDocx, List<String> optimizedParagraphs) {
// 生成 patch 列表
List<DocxPatch> patches = matchParagraphs(originalProfiles, optimizedParagraphs);
if (patches.isEmpty()) {
// 全部匹配失败→回退方案
return fallbackExport(originalDocx, optimizedParagraphs);
}
// 应用 patch
return patchApplier.applyPatches(originalDocx, patches, textBoxTextToPath);
}

这在生产环境跑下来,大约 90% 的导出是完美保留格式的,5% 有少量格式偏差,5% 走回退路径。


效果对比#

这件事如果只看代码会有点抽象,换成画面就是这样:

DOCX 格式保留导出对比

优化前的简历文本:

精通 Java,熟悉 Spring Boot
→ 普通的宋体 10 号

AI 改写后的导出结果:

具备扎实的 Java 开发能力,熟练掌握 Spring Boot 框架
→ 保持宋体 10 号、原加粗风格、原颜色、原行距

字体、字号、加粗、颜色、行距都保留了。不是因为 POI 有多强,而是因为我们没有重建段落 XML,只在原始 XML 上改了文本内容。


回头看我宁愿花点时间在这个方案上,也不走”AI 输出文本 → 手动调格式”的捷径。你永远不知道用户上传的简历模板有多少种花样——什么格式都能保住了,用户才会觉得”这个工具靠谱”。

面面通系列的 5 篇文章就到这里了。从 AiGateway 网关层、AI 面试引擎、论文知识库引用,到 DOCX 格式保留导出,基本都是这种”看起来简单,做起来才发现边角很多”的模块。

完整代码在 GitHub 上。后面如果再遇到更怪的简历模板,我估计还得继续补这个 patcher。

AI 改写简历后,Word 格式没崩——折腾了 3 种方案才搞定
https://qiandaos.top/posts/mianmiantong-series/05-docx-export/
作者
千岛寒流
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00