/* 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 表格的照片,为什么在 Excel 里全变乱码
一张 44 行的班级表,在 WPS 里打开,照片那一列整整齐齐。
同一个人用 Excel 打开同一个文件,照片全没了,格子里躺着一行:
=_xlfn.DISPIMG("ID_9C2F4A1B7E3D8C5A",1)
有的版本更干脆,直接 #NAME?。发到手机上、发到钉钉群里,连公式都不显示,只剩一片空白格。
第一反应通常是「图片坏了」「文件传损了」。都不是。图片一张没坏,它们好端端地躺在文件里,只是格子不知道该怎么叫它们出来。
这篇讲清楚三件事:这个文件里到底存了什么、为什么换个软件就崩、以及怎么用 40 行 Python 把它变回任何软件都能打开的样子。
先看完整脚本
# -*- coding: utf-8 -*-
"""把 WPS 的 DISPIMG 单元格图片,转成标准 Excel 浮动图片。源文件不动。"""
import os, re, shutil, zipfile
import xml.etree.ElementTree as ET
from PIL import Image
from openpyxl import Workbook
from openpyxl.drawing.image import Image as XLImage
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
SRC = r"<YOUR_PATH>\\26牧医二班(2).xlsx"
OUT = r"<YOUR_PATH>\\26牧医二班(2)-修复版.xlsx"
WORK = r"<YOUR_PATH>\\_unzip"
TMP = r"<YOUR_PATH>\\_media"
NS_MAIN = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
NS_ETC = "{http://www.wps.cn/officeDocument/2017/etCustomData}"
NS_R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
NS_A = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
NS_SP = "{http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing}"
# ———- 1. 解压 ———-
for d in (WORK, TMP):
if os.path.isdir(d):
shutil.rmtree(d)
os.makedirs(d, exist_ok=True)
with zipfile.ZipFile(SRC) as z:
z.extractall(WORK)
# ———- 2. 打通「图片ID -> rId -> 文件」三段链路 ———-
id2rid = {}
for pic in ET.parse(os.path.join(WORK, "xl", "cellimages.xml")).iter(NS_ETC + "cellImage"):
cnvpr = pic.find(".//" + NS_SP + "cNvPr")
blip = pic.find(".//" + NS_A + "blip")
if cnvpr is not None and blip is not None:
id2rid[cnvpr.get("name")] = blip.get(NS_R + "embed")
rid2file = {}
for rel in ET.parse(os.path.join(WORK, "xl", "_rels", "cellimages.xml.rels")).getroot():
rid2file[rel.get("Id")] = os.path.join(WORK, "xl", rel.get("Target").replace("/", os.sep))
id2file = {k: rid2file[v] for k, v in id2rid.items() if v in rid2file}
# ———- 3. 读表格内容 ———-
sst = []
for si in ET.parse(os.path.join(WORK, "xl", "sharedStrings.xml")).getroot():
sst.append("".join(t.text or "" for t in si.iter(NS_MAIN + "t")))
rows = {}
for row in ET.parse(os.path.join(WORK, "xl", "worksheets", "sheet1.xml")).getroot().iter(NS_MAIN + "row"):
r = int(row.get("r"))
cells = {}
for c in row.iter(NS_MAIN + "c"):
ref, t = c.get("r"), c.get("t")
col = re.match(r"([A-Z]+)", ref).group(1)
f, v = c.find(NS_MAIN + "f"), c.find(NS_MAIN + "v")
if f is not None and f.text and "DISPIMG" in f.text:
m = re.search(r"ID_([0-9A-Fa-f]+)", f.text)
cells[col] = ("IMG", "ID_" + m.group(1) if m else None)
elif v is None:
continue
elif t == "s":
cells[col] = ("T", sst[int(v.text)])
elif t == "str":
cells[col] = ("T", v.text or "")
else:
cells[col] = ("N", v.text)
if cells:
rows[r] = cells
max_row = max(rows)
# ———- 4. 重建一个新工作簿 ———-
DISPLAY_H = 105 # 单元格里显示的图片高度(像素)
_cache = {}
def get_resized(path):
if path in _cache:
return _cache[path]
im = Image.open(path).convert("RGB")
h = DISPLAY_H * 2 # 存 2 倍,放大不糊
w = int(im.width * h / im.height)
out = os.path.join(TMP, os.path.splitext(os.path.basename(path))[0] + "_s.jpg")
im.resize((w, h), Image.LANCZOS).save(out, "JPEG", quality=82, optimize=True)
_cache[path] = (out, w / 2, DISPLAY_H)
return _cache[path]
wb = Workbook()
ws = wb.active
ws.title = "工作表1"
head_fill = PatternFill("solid", fgColor="DCE6F1")
head_font = Font(name="微软雅黑", size=11, bold=True)
body_font = Font(name="微软雅黑", size=10)
center = Alignment(horizontal="center", vertical="center")
thin = Side(style="thin", color="BFBFBF")
border = Border(left=thin, right=thin, top=thin, bottom=thin)
for col, name in {"A": "姓名", "B": "班级", "C": "学号", "D": "照片"}.items():
cell = ws[f"{col}1"]
cell.value, cell.fill, cell.font = name, head_fill, head_font
cell.alignment, cell.border = center, border
for col, w in {"A": 14, "B": 16, "C": 15, "D": 22}.items():
ws.column_dimensions[col].width = w
ws.row_dimensions[1].height = 26
img_count, miss = 0, []
for r in range(2, max_row + 1):
cells = rows.get(r, {})
for col in ("A", "B", "C"): # 只搬文字,一格不改
if col in cells:
cell = ws[f"{col}{r}"]
cell.value = cells[col][1]
cell.font, cell.alignment, cell.border = body_font, center, border
ws.row_dimensions[r].height = 82
d = cells.get("D")
if d and d[0] == "IMG":
path = id2file.get(d[1])
if path and os.path.exists(path):
out, w, h = get_resized(path)
xi = XLImage(out)
xi.width, xi.height = w, h
ws.add_image(xi, f"D{r}") # 锚在单元格左上角
img_count += 1
else:
miss.append((r, d[1]))
ws[f"D{r}"].border = border
ws.freeze_panes = "A2"
wb.save(OUT)
print("行数", max_row, "插图", img_count, "缺失", miss)
跑完输出:
行数 45 插图 44 缺失 []
下面拆开讲里面每一个判断。
第一件事:.xlsx 就是个 zip,先拆开看
不用任何库,改个扩展名就能拆:
cp 表.xlsx 表.zip
unzip 表.zip -d _unzip
拆开之后是这棵目录:
_unzip/
├── [Content_Types].xml
└── xl/
├── workbook.xml
├── sharedStrings.xml
├── styles.xml
├── worksheets/sheet1.xml ← 单元格的值和公式在这
├── media/image1.jpeg … ← 图片本体
├── cellimages.xml ← 多出来的这个,就是问题所在
└── _rels/
└── cellimages.xml.rels ← 再一个多出来的
判断关键就在这一步:正常的 xlsx 里没有 cellimages.xml。看见它,就说明这文件用了某个软件的私有扩展 —— 而私有扩展,就是「在 A 软件正常、在 B 软件崩掉」的全部原因。
以后碰到同类问题,第一反应应该是拆 zip 看目录,而不是重装软件、换版本、或者去查「Excel 打不开图片怎么解决」。
第二件事:图片根本不在格子里
这是整件事的核心,也是我一开始想错的地方。
我以为「单元格里有图」,所以会去 sheet1.xml 找图片元素。实际打开看,D 列那一格长这样:
<c r="D2" s="12" t="str">
<f>_xlfn.DISPIMG("ID_9C2F4A1B7E3D8C5A",1)</f>
<v>#VALUE!</v>
</c>
格子里没有图,只有一句公式,公式里只有一个 ID 字符串。图片本体在 xl/media/ 下面,跟这个格子之间没有任何直接的引用关系。
中间那层关系,存在 xl/cellimages.xml 里:
<etc:cellImages xmlns:etc="http://www.wps.cn/officeDocument/2017/etCustomData">
<etc:cellImage>
<xdr:pic>
<xdr:nvPicPr><xdr:cNvPr id="1" name="ID_9C2F4A1B7E3D8C5A"/></xdr:nvPicPr>
<xdr:blipFill><a:blip r:embed="rId3"/></xdr:blipFill>
</xdr:pic>
</etc:cellImage>
…
</etc:cellImages>
再看 xl/_rels/cellimages.xml.rels,把 rId3 翻译成文件名:
<Relationship Id="rId3" Type="…/image" Target="media/image3.jpeg"/>
于是整条链路是:
格子里那句公式 cellimages.xml .rels 实际文件
ID_9C2F4A1B… → name → r:embed=rId3 → rId3 → media/image3.jpeg
三段,少一段都拼不起来。 很多人卡在这儿,是因为想当然地拿 ID_xxx 去 media/ 里找同名文件 —— 那里只有 image1.jpeg、image2.jpeg,根本没有叫 ID 的东西。
第三件事:42 个 ID,只落到 13 个文件上
这是这张表最反直觉的地方,也是脚本里必须建两张映射表、不能偷懒的原因。
实测这张表:
| 含 DISPIMG 的单元格 | 44 个(全在 D2:D45) |
| cellimages.xml 里的图片 ID | 42 个(去重后还是 42) |
| media/ 下的实际图片文件 | 13 个 |
引用分布还很不均匀:image1.jpeg 被 11 个 ID 引用,image2.jpeg 被 10 个,其余零散分布。
意思是:42 个人共用 13 张照片 —— 那张表里,同一张集体合影被不同的人反复引用。
如果你按 ID 一个个去 media 里找文件,找到第 14 个就找不到东西了。 必须老老实实走「ID → rId → Target」这条链,一条都不能省。
顺便说一句:文件里 DISPIMG 这个字符串出现了 88 次,但单元格只有 44 个 —— 因为每个格子里 <f> 和 <v> 两处都写了它。数单元格要数 <c> 元素,不能 grep -o DISPIMG | wc -l,一上来就多算一倍。
第四件事:把图片钉回单元格
到这一步数据已经全在手里了,剩下的是让 Excel 认得出来。用 openpyxl:
xi = XLImage(out)
xi.width, xi.height = w, h
ws.add_image(xi, f"D{r}")
add_image(图片, 锚点单元格) 会把图片挂在这一格的左上角,生成的是标准的 xdr:oneCellAnchor(单格锚定)。这是所有软件都认的写法 —— Excel 认、WPS 认、钉钉预览认、手机 WPS 也认。
注意 oneCellAnchor 这个细节:它把图片尺寸写死在文件里,不会跟着行高列宽变。所以图片高度必须和你设的行高对上:
DISPLAY_H = 105
ws.row_dimensions[r].height = 82 # 82 pt ≈ 109 px,和 105 接近
行高用「磅」、图片用「像素」,1 pt = 4/3 px,82 pt ≈ 109 px。留几像素余量,图片才不会顶到边框、也不会把行撑开。
坑一:千万不要把 4000px 的原图直接塞进去
源文件里的图是手机拍的,4096 × 3072。直接塞进单元格,Excel 打开会卡,文件还会暴涨。
正确做法是按显示尺寸另存一份,并且存 2 倍图(显示 105 px,就存 210 px 高),这样放大看也不糊:
h = DISPLAY_H * 2
w = int(im.width * h / im.height) # 按比例算宽,绝不能只设一边
im.resize((w, h), Image.LANCZOS).save(out, "JPEG", quality=82, optimize=True)
quality=82, optimize=True 这两个参数是实测出来的甜点:再往上加质量肉眼看不出差别,体积却涨得很快。
实测结果:9,136,714 字节 → 637,527 字节,小到原来的 7%,而在 1920 宽的屏幕上放大看,看不出和原图的区别。
坑二:只能重建,不能就地改
openpyxl 打开一个 xlsx 再存回去,会丢掉它不认识的东西 —— 而这个文件里最关键的 cellimages.xml,它恰恰不认识。就地改一遍,图片引用会直接消失。
所以脚本的思路必须是重建:新建一个空工作簿,把文字一格一格搬过去,把图片按锚点放进去,样式重新写一遍。原始文件一动不动。
代价是样式得自己写(字体、边框、底纹、列宽、行高)—— 代码里那十几行 Font / Alignment / PatternFill / Border 就是干这个的。麻烦,但可控。
坑三:改完必须逐格比对
这条是我认为最重要的一条,比前面所有技术细节都重要。
这种「重建」型脚本有个固有风险:你以为只动了图片列,实际上别的列也可能悄悄变了。 比如合并单元格丢了、数字被转成字符串、前后空格被吃掉、007 变成 7。
所以收尾必须做一件事:把原文件和输出文件逐格 diff。
diff = 0
for r in range(1, max_row + 1):
for col in ("A", "B", "C"): # 关键列,一格一格比
a = src_rows.get(r, {}).get(col) # 从原 XML 里读出来的
b = ws[f"{col}{r}"].value # 新表里的
if (a or "") != (b or ""):
diff += 1
print("不一致", r, col, repr(a), repr(b))
print("差异数量:", diff)
这张表跑出来:44 行 × 3 列 = 132 格,差异 0。
看到这个 0,才敢把文件发回去。没有这个 0,一切「应该没问题」都是猜的。
写在最后
这类问题的识别信号其实很清晰:
同一个文件,在 A 软件里正常、在 B 软件里异常。
碰到这种,先别怀疑文件坏了、也别急着重装。按这个顺序查:
反过来,如果是所有软件都打不开,那才是文件真的坏了,那时候才轮到修文件、找备份。
还有一个判断值得记:「能显示」不等于「存对了」。 这张表在 WPS 里 44 张照片全都好好显示着,从使用者角度看它毫无问题 —— 但它的数据实际存在一个只有 WPS 认识的地方。一旦要发给别人,问题才爆发。
这也是为什么「在我这儿是好的」永远不能当交付标准。换一台机器、换一个软件打开一次,才算验过。
成本:🟢 零成本零注册
openpyxl + Pillow,都是 pip install 就完事的免费库,不需要任何账号、不需要联网、不需要 Office 授权。整个脚本在本机跑完不到 3 秒。
免责声明(请务必读完)
- 文中的表格是别人交给我处理的,所有姓名、学号、照片在本文里都已隐去,示例代码里的路径全部替换为 <YOUR_PATH>。
- 路径里的文件名保留原样,是因为处理别人的文件时不该顺手给人改名 —— 这是个习惯问题,不是技术问题:改了名,对方回头就找不到自己的文件了。
- 文中的数量(44 个单元格 / 42 个 ID / 13 个图片文件 / 9136714 字节 → 637527 字节 / 132 格差异 0)都是这份文件上的实际统计,换一个文件数字肯定不同,但比例关系大概率类似。
- 代码只处理 spreadsheetml 的最常见结构,合并单元格、条件格式、数据透视表这类复杂表格不在范围内 —— 那种表建议直接在 WPS 里另存为 PDF 或图片,别硬转。
- 处理真实数据时,请遵守你所在单位的数据管理规定。




