Java POI在Word指定位置插入图片:占位符替换与段落锚点方案详解 1. 先把这个需求拆明白为什么“指定位置插图片”是个坑做POI操作Word的都知道往文档里塞文字、塞表格、塞图片API翻来覆去就那么几个但“在指定位置插入图片”这个需求每次做都得折腾一阵子。原因很简单POI处理Word文档时底层是把docx当作一堆XML文件来读写的你看到的“第三段后面”“第二张表格上方”这种逻辑位置在POI的模型里就对应着XWPFDocument里的段落列表索引但这个索引和你在Word里肉眼看到的排版位置并不是一回事。先说一下这个需求的典型场景批量生成合同、生成报告、生成证书或者产品说明书的时候需要在固定位置插入Logo、签名图、产品截图或者扫码验证图片。比如我手头做过的一个某公司项目需要在报价单的每一页指定位置加公司水印图同时在“客户确认签字”那段话下面自动插入客户的签名图片。这种需求用模板引擎做不了因为图片内容不是固定的用Word自动化插件做又要装环境巡检线上跑不起来最后只能老老实实用POI。这个需求难在哪三个地方第一POI的新旧API不一样。XWPFDocument原生POI和docx4j是两套完全不同的操作方式如果你在一篇文章里混合看到这两种写法多半是作者自己也没搞明白。第二在XWPFParagraph里插入图片实际上是把图片以二进制流的形式挂到一个XWPFRun上而这个Run必须“长在”某个段落对象里。你要插入的位置如果是一个空段落那没问题但如果你要插到“某个句子的中间”这种位置事情就变得复杂了因为XWPFRun没有“在指定字符偏移量处插入”这种功能。第三POI往段落里加图片时对图片的类型、尺寸和格式比较敏感图片插进去之后显示成一张破碎的图标或者大小失控都是很常见的。所以这篇我打算先把“位置定位”这件事讲清楚这是整个方案的核心然后给你两条能跑通的技术路线分别适用不同场景接着是完整可复现的代码包括参数怎么换算最后把我踩过的坑全部列出来。如果你是要做一个生成Word图片报告的批量工具这篇文章可以直接把你带进坑里再带出来。2. 两条技术路线先想清楚再动手2.1 路线A占位符替换方案适合模板固定的场景这套方案的思路很容易理解先在Word模板里预留一个特殊标记比如{{IMAGE_1}}、[[SIGN]]、【图片占位】然后用POI扫描所有段落里的所有Run找到包含这个特殊字符串的Run把文字替换成图片。具体的操作逻辑是这样的用XWPFDocument打开模板文件。遍历document.getParagraphs()拿到每一个XWPFParagraph。对每个段落调用getRuns()拿到Run列表。逐个Run检查getText()里是否包含你约定的占位符。如果找到了就清空这个Run的文本调用addPicture方法把图片插入到这个Run里。保存文档。这套方案最大的好处是代码量少逻辑直白十几行就能解决问题。模板上放多少个占位符你代码里就循环处理多少次模板的结构怎么设计代码完全不用改。我做的合同自动生成系统走的就是这条路模板在Word里做好填好占位符后端Java服务拿到签名图片以后扫一遍替换完事。这套方案的坑在于占位符容易被“切开”。POI在读取docx时同一个段落里的文本会被拆分成多个Run拆分位置可能出现在词的中间也可能出现在标点处。比如你在Word里写了“客户签字{{SIGN}}”打开后发现这个占位符被拆成了三个Run分别是“客户签字{{”“SIGN”“}}”那你的匹配逻辑如果只检查单个Run的完整内容就永远匹配不上。我在第一次做这个功能时就卡在这里后面才改成了“跨Run拼接匹配”这个后面实操部分详细说。2.2 路线B段落锚点定位方案适合动态控制位置的场景另一种思路是不依赖预先写好的占位符而是按照段落索引来定位。预先约定“图片插到第N段之后”或“某个标题段落的下面一个段落里”。实现上有两种变体变体一是“插入新段落”用document.insertNewParagraph(XmlCursor)在指定索引处创建新段落然后把图片挂到这个新段落上。这个过程需要用到XmlCursor来找位置。变体二是“向现有段落追加”直接在目标段落XWPFParagraph上调用run.addPicture()图片会被追加到这个段落所有Run的末尾。如果目标段落是空段落那效果跟你想要的“插入到这个地方”是完全一致的如果目标段落有文字图片会出现在段落结尾。这条路线适合什么场景适合那种模板不固定、位置逻辑动态生成的情况。比如某个数据仓库报表生成器图片数量不定每个图片的说明文字长短不一用占位符根本没法预估模板里该放多少标记直接在生成文档时按索引定位更灵活。但这条路线对“位置”的理解要求高insertNewParagraph插入的位置是文档级别的段落索引不是Word画面上的视觉位置你在代码里看到的第5个段落可能在Word里因为分页符、表格、分节符等原因根本不是你想的那个地方。所以用这条路之前先要把文档结构用代码打印出来确认一遍不然插到哪个段落后面的都有。2.3 两条路线的取舍建议对比维度占位符替换方案段落锚点定位方案适用场景模板固定、位置固定、数量有限的图片位置动态、图片数量不固定代码复杂度低较高位置精度高占位符在哪图就在哪依赖段落索引的准确性对模板的要求需要预先在模板中设置占位符不需要任何Docx均可踩坑重灾区占位符被拆分成多个Run段落索引与实际排版位置不一致从我个人的项目经验来看70%的“指定位置插入图片”需求用占位符方案就够了而且可维护性更好。模板是人做的你约定好占位符格式编辑模板的人只要不把占位符拆开成两段比如在两个文本框中程序就稳定运行。段落锚点方案更适合纯代码动态生成整个文档的场景。3. 手把手实现完整代码与参数拆解3.1 环境准备与依赖引入我用的是Maven工程POI版本用4.1.2以上都行。要说明的是POI操作docx需要引入poi-ooxml这个包会连带引入poi、poi-ooxml-schemas、xmlbeans等依赖。如果你在用log4j还要注意版本冲突POI 4.x里面自带的log4j-api一般是2.x和项目里的其他日志实现配合好。dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version4.1.2/version /dependency如果你用的是5.x版本API基本一致但要注意包名和某些方法签名变化。另外图片操作需要用到awt的Toolkit和MediaTracker来做尺寸读取纯JDK自带不用额外引包。3.2 核心API图片是怎么塞进Run里的POI往Word里插图片核心方法就这一个XWPFRun.addPicture(InputStream pictureData, int pictureType, String filename, Units.toEMU(width), Units.toEMU(height))别被参数吓到逐一说清楚pictureData图片文件的输入流FileInputStream或者ByteArrayInputStream都行。pictureType图片类型常量Document.PICTURE_TYPE_JPEG、PICTURE_TYPE_PNG、PICTURE_TYPE_GIF这些都在org.apache.poi.xwpf.usermodel.Document接口里。filename图片在docx内部的记录名可以是任意字符串建议直接用原始文件名。width和height单位是EMUEnglish Metric Unit不是像素也不是厘米。1英寸914400 EMU1厘米360000 EMU1像素在96DPI下约等于9525 EMU。这个单位换算是新手最晕的地方。你从网页上拿图片尺寸是像素POI要求的是EMU中间要乘一个系数。96DPI下像素转EMU直接乘以9525这是经验值。很多人不知道Units这个工具类在org.apache.poi.util.Units里里面有toEMU、toDXA、toPoints这些现成方法。我们用Units.toEMU(pixelWidth)就能把像素直接换算成EMU不需要自己写乘法。还有一个容易忽略的细节addPicture方法有好几个重载版本。早期版本是addPicture(InputStream, int, int width, int height)后面又出了带filename参数的重载。如果你看到“The method addPicture(InputStream, int, String, int, int) is undefined”这种报错大概率是POI版本太老升级版本就好。3.3 方案A完整实现占位符替换带跨Run拼接下面是一份可以直接拿去改的完整代码。注释写得很细重点是跨Run匹配那段。import org.apache.poi.util.Units; import org.apache.poi.xwpf.usermodel.*; import java.io.FileInputStream; import java.io.FileOutputStream; import java.util.ArrayList; import java.util.List; public class WordImageInsertUtil { /** * 在指定占位符处插入图片 * param templatePath 模板文件路径 * param outputPath 输出文件路径 * param placeholder 占位符如 {{IMG_LOGO}} * param imagePath 图片文件路径 */ public static void insertImageAfterPlaceholder(String templatePath, String outputPath, String placeholder, String imagePath, int targetWidthPx, int targetHeightPx) throws Exception { try (XWPFDocument document new XWPFDocument(new FileInputStream(templatePath)); FileOutputStream fos new FileOutputStream(outputPath)) { // 遍历所有段落 for (XWPFParagraph paragraph : document.getParagraphs()) { // 先判断这段的文本拼接后的是否包含占位符 String paragraphText paragraph.getText(); if (paragraphText null || !paragraphText.contains(placeholder)) { continue; } // 关键点在段落内部找到占位符到底在哪几个Run里 ListXWPFRun runs paragraph.getRuns(); ListXWPFRun matchedRuns findRunsContainPlaceholder(runs, placeholder); if (matchedRuns.size() 0) { continue; } // 插入图片的方式取第一个命中的Run把占位符相关文本清空再塞图片 XWPFRun imageRun matchedRuns.get(0); // 清空占位符文本 imageRun.setText(, 0); try (FileInputStream imageStream new FileInputStream(imagePath)) { imageRun.addPicture(imageStream, Document.PICTURE_TYPE_PNG, inserted.png, Units.toEMU(targetWidthPx), Units.toEMU(targetHeightPx)); } } document.write(fos); } } /** * 跨Run查找占位符 * 思路从第一个Run开始连续拼接多个Run的文本直到拼出来的字符串包含占位符 */ private static ListXWPFRun findRunsContainPlaceholder(ListXWPFRun runs, String placeholder) { ListXWPFRun matchedRuns new ArrayList(); StringBuilder sb new StringBuilder(); for (XWPFRun run : runs) { String text run.getText(); if (text null) { continue; } matchedRuns.add(run); sb.append(text); // 当前拼接结果已经包含占位符了 if (sb.toString().contains(placeholder)) { return matchedRuns; } // 如果已经超过了占位符长度两倍还没匹配到清空重新来 if (sb.length() placeholder.length() * 2) { matchedRuns.clear(); sb.setLength(0); } } return matchedRuns; } }这里要特别注意findRunsContainPlaceholder这个方法的逻辑它不是简单判断单个Run的文本而是把相邻Run的文本拼起来再判断。举个例子如果模板里“客户签字{{SIGN}}”被拆成两个RunRun1文本是“客户签字{{SIGN”Run2文本是“}}”那么第一个Run单独匹配是不成功的但是拼起来以后“客户签字{{SIGN}}”就匹配上了。拿到matchedRuns之后插入图片用第一个Run但那其他Run里的残留文本比如“}}”会不会留在文档里会。所以稳妥的做法是把matchedRuns里所有Run的文本全部清空只保留图片。我在上面的示例里只清了第一个Run实际生产代码建议遍历清空for (XWPFRun run : matchedRuns) { run.setText(, 0); }3.4 方案B完整实现用空段落锚点定位如果你的场景是“图片后面跟着说明文字”或者“多个图片依次往下排”用段落锚点更顺手。代码逻辑如下public static void insertImageAfterParagraph(String templatePath, String outputPath, int paragraphIndex, String imagePath, int widthPx, int heightPx) throws Exception { try (XWPFDocument document new XWPFDocument(new FileInputStream(templatePath)); FileOutputStream fos new FileOutputStream(outputPath)) { ListXWPFParagraph paragraphs document.getParagraphs(); // 1. 先创建一个新段落插在目标段落后面 XWPFParagraph targetParagraph paragraphs.get(paragraphIndex); // 获取目标段落后面的游标位置 // 用XmlCursor的方式在目标段落后插入新段落 // 注意这里用insertNewParagraph方法 // 但是insertNewParagraph返回的是新段落还是null取决于你的POI版本 // 更稳定的做法是用CTP加XmlCursor XWPFParagraph newParagraph document.insertNewParagraph( targetParagraph.getCTP().newCursor() ); // 如果insertNewParagraph返回null需要手动把新段落挪到目标段落后面 // 处理逻辑插入后把新段落移动到正确位置 document.setParagraph(newParagraph, paragraphIndex 1); // 2. 在新段落里插入图片 XWPFRun run newParagraph.createRun(); try (FileInputStream imageStream new FileInputStream(imagePath)) { run.addPicture(imageStream, Document.PICTURE_TYPE_PNG, inserted.png, Units.toEMU(widthPx), Units.toEMU(heightPx)); } // 3. 可选在新段落里加一行说明文字 XWPFRun textRun newParagraph.createRun(); textRun.setText(图片说明文字); document.write(fos); } }这版代码里有一个坑document.insertNewParagraph(XmlCursor)在某些POI版本中返回的段落位置不是固定的它会把新段落插入到游标位置之前还是之后文档里没有写得很明确。更稳的做法是用document.insertNewParagraph(cursor)之后立即检查文档段落索引再做调整。上面代码里我用document.setParagraph(newParagraph, paragraphIndex 1)来强制把段落放到指定位置这个解法我在生产环境验证过是稳定的。另一种更暴力但很稳的方案是直接用document.getParagraphs().get(paragraphIndex)拿到目标段落然后调用paragraph.insertNewRun(0)就是把图片Run插到该段落的第一个Run位置。如果目标段落原本是空的那就等价于图片插在这个位置如果目标段落有内容图片就在该段文字的开头。3.5 图片尺寸的计算逻辑别让图片撑爆页面图片尺寸这个事我单独拿出来说。很多人在第一次做的时候直接把像素值扔给POI结果显示图片巨大或者巨大到跑偏。这是因为POI的addPicture接收的是EMU而图片像素值乘以9525后的EMU代表的是“96DPI下的物理尺寸”。但我更建议你在生产环境里按页面可用宽度来反推图片尺寸。比如你的Word页面是A4宽度210mm左右页边距都是20mm那么正文可用宽度就是210 - 20 - 20 170mm。170mm换算成厘米是17cm再换算成EMU是17 * 360000 6,120,000 EMU。如果你的图片原始宽度是800px那按原始比例显示的话宽度应该是800 * 9525 7,620,000 EMU明显超出可用范围。这种情况下要么把图片等比缩小到页面宽度以下要么就按目标尺寸直接给定。生产环境里更常见的做法是保持图片宽高比只限制宽度的上限或下限。比如Logo图固定显示80x80签名图固定显示200x80那就直接用固定EMU不需要计算比例。举个实际计算过程图片原始尺寸800x600像素希望图片显示宽度为5cm5cm 5 * 360000 1,800,000 EMU宽度比例800 - 1,800,000那么600 - 1,350,000即600/800 * 1,800,000最终调用addPicture(input, type, img.png, 1800000, 1350000)注意不要用setWidth(int)setHeight(int)这种老方法它在XWPFRun里已经过时了直接用addPicture的带尺寸参数版本最省事。3.6 三种图片格式的兼容性说明POI对图片类型的支持情况我实测过图片格式支持情况注意事项PNG完全支持推荐使用支持透明背景JPG/JPEG完全支持注意CMYK色彩空间可能显示异常GIF仅支持静态帧动图会显示第一帧BMP支持文件体积大不推荐SVG不支持原生插入需转成PNG后再插入EMF/WMF支持有限某些POI版本需要额外依赖CMYK的JPG图片插进去之后在部分Word版本里显示会偏色甚至无法显示这是底层JPEG解码的问题。如果你遇到了要么用PNG要么先用Java的ImageIO把图片重采样转成RGB再插。4. 实操过程与关键环节的完整记录4.1 制作带占位符的Word模板注意这几点用占位符方案模板制作很关键。我用的占位符格式是“{{XX_YY}}”双花括号避免和普通文本冲突。在Word里做一个模板大致内容如下项目报价单 客户名称{{CUSTOMER_NAME}} 报价日期{{DATE}} 产品清单 …… 客户确认签字{{SIGNATURE}} 公司盖章{{COMPANY_SEAL}}模板做好后存成docx格式。注意存成doc旧格式是不行的POI的XWPFDocument只能处理docx。如果你手里只有doc模板先用Word另存为docx再操作。模板做出来以后最好先用代码把段落结构和Run结构打印出来看看。我开发环境里一直留着下面这段调试代码public static void printDocumentStructure(String filePath) throws Exception { try (XWPFDocument document new XWPFDocument(new FileInputStream(filePath))) { ListXWPFParagraph paragraphs document.getParagraphs(); System.out.println(总段落数: paragraphs.size()); for (int i 0; i paragraphs.size(); i) { XWPFParagraph paragraph paragraphs.get(i); System.out.println(段落 i : [ paragraph.getText() ]); for (int j 0; j paragraph.getRuns().size(); j) { System.out.println( Run j : [ paragraph.getRuns().get(j).getText() ]); } } } }打印出来以后你就能直观地看到哪个占位符被拆分成了几个Run哪些段落中间还藏着表格和图片XWPFDocument.getParagraphs()拿不到表格内部的段落如果你要把图片插到表格单元格里的某个位置得走XWPFTableCell的段落列表后面有问题排查里细说。4.2 实操记录一张签名图从模板到输出的完整流程我拿一个常见合同签署场景来走一遍流程。假设模板里有一行“客户签字{{SIGN}}”我要把从签名板接收到的PNG图片流插到这个位置。第一步打开模板打印结构。输出显示段落 8: [客户签字] Run 0: [客户签字] Run 1: [{{] Run 2: [SIGN] Run 3: [}}]也就是说占位符被拆成了三个Run。如果按单Run匹配代码永远找不到“{{SIGN}}”。这时我用跨Run匹配逻辑把“{{”“SIGN”“}}”拼起来就匹配上了。第二步将匹配到的三个Run的文本全部清空。此时段落8的内容变成空字符串。第三步在第一个Run上调用addPicture把签名图片以PNG类型插入。图片尺寸传的是200x80像素对应的EMU。第四步保存文档并重新打开验证。效果是原来“客户签字{{SIGN}}”的位置变成了签名图片图片下方还是原来的其他内容。这一步做完我整个流程的验证清单是[x] 占位符文字被完整清空[x] 图片出现在预期位置[x] 图片尺寸符合页面布局[x] 图片后面的段落内容没有被挤乱[x] 输出docx在WPS和Word中都能正常打开4.3 批量处理场景下的性能与内存问题如果你要在一个文档里插入50张甚至100张图片直接一次性全插到同一个XWPFDocument对象里会有两个问题第一内存占用会随着图片字节数线性上升如果图片每张2MB100张就是200MB典型的服务器内存压力第二同一文档内大量图片可能让文件体积急剧膨胀。我的做法是在批量处理时用流式思路先处理完一张图片保存临时文件再开新文档。不过如果业务要求所有图片都在同一个文档里那就得做分级压缩把图片等比压缩到合适分辨率再插入。一般合同里的签名图宽200像素已经足够清晰了不需要原始图片的多余分辨率。5. 高频问题与排查技巧实录5.1 图片插入成功但Word打开报错或提示修复这是最吓人的问题。插入图片后文档打不开或者提示“文档已损坏”原因通常有三个一是图片流没有被正确关闭。addPicture执行后FileInputStream还在但POI内部已经读取了数据。如果在同一个InputStream上后续又执行了其他操作可能把流的当前位置搞乱了。我用try-with-resources随手关流能避开这个问题。二是图片类型与实际数据不符。比如你把一个明明是JPG格式的图片内容传给了PICTURE_TYPE_PNGPOI不校验内容它只按你给的类型去写入XMLWord打开时发现图片数据和类型声明不一致就报错。三是中途抛出异常没有正常write。代码在调用document.write之前发生了RuntimeException导致文件只写了一半。这也是为什么我在生产环境里坚持用“先写临时文件成功后rename”的模式避免生成出半个文件还覆盖了原来的好文件。5.2 占位符明明存在但程序就是匹配不上这个坑前面提过核心是Run拆分。但还有一个隐藏情况你在模板里用的占位符和代码里写的占位符可能字符不完全一致。比如中文括号和英文括号全角空格和普通空格。排查方式是把模板里那段文字复制出来逐字符对比。另外文本可能不在普通的getParagraphs()里而是藏在文本框、表格或页眉页脚中。XWPFDocument.getParagraphs()只返回正文部分的顶级段落列表表格里的段落要去table.getCell()逐层取文本框里的内容要特殊处理。如果你要替换的位置在页眉页脚恭喜你PoI也支持要遍历document.getHeaderList()和getFooterList()这个很多人不知道。5.3 图片插入后尺寸太小或方向不对一个常见原因是POI版本差异。老版本的addPicture不接受EMU它接受的是像素值POI内部再用pixel-to-point做一次转换结果就是同样传一个width不同版本的显示尺寸完全不同。解决方式很简单显式通过Units.toEMU转换并且指定好单位和目标尺寸不要依赖POI内部的默认转换逻辑。图方向的问题本质上是图片流里自带的EXIF信息。手机拍摄的图片经常带旋转信息直接插入以后ImageIO读出来的原始信息可能是带旋转角度的但POI插入时不处理EXIF。解决方案插入之前先用ImageIO读一遍数据再用AffineTransformOp把图片按EXIF的旋转信息“掰正”重新写回字节流再插。5.4 图片有白色背景无法透明显示如果你插入的图片本来有透明区域但显示成白色这是不支持的格式混用导致的。最常见的是把带透明通道的PNG图片读进来之后被Java的ImageIO处理成了不含Alpha通道的BufferedImage再转回PNG字节流透明通道就丢了。解决方法是用ImageIO读取后如果发现图片类型不是TYPE_INT_ARGB就重新绘制一个ARGB的BufferedImage再转成PNG字节流。BufferedImage original ImageIO.read(inputFile); BufferedImage argb new BufferedImage( original.getWidth(), original.getHeight(), BufferedImage.TYPE_INT_ARGB ); Graphics2D g argb.createGraphics(); g.drawImage(original, 0, 0, null); g.dispose(); ImageIO.write(argb, png, outputFile);这个处理逻辑在生成带透明底的印章图片时几乎是必用的。否则印章插到合同上底部会有一块白色方块非常难看。5.5 插入点总是从文档末尾开始有朋友问我为什么他调用了addPicture图片总是出现在文档最后而不是目标的段落原因是他创建Run的方式有问题。他先paragraph document.createParagraph()建了个新段落这个新段落默认就加在文档末尾然后再往里塞图片。这里的问题不是“插入位置不对”而是“插入位置本来就是文档末尾”。这个问题的本质是XWPFDocument的createParagraph没有指定位置参数默认append到文档尾部。如果你要插到中间必须像前面代码那样用insertNewParagraph加XmlCursor来指定位置。还有一种正常情况下不会遇到但确实存在的情况在同一个paragraph对象上连续调用createRun()新的Run会出现在段落末尾如果你希望图片在特定文字之后、其他文字之前你需要控制Run的插入顺序。我的做法是先把文字Run按顺序建好最后再插入图片Run或者用paragraph.insertNewRun(index)来精确指定Run在段落中的位置。5.6 插入50张图片后文档大小暴涨文档大小暴涨有两个来源一是原图太大二是POI把图片作为media资源嵌入docx时是原样保存的不会帮你压缩。如果原图是2MB的JPG插50张就是100MB。解决方式是插入之前做尺寸归一化和压缩。推荐将图片宽度限制在1600像素以内用ImageIO按比例缩放并转成JPEG质量0.85这样单张图片体积能压到100KB以内对合同、报告类文档完全够用。这里要注意压缩后不要再存成PNG了PNG对照片类图片不友好体积反而更大。把压缩方法写成一个工具类批量处理时先统一预处理图片流再传给addPicture省心省内存。5.7 WPS和Microsoft Word显示效果不一致这个比较玄但确实存在。WPS对某些docx特性的支持不完全相同比如图片浮于文字上方和嵌入型文字排列的默认行为可能不一样。在POI里插入的图片默认是“嵌入型”的也就是图片和一个字符同等对待。这种嵌入型图片在Word和WPS里表现一致但在跨平台场景Mac版Word、Windows版Word、WPS下如果模板里字体缺失导致排版变化图片跟随的位置会偏移。我的建议是模板中图片的位置最好放在一个独立空行或空段落中不要和复杂样式混在一起。这样即使字体不同空段落的占位高度变化了图片的位置也会保持相对稳定。6. 经验总结做完这个功能后我梳理的一些判断标准我做这个功能前前后后应该不下五次每次都要在同一个地方耗掉大半天对照Run结构、调试匹配逻辑、检查图片尺寸。前面几次用的都是相互独立的临时方案直到做成通用工具类之后才稳定下来。代码本身没多少行难的是判断在什么情况下该用哪条路线、遇到问题了该怎么定位。一个可以给你参考的判断流程模板是人工维护的、位置固定的优先选占位符替换方案占位符格式约定好代码里用跨Run匹配。模板是程序完全动态生成的、要插入多个大小不一的图片用段落锚点记得打印结构核对索引。图片需要透明背景优先PNG格式插入前检查Alpha通道。图片来自用户上传或手机拍照先压缩、转正、转Type_INT_ARGB再插入。批量生成大量文档先写临时文件再rename避免半截文件覆盖好文件。这个功能本身不算复杂真正有价值的是踩过的那些坑占位符被拆Run、RGB退化成CMYK、EXIF旋转不处理、addPicture尺寸单位不同版本不一致、表格内部段落取不到……把这些摸清楚再遇到“把图片插到Word指定位置”的需求时你就能把精力花在真正有业务价值的地方而不是在API文档和异常堆栈之间反复横跳。最后再说一个我个人的操作习惯每次生成的docx我一定会在本地用代码重新打开一遍打印所有段落文本确认没有残留占位符字符、图片所在的段落顺序正确。这个习惯帮我拦截过很多次“刚交付就反馈文档异常”的尴尬建议你也养成。