fix(similar-asin): 货源结果图回WPS DISPIMG并修正协议,WPS打开不再#REF/破损

task-284 曾把图片协议改为 WPS DISPIMG(cellimages.xml),WPS 打开显示 #REF;
9/8 切回 Excel 365 richData 单元格图片后,WPS(用户主环境,12.1 实测)仍不识别,
单元格显示为 #VALUE! 错误值即图片破损。

根因:两套协议都只被单方支持——DISPIMG 是 WPS 私有协议,richData 是 Excel 365 私有协议。
本次回到 WPS DISPIMG(WPS 原生),并修复 WpsCellImageWriter 三处与 WPS 原生产物不一致的
协议细节(此前即 #REF 根因,已在 WPS 12.1.0.28505 实测三图渲染正常):
1. [Content_Types].xml 应为 application/vnd.wps-officedocument.cellimage+xml
2. workbook.xml.rels 关系类型应为 wps.cn/officeDocument/2020/cellImage
3. 公式格需 f t=shared ref + v=公式字符串(POI 输出裸 <f> 且引号转义为 &quot;,
   patch 阶段统一规范化;保留原格样式)
另 spPr 补 a:xfrm(原图尺寸)/a:noFill/a:ln w=9525(实测样本对齐);
patch 幂等(重复 patch 不再 duplicate entry);失败图留空不再写 URL 文本。
新增 WpsCellImageWriterProtocolTest 锁死协议契约防回归。
This commit is contained in:
2026-09-09 14:25:41 +08:00
parent 11d1951c7f
commit b3f7b81b9c
5 changed files with 285 additions and 51 deletions
@@ -41,7 +41,7 @@ import com.nanri.aiimage.modules.similarasin.service.support.SimilarAsinExcelPar
import com.nanri.aiimage.modules.similarasin.service.support.SimilarAsinGroupingConverter;
import com.nanri.aiimage.modules.similarasin.service.support.SimilarAsinHistoryAssembler;
import com.nanri.aiimage.modules.similarasin.util.BoundedImageCache;
import com.nanri.aiimage.modules.similarasin.util.ExcelCellImageWriter;
import com.nanri.aiimage.modules.similarasin.util.WpsCellImageWriter;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
@@ -4553,8 +4553,9 @@ public class SimilarAsinTaskService {
Map<String, SimilarAsinResultRowDto> resultMap,
Map<String, SimilarAsinImageEmbedder.ResizedImage> taskImageCache) {
ensureResultAssemblyNotInterrupted();
// 单元格内嵌图片写完 workbook patch Excel 365 richData 单元格图片结构 Excel/WPS 打开均可见
ExcelCellImageWriter.Session cellImageSession = ExcelCellImageWriter.createSession();
// 单元格内嵌图片写完 workbook patch WPS DISPIMG 结构WPS/钉钉/飞书 打开即为单元格图片
// Excel 365 richData 不同DISPIMG WPS 原生协议协议细节见 WpsCellImageWriter 类注释
WpsCellImageWriter.Session cellImageSession = WpsCellImageWriter.createSession();
SimilarAsinImageEmbedder.ImageSpool imageSpool;
try {
Path spoolDir = Files.createTempDirectory(xlsx.getParentFile().toPath(), "similar-asin-images-");
@@ -4670,7 +4671,7 @@ public class SimilarAsinTaskService {
if (!cellImageSession.isEmpty()) {
try {
ensureResultAssemblyNotInterrupted();
ExcelCellImageWriter.patchXlsxFile(xlsx, cellImageSession);
WpsCellImageWriter.patchXlsxFile(xlsx, cellImageSession);
} catch (IOException ex) {
String cause = ex.toString();
log.error("[similar-asin] excel cell image patch failed xlsx={} images={} err={}",
@@ -565,8 +565,8 @@ public class SimilarAsinImageEmbedder {
}
/**
* 抽取共享:下载 + resize + 写 cache。任一失败即写文本兜底(cell 走 row.createCell(colIdx).setCellValue),
* 失败时返回 null;成功返回缩略图。给 Excel richData 单元格图片写入路径使用。
* 抽取共享:下载 + resize。任一失败即留空(图片列不写内容),
* 失败时返回 null;成功返回缩略图。给 WPS DISPIMG 单元格图片写入路径使用。
*/
private ResizedImage downloadAndResize(String trimmedUrl,
Map<String, ResizedImage> taskImageCache) {
@@ -606,13 +606,17 @@ public class SimilarAsinImageEmbedder {
return null;
}
/**
* WPS DISPIMGcellimages.xml)单元格图片写入入口:注册图片到 WpsCellImageWriter 会话,
* 单元格写 DISPIMG 公式;patch 阶段由 WpsCellImageWriter 再规范化为 WPS 原生公式形态。
*/
public ImageDim embedAsExcelCellImage(int rowIdx,
int colIdx,
String url,
Row row,
Map<String, ResizedImage> taskImageCache,
ExcelCellImageWriter.Session excelCellImageSession) {
return embedAsExcelCellImage(rowIdx, colIdx, url, row, taskImageCache, excelCellImageSession, null);
WpsCellImageWriter.Session cellImageSession) {
return embedAsExcelCellImage(rowIdx, colIdx, url, row, taskImageCache, cellImageSession, null);
}
public ImageDim embedAsExcelCellImage(int rowIdx,
@@ -620,40 +624,39 @@ public class SimilarAsinImageEmbedder {
String url,
Row row,
Map<String, ResizedImage> taskImageCache,
ExcelCellImageWriter.Session excelCellImageSession,
WpsCellImageWriter.Session cellImageSession,
ImageSpool imageSpool) {
if (url == null || url.isBlank() || excelCellImageSession == null) {
if (url == null || url.isBlank() || cellImageSession == null) {
return null;
}
String trimmedUrl = url.trim();
SpoolImage spooled = imageSpool == null ? null : imageSpool.get(trimmedUrl);
String imageId = null;
ImageDim dim = null;
if (spooled != null) {
// 预取已落盘:按路径注册,patch 阶段再读文件,避免整份图片字节驻留堆内存。
row.createCell(colIdx).setCellValue("#VALUE!");
excelCellImageSession.registerImage(rowIdx, colIdx, spooled.path());
return new ImageDim(spooled.width(), spooled.height());
}
if (imageSpool != null) {
// 预取未命中说明该 URL 下载失败:不重试,单元格回退为 URL 文本便于人工核对。
row.createCell(colIdx).setCellValue(trimmedUrl);
imageId = cellImageSession.registerImageByPath(spooled.path(), spooled.width(), spooled.height());
dim = new ImageDim(spooled.width(), spooled.height());
} else if (imageSpool != null) {
// 预取未命中说明该 URL 下载失败:不重试、不把 URL 文本写进单元格,留空并打日志。
log.warn("[similar-asin][image] cell-image-miss url={} colIdx={} (prefetch did not spool), leave blank",
trimmedUrl, colIdx);
return null;
}
ResizedImage thumb = downloadAndResize(trimmedUrl, taskImageCache);
if (thumb == null) {
return null;
}
try {
row.createCell(colIdx).setCellValue("#VALUE!");
excelCellImageSession.registerImage(rowIdx, colIdx, thumb.bytes());
} else {
ResizedImage thumb = downloadAndResize(trimmedUrl, taskImageCache);
if (thumb == null) {
log.warn("[similar-asin][image] cell-image-miss url={} colIdx={} (download failed), leave blank",
trimmedUrl, colIdx);
return null;
}
imageId = cellImageSession.registerImage(thumb.bytes(), thumb.width(), thumb.height());
taskImageCache.remove(trimmedUrl);
return new ImageDim(thumb.width(), thumb.height());
} catch (RuntimeException ex) {
taskImageCache.remove(trimmedUrl);
log.warn("[similar-asin][image] excel-cell-image-fail url={} colIdx={} err={}",
trimmedUrl, colIdx, ex.getMessage());
row.createCell(colIdx).setCellValue(trimmedUrl);
return null;
dim = new ImageDim(thumb.width(), thumb.height());
}
if (imageId != null) {
row.createCell(colIdx).setCellFormula(WpsCellImageWriter.dispImgFormula(imageId));
}
return dim;
}
private boolean hasLocalCachedThumb(String url) {
@@ -30,20 +30,25 @@ import java.util.zip.ZipOutputStream;
* <p>
* Excel 1997-2007 OOXML 没有 "图片嵌入单元格" 概念,POI 5.2.5 也不支持写入 Excel 365 的 IMAGE() 函数。
* 本类基于 WPS 在 OOXML 上的扩展协议(cellimages.xml),让生成的 xlsx 在 WPS、钉钉表格、
* 飞书表格、Excel 365 中表现为 "图片真嵌入到单元格内":解除工作表保护后图片仍跟着单元格走,
* 飞书表格中表现为 "图片真嵌入到单元格内":解除工作表保护后图片仍跟着单元格走,
* 拷贝粘贴行/排序时图片随单元格移动,无法被鼠标拖出。
* <p>
* 用法:
* <pre>
* WpsCellImageWriter.Session session = WpsCellImageWriter.createSession();
* // 写每个 cell
* String id = session.registerImage(jpegBytes);
* String id = session.registerImage(jpegBytes, width, height);
* cell.setCellFormula(WpsCellImageWriter.dispImgFormula(id));
* // workbook.write(file) 之后调用:
* WpsCellImageWriter.patchXlsxFile(xlsx, session);
* </pre>
* <p>
* 协议参考:WPS Office 表格扩展 cellimages.xml。
* 协议参考:WPS Office 表格扩展 cellimages.xml。2026-09-09 修复三处与 WPS 原生产物不一致的协议细节
* (此前 WPS 打开 DISPIMG 显示 #REF,根因见各常量注释):
* 1. [Content_Types].xml 的 Content-Type 应为 vnd.wps-officedocument.cellimage+xml
* 2. workbook.xml.rels 的关系类型应为 wps.cn/officeDocument/2020/cellImage
* 3. 公式单元格需为 &lt;f t="shared" ref="X"&gt; + &lt;v&gt;=DISPIMG(...)&lt;/v&gt; 形态,POI 输出的裸 &lt;f&gt; 不满足;
* 另 spPr 补 a:xfrm / a:noFill / a:ln w=9525(原图尺寸按 9525 EMU/px 换算)。
*/
@Slf4j
public final class WpsCellImageWriter {
@@ -52,13 +57,29 @@ public final class WpsCellImageWriter {
public static final String CELLIMAGES_RELS_PART = "xl/_rels/cellimages.xml.rels";
public static final String CONTENT_TYPES_PART = "[Content_Types].xml";
public static final String WORKBOOK_RELS_PART = "xl/_rels/workbook.xml.rels";
public static final String SHEET1_PART = "xl/worksheets/sheet1.xml";
public static final String CELLIMAGES_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.spreadsheetml.cellimages+xml";
"application/vnd.wps-officedocument.cellimage+xml";
public static final String CELLIMAGES_REL_TYPE =
"http://www.wps.cn/officeDocument/2018/relationships/cellimage";
"http://www.wps.cn/officeDocument/2020/cellImage";
public static final String IMAGE_REL_TYPE =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
/** 1 像素 = 9525 EMU(Excel 内部单位),用于 cellimages.xml 中 a:ext 的原图尺寸。 */
static final int EMU_PER_PIXEL = 9525;
/**
* 公式单元格规范化正则:匹配 POI 写出的裸公式格(f 无 t="shared"),
* 替换为 WPS 原生形态(f t="shared" ref= + v=公式字符串)。
* POI 输出公式中的双引号为 XML 实体 &quot;,此处两种形态都兼容。
* 负向前瞻保证已规范化的单元格不被二次改写(重复 patch 幂等)。
*/
private static final Pattern DISPIMG_CELL_PATTERN = Pattern.compile(
"<c r=\"([A-Z]+[1-9][0-9]*)\"([^>]*)>\\s*<f(?![^>]*t=\"shared\")[^>]*>"
+ "_xlfn\\.DISPIMG\\((?:&quot;|\")([^&\"<>]+)(?:&quot;|\"),1\\)</f>(?:<v>[^<]*</v>)?</c>",
Pattern.DOTALL);
private static final Pattern CELL_STYLE_PATTERN = Pattern.compile("s=\"(\\d+)\"");
private WpsCellImageWriter() {
}
@@ -86,6 +107,8 @@ public final class WpsCellImageWriter {
* - 新建 xl/media/cellimage{N}.jpeg
* - 修改 [Content_Types].xml(加 Override
* - 修改 xl/_rels/workbook.xml.rels(加 Relationship
* - 修改 xl/worksheets/sheet1.xml(把 POI 写出的裸 DISPIMG 公式格规范化为 WPS 原生形态,
* 保证 WPS 识别;POI 的 setCellFormula 输出缺 f t="shared"/ref 与 v=公式字符串,WPS 会判 #REF
* <p>
* 通过临时文件 + REPLACE_EXISTING move 实现:失败时 xlsx 文件保持原样不被破坏。
* 历史使用 ATOMIC_MOVE+REPLACE_EXISTING,但 Java NIO 规范说 ATOMIC_MOVE 下其它选项被忽略,
@@ -103,6 +126,7 @@ public final class WpsCellImageWriter {
}
Path tmp = Files.createTempFile(parent, "wps-dispimg-", ".xlsx");
boolean success = false;
int patchedCells = -1;
try {
// 使用 ZipFile(基于中央目录读)替代 ZipInputStream(顺序流读)。
// 原因:POI SXSSFWorkbook 写出的 xlsx,部分 entry 的 LFHLocal File Header)会用 data descriptor
@@ -119,6 +143,11 @@ public final class WpsCellImageWriter {
writeDirectoryEntry(zout, name);
continue;
}
// DISPIMG 专属 part 由尾部统一重建(重复 patch 时原样保留,保证幂等)
if (CELLIMAGES_PART.equals(name) || CELLIMAGES_RELS_PART.equals(name)
|| name.startsWith("xl/media/cellimage")) {
continue;
}
byte[] bytes;
try (InputStream in = zfIn.getInputStream(entry)) {
bytes = readAll(in);
@@ -127,6 +156,10 @@ public final class WpsCellImageWriter {
bytes = patchContentTypes(bytes);
} else if (WORKBOOK_RELS_PART.equals(name)) {
bytes = patchWorkbookRels(bytes);
} else if (SHEET1_PART.equals(name)) {
PatchSheetResult sheetResult = patchSheet(bytes, session);
bytes = sheetResult.bytes();
patchedCells = sheetResult.patchedCount();
}
writeEntry(zout, name, bytes);
}
@@ -154,7 +187,8 @@ public final class WpsCellImageWriter {
xlsx.getName(), moveEx.toString());
}
success = true;
log.info("[wps-dispimg] patch xlsx finished file={} images={}", xlsx.getName(), session.size());
log.info("[wps-dispimg] patch xlsx finished file={} images={} patchedCells={}",
xlsx.getName(), session.size(), patchedCells);
} catch (IOException ioe) {
log.error("[wps-dispimg] patch xlsx failed file={} images={} err={}",
xlsx.getName(), session.size(), ioe.toString(), ioe);
@@ -193,6 +227,45 @@ public final class WpsCellImageWriter {
zout.closeEntry();
}
/**
* 规范化 sheet 中 POI 写出的 DISPIMG 公式单元格:
* &lt;c r="M2" s="9" t="str"&gt;&lt;f&gt;_xlfn.DISPIMG("ID_x",1)&lt;/f&gt;…&lt;/c&gt;
* → &lt;c r="M2" s="9" t="str"&gt;&lt;f t="shared" ref="M2"&gt;_xlfn.DISPIMG("ID_x",1)&lt;/f&gt;&lt;v&gt;=DISPIMG("ID_x",1)&lt;/v&gt;&lt;/c&gt;
* 保留原单元格样式 s。
*/
private static PatchSheetResult patchSheet(byte[] original, Session session) {
String content = new String(original, StandardCharsets.UTF_8);
Matcher matcher = DISPIMG_CELL_PATTERN.matcher(content);
StringBuilder patched = new StringBuilder(content.length());
int patchedCount = 0;
while (matcher.find()) {
String cellRef = matcher.group(1);
String imageId = matcher.group(3);
String style = "";
Matcher styleMatcher = CELL_STYLE_PATTERN.matcher(matcher.group(2));
if (styleMatcher.find()) {
style = " s=\"" + styleMatcher.group(1) + "\"";
}
String replacement = "<c r=\"" + cellRef + "\"" + style + " t=\"str\">"
+ "<f t=\"shared\" ref=\"" + cellRef + "\">_xlfn.DISPIMG(\"" + imageId + "\",1)</f>"
+ "<v>=DISPIMG(\"" + imageId + "\",1)</v></c>";
matcher.appendReplacement(patched, Matcher.quoteReplacement(replacement));
patchedCount++;
}
matcher.appendTail(patched);
if (patchedCount != session.size()) {
// 重复 patch(公式格已规范化,patchedCount=0)属预期,降级为 debug
if (patchedCount == 0) {
log.debug("[wps-dispimg] dispimg cells already normalized file={} images={}",
session.size());
} else {
log.warn("[wps-dispimg] patched dispimg cells mismatch file={} images={} patchedCells={}",
session.size(), patchedCount);
}
}
return new PatchSheetResult(patched.toString().getBytes(StandardCharsets.UTF_8), patchedCount);
}
static byte[] patchContentTypes(byte[] original) {
String content = new String(original, StandardCharsets.UTF_8);
// 已经存在则跳过(幂等:避免重复 patch)
@@ -248,8 +321,8 @@ public final class WpsCellImageWriter {
.append(" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">");
int idx = 1;
for (RegisteredImage img : session.images) {
// cNvPr id 从 2 起步、descr 为空字符串、spPr 末尾追加 a:ln/a:noFill —— 与 WPS 实测样本完全对齐,
// 减少 Excel 在"修复"路径上把整个 part 丢弃的概率
// cNvPr id 从 2 起步、descr 为空字符串、spPr 含 a:xfrm(原图尺寸 EMU/a:noFill/a:ln w=9525——
// 与 WPS 实测样本完全对齐(2026-09-09 补齐后 WPS 12.1 实测显示正常,此前缺 xfrm 等导致 #REF)
int cNvPrId = idx + 1;
sb.append("<etc:cellImage>")
.append("<xdr:pic>")
@@ -265,8 +338,15 @@ public final class WpsCellImageWriter {
.append("<a:stretch><a:fillRect/></a:stretch>")
.append("</xdr:blipFill>")
.append("<xdr:spPr>")
.append("<a:xfrm>")
.append("<a:off x=\"0\" y=\"0\"/>")
.append("<a:ext cx=\"").append(Math.max(1L, (long) img.width * EMU_PER_PIXEL))
.append("\" cy=\"").append(Math.max(1L, (long) img.height * EMU_PER_PIXEL))
.append("\"/>")
.append("</a:xfrm>")
.append("<a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom>")
.append("<a:ln><a:noFill/></a:ln>")
.append("<a:noFill/>")
.append("<a:ln w=\"9525\"><a:noFill/></a:ln>")
.append("</xdr:spPr>")
.append("</xdr:pic>")
.append("</etc:cellImage>");
@@ -302,7 +382,7 @@ public final class WpsCellImageWriter {
// 磁盘路径去重:同一任务内相同源文件(预取 spool 同一份)只写一次 media,避免内存/体积膨胀。
private final Map<String, String> pathHashToId = new LinkedHashMap<>();
public synchronized String registerImage(byte[] jpegBytes) {
public synchronized String registerImage(byte[] jpegBytes, int width, int height) {
if (jpegBytes == null || jpegBytes.length == 0) {
return null;
}
@@ -312,13 +392,13 @@ public final class WpsCellImageWriter {
return existingId;
}
String id = newImageId();
images.add(new RegisteredImage(id, jpegBytes));
images.add(new RegisteredImage(id, jpegBytes, width, height));
bytesHashToId.put(hash, id);
return id;
}
/** 按磁盘路径注册(预取 spool 已落盘,避免整份字节驻留内存);按绝对路径去重,重复路径复用同一 media。 */
public synchronized String registerImageByPath(Path path) {
public synchronized String registerImageByPath(Path path, int width, int height) {
if (path == null) {
return null;
}
@@ -328,7 +408,7 @@ public final class WpsCellImageWriter {
return existingId;
}
String id = newImageId();
images.add(new RegisteredImage(id, path));
images.add(new RegisteredImage(id, path, width, height));
pathHashToId.put(normalized, id);
return id;
}
@@ -342,7 +422,7 @@ public final class WpsCellImageWriter {
}
private static String newImageId() {
// ID_xxx 格式(24 hex chars)模拟 WPS 默认风格,避免特殊字符让公式解析出错。
// ID_xxx 格式(32 hex chars = MD5 风格)模拟 WPS 默认风格,避免特殊字符让公式解析出错。
return "ID_" + UUID.randomUUID().toString().replace("-", "").toUpperCase();
}
@@ -366,21 +446,30 @@ public final class WpsCellImageWriter {
final String id;
final byte[] bytes;
final Path sourcePath;
final int width;
final int height;
RegisteredImage(String id, byte[] bytes) {
RegisteredImage(String id, byte[] bytes, int width, int height) {
this.id = id;
this.bytes = bytes;
this.sourcePath = null;
this.width = width;
this.height = height;
}
RegisteredImage(String id, Path sourcePath) {
RegisteredImage(String id, Path sourcePath, int width, int height) {
this.id = id;
this.bytes = null;
this.sourcePath = sourcePath;
this.width = width;
this.height = height;
}
byte[] bytes() throws IOException {
return sourcePath != null ? Files.readAllBytes(sourcePath) : bytes;
}
}
private record PatchSheetResult(byte[] bytes, int patchedCount) {
}
}
@@ -229,13 +229,12 @@ class SimilarAsinImageEmbedderTest {
var row = workbook.createSheet().createRow(1);
SimilarAsinImageEmbedder.ImageDim result = failedEmbedder.embedAsExcelCellImage(
1, 9, url, row, new HashMap<>(), ExcelCellImageWriter.createSession(), spool);
1, 9, url, row, new HashMap<>(), WpsCellImageWriter.createSession(), spool);
assertEquals(SimilarAsinImageEmbedder.DOWNLOAD_MAX_RETRY + 1, callsAfterPrefetch);
assertEquals(callsAfterPrefetch, networkCalls.get());
assertNull(result);
assertTrue(row.getCell(9) != null, "预取失败应回退为 URL 文本便于人工核对");
assertEquals(url, row.getCell(9).getStringCellValue());
assertNull(row.getCell(9), "预取失败(WPS DISPIMG 路径)单元格留空,不写 URL 文本");
} finally {
failedEmbedder.shutdown();
}
@@ -0,0 +1,142 @@
package com.nanri.aiimage.modules.similarasin.util;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.zip.ZipFile;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* WPS DISPIMG 协议回归测试:真实写入流水线(SXSSF 写公式格 → patch)产出的 part 必须与
* WPS 12.1 实测通过的原生形态一致。2026-09-09 曾因三处协议细节与 WPS 原生产物不一致
* Content-Type / workbook 关系类型 / 公式格形态)导致 WPS 打开显示 #REF,本测试锁死这些契约。
*/
class WpsCellImageWriterProtocolTest {
private static byte[] jpeg(int size) throws Exception {
BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
g.setColor(Color.WHITE);
g.fillRect(0, 0, size, size);
g.dispose();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
assertTrue(ImageIO.write(image, "jpg", baos), "JPEG writer should be available");
return baos.toByteArray();
}
@Test
void patchedWorkbookMatchesWpsNativeShape() throws Exception {
File xlsx = File.createTempFile("wps-dispimg-protocol-", ".xlsx");
xlsx.deleteOnExit();
try {
WpsCellImageWriter.Session session = WpsCellImageWriter.createSession();
byte[] jpeg = jpeg(300);
String imageId = session.registerImage(jpeg, 300, 300);
assertNotNull(imageId, "图片注册应返回 ID");
assertTrue(imageId.matches("ID_[0-9A-F]{32}"), "图片 ID 应为 WPS 风格 ID_+32hex, got=" + imageId);
try (SXSSFWorkbook workbook = new SXSSFWorkbook(200)) {
Sheet sheet = workbook.createSheet("相似asin检测");
CellStyle headerStyle = workbook.createCellStyle();
Font font = workbook.createFont();
font.setBold(true);
headerStyle.setFont(font);
sheet.setColumnWidth(12, 80 * 256);
Row header = sheet.createRow(0);
header.createCell(0).setCellValue("id");
sheet.createRow(1).createCell(0).setCellValue("827665");
Row row = sheet.createRow(1);
row.setHeightInPoints(409f);
row.createCell(12).setCellFormula(WpsCellImageWriter.dispImgFormula(imageId));
try (var fos = Files.newOutputStream(xlsx.toPath())) {
workbook.write(fos);
}
workbook.dispose();
}
WpsCellImageWriter.patchXlsxFile(xlsx, session);
try (ZipFile zf = new ZipFile(xlsx)) {
String sheetXml = readEntry(zf, "xl/worksheets/sheet1.xml");
assertTrue(sheetXml.contains("<f t=\"shared\" ref=\"M2\">_xlfn.DISPIMG(\"" + imageId + "\",1)</f>"),
"公式格应为 WPS 原生形态(f t=shared/ref+公式字符串 v), got=" + snippet(sheetXml, "M2"));
assertTrue(sheetXml.contains("<v>=DISPIMG(\"" + imageId + "\",1)</v>"),
"公式格 <v> 应为 =DISPIMG(...) 形态");
String ct = readEntry(zf, "[Content_Types].xml");
assertTrue(ct.contains("application/vnd.wps-officedocument.cellimage+xml"),
"cellimages Content-Type 应为 vnd.wps-officedocument.cellimage+xml");
String rels = readEntry(zf, "xl/_rels/workbook.xml.rels");
assertTrue(rels.contains("http://www.wps.cn/officeDocument/2020/cellImage"),
"cellimages 关系类型应为 wps.cn/officeDocument/2020/cellImage");
String cellImages = readEntry(zf, "xl/cellimages.xml");
assertTrue(cellImages.contains("r:embed=\"rId1\""), "cellimages.xml 应引用 rId1");
assertTrue(cellImages.contains("<a:xfrm>"), "spPr 应含 a:xfrm");
assertTrue(cellImages.contains("cx=\"2857500\""), "a:ext 应按 300px×9525EMU 换算");
assertTrue(cellImages.contains("name=\"" + imageId + "\""), "cNvPr name 应为图片 ID");
String cellImagesRels = readEntry(zf, "xl/_rels/cellimages.xml.rels");
assertTrue(cellImagesRels.contains("Target=\"media/cellimage1.jpeg\""), "rels 应指向 media/cellimage1.jpeg");
}
} finally {
Files.deleteIfExists(xlsx.toPath());
}
}
@Test
void repatchIsIdempotent() throws Exception {
File xlsx = File.createTempFile("wps-dispimg-idempotent-", ".xlsx");
xlsx.deleteOnExit();
try {
WpsCellImageWriter.Session session = WpsCellImageWriter.createSession();
String imageId = session.registerImage(jpeg(64), 64, 64);
try (SXSSFWorkbook workbook = new SXSSFWorkbook(200)) {
Sheet sheet = workbook.createSheet("s");
Row row = sheet.createRow(1);
row.setHeightInPoints(409f);
row.createCell(12).setCellFormula(WpsCellImageWriter.dispImgFormula(imageId));
try (var fos = Files.newOutputStream(xlsx.toPath())) {
workbook.write(fos);
}
workbook.dispose();
}
WpsCellImageWriter.patchXlsxFile(xlsx, session);
WpsCellImageWriter.patchXlsxFile(xlsx, session);
try (ZipFile zf = new ZipFile(xlsx)) {
String sheetXml = readEntry(zf, "xl/worksheets/sheet1.xml");
assertTrue(sheetXml.contains("<f t=\"shared\" ref=\"M2\">_xlfn.DISPIMG(\"" + imageId + "\",1)</f>"),
"重复 patch 不应破坏公式格");
assertTrue(!sheetXml.contains("<f>_xlfn.DISPIMG"),
"不应残留未规范化的裸公式格");
}
} finally {
Files.deleteIfExists(xlsx.toPath());
}
}
private static String readEntry(ZipFile zf, String name) throws Exception {
try (var in = zf.getInputStream(zf.getEntry(name))) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
private static String snippet(String xml, String cellRef) {
int idx = xml.indexOf("r=\"" + cellRef + "\"");
return idx < 0 ? "<not found>" : xml.substring(Math.max(0, idx), Math.min(xml.length(), idx + 160));
}
}