一个看起来很简单的问题
面面通的简历优化流程是:用户上传简历 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 里面的文本内容。
完整的流程分三步走:

第一步:解析(DocxTextLocator)
读取原始 DOCX,遍历所有段落(正文、表格、页眉、页脚),记录每个段落的路径和文本内容。
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 XMLDocument dom = DocumentBuilderFactory.newInstance() .newDocumentBuilder() .parse(new ByteArrayInputStream(xmlBytes));
// 搜索 wps:wsp 标签内的文本// 这些就是 POI 读不到的文本框段落这个 DOM fallback 能覆盖大约 95% 的文本框场景。还有 5% 是嵌套太深的复杂模板——人工微调就好。
降级策略
所有替换操作都是可逆的。如果某个段落匹配失败,不会让整个导出崩掉:
- 匹配成功的段落 → 用改写文本替换
- 匹配失败的段落 → 保留原文
- 全部失败 → 走全文本提取回退(
extractFallbackText)
// TemplatePreservingExportServicepublic 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% 走回退路径。
效果对比
这件事如果只看代码会有点抽象,换成画面就是这样:

优化前的简历文本:
精通 Java,熟悉 Spring Boot→ 普通的宋体 10 号AI 改写后的导出结果:
具备扎实的 Java 开发能力,熟练掌握 Spring Boot 框架→ 保持宋体 10 号、原加粗风格、原颜色、原行距字体、字号、加粗、颜色、行距都保留了。不是因为 POI 有多强,而是因为我们没有重建段落 XML,只在原始 XML 上改了文本内容。
回头看我宁愿花点时间在这个方案上,也不走”AI 输出文本 → 手动调格式”的捷径。你永远不知道用户上传的简历模板有多少种花样——什么格式都能保住了,用户才会觉得”这个工具靠谱”。
面面通系列的 5 篇文章就到这里了。从 AiGateway 网关层、AI 面试引擎、论文知识库引用,到 DOCX 格式保留导出,基本都是这种”看起来简单,做起来才发现边角很多”的模块。
完整代码在 GitHub 上。后面如果再遇到更怪的简历模板,我估计还得继续补这个 patcher。
部分信息可能已经过时





