欢迎光临
我们一直在努力

「Python 实现 WPS 图片嵌入单元格:绕开 COM,手写 DISPIMG」

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

WPS 表格用 Python 实现「图片嵌入单元格」:COM 走不通,直接手工构造 DISPIMG 私有格式

前言

在 WPS 表格里把图片"嵌入单元格"(像 Excel 365 的 Place in Cell 那样,图片是单元格内容而非浮动对象)是个很常见的需求,但实现起来坑不少:COM 接口在 WPS 12.0 上根本不支持,公开文档里也找不到现成方案。本文记录完整的踩坑过程——从 COM 全军覆没,到拆解 WPS 私有 xlsx 结构,最终手工构造 DISPIMG 格式成功嵌入图片,并给出可直接复用的 Python 代码。

1. 核心章节

1.1 为什么 COM 走不通

  • WPS 12.0 的 COM 层没有暴露"图片嵌入单元格"的接口
  • PlaceInCell、PlacePictureInCell 等方法要么不存在、要么是空操作
  • 结论:这条路堵死,必须另寻出路

1.2 破局:拆解 WPS 私有 DISPIMG 格式

  • 用 WPS 手工做一张"嵌格图"存成 xlsx,再拆包逐字节分析
  • 发现核心机制:单元格里写 =_xlfn.DISPIMG("ID",1) 公式,配合 cellimages.xml、.rels、ContentType、workbook 关系五个部件
  • 关键坑:ContentType 和关系类型必须用 WPS 私有值,否则重存时部件会被删光

1.3 完整实现与避坑

  • 两步走:openpyxl 写公式 + zip 注入五个部件
  • 提供完整可复现代码,含调整显示大小的方法
  • 最大的坑:openpyxl 追加保存会丢图,用"旁路注册表"方案解决

2. 总结

WPS 的"图片嵌入单元格"是纯私有格式,COM 做不了,只能手工构造 DISPIMG。核心就五件事:公式、cellimages.xml、.rels、ContentType、workbook 关系——ID 串起来,WPS 就在单元格里渲染。别用 openpyxl 反复重存带嵌格图的文件,要么一次性注入,要么维护注册表。

3. 参考资料

  • openpyxl 官方文档
  • WPS 表格开放平台
  • Excel 365 Place in Cell 官方说明
  • Python zipfile 模块文档

关键词:WPS 表格 / Python / openpyxl / DISPIMG / 单元格内嵌图片 / cellimages.xml

0. 背景

需要实现的小工具需求里有一条:聊天截图要"嵌入单元格",而不是浮动在表格上,要求:

  • 图片是单元格内容(像 Excel 365 的 Place in Cell),选中单元格、排序、复制都不散
  • 不是浮动图片,不能叠在别的格子上、不能乱
  • 环境是 WPS 12.0:没有 MS Excel、没有 VBA 引擎、不能用宏

折腾了一整天,最后绕开了 COM,直接按 WPS 私有的 DISPIMG 格式手工构造 xlsx 成功了。这篇文章记录完整的踩坑过程和可复现代码。


1. 先试 COM:全军覆没

第一反应是走 COM(WPS 支持 KET.Application,兼容 Excel.Application 的一大堆接口)。用 win32com 反射 WPS 12.0 的对象模型,把能想到的策略都试了一遍:

策略结果
Pictures.PlaceInCell(文件)(Excel 365 官方 API) 集合里根本没有这个方法
Shape.PlacePictureInCell()(WPS 专属) 方法存在,但调用后 Shapes.Count、Placement 全部不变,保存后文件里仍是浮动 <xdr:pic> —— 空操作
Pictures.Add(路径) 后再嵌格 Add 只接受数字坐标,塞路径直接 “could not convert string to float”
Shapes.AddPicture 精确摆到单元格上方 只是"看着像嵌格",一滚动就露馅,本质还是浮动
Range.AddPicture / ws.CellImages 集合 对象模型里不存在
=IMAGE("路径") 函数 本地路径公式,WPS 不认
保存成 .et 再分析 内部结构里找不到任何"嵌格"标记

结论:WPS 12.0 的 COM 层根本没有暴露"图片嵌入单元格"的接口。 这条路堵死。

2. 破局:发现 WPS 私有的 DISPIMG

转机在于:WPS 自己的「插入→图片→嵌入单元格」功能是能用的(手动操作可以),所以 WPS 一定有某种文件格式表达它。于是在 WPS 里手工做了一张"嵌格图"存成 xlsx,再把这个文件拆开逐字节分析。

xlsx 本质是个 zip,解开后发现了 WPS 私有的图片嵌格机制:

客户沟通记录.xlsx
├── [Content_Types].xml ← 多了一个 Override
├── xl/
│ ├── cellimages.xml ← ★ 核心:图片清单(WPS 私有命名空间)
│ ├── _rels/
│ │ ├── cellimages.xml.rels ← 图片 → media 的映射
│ │ └── workbook.xml.rels ← 多了 cellImage 关系
│ ├── media/image1.png ← 图片本体
│ └── worksheets/sheet1.xml ← 单元格里是 DISPIMG 公式

关键信息全拿到了:

① 单元格 —— 不是普通图片,是公式:

<c r="A1" t="str">
<f>_xlfn.DISPIMG(&quot;ID_6D9C349B73B24841A5A713A81DC928E4&quot;,1)</f>
<v>=DISPIMG(&quot;ID_6D9C349B73B24841A5A713A81DC928E4&quot;,1)</v>
</c>

② xl/cellimages.xml —— WPS 私有命名空间,图片清单(cNvPr name 里的 ID 就是公式里那个 ID,靠它关联):

<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_6D9C349B73B24841A5A713A81DC928E4"
descr="ID_6D9C349B73B24841A5A713A81DC928E4"/>

<xdr:cNvPicPr/>
</xdr:nvPicPr>
<xdr:blipFill>
<a:blip r:embed="rId1"/>
<a:stretch><a:fillRect/></a:stretch>
</xdr:blipFill>
<xdr:spPr>
<a:xfrm><a:off x="0" y="0"/><a:ext cx="5080000" cy="5080000"/></a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
</xdr:spPr>
</xdr:pic>
</etc:cellImage>
</etc:cellImages>

③ xl/_rels/cellimages.xml.rels —— 图片引用关系:

<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
Target="media/image1.png"/>

④ [Content_Types].xml —— 必须注册这个 MIME:

<Override PartName="/xl/cellimages.xml"
ContentType="application/vnd.wps-officedocument.cellimage+xml"/>

⑤ xl/_rels/workbook.xml.rels —— 必须加 WPS 私有关系类型:

<Relationship Id="rIdCELLIMG"
Type="http://www.wps.cn/officeDocument/2020/cellImage"
Target="cellimages.xml"/>

这一节是整个方案成败的关键。最初我按 Excel365 嵌格图的常见 ContentType(application/vnd.ms-excel.cellimages+xml)和关系类型去构造,WPS 打开后直接把这些部件删光(重存后凭空消失)。换成 WPS 上面这套私有值后,重存时 WPS 逐字节保留,甚至主动把单元格补写成它自己的规范格式。

3. 实现思路

两步走,分开解决「单元格」和「图片部件」两个问题:

  • openpyxl 写公式:openpyxl 天然支持公式,把目标格写成 =_xlfn.DISPIMG("ID_xxx",1)。_xlfn. 前缀 openpyxl 会原样保留,WPS 认得。
  • zip 注入部件:openpyxl 只认识它自己那套部件,cellimages.xml、media、私有关系它一概不写。所以保存完文件后,我手动解开 zip,把 ⑤ 个部件补进去再重新打包。
  • 注意 _xlfn. 这个前缀:它是"函数在 XML 里的真实名字"。openpyxl 写出 <f>_xlfn.DISPIMG(…)</f>,WPS 读公式时会还原成 =DISPIMG(…),显示正常。

    4. 完整可复现代码

    # -*- coding: utf-8 -*-
    """
    把图片写入 xlsx 的单元格 (WPS DISPIMG 真·嵌格图)

    用法:
    # 先做所有 openpyxl 能干的活(写公式、调行高/列宽), zip 注入必须放最后
    fit_cell("记录.xlsx", [("Q4", r"C:\\shot1.png"), ("Q5", r"C:\\shot2.png")])
    write_cell_images("记录.xlsx", [("Q4", r"C:\\shot1.png"),
    ("Q5", r"C:\\shot2.png")])

    依赖: openpyxl, Pillow
    运行环境: Python 3.8+; 用 WPS 打开文件查看效果
    """
    import hashlib
    import io
    import zipfile

    from openpyxl import load_workbook

    def _img_id(path):
    """按图片内容生成 32 位 ID, WPS 靠它与 cellimages.xml 里的 name 关联"""
    with open(path, "rb") as f:
    digest = hashlib.md5(f.read()).hexdigest()[:32].upper()
    return "ID_" + digest

    def _read_image(path):
    """读取图片尺寸, 统一转成 PNG 字节 (省得为各种扩展名注册 ContentType)"""
    from PIL import Image
    with Image.open(path) as im:
    im = im.convert("RGB")
    w, h = im.size
    buf = io.BytesIO()
    im.save(buf, "PNG")
    return w, h, buf.getvalue()

    def write_cell_images(xlsx_path, cells):
    """
    cells: [(坐标, 图片路径), …], 例如 [("Q4", r"C:\\shot1.png")]
    图片会被写进对应单元格, 作为单元格内容 (WPS 打开即显示在格内)
    """

    # —- 第 1 步: openpyxl 在目标格写入 DISPIMG 公式 —-
    wb = load_workbook(xlsx_path)
    ws = wb.active
    images = {} # img_id -> 图片路径
    coords = {} # img_id -> 坐标
    for coord, path in cells:
    img_id = _img_id(path)
    images[img_id] = path
    coords[img_id] = coord
    ws[coord] = '=_xlfn.DISPIMG("%s",1)' % img_id
    wb.save(xlsx_path)

    # —- 第 2 步: 解包 zip, 注入 cellimages.xml / media / rels / 注册 —-
    with zipfile.ZipFile(xlsx_path) as zin:
    items = {n: zin.read(n) for n in zin.namelist()}

    cellimage_parts, rel_parts = [], []
    media_parts = {}
    for idx, (img_id, path) in enumerate(images.items()):
    w_px, h_px, png = _read_image(path)
    mi = idx + 1 # media 序号
    r_id = "rId%d" % mi
    media_parts["xl/media/image%d.png" % mi] = png
    rel_parts.append(
    '<Relationship Id="%s" '
    'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" '
    'Target="media/image%d.png"/>' % (r_id, mi))
    cellimage_parts.append(
    '<etc:cellImage><xdr:pic><xdr:nvPicPr>'
    '<xdr:cNvPr id="%d" name="%s" descr="%s"/><xdr:cNvPicPr/></xdr:nvPicPr>'
    '<xdr:blipFill><a:blip r:embed="%s"/>'
    '<a:stretch><a:fillRect/></a:stretch></xdr:blipFill>'
    '<xdr:spPr><a:xfrm><a:off x="0" y="0"/>'
    '<a:ext cx="%d" cy="%d"/></a:xfrm>'
    '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr>'
    '</xdr:pic></etc:cellImage>'
    % (idx + 2, img_id, img_id, r_id, int(w_px * 9525), int(h_px * 9525)))

    items.update(media_parts)
    items["xl/cellimages.xml"] = (
    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\\n'
    '<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">'
    + "".join(cellimage_parts) + "</etc:cellImages>").encode("utf-8")
    items["xl/_rels/cellimages.xml.rels"] = (
    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\\n'
    '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
    + "".join(rel_parts) + "</Relationships>").encode("utf-8")

    # 注册 ContentType: Default(png) + Override(cellimages.xml)
    ct = items["[Content_Types].xml"].decode("utf-8")
    if 'Extension="png"' not in ct:
    ct = ct.replace("</Types>",
    ' <Default Extension="png" ContentType="image/png"/>\\n</Types>')
    if "cellimages.xml" not in ct:
    ct = ct.replace(
    "</Types>",
    ' <Override PartName="/xl/cellimages.xml" '
    'ContentType="application/vnd.wps-officedocument.cellimage+xml"/>\\n</Types>')
    items["[Content_Types].xml"] = ct.encode("utf-8")

    # workbook 关系: 引用 cellimages.xml (WPS 私有类型)
    wb_rels = items["xl/_rels/workbook.xml.rels"].decode("utf-8")
    if "cellimages.xml" not in wb_rels:
    wb_rels = wb_rels.replace(
    "</Relationships>",
    ' <Relationship Id="rIdCELLIMG" '
    'Type="http://www.wps.cn/officeDocument/2020/cellImage" '
    'Target="cellimages.xml"/>\\n</Relationships>')
    items["xl/_rels/workbook.xml.rels"] = wb_rels.encode("utf-8")

    with zipfile.ZipFile(xlsx_path, "w", zipfile.ZIP_DEFLATED) as zout:
    for name, data in items.items():
    zout.writestr(name, data)

    return coords

    调整显示大小

    嵌格图的显示大小 = 单元格大小(列宽 × 行高)。WPS 渲染是等比不变形的(实测 320×240 的源图在格子里始终 4:3)。想让它看起来舒服,按列宽把行高撑到图片的等比高度即可:

    def fit_cell(xlsx_path, cells, max_width_px=150):
    """cells: [(坐标, 图片路径), …]; 按 150px 宽等比算出每行行高并写入"""
    from PIL import Image
    from openpyxl import load_workbook
    wb = load_workbook(xlsx_path)
    ws = wb.active
    for coord, path in cells:
    row = int(coord[1:])
    with Image.open(path) as im:
    w, h = im.size
    disp_h = int(max_width_px * h / w * 0.75) # 像素 -> 磅
    if ws.row_dimensions[row].height is None or ws.row_dimensions[row].height < disp_h:
    ws.row_dimensions[row].height = max(20, disp_h)
    wb.save(xlsx_path)

    ⚠️ 顺序必须这样:fit_cell 这种 openpyxl 重存要放在 write_cell_images 之前。因为 write_cell_images 末尾的 zip 注入会把 cellimages.xml、media 这些部件写进文件,而 openpyxl 重存(load_workbook→save)会把这些不认识的自定义部件全部清掉。所以"openpyxl 的活"(写公式、设行高列宽)一律先做,zip 注入必须放最后一步。如果你注入完还想用 openpyxl 改点东西,图会消失——这也是第 6 节要解决的坑。

    5. 怎么验证真的"嵌格"了

    写完之后别只凭"看着像"就完事,三招硬核验证:

  • 解包看部件:用 zipfile 看文件里有没有 xl/cellimages.xml 和 xl/media/。没有 → 根本没嵌。
  • WPS 重存检验:用 COM 打开再保存一遍,重存后 cellimages.xml 还在、media 还在、Shapes.Count == 0 → WPS 认可这套格式。我最初那套错误 ContentType 就是在这一步现原形的(重存后部件全被 WPS 删光)。
  • 导出 PDF 实证:COM 里 wb.ExportAsFixedFormat(0, "out.pdf"),然后用 PyMuPDF 把 PDF 渲染出来看——图片有没有在对应格子里、宽高比对不对,一目了然。这是最硬核的"眼见为实"。
  • 6. 最大的坑:openpyxl 追加保存会丢图

    这是上线后才发现的:openpyxl 重存文件时会丢掉所有它不认识的自定义部件(cellimages.xml、media、私有关系全被清掉)。后果很隐蔽——第一次保存嵌好了图,WPS 打开也正常;但下一次"追加一行记录再保存",之前的截图全没了(只剩公式还在单元格里)。

    解法:旁路注册表。写一个 <文件名>.images.json,记录每个 img_id → 源图片绝对路径;每次保存时:

  • 从 sheet 的公式里收集所有存活图片 ID(openpyxl 保留公式,所以能拿到)
  • 注册表里查出每张图的源文件,连同新图一起重建全部部件
  • 核心逻辑:

    def _live_ids(sheet_xml):
    import re
    return set(re.findall(r'DISPIMG\\("([^"]+)"', sheet_xml))

    # 每次注入前:
    live = {img_id: reg[img_id] for img_id in _live_ids(sheet_xml) if img_id in reg}
    live.update({img_id: {"src": src} for img_id, src in new_images.items()})
    # …然后按 live 里全部条目重建 cellimages.xml / media / rels, 再存回

    因为所有图片都从注册表指向的源文件现场重新生成,openpyxl 丢多少次部件都不怕。

    7. 结语

    • WPS 的"图片嵌入单元格"是纯私有格式(DISPIMG / etc 命名空间 / vnd.wps-officedocument.cellimage+xml),公开文档里基本找不到,只能靠拆样本文件反推。
    • COM 在 WPS 12.0 上做不了这件事,别再浪费时间试 PlacePictureInCell 了。
    • 核心就五件事:公式 =_xlfn.DISPIMG("ID",1)、cellimages.xml、.rels、ContentType、workbook 关系——ID 串起来,WPS 就在单元格里渲染。
    • 别用 openpyxl 反复重存带嵌格图的文件,要么一次性注入,要么像我一样维护注册表。

    本文方法在 WPS 12.0 (Windows) + Python 3.13 + openpyxl 3.1.5 下实测通过。WPS 版本升级后私有格式可能有变,遇到"重存后部件消失"记得再拆一次样本文件核对。


    如果本文对你有帮助,欢迎点赞收藏。遇到问题请在评论区贴报错,我看到会回。

    赞(0)
    未经允许不得转载:171主机测评 » 「Python 实现 WPS 图片嵌入单元格:绕开 COM,手写 DISPIMG」
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址