本文基于实际项目(SHEIN定制表格处理插件)踩坑经验整理,讲解 WPS 私有函数
DISPIMG的原理,以及如何用 ExcelJS + JSZip 在浏览器/Node 中写入 和读取单元格嵌入图片。
一、背景:为什么 ExcelJS 插不进去「单元格图片」?
在 WPS 表格中,右键插入「单元格图片」,单元格会出现类似这样的公式:
excel
=DISPIMG("ID_A16C8CE6E39B4F25934D584A1C41E1E1", 1)
这种图片有几个特点:
- 不是 普通的浮动 Shape,不会出现在
shapes集合里 - 图片会跟随单元格移动、缩放,筛选时不会串行
- Microsoft Excel 不支持 该函数,打开会显示
#NAME? - ExcelJS 的
addImage()只能插入浮动图(drawing 层),无法生成 DISPIMG 格式
因此,如果目标是在 WPS 中实现「图片嵌入单元格」,必须走 DISPIMG + OOXML 后处理这条路。
二、DISPIMG 函数说明
excel
=DISPIMG("图片ID", 显示模式)
| 参数 | 含义 |
|---|---|
| 第 1 参数 | 图片唯一 ID,格式为 ID_ + 32 位十六进制,如 ID_A16C8CE6E39B4F25934D584A1C41E1E1 |
| 第 2 参数 | 0 = 裁剪填满单元格;1 = 等比缩放,完整显示在单元格内(推荐) |
在 sheet XML 中,公式实际写成:
xml
<c r="I2" t="str">
<f>_xlfn.DISPIMG("ID_XXXX",1)</f>
<v>=DISPIMG("ID_XXXX",1)</v>
</c>
<f>节点:带_xlfn.前缀的内部公式<v>节点:单元格显示值,以=DISPIMG(开头(ExcelJS 读取时用value.result匹配)
三、揭开 xlsx 内幕:图片存在哪里?
把 .xlsx 改成 .zip 解压,会发现 WPS 单元格图片的存储链路与标准 Excel 完全不同:
xl/
├── media/
│ └── image1.png ← 图片二进制
├── cellimages.xml ← 图片 ID ↔ rId 映射
├── _rels/
│ └── cellimages.xml.rels ← rId ↔ media 路径
├── worksheets/
│ └── sheet1.xml ← 单元格 DISPIMG 公式
└── _rels/
└── workbook.xml.rels ← 关联 cellimages.xml
映射关系:
单元格公式 ID_XXXX
↓ (cellimages.xml 中 cNvPr@name)
rId1
↓ (cellimages.xml.rels 中 Relationship@Target)
xl/media/image1.png
3.1 cellimages.xml 示例
xml
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<etc:cellImages
xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:etc="http://www.wps.cn/officeDocument/2017/etCustomData">
<etc:cellImage>
<xdr:pic>
<xdr:nvPicPr>
<xdr:cNvPr id="2" name="ID_A16C8CE6E39B4F25934D584A1C41E1E1" descr="Picture"/>
<xdr:cNvPicPr><a:picLocks noChangeAspect="1"/></xdr:cNvPicPr>
</xdr:nvPicPr>
<xdr:blipFill>
<a:blip r:embed="rId1" cstate="print"/>
<a:stretch><a:fillRect/></a:stretch>
</xdr:blipFill>
<xdr:spPr>
<a:xfrm>
<a:off x="635" y="635"/>
<a:ext cx="1714500" cy="1143000"/>
</a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
</xdr:spPr>
</xdr:pic>
</etc:cellImage>
</etc:cellImages>
关键点:
cNvPr@name= 公式里的图片 IDa:blip@r:embed= 关联到 rels 里的 rIda:ext@cx/cy= 图片尺寸(EMU 单位),建议按原始像素比例写入noChangeAspect="1"= 锁定宽高比
3.2 还需更新的两个文件
workbook.xml.rels 增加:
xml
<Relationship
Id="rIdN"
Type="http://www.wps.cn/officeDocument/2020/cellImage"
Target="cellimages.xml"/>
Content_Types.xml 增加:
xml
<Override
PartName="/xl/cellimages.xml"
ContentType="application/vnd.wps-officedocument.cellimage+xml"/>
四、写入流程:ExcelJS 生成 + JSZip 后处理
整体思路:ExcelJS 只负责生成表格骨架,图片通过 JSZip 注入 OOXML 部件。
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ ExcelJS 生成 │ ──▶ │ JSZip 后处理注入 │ ──▶ │ WPS 打开 xlsx │
│ 表格(无浮动图) │ │ cellimages+media │ │ 图片嵌入单元格 │
└─────────────────┘ └──────────────────┘ └─────────────────┘
4.1 生成图片 ID
typescript
function generateWpsImageId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16))
const hex = Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("")
return `ID_${hex.toUpperCase()}`
}
// 示例:ID_A16C8CE6E39B4F25934D584A1C41E1E1
4.2 注入图片到 zip
typescript
import JSZip from "jszip"
async function injectWpsDispimgImages(
xlsxBuffer: ArrayBuffer,
placements: Array<{
imageId: string
buffer: Uint8Array
extension: "png" | "jpeg" | "gif"
width: number
height: number
row: number // 1-based
col: number // 1-based
}>
): Promise<ArrayBuffer> {
const zip = await JSZip.loadAsync(xlsxBuffer)
// 1. 写入 xl/media/imageN.png
// 2. 构建 xl/cellimages.xml + xl/_rels/cellimages.xml.rels
// 3. 更新 workbook.xml.rels 和 [Content_Types].xml
// 4. 修补 sheet1.xml 单元格公式
return zip.generateAsync({ type: "arraybuffer" })
}
4.3 写入单元格公式
typescript
function setDispimgFormulaOnSheet(sheetRoot, row, col, imageId) {
const cellRef = `${colToLetter(col)}${row}`
// ...
formulaNode.textContent = `_xlfn.DISPIMG("${imageId}",1)`
valueNode.textContent = `=DISPIMG("${imageId}",1)`
}
4.4 完整调用示例
typescript
// 1. ExcelJS 生成表格(不要调用 addImage)
const workbook = new ExcelJS.Workbook()
const sheet = workbook.addWorksheet("订单表")
sheet.addRow(["订单号", "定制图稿"])
// ...填充数据
const placements = []
const imageId = generateWpsImageId()
placements.push({
imageId,
buffer: imageBytes,
extension: "png",
width: 800,
height: 600,
row: 2,
col: 2
})
// 2. 写出 buffer 后做后处理
const rawBuffer = await workbook.xlsx.writeBuffer()
const finalBuffer = await injectWpsDispimgImages(rawBuffer, placements)
五、读取流程:解析 DISPIMG 图片
写入的逆过程,参考 exceljs#2786:
typescript
async function parseWpsDispimgImageMap(xlsxBuffer: ArrayBuffer) {
const zip = await JSZip.loadAsync(xlsxBuffer)
const result = new Map<string, string>() // imageId → media 路径
const relsXml = await zip.file("xl/_rels/cellimages.xml.rels").async("string")
const imagesXml = await zip.file("xl/cellimages.xml").async("string")
// 1. 解析 cellimages.xml:rId → imageId
const ridToImageId = new Map()
for (const block of imagesXml.match(/<etc:cellImage>[\s\S]*?<\/etc:cellImage>/g) ?? []) {
const imageId = block.match(/name="(ID_[0-9A-F]{32})"/i)?.[1]
const rid = block.match(/r:embed="(rId\d+)"/)?.[1]
if (imageId && rid) ridToImageId.set(rid, imageId)
}
// 2. 解析 rels:rId → media 路径
for (const rel of relsXml.match(/<Relationship\b[^>]*\/>/g) ?? []) {
const id = rel.match(/\bId="(rId\d+)"/)?.[1]
const target = rel.match(/\bTarget="([^"]+)"/)?.[1]
const mediaMatch = target?.match(/media\/(.+)$/)
const imageId = ridToImageId.get(id)
if (imageId && mediaMatch) {
result.set(imageId, `xl/media/${mediaMatch[1]}`)
}
}
return result
}
配合 ExcelJS 读取单元格:
typescript
const cellValue = worksheet.getCell("I2").value
if (cellValue?.result?.indexOf("=DISPIMG(") === 0) {
const id = cellValue.result.match(/ID_[0-9A-F]{32}/i)[0]
const mediaPath = imageMap.get(id) // xl/media/image1.png
const imageBuffer = await zip.file(mediaPath).async("arraybuffer")
}
六、保持图片宽高比
DISPIMG 本身通过第 2 参数控制显示模式,但要避免图片变形,还需注意:
| 层面 | 做法 |
|---|---|
| 公式参数 | 使用 DISPIMG("ID_xxx", 1),等比缩放完整显示 |
| cellimages.xml | picLocks noChangeAspect="1" |
| spPr 尺寸 | cx/cy 按原始像素比例计算(1px ≈ 9525 EMU) |
| 行高 | 按列宽 × 图片高宽比动态设置行高 |
typescript
const EMU_PER_PIXEL = 9525
function imageExtentsEmu(width: number, height: number) {
return {
cx: Math.round(width * EMU_PER_PIXEL),
cy: Math.round(height * EMU_PER_PIXEL)
}
}
七、常见坑与注意事项
坑 1:用 ExcelJS addImage 再转 DISPIMG
addImage 生成的是 drawing 浮动层,与 DISPIMG 是两套机制,混用会导致 WPS 里出现双层图片或筛选串行。
正确做法: ExcelJS 不插图片,全部走后处理注入。
坑 2:只写公式,不注入 cellimages.xml
单元格会显示 #NAME? 或空白,因为 WPS 找不到图片资源。
坑 3:忘记更新 workbook.xml.rels
缺少 cellimages 关联时,WPS 认为 cellimages.xml 不存在,公式计算失败。
坑 4:手动解压 zip 改文件再压缩
OOXML 对 zip 结构和关系文件有严格要求,手动压缩容易损坏。务必用 JSZip 等库操作。
坑 5:在 Microsoft Excel 中打开
DISPIMG 是 WPS 私有函数,Excel 不支持,会显示错误值。这是预期行为。
八、与浮动图片的对比
| 特性 | ExcelJS addImage(浮动图) | DISPIMG(单元格嵌入) |
|---|---|---|
| WPS 兼容性 | 可用,但是浮动层 | 原生单元格图片 |
| 跟随单元格 | 需设置锚点,筛选可能串行 | 天然跟随 |
| Excel 兼容 | ✅ | ❌ |
| 实现难度 | 简单 | 需 OOXML 后处理 |
| 适用场景 | 通用导出 | WPS 专用场景 |
九、参考资源
- exceljs#2786 - 如何解析 DISPIMG 函数的图片
- ce_img_ll - Python 写入 DISPIMG 参考实现
- WPSInlineImage - C# 实现参考
- 怡氧博客 - WPS DISPIMG 公式解析
十、总结
WPS 单元格图片的核心不是「往单元格里画图」,而是维护一条完整的引用链:
DISPIMG 公式 → cellimages.xml → cellimages.xml.rels → xl/media/
用 JavaScript 实现的关键步骤:
- ExcelJS 生成表格骨架(不含浮动图)
- JSZip 注入
cellimages.xml、rels、media - 修补 sheet XML 写入
_xlfn.DISPIMG公式 - 更新
workbook.xml.rels和[Content_Types].xml
按这个流程,就能在浏览器扩展、Node 服务等场景中,程序化地将图片嵌入 WPS 表格单元格,且筛选、排序时图片始终跟随对应行。
本项目对应实现见:lib/excel-wps-dispimg.ts、lib/export-excel.ts。