diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java index b8acba4c..2f1b281a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java @@ -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 resultMap, Map 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={}", diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java index d5acceeb..003902cd 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java @@ -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 taskImageCache) { @@ -606,13 +606,17 @@ public class SimilarAsinImageEmbedder { return null; } + /** + * WPS DISPIMG(cellimages.xml)单元格图片写入入口:注册图片到 WpsCellImageWriter 会话, + * 单元格写 DISPIMG 公式;patch 阶段由 WpsCellImageWriter 再规范化为 WPS 原生公式形态。 + */ public ImageDim embedAsExcelCellImage(int rowIdx, int colIdx, String url, Row row, Map 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 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) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/WpsCellImageWriter.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/WpsCellImageWriter.java index 4aed2c9b..9260603e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/WpsCellImageWriter.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/WpsCellImageWriter.java @@ -30,20 +30,25 @@ import java.util.zip.ZipOutputStream; *

* Excel 1997-2007 OOXML 没有 "图片嵌入单元格" 概念,POI 5.2.5 也不支持写入 Excel 365 的 IMAGE() 函数。 * 本类基于 WPS 在 OOXML 上的扩展协议(cellimages.xml),让生成的 xlsx 在 WPS、钉钉表格、 - * 飞书表格、Excel 365 中表现为 "图片真嵌入到单元格内":解除工作表保护后图片仍跟着单元格走, + * 飞书表格中表现为 "图片真嵌入到单元格内":解除工作表保护后图片仍跟着单元格走, * 拷贝粘贴行/排序时图片随单元格移动,无法被鼠标拖出。 *

* 用法: *

  *     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);
  * 
*

- * 协议参考: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. 公式单元格需为 <f t="shared" ref="X"> + <v>=DISPIMG(...)</v> 形态,POI 输出的裸 <f> 不满足; + * 另 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 实体 ",此处两种形态都兼容。 + * 负向前瞻保证已规范化的单元格不被二次改写(重复 patch 幂等)。 + */ + private static final Pattern DISPIMG_CELL_PATTERN = Pattern.compile( + "]*)>\\s*]*t=\"shared\")[^>]*>" + + "_xlfn\\.DISPIMG\\((?:"|\")([^&\"<>]+)(?:"|\"),1\\)(?:[^<]*)?", + 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) *

* 通过临时文件 + 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 的 LFH(Local 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 公式单元格: + * <c r="M2" s="9" t="str"><f>_xlfn.DISPIMG("ID_x",1)</f>…</c> + * → <c r="M2" s="9" t="str"><f t="shared" ref="M2">_xlfn.DISPIMG("ID_x",1)</f><v>=DISPIMG("ID_x",1)</v></c> + * 保留原单元格样式 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 = "" + + "_xlfn.DISPIMG(\"" + imageId + "\",1)" + + "=DISPIMG(\"" + imageId + "\",1)"; + 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("") .append("") @@ -265,8 +338,15 @@ public final class WpsCellImageWriter { .append("") .append("") .append("") + .append("") + .append("") + .append("") + .append("") .append("") - .append("") + .append("") + .append("") .append("") .append("") .append(""); @@ -302,7 +382,7 @@ public final class WpsCellImageWriter { // 磁盘路径去重:同一任务内相同源文件(预取 spool 同一份)只写一次 media,避免内存/体积膨胀。 private final Map 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) { + } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderTest.java index a6808030..2fe02a07 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderTest.java @@ -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(); } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/WpsCellImageWriterProtocolTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/WpsCellImageWriterProtocolTest.java new file mode 100644 index 00000000..26b7e809 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/WpsCellImageWriterProtocolTest.java @@ -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("_xlfn.DISPIMG(\"" + imageId + "\",1)"), + "公式格应为 WPS 原生形态(f t=shared/ref+公式字符串 v), got=" + snippet(sheetXml, "M2")); + assertTrue(sheetXml.contains("=DISPIMG(\"" + imageId + "\",1)"), + "公式格 应为 =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(""), "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("_xlfn.DISPIMG(\"" + imageId + "\",1)"), + "重复 patch 不应破坏公式格"); + assertTrue(!sheetXml.contains("_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 ? "" : xml.substring(Math.max(0, idx), Math.min(xml.length(), idx + 160)); + } +}