有一类 Excel 问题特别让人头疼:明明电脑上渲染正常,发到手机端打开,插图要么看不见,要么位置跑偏。表格里带照片本来是常见需求,偏偏跨端兼容成了坑。这篇手册记录了我在处理这类文件时踩过的所有真实坑,从工具选型到排查思路到解决方案,全部来自实际项目复盘。如果你也需要在 Excel 里稳定地插入图片,并且确保手机端能正常显示,看这一篇就够了。
文章目录
- 为什么做这个
- 工具与环境
- 怎么做的
- 踩过的坑
- [坑1:rels Target 绝对路径改相对路径(最核心)](#坑1:rels Target 绝对路径改相对路径(最核心))
- [坑2:openpyxl 给图片 blip 硬编码 cstate="print"](#坑2:openpyxl 给图片 blip 硬编码 cstate="print")
- [坑3:openpyxl 会保留原文件的孤儿 drawing](#坑3:openpyxl 会保留原文件的孤儿 drawing)
- [坑4:手机 App 重存文件会改名 media 并丢图](#坑4:手机 App 重存文件会改名 media 并丢图)
- 坑5:毕业时间格式
- 坑6:长文本格子的标签/数据交错布局
- 坑7:照片在手机端位置偏移
- 坑8:行高按最多行数估会翻车
- [坑9:A4 打印先看纸型设置](#坑9:A4 打印先看纸型设置)
- [坑10:openpyxl 同一进程内连 save 两次会炸](#坑10:openpyxl 同一进程内连 save 两次会炸)
- [坑11:openpyxl 每次保存都会把 rels Target 改回绝对路径](#坑11:openpyxl 每次保存都会把 rels Target 改回绝对路径)
- 注意事项
- 长期维护与扩展
- 复盘
- 总结
为什么做这个
接到一个带照片的 xlsx 处理任务:表格本身是一份多栏格式的申报类文档,有合并单元格、文字内容、嵌入照片,版式已经调好,要求只改文字内容不改任何版式。电脑上用 LibreOffice 打开看起来没问题,但最终用户需要在手机 App 上查看和填写。结果手机端照片格只显示文字,或者位置错位压住了边框线。
这个需求背后有个关键矛盾:程序生成的 Excel 和手工维护的 Excel 在底层 XML 结构上存在微妙差异。桌面软件容错率高,手机 App 解析严格。很多在电脑上看似正常的文件,手机端就会原形毕露。我需要找出一套既能批量处理内容、又能保证手机端正常显示的完整方案。
工具与环境
这个任务涉及 xlsx 读写、图片处理、PDF 渲染验证三个环节,我把用到的工具列成表格方便对照。
| 工具 | 版本 | 用途 |
|---|---|---|
| Python | 3.12 | 主力脚本环境,openpyxl、Pillow、zipfile 均在此运行 |
| openpyxl | 系统自带 | xlsx 读写:填值、改样式、插图、调打印设置 |
| Pillow | 系统自带 | 图片裁剪、缩放、格式转换 |
| LibreOffice | 24.2 | soffice 命令将 xlsx 转 PDF,渲染效果最接近真实打印 |
| poppler | 系统自带 | pdftoppm 将 PDF 转 PNG,用于目检排版 |
| zipfile | Python 标准库 | xlsx 本质是 zip 包,拆包做结构级手术 |
环境注意一点:pip 安装受 PEP 668 限制,装新包需要用系统包管理器,或者加 --break-system-packages 参数。所有操作默认在本机完成,文件不上传任何第三方平台。
怎么做的
我把完整流程拆成两条并行线:内容处理线和图片插入线。
内容处理标准流程 分为六个步骤。第一步读结构,用 openpyxl 载入文件后先打印 dimensions、merged_cells、有值单元格坐标,搞清楚标签格和数据格的布局。第二步填值,向目标单元格写入内容,合并单元格只写左上角那个格,其他位置不写。第三步样式微调,按需调整字号、字体、对齐方式。第四步保存后回读验证,把填过的格子读出来逐项核对,确保没有遗漏。第五步 LibreOffice 转 PDF,再用 pdftoppm 转 PNG,在本地目检排版是否正常。第六步交付 xlsx 和 PDF 两个版本给用户。
图片插入流程 更复杂一点。PIL 按目标比例裁剪并缩放图片后,用 openpyxl 的 add_image 插入,锚点指向目标单元格。插入后需要拆包检查 xl/drawings/drawing*.xml,确认每张图都有对应引用、没有孤儿 drawing。转 PDF 目检照片位置、大小、是否压住文字。最后在手机端确认能看到图。
两条线在第五步合流,用 PDF 渲染结果做跨端一致性验证。
踩过的坑
这一节记录整个过程中遇到的所有实际问题,每个坑都写清楚现象、排查过程或原因、解法。
坑1:rels Target 绝对路径改相对路径(最核心)
这是整个任务里最关键的一个坑,排查过程最长,解决思路也最值得复盘。
现象很清楚:xlsx 里插入了一寸照片,电脑端用 LibreOffice 渲染能看到,用户手机 App 上照片格只显示文字内容,图片完全不显示。
排查从四个方向展开,每一步都排除了一种可能。
第 1 步,我先怀疑是锚点或者尺寸的问题。把 xlsx 拆包,确认 <anchor> 节点写法正确、图片文件本身有效,排除锚点问题。
第 2 步,我发现原模板自带一张示例占位图。openpyxl 保留了孤儿 drawing,虽然图片本身删掉了,但 drawing 节点还留在文件里。清理掉孤儿 drawing 后重新插入,问题仍然存在,排除这一步。
第 3 步,手写 drawing XML 的时候把 <clientData/> 塞进了 <pic> 内部。对照原模板结构逐项校验,发现 <clientData/> 应该与 <pic> 平级,而不是作为 <pic> 的子节点。修正后仍然不行,但这确实是一个 bug,严格解析的查看器会跳过整张图。这步排除,但不是真凶。
第 4 步,真凶浮出水面。检查图片引用的 Target 路径发现,openpyxl 生成的是绝对路径 /xl/media/xxx.jpeg,而原模板(真实 Excel 产出的)用的是相对路径 ../media/xxx.jpeg。桌面软件对两种路径都能识别,手机 App 只认相对路径。
解法:把 xl/worksheets/_rels/sheet1.xml.rels 和 xl/drawings/_rels/drawing1.xml.rels 里的 Target 全部改成相对路径。改完之后重新打包,手机端照片正常显示。
下面这张图是实测对比,上面是 openpyxl 生成的绝对路径,下面是改成相对路径之后的样子:

这个坑的教训是:程序生成的 xlsx 在手机端打不开或显示异常时,拿到原模板逐字节对比结构差异,rels 路径写法、drawing 结构、命名空间写法都是嫌疑点。不要只在电脑端验证就交付给用户。
坑2:openpyxl 给图片 blip 硬编码 cstate="print"
openpyxl 在生成图片节点时,spreadsheet_drawing.py 的 _picture_frame 里写死了 pic.blipFill.blip.cstate = "print"。目前观察多数查看器忽略这个属性,暂无实害。但手写 XML 的时候直接省略该属性会更干净。
坑3:openpyxl 会保留原文件的孤儿 drawing
模板自带示例图时,对应的 drawing 文件(比如 drawing2.xml)在 openpyxl 多次读写后变成无人引用的孤儿 parts,一直躺在 zip 包里。编辑别人给的、带过图的 xlsx 时,先拆包看 xl/drawings/ 和 xl/media/ 目录,确认每张图都有对应的 <drawing> 引用元素,没有引用的图就是孤儿,要删掉。
坑4:手机 App 重存文件会改名 media 并丢图
这是最意外的坑。用户拿到处理好的文件后,在手机 App 上改了几个字再保存,传回来的文件在 openpyxl 里直接打不开,报 KeyError。拆包排查发现:WPS 把 image1.jpeg 重命名成了 1.jpeg,还删掉了另一张图但留下了悬空引用(drawing2.xml 指向 ../media/2.jpeg,但这个文件不存在)。
修法:拆包删掉悬空的 drawing xml、它的 rels 文件、[Content_Types].xml 里的声明,以及 sheet 文件里残留的 <drawing> 引用元素。处理完后再用 openpyxl 打开就没问题了。
这个坑的教训是:用户手机改完传回来的文件,先做一次开包体检------尝试用 openpyxl 打开,同时检查有无悬空引用------确认没问题再当成新的母版使用。
坑5:毕业时间格式
预填值不一定对,关键字段要用权威来源交叉核对,毕业时间这类字段尤其要以证明材料为准,不能直接沿用预填值。
坑6:长文本格子的标签/数据交错布局
动手填值前先打印有值单元格坐标,看清楚标签格和数据格的交错布局再动笔,避免把内容写到错误的格子里。
坑7:照片在手机端位置偏移
现象和坑1不同:照片能显示了,但在手机 App 上位置跑偏,压住了边框线或者跑到别的格子里,电脑端 LibreOffice 渲染却正常。
排查后发现原因:之前用的 <twoCellAnchor> 写法里,from 和 to 都在同一列(比如 H 列),只靠 colOff 差值撑宽度。部分手机 App 对这种写法解析不一致,导致偏移。
解法:改用最标准的 <oneCellAnchor>。from 左上对准目标格(如 H2,可以带微小的 rowOff 垂直居中),ext 写死图片尺寸。结构是 from → ext → pic → clientData,不要手写多余属性。
下面是实测拆包看到的 oneCellAnchor 结构,from、ext、pic、clientData 依次排开,clientData 和 pic 平级:

教训:给手机端看的 xlsx,drawing 有多少种写法就用最标准的那一种,别玩花样。<oneCellAnchor> 是兼容性最好的方案。
坑8:行高按最多行数估会翻车
估算行高时不要按"最多行数"直接估算,要数最长格子的实际字符数,除以每行容纳字数,向上取整再加一行余量。手机端字体渲染可能更宽,余量宁多勿少。
坑9:A4 打印先看纸型设置
涉及打印的文档先检查 page_setup.paperSize,数值 9 等于 A4,加上 fitToWidth=1、fitToHeight=1 单页自适应。如果这些都对就不用调,直接打。
坑10:openpyxl 同一进程内连 save 两次会炸
现象:写一个脚本里先 wb.save(A),然后删一张表,再 wb.save(B),第二次 save 报错 ValueError: I/O operation on closed file。原因是图片流被第一次 save 关掉了。
解法:分成两个 Python 进程来做。第一进程只管打开原文件、改内容、保存。第二进程重新 load 文件、删除目标表、另存。文件 IO 和内存对象的生命周期要分开管理。
教训:openpyxl + 图片的场景,一次进程只做一次 save。
坑11:openpyxl 每次保存都会把 rels Target 改回绝对路径
这个坑和坑1紧密关联,是坑1的复发机制。
现象:照片之前改好了,手机端显示正常。用 openpyxl 改了几个单元格内容或者调整了行高,再保存,照片又不显示了。
排查后发现原因:openpyxl 保存时会重写 xl/drawings/_rels/drawing1.xml.rels,把 Target 改回绝对路径 /xl/media/image1.jpeg(还会把 media 目录里的文件名改成 image1.jpeg)。drawing 结构本身没坏,纯粹是路径问题,电脑端和 LibreOffice 照样显示,只有手机 App 严格解析才出问题。
解法:zip 手术把 Target 改回相对路径 ../media/image1.jpeg 即可,不用动其他内容。
教训涉及操作顺序:带图的 xlsx,所有 openpyxl 操作放在前面做,最后一步再用 zip 手术修复 rels。顺序反了就会反复翻车------每改一次内容就要再修一次路径。
注意事项
动手前先确认以下事项,按清单逐项检查:
-
先拆包看原模板结构 。不要直接在原文件上改,先把 xlsx 当 zip 包拆开,看
xl/worksheets/、xl/drawings/、xl/media/的内容,确认有多少张图、对应哪些 sheet、有没有孤儿 drawing。 -
手机是最终验收环境之一。不能只在电脑端 LibreOffice 渲染正常就算通过,手机端必须实测。每一次交付前都要在手机上打开文件确认。
-
有图的文档必须在手机上确认。文字内容改动可能引起行高变化,导致图片位置偏移。行高调整后要在手机上重新看图有没有被挤压或跑位。
-
交付前必须经过渲染目检。LibreOffice 转 PDF、pdftoppm 转 PNG,在本地屏幕上放大检查文字有没有被截断、图有没有压线。图片清晰度也要检查。
-
代码写对不等于显示对。openpyxl 生成的 XML 结构在代码层面没问题,但手机端解析可能有问题。rels 路径写法、anchor 类型选择都要对照原模板来,不能靠想当然。
-
用户返回的文件先体检再使用。用户可能在手机 App 上修改过,传回来的文件要先用 openpyxl 打开测试,检查有没有悬空引用,再当成新的母版。
-
涉及敏感信息的文档,过程文件放临时目录,完工即删。中间生成的 PDF、PNG、拆包出来的临时文件不要留在工作目录,做完就删。
长期维护与扩展
我给自己定了一套标准动作清单,每次处理这类带图的 xlsx 都按这个流程走。
标准动作清单:
- 读结构:openpyxl 载入后先打印 dimensions、merged_cells、有值单元格坐标,搞清布局再动手。
- 填值:合并单元格只写左上角,写完回读验证逐项核对。
- 微调样式:只在必要的地方调字号和对齐,不动版式。
- 回读验证:把填过的格子读出来对照原始数据确认无误。
- 转 PDF 目检:LibreOffice 转 PDF,pdftoppm 转 PNG,放大检查排版。
- 手机端确认:最终在手机上打开文件,看图是否显示、位置是否正常。
- 双份归档:定稿存两份,交付版给用户,敏感母版只存本地,不上传、不进仓库。
- 清过程文件:临时拆包、PDF、PNG 全部删除,不留痕。
- 更新手册:遇到新坑就回这篇手册补一条,经验累积起来。
核心原则:
- 内容核对用权威来源,不拍脑袋。关键字段(数字、日期、名称)要以原始证明材料为准,不能依赖原模板的预填值。
- 敏感信息只存本地,处理过程不经过任何云端服务。
- 过程文件放临时目录,完工即删。工作目录只保留最终交付物。
复盘
做对的三件事:
第一,把 PDF 渲染目检作为标准环节,而不是可选项。没有这一环,很多文字截断和图压线问题要到用户反馈才发现。加上这一步骤后,交付质量明显提升。
第二,遇到坑1的时候没有只在电脑上反复试,而是直接拆包对比原模板的结构差异。这才找到了 rels 路径这个关键点。只看代码不看 XML 结构会走很多弯路。
第三,把坑11的操作顺序固化下来:所有 openpyxl 操作在前,zip rels 修正在后。这避免了反复修反复翻的问题,每次只修一次就到位。
可迁移的建议:
处理任何涉及跨端兼容的文档格式问题时,优先怀疑结构细节而不是代码逻辑。程序生成的文档和手工维护的文档在结构上有微妙差异,手机 App 解析比桌面软件严格。把文件拆开看 XML 结构、逐字节对比差异,比反复调代码更有效率。
有图的文件,处理完内容后一定要在目标端(手机)实测,不能只看电脑端渲染正常就交付。电脑端验证只是必要条件,不是充分条件。
总结
- 手机端不显示图片的第一嫌疑是 rels Target 路径:openpyxl 生成的绝对路径在桌面软件正常但手机 App 不认,改成相对路径即可解决。
- openpyxl 保存会重写 rels 路径,所有内容修改操作要放在前面,最后一步再做路径修正,顺序不能反。
- 孤儿 drawing 会导致 openpyxl 打开报错,编辑别人提供的带图文件前要先拆包检查有没有悬空引用。
- drawing anchor 写法用 oneCellAnchor 最标准,避免 twoCellAnchor 同列靠 colOff 撑宽度的写法,手机端兼容性更好。
- 带图文件要在手机端实测、PDF 渲染目检,双重验证通过才算完成交付。
以上,全文完。
这篇对你有帮助的话,欢迎留言交流你的做法,也欢迎提出不同看法。
最后认识一下,我是黎羽,一名 AI 实践爱好者,会不定期记录我的学习过程,期待你的关注和分享。