前言
在企业应用中,经常需要处理包含图片的Excel文件。WPS和Microsoft Excel都支持在单元格中嵌入图片(通过DISPIMG函数),但这种图片的存储方式与传统的浮动图片不同,给程序化处理带来了一定挑战。
本文将深入分析WPS内嵌图片的存储原理,并介绍如何正确实现导入和导出功能。
一、XLSX文件结构解析
1.1 XLSX本质是ZIP压缩包
XLSX文件本质上是一个ZIP压缩包,解压后可以看到如下结构:
bash
xlsx文件/
├── [Content_Types].xml
├── _rels/
├── docProps/
└── xl/
├── workbook.xml # 工作簿定义
├── worksheets/ # 工作表数据
│ └── sheet1.xml
├── cellimages.xml # 【关键】单元格图片定义
├── _rels/
│ └── cellimages.xml.rels # 【关键】图片资源映射
└── media/ # 实际图片文件
├── image1.png
└── image2.png
1.2 DISPIMG函数
WPS/Excel使用DISPIMG函数在单元格中嵌入图片。当你在单元格中看到图片时,实际上单元格的公式是:
bash
=_xlfn.DISPIMG("ID_XXXXX",1)
其中ID_XXXXX是图片的唯一标识符。
二、导入逻辑分析
2.1 传统方案的问题
最初的实现方案是:
- 扫描所有DISPIMG公式,按顺序收集不同的图片ID
- 获取工作簿中的所有图片
- 按顺序一一对应
java
// 伪代码:按顺序映射
for (int i = 0; i < imageIds.size(); i++) {
imageIdMap.put(imageIds.get(i), allPictures.get(i));
}
问题 :当两行使用相同的图片时(即使是复制粘贴同一张图片),WPS会进行去重优化 ------只在media/目录存储一份图片,但为每个单元格生成不同的DISPIMG ID。
例如:
- 第1行公式:
DISPIMG("ID_001", 1) - 第2行公式:
DISPIMG("ID_002", 1)← 不同的ID!
但allPictures只有1张图片(因为内容相同被去重),导致ID_002无法找到对应图片。
2.2 正确的解决方案
正确的做法是解析Excel内部的元数据文件,获取精确的映射关系。
核心文件1:xl/cellimages.xml
定义了图片ID与资源引用ID(rId)的对应关系:
xml
<etc:cellImages>
<etc:cellImage>
<xdr:pic>
<xdr:nvPicPr>
<xdr:cNvPr name="ID_001"/> <!-- 图片ID -->
</xdr:nvPicPr>
<xdr:blipFill>
<a:blip r:embed="rId1"/> <!-- 资源引用ID -->
</xdr:blipFill>
</xdr:pic>
</etc:cellImage>
<etc:cellImage>
<xdr:nvPicPr>
<xdr:cNvPr name="ID_002"/> <!-- 另一个图片ID -->
</xdr:nvPicPr>
<xdr:blipFill>
<a:blip r:embed="rId1"/> <!-- 指向同一个rId!-->
</xdr:blipFill>
</etc:cellImage>
</etc:cellImages>
核心文件2:xl/_rels/cellimages.xml.rels
定义了rId与实际图片文件的对应关系:
xml
<Relationships>
<Relationship Id="rId1" Target="../media/image1.png"/>
</Relationships>
映射链路
bash
DISPIMG("ID_001") → rId1 → media/image1.png → 图片数据
DISPIMG("ID_002") → rId1 → media/image1.png → 图片数据(同一张)
2.3 实现流程
java
public static Map<String, PictureData> extractImageMapping(InputStream xlsxStream, XSSFWorkbook workbook) {
Map<String, PictureData> result = new HashMap<>();
// 1. 构建 图片路径 → 图片数据 的映射
Map<String, PictureData> pathToPictureMap = buildPathToPictureMap(workbook);
// 2. 解析ZIP,读取元数据文件
ZipInputStream zipStream = new ZipInputStream(xlsxStream);
ZipEntry entry;
Map<String, String> nameToRidMap = new HashMap<>(); // ID_xxx → rId
Map<String, String> ridToPathMap = new HashMap<>(); // rId → media/image.png
while ((entry = zipStream.getNextEntry()) != null) {
if ("xl/cellimages.xml".equals(entry.getName())) {
// 解析:图片名称 → rId
nameToRidMap = parseCellImagesXml(zipStream);
} else if ("xl/_rels/cellimages.xml.rels".equals(entry.getName())) {
// 解析:rId → 图片路径
ridToPathMap = parseCellImagesRels(zipStream);
}
}
// 3. 组合映射链:图片ID → rId → 图片路径 → 图片数据
for (Map.Entry<String, String> e : nameToRidMap.entrySet()) {
String imageId = e.getKey();
String rId = e.getValue();
String path = ridToPathMap.get(rId);
PictureData picture = pathToPictureMap.get(path);
if (picture != null) {
result.put(imageId, picture);
}
}
return result;
}
三、导出逻辑分析
3.1 需求背景
数据库中存储的是图片的URL或本地路径,导出Excel时希望:
- 图片直接嵌入单元格显示
- 而不是显示为文本链接
3.2 实现思路
- 下载/读取图片:根据URL或本地路径获取图片字节数据
- 添加图片到工作簿 :使用POI的
addPicture方法 - 创建锚点定位 :使用
ClientAnchor将图片定位到指定单元格 - 插入图片 :使用
Drawing.createPicture创建图片
3.3 关键代码
java
private static void insertImage(XSSFWorkbook workbook, XSSFSheet sheet,
XSSFDrawing drawing, String imageUrl, int rowIndex, int colIndex) {
// 1. 下载图片
byte[] imageBytes = downloadImage(imageUrl);
// 2. 判断图片类型
int pictureType = getPictureType(imageUrl, imageBytes);
// 3. 添加图片到工作簿,获取图片索引
int pictureIndex = workbook.addPicture(imageBytes, pictureType);
// 4. 创建锚点,定位图片位置
XSSFClientAnchor anchor = new XSSFClientAnchor();
anchor.setCol1(colIndex); // 起始列
anchor.setRow1(rowIndex); // 起始行
anchor.setCol2(colIndex + 1); // 结束列
anchor.setRow2(rowIndex + 1); // 结束行
// 设置边距(单位:EMU,1像素 ≈ 9525 EMU)
int margin = 5 * 9525;
anchor.setDx1(margin);
anchor.setDy1(margin);
// 5. 设置锚点类型:随单元格移动和调整大小
anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_AND_RESIZE);
// 6. 创建图片
drawing.createPicture(anchor, pictureIndex);
}
3.4 图片类型判断
通过文件扩展名或文件头魔数判断:
java
private static int getPictureType(String imageUrl, byte[] imageBytes) {
// 优先根据扩展名判断
if (imageUrl.toLowerCase().endsWith(".png")) {
return Workbook.PICTURE_TYPE_PNG;
} else if (imageUrl.toLowerCase().endsWith(".jpg")) {
return Workbook.PICTURE_TYPE_JPEG;
}
// 根据文件头魔数判断
if (imageBytes[0] == (byte)0x89 && imageBytes[1] == (byte)0x50) {
return Workbook.PICTURE_TYPE_PNG; // PNG魔数:89 50 4E 47
}
if (imageBytes[0] == (byte)0xFF && imageBytes[1] == (byte)0xD8) {
return Workbook.PICTURE_TYPE_JPEG; // JPEG魔数:FF D8 FF
}
return Workbook.PICTURE_TYPE_PNG; // 默认
}
四、总结
导入关键点
| 问题 | 解决方案 |
|---|---|
| WPS对相同图片去重存储 | 解析cellimages.xml元数据获取精确映射 |
| 图片ID与图片数据顺序不对应 | 通过ID → rId → Path → Data链路映射 |
导出关键点
| 问题 | 解决方案 |
|---|---|
| 图片定位到单元格 | 使用ClientAnchor设置行列范围 |
| 图片自适应单元格大小 | 使用MOVE_AND_RESIZE锚点类型 |
| 支持多种图片格式 | 根据扩展名或魔数判断类型 |
核心依赖
xml
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.3</version>
</dependency>
参考资料
- Apache POI官方文档
- OOXML规范(ECMA-376)
- WPS开发者文档