
1. 为什么 XWPFDocument 里需要 XmlCursor段落定位与批量改写的真实痛点如果你用 Apache POI 处理过 Word 文档大概率遇到过这种场景想把文档里所有「本文」替换成「本文档」但直接对XWPFParagraph.getText()做字符串替换后回写时格式全乱了或者想给某个段落里特定的w:r加粗却发现XWPFParagraph提供的 API 只能整段操作颗粒度根本不够。问题的根源在于XWPFDocument是 POI 对 OOXML 的高层封装它把w:p、w:r、w:t这些底层 XML 元素包装成了XWPFParagraph、XWPFRun等对象。高层 API 好用但一旦你要做「token 级」的精准操作——比如只改某个w:t的文本、只动某个w:rPr的属性、在特定位置插入新元素——高层封装就力不从心了。这时候XmlCursor就派上用场了。它是 XMLBeans 提供的底层游标接口能让你像用文本编辑器光标一样在 OOXML 的 XML 树里逐 token 移动。XWPFParagraph.getCTP()返回的是CTP对象对应w:p元素调用它的newCursor()就能拿到一个游标从段落根节点开始遍历。XmlCursor能做什么简单说三件事定位找到目标 token 的精确位置、读取拿到当前 token 的类型和值、改写在当前位置插入、删除、替换元素或属性。适合谁适合那些已经用 POI 做过基础文档处理、现在需要处理「格式保留的批量替换」「按条件改样式」「批注内容精准修改」的开发者。我试过在一个 200 页的合同模板里批量替换甲乙方名称用高层 API 替换后字体全变成了默认宋体后来改用XmlCursor只动w:t的文本节点格式一点没丢。这篇就把这套操作拆成可复制的配置和验证步骤。2. 前置准备XWPFDocument 与 XmlCursor 的依赖配置和初始化在动手写游标逻辑之前先把环境和依赖理清楚。XmlCursor来自org.apache.xmlbeans包它不是一个独立依赖而是随 POI 的poi-ooxml一起引入的。所以你的pom.xml里只要有poi-ooxmlXmlCursor就能直接用。2.1 Maven 依赖配置dependencies dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependency dependency groupIdorg.apache.xmlbeans/groupId artifactIdxmlbeans/artifactId version5.2.0/version /dependency /dependencies注意poi-ooxml会传递依赖xmlbeans但版本可能偏旧。如果你要用到较新的XmlCursorAPI比如toNextToken的某些重载建议显式声明xmlbeans版本。实测下来 5.2.x 组合比较稳。2.2 初始化 XmlCursor 的三种入口XmlCursor的获取方式取决于你要操作的对象层级import org.apache.poi.xwpf.usermodel.*; import org.apache.xmlbeans.XmlCursor; import org.apache.xmlbeans.XmlObject; // 方式一从段落入手操作 w:p 内部 XWPFParagraph paragraph document.getParagraphs().get(0); XmlCursor cursor paragraph.getCTP().newCursor(); // 方式二从表格单元格入手 XWPFTableCell cell document.getTables().get(0).getRow(0).getCell(0); XmlCursor cellCursor cell.getCTTc().newCursor(); // 方式三从整个文档体入手遍历所有块级元素 XmlCursor bodyCursor document.getDocument().getBody().newCursor();三种方式的区别在于游标的起始位置和可遍历范围。段落游标只能看到w:p内部的 token文档体游标能看到所有w:p、w:tbl、w:sectPr。选哪个取决于你的改写范围。2.3 游标移动的核心方法XmlCursor的移动方法不多但每个都要理解清楚方法作用返回值toNextToken()移动到下一个 token新位置的 TokenTypetoFirstChild()移动到第一个子元素是否成功toParent()移动到父元素是否成功toNextSibling()移动到下一个兄弟元素是否成功toPrevToken()移动到上一个 token新位置的 TokenTypetoEndToken()移动到结束 token新位置的 TokenTypetoNextToken()是最常用的它按文档顺序遍历所有 token包括START元素开始、END元素结束、ATTR属性、TEXT文本、COMMENT注释等类型。理解 token 类型是后面做条件判断的基础。注意newCursor()创建的游标初始位置在对象的 START token 之前第一次调用toNextToken()才会进入第一个 token。如果你直接读getObject()拿到的是整个xml-fragment不是单个 token。3. 可复制的 XmlCursor 遍历配置token 级定位与批量改写这一节是核心。我会给出一份完整的、可直接复制运行的配置类它封装了游标初始化、token 遍历、条件匹配和改写动作。你可以把它当成一个工具类按需改匹配条件。3.1 完整的游标遍历骨架import org.apache.poi.xwpf.usermodel.*; import org.apache.xmlbeans.XmlCursor; import org.apache.xmlbeans.XmlObject; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR; public class ParagraphCursorRewriter { private final XWPFParagraph paragraph; public ParagraphCursorRewriter(XWPFParagraph paragraph) { this.paragraph paragraph; } public void rewriteText(String oldText, String newText) { CTP ctp paragraph.getCTP(); XmlCursor cursor ctp.newCursor(); try { while (cursor.hasNextToken()) { XmlCursor.TokenType tokenType cursor.toNextToken(); if (tokenType XmlCursor.TokenType.TEXT) { String text cursor.getTextValue(); if (oldText.equals(text)) { cursor.setTextValue(newText); } } } } finally { cursor.dispose(); } } }这段代码的关键点cursor.getTextValue()拿到的是当前 TEXT token 的文本内容cursor.setTextValue()直接改写。注意dispose()必须放在finally里否则游标不释放会导致内存泄漏处理大文档时尤其明显。3.2 按 token 类型做条件分支实际场景里你往往不是只改文本还要根据 token 类型做不同处理。下面这份配置展示了如何区分START、ATTR、TEXT、END四类 tokenpublic void inspectAndRewrite() { XmlCursor cursor paragraph.getCTP().newCursor(); try { while (cursor.hasNextToken()) { XmlCursor.TokenType tokenType cursor.toNextToken(); switch (tokenType) { case START: XmlObject startObj cursor.getObject(); System.out.println(START: startObj.xmlText()); break; case ATTR: String attrName cursor.getName().getLocalPart(); String attrValue cursor.getTextValue(); System.out.println(ATTR: attrName attrValue); break; case TEXT: System.out.println(TEXT: cursor.getTextValue()); break; case END: System.out.println(END); break; default: break; } } } finally { cursor.dispose(); } }运行这段代码你会看到类似这样的输出顺序对应你 excerpt 里的w:p结构ATTR: paraId143E3662 ATTR: textId4167FBA7 START: w:pStyle w:vala1/ ATTR: vala1 END START: w:ind w:firstLine459/ ATTR: firstLine459 END START: w:rPr.../w:rPr ... TEXT: 本文 END这个顺序和你在断点里看到的一致属性先于子元素START和END成对出现TEXT夹在w:t的START和END之间。3.3 批量改写的配置化封装把匹配规则抽成配置避免硬编码。下面这份 JSON 配置定义了「哪些文本要替换成什么」{ rewriteRules: [ { match: 本文, replace: 本文档, scope: TEXT }, { match: 甲方, replace: 委托方, scope: TEXT }, { match: 乙方, replace: 受托方, scope: TEXT } ], preserveFormat: true, skipEmptyText: true }对应的 Java 加载逻辑public void applyRules(ListRewriteRule rules) { XmlCursor cursor paragraph.getCTP().newCursor(); try { while (cursor.hasNextToken()) { XmlCursor.TokenType tokenType cursor.toNextToken(); if (tokenType ! XmlCursor.TokenType.TEXT) continue; String text cursor.getTextValue(); if (text null || text.trim().isEmpty()) continue; for (RewriteRule rule : rules) { if (rule.getMatch().equals(text)) { cursor.setTextValue(rule.getReplace()); break; } } } } finally { cursor.dispose(); } }preserveFormat为 true 时只改w:t的文本值不动w:rPr格式自然保留。这是XmlCursor相比字符串替换的最大优势。3.4 表格与批注的游标入口表格单元格的游标入口是getCTTc().newCursor()批注则是getCTComment().newCursor()。批注的 XML 结构和段落类似也有w:p、w:r、w:t所以同一套遍历逻辑可以复用// 表格单元格 XWPFTableCell cell table.getRow(0).getCell(0); XmlCursor cellCursor cell.getCTTc().newCursor(); // 批注POI 5.x 支持 XWPFComment comment document.getComments().get(0); XmlCursor commentCursor comment.getCTComment().newCursor();提示批注的getCTComment()在 POI 5.2 才稳定低版本可能拿不到。如果编译报错先确认 POI 版本。4. 验证请求与成功结果定位偏移和改写结果的检查动作写完游标逻辑怎么确认它真的按预期工作了不能只看代码跑通要验证「定位准不准」和「改写对不对」。这一节给出三个可复现的验证动作。4.1 验证一打印 token 序列比对偏移在遍历循环里加一个计数器记录每个 token 的序号和类型int index 0; while (cursor.hasNextToken()) { XmlCursor.TokenType tokenType cursor.toNextToken(); System.out.printf([%d] %s - %s%n, index, tokenType, tokenType XmlCursor.TokenType.TEXT ? cursor.getTextValue() : cursor.getName()); }拿一个已知结构的段落跑一遍把输出和你手动解析 XML 得到的 token 序列对比。如果序号对不上说明游标起始位置或移动逻辑有问题。常见的偏移错误是在STARTtoken 上误判为TEXT导致getTextValue()返回空。4.2 验证二改写前后 XML 对比改写完成后把CTP的 XML 打印出来和改写前对比String before paragraph.getCTP().xmlText(); rewriter.rewriteText(本文, 本文档); String after paragraph.getCTP().xmlText(); System.out.println(BEFORE: before); System.out.println(AFTER: after);成功的标志是只有w:t的文本变了w:rPr、w:pPr、w:ind等格式元素完全没动。如果发现w:rPr被删了或多了新属性说明你的改写动作误伤了其他 token。4.3 验证三写回文档并重新打开最终验证是把改好的文档写回磁盘再用 POI 重新读一遍try (FileOutputStream out new FileOutputStream(output.docx)) { document.write(out); } // 重新读取验证 try (XWPFDocument reopened new XWPFDocument(new FileInputStream(output.docx))) { String text reopened.getParagraphs().get(0).getText(); System.out.println(Reopened text: text); }如果重新打开后文本正确、格式正常说明改写成功。如果 Word 打开报「文档损坏」通常是游标操作破坏了 XML 结构比如在ENDtoken 后插入了元素。4.4 成功结果示例一个正确的改写结果控制台输出应该类似[0] START - w:p [1] ATTR - paraId [2] ATTR - textId [3] START - w:pPr ... [12] START - w:t [13] TEXT - 本文 [14] END 改写后 TEXT: 本文档 Reopened text: 本文档token 序号连续、类型正确、文本替换后格式元素数量不变三个条件同时满足才算通过。5. 本篇常见错排查光标越界、401、local proxy failed 与 OAuth 报错游标操作和接入配置过程中有几类报错特别常见。这一节按「报错原文 → 原因 → 解决」的结构逐个拆。5.1 光标越界IllegalStateException: Cursor is not on a token这个报错通常出现在你调用了getTextValue()但当前 token 不是TEXT或ATTR类型时。比如在STARTtoken 上直接读文本值就会抛这个异常。解决方式每次读值前先判断 token 类型。if (tokenType XmlCursor.TokenType.TEXT || tokenType XmlCursor.TokenType.ATTR) { String value cursor.getTextValue(); }另一个越界场景是游标已经走到末尾你还继续调toNextToken()。hasNextToken()返回 false 后必须停止否则游标状态未定义。5.2 401 Unauthorized接入配置里的 Key 问题如果你在用 TaoToken 这类 API 网关做模型调用辅助文档处理401 通常意味着 Key 没带对或过期了。检查三件套Base URL、Key、Model ID 是否一致。{ baseUrl: https://taotoken.net/api, apiKey: sk-你的Key, model: claude-3-5-sonnet }Base URL 不要带 UTM 参数API 地址就是https://taotoken.net/api。Key 去控制台的 API Keys 页面生成别用错环境的 Key。5.3 local proxy failed本地代理配置冲突这个报错一般出现在你本地开了代理工具但 API 请求走了代理导致连接失败。解决方式是检查系统代理设置或者在代码里显式指定不走代理System.setProperty(http.proxyHost, ); System.setProperty(http.proxyPort, );如果你用的是 HttpClient可以在构建时设置ProxySelector.of(null)。核心原则API 请求直连不要经过本地代理。5.4 reading choices 报错响应结构解析失败reading choices类报错通常出现在你解析模型返回的 JSON 时字段路径不对。不同模型的响应结构不同Claude 系列返回的是content数组OpenAI 系列返回的是choices数组。如果你用统一的解析逻辑处理所有模型就会在某个模型上报reading choices失败。解决方式按模型类型分支解析或者用网关的统一响应格式。TaoToken 的模型对话接口返回结构统一可以直接取content字段。5.5 OAuth 报错Claude Code 接入时的认证问题如果你在用 Claude Code 接入OAuth 报错通常是 token 过期或回调地址不匹配。检查settings.json里的配置{ apiProvider: anthropic, baseUrl: https://taotoken.net/api, apiKey: sk-你的Key, model: claude-3-5-sonnet }三件套齐全后重新触发一次认证流程。如果还是报 OAuth 错误去控制台确认 Key 的权限范围是否包含 Claude 模型。5.6 改写后格式丢失游标操作误伤 rPr这是最隐蔽的坑。你在TEXTtoken 上调用setTextValue()本身不会动格式但如果你在遍历过程中对STARTtoken 做了removeXml()或insertXml()就可能把w:rPr删掉。排查方式改写前后对比 XML看w:rPr节点数量是否变化。6. 从游标到工作流把 XmlCursor 接入你的文档处理链路游标逻辑跑通之后下一步是把它接入实际工作流。单段落的改写只是起点真实场景往往是「遍历整个文档的所有段落和表格按规则批量改写」。6.1 全文档遍历的封装public void rewriteDocument(XWPFDocument document, ListRewriteRule rules) { for (XWPFParagraph paragraph : document.getParagraphs()) { new ParagraphCursorRewriter(paragraph).applyRules(rules); } for (XWPFTable table : document.getTables()) { for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph paragraph : cell.getParagraphs()) { new ParagraphCursorRewriter(paragraph).applyRules(rules); } } } } }这段代码覆盖了正文段落和表格内段落。批注需要单独遍历document.getComments()。6.2 性能注意事项XmlCursor的创建和销毁有开销。处理大文档时不要在每个 token 上创建新游标而是复用一个游标走完整个段落。另外dispose()一定要调用否则 XMLBeans 的内部缓存会持续增长。6.3 与模型辅助的结合点如果你想让模型帮你生成改写规则可以把文档的纯文本提取出来发给模型分析让它输出 JSON 格式的rewriteRules。这一步用 TaoToken 的模型对话接口就能做返回的规则直接喂给上面的applyRules方法。curl -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer sk-你的Key \ -H Content-Type: application/json \ -d { model: claude-3-5-sonnet, messages: [{role: user, content: 分析以下合同文本输出替换规则JSON...}] }拿到规则后本地用XmlCursor执行改写格式保留和批量处理都在本地完成模型只负责规则生成。这样既利用了模型的语义理解又保证了文档处理的确定性。6.4 长期编码场景的配置如果你要长期做这类文档处理工具开发建议把 API Key 和模型配置放到环境变量或配置文件里不要硬编码。Coding Plan 适合这种持续开发场景配置一次后续直接复用。# ~/.taotoken/config.toml [api] base_url https://taotoken.net/api api_key sk-你的Key [model] default claude-3-5-sonnet需要生成新 Key 或查看用量去控制台的 API Keys 页面接入细节查接入文档想先验证模型输出效果用模型对话页面试几条规则生成确认格式对了再写进代码。文档处理的确定性靠XmlCursor保证规则生成的灵活性靠模型补足两者结合才是完整的链路。