公司动态
Java使用Apache POI读取Excel图片:从原理到实战完整指南
1. 项目概述为什么读取Excel图片是个技术活如果你做过Java后台的数据导入功能尤其是处理那些由业务人员手工维护的Excel报表大概率会遇到一个头疼的问题表格里不仅有数据还嵌入了各种图片。可能是产品的示意图可能是领导的手写签名扫描件也可能是流程的截图。这些图片往往承载着关键的业务信息单纯读取单元格文本会导致信息丢失。这时候你就需要让程序“看懂”Excel里的图片。这个需求听起来简单但实操起来坑不少。Excel的图片存储机制因版本xls和xlsx和类型嵌入单元格的、浮在表上的而异Apache POI这个强大的Java操作Office文档的库其API在这部分也略显复杂和分散。网上能找到的代码片段往往只解决某一类问题或者版本老旧直接复制粘贴大概率跑不通。我经历过好几次在项目上线前夕因为客户提供的Excel模板里多了一张浮动图片导致整个导入服务崩溃的惊险时刻。所以今天我就把这块内容彻底梳理清楚从原理到代码从xls到xlsx从嵌入图片到浮动图片给你一份能直接抄作业的解决方案。2. 核心原理与POI模型深度解析要正确读取图片首先得理解Excel是如何存储它们的。这直接决定了我们用POI的哪个对象、哪个方法去获取。2.1 xls (HSSF) 与 xlsx (XSSF/SXSSF) 的本质区别这是第一个分水岭。.xls是Excel 97-2003的二进制格式POI中用HSSFHorrible SpreadSheet Format模块处理。.xlsx是Excel 2007及以后基于XML的开放打包约定OPC格式本质上是一个ZIP压缩包POI中用XSSFXML SpreadSheet Format模块处理。SXSSF是XSSF的流式变种用于处理海量数据但在图片处理层面其原理与XSSF一致。对于图片存储而言核心差异如下xls (HSSF) 图片以二进制记录的形式直接嵌入文件流中。POI将其抽象为HSSFPatriarch绘图 patriarch和HSSFShape。所有的图形包括图片都通过HSSFPatriarch来创建和管理。图片数据存储在HSSFClientAnchor关联的HSSFPicture对象中。xlsx (XSSF) 图片作为独立的媒体文件如image1.png, image2.jpeg存放在ZIP包的xl/media/目录下。工作表XML文件里通过xdr:pic元素引用这些媒体文件的rId。POI中对应的是XSSFDrawing和XSSFPicture。XSSFWorkbook可以通过其getAllPictures()方法获取到一个ListXSSFPictureData这里面包含了所有图片的原始数据。理解这个区别至关重要因为它意味着我们的读取代码必须对两种格式做分支处理。2.2 嵌入图片 vs. 浮动图片锚定Anchor是关键第二个关键概念是“锚点”Anchor。它定义了图片在工作表上的位置和大小。POI用ClientAnchor对象及其子类HSSFClientAnchor,XSSFClientAnchor来表示。嵌入单元格的图片 这种图片的锚点通常与一个或一组单元格紧密绑定。它的位置由锚点关联的行、列坐标决定。当你调整行高列宽时这类图片可能会随之移动或拉伸。在POI中无论哪种格式图片对象HSSFPicture/XSSFPicture都通过getClientAnchor()方法获取其锚点信息从而可以定位到具体的单元格。// 示例获取图片关联的左上角单元格位置 ClientAnchor anchor picture.getClientAnchor(); int rowIndex anchor.getRow1(); // 起始行从0开始 int colIndex anchor.getCol1(); // 起始列从0开始浮动图片 这种图片的位置是绝对的基于工作表上的像素坐标或点point与单元格网格无关。它浮在所有单元格之上。在POI中浮动图片同样有ClientAnchor但其getRow1()/getCol1()等方法的返回值可能为-1或没有明确的单元格对应关系其位置更多地由getDx1(), getDy1(), getDx2(), getDy2()这些偏移量以缇为单位1/1440英寸决定。读取时我们更关心图片数据本身而非其精确的页面位置。实操心得 业务上所谓的“单元格里的图片”在技术上几乎都是“锚定到该单元格的嵌入图片”。真正的“浮动图片”在报表中较少但一旦出现如果你的代码只按单元格去查找图片就会漏掉它导致数据缺失。一个健壮的读取器必须能同时处理这两种情况。3. 完整实现方案与代码逐行解读下面我将提供一个完整的工具类它能够处理.xls和.xlsx格式并提取出所有图片包括嵌入和浮动同时记录每张图片的格式、位置信息如果可能和二进制数据。3.1 工具类设计PictureExtractor我们将创建一个PictureExtractor类其核心方法是extractPictures它接收一个Workbook对象POI的顶级抽象返回一个自定义的PictureInfo列表。import org.apache.poi.ss.usermodel.*; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.apache.poi.hssf.usermodel.HSSFWorkbook; import org.apache.poi.xssf.usermodel.XSSFPicture; import org.apache.poi.xssf.usermodel.XSSFDrawing; import org.apache.poi.xssf.usermodel.XSSFPictureData; import org.apache.poi.xssf.usermodel.XSSFClientAnchor; import org.apache.poi.hssf.usermodel.HSSFPatriarch; import org.apache.poi.hssf.usermodel.HSSFShape; import org.apache.poi.hssf.usermodel.HSSFPicture; import org.apache.poi.hssf.usermodel.HSSFClientAnchor; import org.apache.poi.util.IOUtils; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.*; import java.util.*; /** * Excel图片提取器 * 支持 xls (HSSF) 和 xlsx (XSSF) 格式 * 支持嵌入单元格图片和浮动图片 */ public class PictureExtractor { /** * 图片信息承载对象 */ public static class PictureInfo { private String pictureName; // 图片名称如 image1.png private String extension; // 扩展名如 png, jpeg private byte[] data; // 图片二进制数据 private int sheetIndex; // 所在工作表索引 private Integer rowIdx; // 关联的起始行可能为null如浮动图片 private Integer colIdx; // 关联的起始列可能为null private String anchorType; // 锚点类型描述 // 省略 getter/setter 和 toString 方法实际使用时请补全 } /** * 从Workbook中提取所有图片 * param workbook POI Workbook对象 (HSSFWorkbook 或 XSSFWorkbook) * return 图片信息列表 */ public ListPictureInfo extractPictures(Workbook workbook) throws IOException { ListPictureInfo pictureInfoList new ArrayList(); // 1. 判断Workbook类型分发处理 if (workbook instanceof HSSFWorkbook) { processHSSFWorkbook((HSSFWorkbook) workbook, pictureInfoList); } else if (workbook instanceof XSSFWorkbook) { processXSSFWorkbook((XSSFWorkbook) workbook, pictureInfoList); } else { throw new IllegalArgumentException(不支持的Workbook类型: workbook.getClass().getName()); } return pictureInfoList; } /** * 处理 xls (HSSF) 格式 */ private void processHSSFWorkbook(HSSFWorkbook workbook, ListPictureInfo result) { for (int sheetIdx 0; sheetIdx workbook.getNumberOfSheets(); sheetIdx) { Sheet sheet workbook.getSheetAt(sheetIdx); // HSSF中绘图容器是HSSFPatriarch HSSFPatriarch drawingPatriarch (HSSFPatriarch) sheet.getDrawingPatriarch(); if (drawingPatriarch ! null) { // 遍历所有图形 ListHSSFShape shapes drawingPatriarch.getChildren(); for (HSSFShape shape : shapes) { if (shape instanceof HSSFPicture) { HSSFPicture picture (HSSFPicture) shape; PictureInfo info extractInfoFromHSSFPicture(picture, sheetIdx); result.add(info); } // 其他HSSFShape类型如文本框、线条在此忽略 } } } } /** * 从HSSFPicture中提取信息 */ private PictureInfo extractInfoFromHSSFPicture(HSSFPicture picture, int sheetIndex) { PictureInfo info new PictureInfo(); info.setSheetIndex(sheetIndex); // 获取图片数据 HSSFPictureData pictureData picture.getPictureData(); info.setData(pictureData.getData()); info.setExtension(pictureData.suggestFileExtension()); // 如 png, jpeg info.setPictureName(hssf_picture_ System.currentTimeMillis() . info.getExtension()); // 获取锚点信息判断位置 HSSFClientAnchor anchor (HSSFClientAnchor) picture.getClientAnchor(); populateAnchorInfo(info, anchor); return info; } /** * 处理 xlsx (XSSF) 格式 * 注意XSSF有两种方式获取图片这里采用更可靠的方式 */ private void processXSSFWorkbook(XSSFWorkbook workbook, ListPictureInfo result) { // 方式一通过 getAllPictures() 获取所有图片数据包 ListXSSFPictureData allPictures workbook.getAllPictures(); MapString, XSSFPictureData pictureDataMap new HashMap(); for (XSSFPictureData picData : allPictures) { // 关键通过关系IDrId关联图片数据与绘图中的图片对象 String relId picData.getPackageRelationship().getId(); pictureDataMap.put(relId, picData); } // 方式二遍历每个工作表的绘图对象找到图片的引用 for (int sheetIdx 0; sheetIdx workbook.getNumberOfSheets(); sheetIdx) { Sheet sheet workbook.getSheetAt(sheetIdx); XSSFDrawing drawing (XSSFDrawing) sheet.getDrawingPatriarch(); if (drawing ! null) { ListXSSFShape shapes drawing.getShapes(); for (XSSFShape shape : shapes) { if (shape instanceof XSSFPicture) { XSSFPicture picture (XSSFPicture) shape; PictureInfo info extractInfoFromXSSFPicture(picture, pictureDataMap, sheetIdx); if (info ! null) { result.add(info); } } } } } } /** * 从XSSFPicture中提取信息需要关联之前提取的PictureData */ private PictureInfo extractInfoFromXSSFPicture(XSSFPicture picture, MapString, XSSFPictureData pictureDataMap, int sheetIndex) { // 获取图片的关系ID String relId picture.getPictureData().getPackageRelationship().getId(); XSSFPictureData pictureData pictureDataMap.get(relId); if (pictureData null) { // 理论上不应该发生但安全起见 return null; } PictureInfo info new PictureInfo(); info.setSheetIndex(sheetIndex); info.setData(pictureData.getData()); // XSSFPictureData 可以直接获取建议的文件名和扩展名 String fileName pictureData.getFileName(); // 如 image1.png info.setPictureName(fileName ! null ? fileName : xssf_picture_ relId); info.setExtension(pictureData.suggestFileExtension()); // 获取锚点信息 XSSFClientAnchor anchor picture.getClientAnchor(); populateAnchorInfo(info, anchor); return info; } /** * 通用方法根据ClientAnchor填充位置信息 */ private void populateAnchorInfo(PictureInfo info, ClientAnchor anchor) { if (anchor null) { info.setAnchorType(未知锚点); return; } int row1 anchor.getRow1(); int col1 anchor.getCol1(); int row2 anchor.getRow2(); int col2 anchor.getCol2(); // 判断锚点类型 if (row1 0 col1 0) { info.setRowIdx(row1); info.setColIdx(col1); // 如果起始和结束单元格不同可能是跨单元格的图片 if (row1 row2 col1 col2) { info.setAnchorType(嵌入单元格 ( row1 , col1 )); } else { info.setAnchorType(跨单元格嵌入 ( row1 , col1 )-( row2 , col2 )); } } else { // 行或列为负通常是浮动图片 info.setAnchorType(浮动图片); // 可以记录像素偏移量如果需要的话 // info.setDx1(anchor.getDx1()); info.setDy1(anchor.getDy1()); ... } } /** * 使用示例从文件读取并提取图片 */ public static void main(String[] args) { String filePath 你的测试文件路径.xlsx; // 或 .xls try (InputStream is new FileInputStream(filePath); Workbook workbook WorkbookFactory.create(is)) { // WorkbookFactory自动识别类型 PictureExtractor extractor new PictureExtractor(); ListPictureInfo pictures extractor.extractPictures(workbook); System.out.println(共提取到 pictures.size() 张图片); for (int i 0; i pictures.size(); i) { PictureInfo pic pictures.get(i); System.out.printf(图片%d: 名称%s, 格式%s, 所在Sheet%d, 位置%s, 大小%d bytes%n, i 1, pic.getPictureName(), pic.getExtension(), pic.getSheetIndex(), pic.getAnchorType(), pic.getData().length); // 可选将图片保存到本地文件系统 String outputPath ./extracted_images/ pic.getPictureName(); File outputFile new File(outputPath); outputFile.getParentFile().mkdirs(); try (FileOutputStream fos new FileOutputStream(outputFile)) { fos.write(pic.getData()); } System.out.println( 已保存至: outputPath); } } catch (Exception e) { e.printStackTrace(); } } }3.2 关键代码段解析与避坑指南WorkbookFactory.create(is) 这是POI推荐的入口。它能根据文件头信息自动判断是HSSF还是XSSF并创建对应的Workbook对象。比自己用File后缀判断要可靠得多因为有些.xls文件实际是.xlsx格式反之亦然。HSSF处理流程 核心是拿到HSSFPatriarch然后遍历其getChildren()。这里返回的是HSSFShape列表需要过滤出HSSFPicture实例。注意一个工作表最多只能有一个DrawingPatriarch如果工作表没有图形对象getDrawingPatriarch()返回null。XSSF处理流程 这里采用了组合方式这是最稳妥的。先通过workbook.getAllPictures()拿到所有图片数据包XSSFPictureData并以关系IDRelId为Key存入Map。然后遍历每个工作表的XSSFDrawing找到XSSFPicture对象通过其getPictureData().getPackageRelationship().getId()获取RelId再从Map中找到对应的数据。为什么这么做因为XSSFPicture对象本身不直接包含完整的图片字节数组它只是一个引用。直接通过XSSFPicture.getPictureData().getData()在某些POI版本或复杂文件下可能无法获取到数据而通过Workbook级别的getAllPictures()获取的数据源最可靠。锚点信息处理populateAnchorInfo方法展示了如何区分图片类型。通过检查anchor.getRow1()和getCol1()是否大于等于0可以基本判断图片是否与单元格关联。对于浮动图片这些值通常是-1。getDx1(), getDy1()等偏移量单位是“缇”twips1英寸1440缇如果需要精确的像素位置需要进行换算这通常涉及DPI每英寸点数设置比较复杂在大多数“读取内容”的场景下不是必须的。图片数据与格式PictureData.suggestFileExtension()方法返回的是建议的扩展名如png,jpeg,emf,wmf等。对于.xlsx常见的嵌入图片是PNG和JPEG。对于.xls还可能遇到EMF、WMF等Windows图元格式。保存文件时可以直接使用这个扩展名。重要提示 处理.xls文件中的EMF/WMF图片时Java标准库如ImageIO可能无法直接解码显示。如果你需要在界面上渲染可能需要引入额外的库如org.apache.poi:poi-scratchpad来处理HSSF的图元文件或者使用batik库进行SVG/EMF转换或者仅保存二进制数据由前端或其他专业工具处理。4. 高级话题与性能优化4.1 处理超大型Excel文件SXSSF当处理几十万行、包含大量图片的Excel时使用XSSFWorkbook可能会导致内存溢出OOM因为它会将整个文档模型加载到内存。此时应使用SXSSFWorkbook。但是SXSSF对图片读取不友好。SXSSF是写优化模型它通过滑动窗口机制将大部分行数据刷写到磁盘临时文件以节省内存。然而POI的SXSSF实现中getDrawingPatriarch()方法可能返回null或无法获取完整的绘图信息因为绘图信息可能不在当前内存窗口中。解决方案 如果必须从海量数据的SXSSF流式文件中读图片一个可行的思路是使用基于事件的解析器如XSSF and SAX (Event API)来解析.xlsx文件。你可以定制XSSFReader和SheetContentsHandler在解析过程中捕获xdr:pic标签和对应的rId并直接从ZIP包的xl/media/目录下提取对应的图片文件。这种方式内存占用极低但代码复杂度高需要深入理解OOXML和SAX解析。折中方案如果图片数量不多但数据行多可以先用SXSSF的getXSSFWorkbook()方法谨慎使用会失去部分流式优势获取底层的XSSFWorkbook对象然后再用我们上面的方法提取图片。但这会瞬间将整个工作簿的元数据包括所有图片引用加载到内存失去了SXSSF处理大数据行的核心优势。实操建议 在真实业务中极少有需要在数十万行数据中嵌入大量图片的场景。更常见的模式是图片集中在报表前几行的表头、logo或特定摘要区域。因此一个实用的策略是如果业务确定图片只出现在前N行比如前1000行可以先用普通XSSFWorkbook打开文件提取图片然后再决定是否用SXSSF模式处理后续的纯数据行。或者与业务方协商将图片与大数据分离存储。4.2 图片去重与关联单元格数据有时同一张图片可能被多个单元格引用尽管不常见或者我们需要将图片与它旁边的单元格文本精确关联起来。去重 可以通过计算图片数据的MD5或SHA-256哈希值来判断是否为同一张图片。将哈希值存入PictureInfo对象在添加到结果列表前进行比对。import java.security.MessageDigest; // 在PictureInfo类中添加hash字段和计算方法 public void calculateHash() { try { MessageDigest md MessageDigest.getInstance(SHA-256); this.dataHash bytesToHex(md.digest(this.data)); } catch (Exception e) { this.dataHash null; } } // 然后在提取后调用calculateHash并根据dataHash去重关联数据 在PictureInfo中我们已经记录了rowIdx和colIdx。你可以很容易地通过Sheet.getRow(rowIdx).getCell(colIdx)获取到该单元格的文本内容。对于跨单元格的图片你可能需要获取一个矩形区域内的所有单元格数据。4.3 内存管理与资源释放POI的Workbook、Sheet等对象持有对文件流或内存中数据的引用必须及时关闭。使用try-with-resources 如示例代码所示用try-with-resources语句包裹InputStream和Workbook确保它们被自动关闭。处理大量图片数据 提取出的byte[]数组可能非常大。如果一次性处理成千上万张大图即使Workbook关闭了这些字节数组仍驻留在内存中。建议在提取并处理完一张图片后例如保存到文件系统或上传到对象存储后及时将PictureInfo.data引用置为null或者使用流式处理避免在内存中累积所有图片数据。for (PictureInfo pic : pictures) { // 1. 处理图片数据如保存到文件 saveToFile(pic); // 2. 及时释放该图片的字节数组内存 pic.setData(null); }5. 常见问题排查与实战技巧在实际开发中你肯定会遇到各种奇怪的问题。下面是我踩过的一些坑和解决方案。5.1 问题排查清单问题现象可能原因排查步骤与解决方案读取.xlsx文件报错Invalid header signature1. 文件损坏。2. 文件实际是.xls格式但用了.xlsx后缀。3. 文件被其他进程占用。1. 用Excel软件尝试打开确认文件完好。2. 使用File对象获取文件头魔数判断真实格式或直接使用WorkbookFactory.create()。3. 确保文件流被正确关闭。getDrawingPatriarch()返回null工作表内没有任何图形对象图片、图表、形状。这是正常情况代码中需要做空值判断。可以打印日志提示“第X页无图片”。能读取到XSSFPicture对象但getPictureData().getData()返回空数组或报错。POI版本问题或图片引用已损坏。采用本文的“组合方式”先通过workbook.getAllPictures()获取数据Map再通过RelId关联获取。这是最稳定的方法。提取的图片无法用常用看图软件打开。1. 图片格式特殊如.xls中的EMF。2. 图片数据在提取或保存过程中损坏。1. 检查PictureData.suggestFileExtension()返回的格式。对于EMF/WMF需要特殊渲染器。2. 将提取的字节数组与用解压工具如7-Zip直接打开.xlsx文件从xl/media/目录提取的原始文件进行二进制比较。处理大文件时内存溢出OOM。1. 使用XSSFWorkbook加载了超大文件。2. 提取的图片数据总量过大。1. 评估是否必须用POI。对于纯数据读取考虑SAX事件模型。2. 对于含图片的大文件如无流式读取完美方案可尝试分拆文件或提升JVM堆内存-Xmx。3. 及时释放已处理的图片数据pic.setData(null)。浮动图片的位置信息getRow1()为-1如何定位浮动图片的锚点不绑定单元格行号列号即为-1。如果需要近似定位可以计算其锚点偏移量getDx1(), getDy1()相对于工作表左上角的像素位置。但这需要知道工作表的默认列宽行高以像素为单位计算复杂且不精确。业务上通常只需知道存在这张图片即可。5.2 实战技巧与心得依赖版本管理 POI的不同版本API可能有细微差别。建议使用较新的稳定版本如本文撰写时的POI 5.x。在pom.xml中通常引入poi-ooxml就足够了它会传递依赖poi。dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.3/version !-- 请使用最新稳定版 -- /dependency单元测试覆盖 准备多种测试文件包含嵌入图片的.xls/.xlsx包含浮动图片的混合类型的空白的损坏的。确保你的提取工具在各种情况下行为符合预期尤其是异常处理。日志与监控 在工具类中添加详细的日志使用SLF4JLogback记录处理了哪个文件、每个工作表找到多少图形、最终提取了多少张图片、每张图片的大小和格式。这对于线上问题排查和性能分析非常有帮助。与业务逻辑解耦 本文的PictureExtractor只负责“提取”不负责“处理”。提取出的PictureInfo列表交给上层业务代码决定如何处置存入数据库、上传到文件服务器、还是直接返回给前端。这样的设计更清晰也便于复用。性能考量 对于需要高频处理Excel图片的服务可以考虑将WorkbookFactory.create()和图片提取过程异步化或者引入简单的缓存例如对相同的文件哈希值短时间内缓存提取结果。但要注意Excel文件内容可能变化缓存策略需谨慎。最后再强调一个最容易出错的地方不要假设图片一定在某个工作表里也不要假设图片一定锚定在单元格上。健壮的程序必须能优雅地处理这些边界情况记录日志而不是直接抛出异常导致整个导入任务失败。把提取功能做扎实后续无论是做数据核对、内容审核还是报表重构你都会感谢自己当初多花了这点功夫。