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> 且引号转义为 ", patch 阶段统一规范化;保留原格样式) 另 spPr 补 a:xfrm(原图尺寸)/a:noFill/a:ln w=9525(实测样本对齐); patch 幂等(重复 patch 不再 duplicate entry);失败图留空不再写 URL 文本。 新增 WpsCellImageWriterProtocolTest 锁死协议契约防回归。
This commit is contained in:
+5
-4
@@ -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={}",
|
||||
|
||||
+27
-24
@@ -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 DISPIMG(cellimages.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;
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
try {
|
||||
row.createCell(colIdx).setCellValue("#VALUE!");
|
||||
excelCellImageSession.registerImage(rowIdx, colIdx, thumb.bytes());
|
||||
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) {
|
||||
|
||||
+105
-16
@@ -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. 公式单元格需为 <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(
|
||||
"<c r=\"([A-Z]+[1-9][0-9]*)\"([^>]*)>\\s*<f(?![^>]*t=\"shared\")[^>]*>"
|
||||
+ "_xlfn\\.DISPIMG\\((?:"|\")([^&\"<>]+)(?:"|\"),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 的 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 = "<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) {
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -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();
|
||||
}
|
||||
|
||||
+142
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user