WPS 表格 DISPIMG 函数实战:用 JavaScript 将图片真正嵌入单元格

本文基于实际项目(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 = 公式里的图片 ID
  • a:blip@r:embed = 关联到 rels 里的 rId
  • a: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 专用场景

九、参考资源


十、总结

WPS 单元格图片的核心不是「往单元格里画图」,而是维护一条完整的引用链:

复制代码
DISPIMG 公式 → cellimages.xml → cellimages.xml.rels → xl/media/

用 JavaScript 实现的关键步骤:

  1. ExcelJS 生成表格骨架(不含浮动图)
  2. JSZip 注入 cellimages.xml、rels、media
  3. 修补 sheet XML 写入 _xlfn.DISPIMG 公式
  4. 更新 workbook.xml.rels[Content_Types].xml

按这个流程,就能在浏览器扩展、Node 服务等场景中,程序化地将图片嵌入 WPS 表格单元格,且筛选、排序时图片始终跟随对应行。


本项目对应实现见:lib/excel-wps-dispimg.tslib/export-excel.ts

相关推荐
晴天162 小时前
Rust 快速入门 (从 JavaScript 开发者视角)-Day09
开发语言·javascript·rust
其美杰布-富贵-李2 小时前
第 6 篇:相机与 OrbitControls
前端·javascript·three.js
Hilaku3 小时前
当 AI 一天写完一周的代码,我们还剩什么优势?
前端·javascript·程序员
无责任此方_修行中3 小时前
换个版本号就能升级?可没那简单:pnpm 11 升级踩坑记
javascript·后端·npm
尘世中一位迷途小书童3 小时前
无人机航线里的云台角度怎么调?Cesium 做了个视锥 + 鹰眼联动
前端·javascript·数据可视化
张元清3 小时前
React useMeasure Hook:用 ResizeObserver 测量 DOM 元素 (2026)
javascript·react.js
A24207349304 小时前
Vue3 + TypeScript:后端数据在表格内渲染后进行增删改的完整实现步骤
前端·javascript·typescript
他们叫我 悦儿遥遥雨4 小时前
.NET 8 Web开发入门(六):Blazor 全栈开发——告别 JavaScript 焦虑
前端·javascript·.net
其美杰布-富贵-李4 小时前
Vue 3 模块导入教程:import、export 与文件组织
前端·javascript·vue.js