Java读取WPS Excel嵌入图片:深入OOXML底层解析与POI扩展实践
1. 项目缘起一个被“图片”卡住的Excel导入需求最近在做一个后台管理系统的数据导入模块需求方给过来的Excel模板里有一个“产品主图”列里面不是图片的URL链接而是直接嵌入了WPS编辑生成的图片。开发小哥跑过来跟我说用Apache POI的XSSFWorkbook读这个文件其他文本数据都正常唯独图片读不出来getAllPictures()返回的列表是空的但用微软的Office打开文件图片明明就在那里。这其实是个挺典型的场景。很多业务人员习惯用WPS Office进行日常办公他们在制作数据模板时会直接使用WPS的“插入图片”功能。从用户视角看这和在Excel里操作没区别。但从技术实现看WPS在处理嵌入式对象尤其是OLE对象时其底层存储结构和序列化方式与微软Office存在一些微妙的差异。当Java程序使用标准的POI库去解析时如果只处理标准的x:drawing标签和对应的v:shape、v:imagedata关系很可能就会漏掉WPS特有的存储部分导致图片“消失”。所以这个项目的核心目标很明确实现一个Java读取Excel文件的组件不仅要能读取常规的单元格数据还必须完整支持读取由WPS嵌入的图片。这意味着我们不能只满足于标准POI API必须深入文件内部OOXML去适配和解析WPS可能采用的存储格式。2. 技术选型与底层原理为什么是POI以及它的局限要实现这个需求Apache POI几乎是Java生态下的唯一选择。它是一个功能强大的开源库用于读写Microsoft Office格式文件如Excel、Word和PowerPoint。对于Excel它主要提供两种模型HSSF (Horrible SpreadSheet Format) 用于处理老旧的.xls格式Excel 97-2003。XSSF (XML SpreadSheet Format) 用于处理现代的.xlsx格式Excel 2007及以后基于OOXMLOffice Open XML标准。我们的场景显然是.xlsx文件所以核心是XSSFWorkbook。一个.xlsx文件本质上是一个ZIP压缩包里面包含了描述工作表、样式、关系的多个XML文件以及二进制资源如图片。当你用POI读取一个包含图片的Excel时其标准流程是POI解压ZIP包。解析xl/drawings/drawingX.xml文件找到所有绘图对象形状的定义。根据形状中a:blip元素的r:embed属性找到对应的关系ID例如rId1。去xl/drawings/_rels/drawingX.xml.rels关系文件中根据ID找到对应的图片资源路径例如../media/image1.png。最后从xl/media/目录下读取对应的图片二进制数据。那么问题出在哪里POI的实现严格遵循了微软定义的OOXML标准。然而WPS作为另一个实现者在追求高度兼容的同时有时会采用一些“扩展”或“变通”的存储方式。特别是在处理早期版本WPS或某些特定操作如从其他文档复制粘贴带格式内容生成的OLE对象时它可能将图片数据以不同的MIME类型或格式进行封装。使用非标准的Relationship类型或ID引用方式。甚至将图片数据直接以Base64编码的形式内联在drawingX.xml中而非作为独立媒体文件存放。标准的XSSFWorkbook.getAllPictures()方法其内部逻辑是基于一套预定义的、针对微软Office格式的解析器。当它遇到WPS的这些“非标”存储时解析链就可能中断无法正确识别和收集图片资源。因此我们的解决方案不能停留在Workbook的层面必须下沉到OPCPackage代表整个OOXML包和PackagePart代表包内的各个部分如XML文件、图片文件的层级进行更底层的、定制化的扫描和解析。3. 核心实现深入OOXML包捕获“隐藏”的图片我们的核心思路是绕过XSSFWorkbook提供的图片获取API直接操作底层的OPCPackage对象遍历包内所有部件Part通过其内容类型ContentType和关系Relationships来识别和提取所有可能的图片数据。下面是一个增强版的ExcelWithWPSImageReader工具类的核心代码实现import org.apache.poi.openxml4j.opc.OPCPackage; import org.apache.poi.openxml4j.opc.PackagePart; import org.apache.poi.openxml4j.opc.PackageRelationship; import org.apache.poi.openxml4j.opc.PackageRelationshipCollection; import org.apache.poi.ss.usermodel.*; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.apache.poi.xssf.usermodel.XSSFDrawing; import org.apache.poi.xssf.usermodel.XSSFPicture; import org.apache.poi.xssf.usermodel.XSSFShape; import org.apache.poi.xssf.usermodel.XSSFClientAnchor; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.*; import java.util.*; import java.util.regex.Pattern; /** * 增强版Excel读取器支持读取WPS嵌入的图片 */ public class ExcelWithWPSImageReader { /** * 读取Excel文件返回工作表数据及所有图片信息 * param filePath Excel文件路径 * return Map key为工作表索引 value为该表的数据和图片列表 */ public static MapInteger, SheetDataWithImages readExcelWithImages(String filePath) throws Exception { MapInteger, SheetDataWithImages result new HashMap(); // 1. 使用OPCPackage打开文件保留底层包引用 OPCPackage pkg OPCPackage.open(new File(filePath)); Workbook workbook new XSSFWorkbook(pkg); try { int numberOfSheets workbook.getNumberOfSheets(); for (int sheetIndex 0; sheetIndex numberOfSheets; sheetIndex) { Sheet sheet workbook.getSheetAt(sheetIndex); SheetDataWithImages sheetData new SheetDataWithImages(); sheetData.setSheetName(sheet.getSheetName()); // 2. 读取单元格数据标准POI方式 readSheetData(sheet, sheetData); // 3. 关键步骤提取图片增强方法 extractAllImages(pkg, sheet, sheetIndex, sheetData); result.put(sheetIndex, sheetData); } } finally { // 注意关闭workbook也会关闭底层的pkg无需重复关闭 workbook.close(); } return result; } /** * 读取工作表单元格数据 */ private static void readSheetData(Sheet sheet, SheetDataWithImages sheetData) { ListListObject data new ArrayList(); for (Row row : sheet) { ListObject rowData new ArrayList(); for (Cell cell : row) { rowData.add(getCellValue(cell)); } data.add(rowData); } sheetData.setCellData(data); } /** * 增强的图片提取方法结合标准API和底层扫描 */ private static void extractAllImages(OPCPackage pkg, Sheet sheet, int sheetIndex, SheetDataWithImages sheetData) throws Exception { ListExcelImage images new ArrayList(); // 3.1 方法A使用标准POI API获取已知图片对标准Excel有效 if (sheet instanceof org.apache.poi.xssf.usermodel.XSSFSheet) { XSSFSheet xssfSheet (XSSFSheet) sheet; ListXSSFShape shapes xssfSheet.getDrawingPatriarch().getShapes(); for (XSSFShape shape : shapes) { if (shape instanceof XSSFPicture) { XSSFPicture pic (XSSFPicture) shape; XSSFClientAnchor anchor (XSSFClientAnchor) pic.getAnchor(); byte[] pictureData pic.getPictureData().getData(); String format pic.getPictureData().getSuggestFileExtension(); ExcelImage img new ExcelImage(); img.setRowIndex(anchor.getRow1()); img.setColIndex(anchor.getCol1()); img.setImageData(pictureData); img.setFormat(format); img.setSource(POI_Standard_API); images.add(img); } } } // 3.2 方法B底层扫描OOXML包查找所有可能的图片部件关键用于捕获WPS图片 // 定义常见的图片内容类型 SetString imageContentTypes new HashSet(Arrays.asList( image/png, image/jpeg, image/jpg, image/gif, image/bmp, application/vnd.openxmlformats-officedocument.drawingml.picture, application/vnd.openxmlformats-officedocument.oleObject // 特别注意OLE对象类型 )); // 遍历包中的所有部件 for (PackagePart part : pkg.getParts()) { String contentType part.getContentType(); // 判断是否为图片或可能的OLE对象WPS可能将图片包装在OLE中 if (isImagePart(part, imageContentTypes)) { try (InputStream is part.getInputStream()) { byte[] data readAllBytes(is); if (data ! null data.length 0) { // 尝试从部件名称或关系中推断其所属工作表及位置这是一个难点 // 这里简化处理先收集所有图片数据。实际项目中可能需要解析drawingXML来关联位置。 ExcelImage img new ExcelImage(); img.setImageData(data); img.setFormat(guessImageFormat(contentType, part.getPartName().getName())); img.setSource(OOXML_Package_Scan); img.setPartName(part.getPartName().toString()); // 关联位置信息需要更复杂的解析此处为示例 associateImageWithCell(part, pkg, sheetIndex, img); images.add(img); } } } } // 3.3 去重基于图片数据的MD5或SHA哈希避免同一图片因不同引用被多次添加 images deduplicateImages(images); sheetData.setImages(images); } /** * 判断一个PackagePart是否为图片部分 * 增加了对WPS可能存储方式的判断 */ private static boolean isImagePart(PackagePart part, SetString imageContentTypes) { String contentType part.getContentType(); String partName part.getPartName().getName().toLowerCase(); // 1. 标准图片内容类型 if (imageContentTypes.contains(contentType)) { return true; } // 2. 通过部件路径名判断常见于/media/目录 if (partName.contains(/media/) (partName.endsWith(.png) || partName.endsWith(.jpg) || partName.endsWith(.jpeg) || partName.endsWith(.gif))) { return true; } // 3. 针对WPS处理可能是OLE包装的图片 // WPS有时会将图片存储为OLE对象但其内容实为图片数据 if (application/vnd.openxmlformats-officedocument.oleObject.equals(contentType)) { // 可以通过检查部件名称或尝试解析前几个字节的魔数来进一步确认 // 例如检查partName是否包含“image”字样或读取流的前几个字节判断是否为PNG/JPEG return mayContainImageData(part); } return false; } /** * 尝试判断一个OLE对象部件是否包含图片数据 */ private static boolean mayContainImageData(PackagePart part) { // 简易实现读取前8个字节检查常见图片文件的魔数Magic Number try (InputStream is part.getInputStream()) { byte[] header new byte[8]; if (is.read(header) 8) { // PNG: 89 50 4E 47 0D 0A 1A 0A if (header[0] (byte)0x89 header[1] P header[2] N header[3] G) { return true; } // JPEG: FF D8 FF if (header[0] (byte)0xFF header[1] (byte)0xD8 header[2] (byte)0xFF) { return true; } } } catch (Exception e) { // 忽略读取错误返回false } return false; } /** * 尝试将图片与单元格关联简化示例实际逻辑复杂 * 需要解析 drawingX.xml 和其关系文件建立“图片资源”与“单元格锚点”的映射。 */ private static void associateImageWithCell(PackagePart part, OPCPackage pkg, int sheetIndex, ExcelImage img) { // 这是一个复杂且易出错的部分。 // 基本思路 // 1. 找到当前工作表对应的 /xl/drawings/drawing{sheetIndex1}.xml // 2. 解析该XML找到所有 xdr:twoCellAnchor 元素里面包含了图片引用(r:embed)和位置坐标(col, row)。 // 3. 根据图片引用ID在 /xl/drawings/_rels/drawing{sheetIndex1}.xml.rels 中找到对应的目标URI。 // 4. 将目标URI与当前图片部件的PartName进行匹配。 // 5. 如果匹配成功则将坐标设置到img对象中。 // 由于代码较长此处仅示意。在实际项目中你可能需要借助XPath或DOM解析XML。 // img.setRowIndex(inferredRow); // img.setColIndex(inferredCol); img.setRowIndex(-1); // -1 表示位置未知 img.setColIndex(-1); } /** * 根据内容类型或文件名猜测图片格式 */ private static String guessImageFormat(String contentType, String fileName) { if (contentType.contains(png)) return png; if (contentType.contains(jpeg) || contentType.contains(jpg)) return jpg; if (contentType.contains(gif)) return gif; if (contentType.contains(bmp)) return bmp; if (fileName.endsWith(.png)) return png; if (fileName.endsWith(.jpg) || fileName.endsWith(.jpeg)) return jpg; if (fileName.endsWith(.gif)) return gif; if (fileName.endsWith(.bmp)) return bmp; return unknown; } /** * 基于图片二进制数据去重 */ private static ListExcelImage deduplicateImages(ListExcelImage images) { MapString, ExcelImage map new LinkedHashMap(); // 用LinkedHashMap保持顺序 for (ExcelImage img : images) { String key calculateHash(img.getImageData()); // 例如用MD5 if (!map.containsKey(key)) { map.put(key, img); } else { // 如果已存在优先保留有位置信息的那个 ExcelImage existing map.get(key); if (existing.getRowIndex() -1 img.getRowIndex() ! -1) { map.put(key, img); } } } return new ArrayList(map.values()); } private static String calculateHash(byte[] data) { // 简化的哈希计算实际应用请使用MessageDigest生成MD5或SHA-1 return Integer.toHexString(Arrays.hashCode(data)); } private static byte[] readAllBytes(InputStream is) throws IOException { ByteArrayOutputStream buffer new ByteArrayOutputStream(); byte[] data new byte[4096]; int nRead; while ((nRead is.read(data, 0, data.length)) ! -1) { buffer.write(data, 0, nRead); } return buffer.toByteArray(); } private static Object getCellValue(Cell cell) { // ... 标准的单元格值获取逻辑根据CellType处理字符串、数字、公式等 ... switch (cell.getCellType()) { case STRING: return cell.getStringCellValue(); case NUMERIC: if (DateUtil.isCellDateFormatted(cell)) { return cell.getDateCellValue(); } return cell.getNumericCellValue(); case BOOLEAN: return cell.getBooleanCellValue(); case FORMULA: return cell.getCellFormula(); case BLANK: return ; default: return null; } } /** * 封装工作表数据及图片 */ public static class SheetDataWithImages { private String sheetName; private ListListObject cellData; private ListExcelImage images; // getters and setters ... } /** * 封装图片信息 */ public static class ExcelImage { private int rowIndex; // 图片左上角所在行0-based private int colIndex; // 图片左上角所在列0-based private byte[] imageData; private String format; // png, jpg等 private String source; // 标识来源用于调试 private String partName; // OOXML中的部件名 // getters and setters ... } }4. 关键难点与避坑指南不只是读出来还要放对地方实现“读取”只是第一步。在实际业务中我们通常需要知道这张图片属于哪个单元格以便将图片与对应的业务数据如产品ID、名称关联起来。这正是整个需求中最棘手的地方也是很多开源方案语焉不详的部分。难点一位置信息Anchor的丢失WPS存储的图片其位置锚点信息XSSFClientAnchor 包含起始行、列、结束行、列以及偏移像素可能在转换或存储过程中变得不标准或者POI无法正确解析WPS生成的对应XML结构。这导致即使你通过底层扫描拿到了图片二进制数据也无法确定它该放在表格的哪个位置。避坑经验不要完全依赖POI的XSSFPicture.getAnchor()。在底层扫描到图片后必须尝试主动去解析当前工作表对应的drawingX.xml文件。你需要手动处理XML寻找xdr:twoCellAnchor或xdr:oneCellAnchor元素并解析其中的xdr:from子元素来获取行(row)、列(col)索引。这个过程非常繁琐且XML命名空间和结构可能因WPS版本而异需要强大的容错处理。难点二图片与位置的关联关系断裂在OOXML中图片文件如/xl/media/image1.png与它在工作表上的位置形状是分开存储的通过关系Relationship文件链接。WPS可能创建非标准的关系类型Relationship Type或者使用自定义的属性导致POI的标准关系解析器无法建立这个链接。实操技巧在associateImageWithCell方法中我们的匹配逻辑不能只依赖标准的r:embed。可以尝试以下策略路径匹配将drawingX.xml.rels中Target属性的值如../media/image1.png与扫描到的图片部件的PartName进行字符串匹配。ID回查如果路径匹配失败可以尝试提取图片部件的原始关系ID这需要从包含该部件的上级部件的关系集中查找再用这个ID去drawingX.xml中搜索对应的形状定义。模糊匹配作为保底方案如果无法建立精确关联可以记录下“未定位图片”并在界面上提供给用户进行手动关联。或者如果业务逻辑允许可以根据图片被发现的顺序例如在OOXML包中的出现顺序与数据行的顺序进行“软关联”但这风险很高。难点三内存管理与性能使用OPCPackage.open(File)会一次性将整个ZIP包可能包含大量高分辨率图片加载到内存中。对于几十兆甚至上百兆的Excel文件极易引发java.lang.OutOfMemoryError: Java heap space错误。解决方案增加JVM堆内存这是最简单的办法通过启动参数-Xmx2048m或-Xmx4096m来调整但治标不治本。使用流式处理POI提供了org.apache.poi.xssf.eventusermodel.XSSFReader和SAX解析器可以按工作表流式读取但对于图片的随机访问支持不佳。分治策略对于超大文件最稳妥的方法是引导用户拆分文件或者在后端处理时采用“先解压到临时目录再流式读取图片文件”的方式。可以使用ZipInputStream手动解压.xlsx文件到临时文件夹然后只将xl/workbook.xml和xl/worksheets/下的XML文件用DOM或SAX解析而xl/media/下的大图片文件则用FileInputStream按需读取。这样能极大降低内存峰值。处理完成后务必清理临时目录。5. 实战测试与结果验证如何确保真的读到了WPS图片理论再好也需要实战检验。我们设计一个测试流程来验证我们的增强读取器是否有效。测试文件准备使用WPS Office新建一个.xlsx文件。在A1单元格输入“产品ID” B1输入“产品名称” C1输入“产品主图”。在C2单元格使用WPS的“插入”-“图片”功能嵌入一张本地图片。保存文件命名为test_wps_image.xlsx。测试代码public class TestWPSImageRead { public static void main(String[] args) { String filePath path/to/your/test_wps_image.xlsx; try { MapInteger, ExcelWithWPSImageReader.SheetDataWithImages result ExcelWithWPSImageReader.readExcelWithImages(filePath); for (Map.EntryInteger, ExcelWithWPSImageReader.SheetDataWithImages entry : result.entrySet()) { int sheetIndex entry.getKey(); ExcelWithWPSImageReader.SheetDataWithImages sheetData entry.getValue(); System.out.println( Sheet: sheetData.getSheetName() ); // 打印单元格数据 ListListObject data sheetData.getCellData(); for (ListObject row : data) { System.out.println(row); } // 打印图片信息 ListExcelWithWPSImageReader.ExcelImage images sheetData.getImages(); System.out.println(Found images.size() image(s).); for (int i 0; i images.size(); i) { ExcelWithWPSImageReader.ExcelImage img images.get(i); System.out.println( Image i :); System.out.println( Source: img.getSource()); System.out.println( Format: img.getFormat()); System.out.println( Size: (img.getImageData() ! null ? img.getImageData().length bytes : N/A)); System.out.println( Position: Row img.getRowIndex() , Col img.getColIndex()); System.out.println( PartName: img.getPartName()); // 可选将图片保存到本地验证 if (img.getImageData() ! null img.getFormat() ! null) { String outputPath extracted_image_ i . img.getFormat(); try (FileOutputStream fos new FileOutputStream(outputPath)) { fos.write(img.getImageData()); System.out.println( Saved to: outputPath); } } } } } catch (Exception e) { e.printStackTrace(); } } }预期结果与验证成功情况控制台输出显示找到了至少一张图片且Source字段包含OOXML_Package_Scan。将保存的图片文件用图片查看器打开确认与WPS中嵌入的图片一致。单元格数据被正确读取。失败情况如果只找到0张图片或图片数据为空说明我们的扫描逻辑仍有遗漏。需要进一步调试打印出OPCPackage中所有PackagePart的ContentType和PartName查看WPS图片到底被存成了什么类型、放在哪个路径下。检查mayContainImageData方法中的魔数判断是否覆盖了你的图片格式。用解压软件如7-Zip手动解压.xlsx文件对比WPS生成的文件和Office生成的文件在xl/目录结构上的差异特别是drawings和media文件夹。一个常见的“坑”测试时一切正常一上生产环境就读取失败。很可能是因为生产环境的文件来自不同用户、不同版本的WPS甚至可能是从网页或移动端导出的其内部结构变异性更大。因此在生产代码中必须对isImagePart和associateImageWithCell方法添加更宽松的判断和更完善的异常捕获与日志记录确保单一文件的解析失败不会导致整个导入任务中断。6. 进阶优化与扩展思路解决了基本读取问题后我们可以从性能、功能和鲁棒性上进行优化。6.1 性能优化缓存与懒加载每次读取都全量扫描OOXML包是耗时的。如果同一个文件需要多次访问例如分页预览数据可以引入缓存。元数据缓存第一次解析时将图片的PartName、哈希值、推断出的位置信息等元数据缓存起来。下次读取时先检查文件哈希或最后修改时间如果未变则直接使用缓存的元数据仅按需加载图片二进制数据。图片懒加载在ExcelImage对象中不直接存储byte[] imageData而是存储一个Supplierbyte[]或类似引用只有在真正需要图片内容如展示、上传时才去读取对应的PackagePart的输入流。6.2 功能扩展支持更多对象类型我们的方案聚焦于图片但WPS可能嵌入的其他对象如图表、智能图形、甚至其他文档原理类似。你可以扩展imageContentTypes集合和isImagePart判断逻辑来支持检测和提取其他类型的嵌入式对象。// 扩展内容类型判断 SetString embeddedObjectTypes new HashSet(imageContentTypes); embeddedObjectTypes.add(application/vnd.openxmlformats-officedocument.drawingml.chart); embeddedObjectTypes.add(application/vnd.ms-office.drawing); // ... 其他类型6.3 鲁棒性增强防御性编程与降级策略格式嗅探guessImageFormat方法可以加强使用javax.imageio.ImageIO的ImageReader来探测真实的图片格式而不是仅依赖文件扩展名或ContentType。降级策略当我们的增强扫描器也无法定位图片时可以提供降级方案。例如记录一条警告日志并将图片数据以“未关联”的状态返回给上层业务。业务层可以根据其他上下文如固定列、文件名顺序进行匹配或者提供人工干预界面。依赖管理确保使用的POI版本足够新以包含最新的兼容性修复。同时注意POI相关依赖如poi-ooxml、poi-ooxml-schemas的版本一致性避免因依赖冲突导致奇怪的解析错误。实现一个健壮的、能处理WPS嵌入式图片的Excel读取器是对开发者耐心和细心的考验。它要求我们不仅会使用高级API还要愿意深入文件格式的细节处理各种边界情况和兼容性问题。最终得到的这个组件将成为处理来自“真实世界”的、杂乱无章的Excel数据源的可靠工具。