/* 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("ID_6D9C349B73B24841A5A713A81DC928E4",1)</f>
<v>=DISPIMG("ID_6D9C349B73B24841A5A713A81DC928E4",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. 实现思路
两步走,分开解决「单元格」和「图片部件」两个问题:
注意 _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. 怎么验证真的"嵌格"了
写完之后别只凭"看着像"就完事,三招硬核验证:
6. 最大的坑:openpyxl 追加保存会丢图
这是上线后才发现的:openpyxl 重存文件时会丢掉所有它不认识的自定义部件(cellimages.xml、media、私有关系全被清掉)。后果很隐蔽——第一次保存嵌好了图,WPS 打开也正常;但下一次"追加一行记录再保存",之前的截图全没了(只剩公式还在单元格里)。
解法:旁路注册表。写一个 <文件名>.images.json,记录每个 img_id → 源图片绝对路径;每次保存时:
核心逻辑:
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 版本升级后私有格式可能有变,遇到"重存后部件消失"记得再拆一次样本文件核对。
如果本文对你有帮助,欢迎点赞收藏。遇到问题请在评论区贴报错,我看到会回。


