文字没变,格式却变了:一次PPT局部加粗漏检的实测
比较两份PPT时,“拼出来的字符串相同”和“视觉样式相同”是两个不同的问题。今天在PPT-diff的实测中,我遇到一个很小却容易漏掉的例子:一个文本框原来全部是普通字重,新版只把最后几个字符加粗,文字一字未改。解析器已经读到了加粗属性,最终报告却没有字体变化。
这次记录只讨论这一处可复现的比较问题,不评价整个工具,也不把“建议怎样修复”写成“已经修复”。公开复现及固定提交见GitCode的agent-best-practices/PPT-diff仓库Issue #48。
一段文字为什么会变成两个run
PowerPoint里的一个段落可以由多个文字片段组成。每个片段称为run,它带有自己的文字和字符格式。只选中后半段文字加粗时,段落不需要增加任何字符,内部片段边界却可能改变。
这次合成样例没有学校、公司或真实文档内容,仅包含一个名为UniqueBody的普通文本框。它的位置、字号、字体名称和颜色都显式固定。旧版和新版的完整文字都为Total:
| 版本 | 片段0 | 片段1 |
|---|---|---|
| 旧版 | 普通 Total | 无 |
| 新版 | 普通 To | 加粗 tal |
先单独检查解析结果,可以看到新版确实得到两个run,bold值分别为False和True。这样就能把问题范围缩小:这次不是因为加粗属性根本没读出来,而是读取后如何配对、比较和汇总的问题。
这里显式指定了字体属性,因此没有把继承样式问题混入测试。python-pptx的官方文档说明,Font.bold返回None时表示该属性需要从样式层级继承,不能直接等同于False。真实工具如果要比较“最终显示样式”,还需要额外处理这些继承关系。出处为python-pptx官方文档的Text-related objects章节、Font objects小节、Font.bold条目。
用三个对照缩小问题范围
单个失败样例只能说明某种输入没有得到预期结果,不能证明原因。因此我又准备了两个对照,三组输入共用同一个旧版:
| 新版变化 | 预期字体变化 | 实测font_changes | 实测modified |
|---|---|---|---|
| 把普通Total拆为普通To和普通tal | 无 | 0 | 0 |
| 普通To和加粗tal | 有 | 0 | 0 |
| 整个Total直接加粗 | 有 | 1 | 1 |
第一个对照说明,拆分run本身不应被当成视觉变化。第三个对照说明,这个测试环境和普通整段加粗路径能够识别字体修改。真正异常的是第二行:文字相同,尾部字符的字重不同,解析器也读到了不同值,但比较结果没有体现出来。
实测环境为Python 3.12.14、python-pptx 1.0.2;PPT-diff的main固定在db6c75892650854abf663c91b66ead486fde1299。所有文件均由脚本生成,没有使用个人演示文稿。
复现代码怎样保持可核对
下面复现会在仓库根目录生成四份很小的PPTX,直接调用项目原有解析和比较函数。它不修改项目源码,也不自动启动PowerPoint或浏览器。
from pathlib import Path
import sys
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
sys.path.insert(0, str(Path("scripts").resolve()))
from ppt_parser import parse_pptx
from comparator import compare_pptx, generate_summary
out = Path("repro_tail_style")
out.mkdir(exist_ok=True)
def save(name, values):
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[6])
box = slide.shapes.add_textbox(Inches(1), Inches(1), Inches(5), Inches(1))
box.name = "UniqueBody"
p = box.text_frame.paragraphs[0]
for text, bold in values:
r = p.add_run()
r.text = text
r.font.bold = bold
r.font.italic = False
r.font.underline = False
r.font.name = "Arial"
r.font.size = Pt(24)
r.font.color.rgb = RGBColor(0, 0, 0)
path = out / (name + ".pptx")
prs.save(path)
return parse_pptx(str(path))
old = save("old", [("Total", False)])
for name, values in [
("same_style", [("To", False), ("tal", False)]),
("bold_tail", [("To", False), ("tal", True)]),
("whole_bold", [("Total", True)]),
]:
new = save(name, values)
s = generate_summary(compare_pptx(old, new))
print(name, "font_changes=", s["font_changes"], "modified=", s["modified"])
print("parsed_bold=", [r.bold for r in new[0].shapes[0].paragraphs[0].runs])
实际输出为:
same_style font_changes= 0 modified= 0
parsed_bold= [False, False]
bold_tail font_changes= 0 modified= 0
parsed_bold= [False, True]
whole_bold font_changes= 1 modified= 1
parsed_bold= [True]
当前版本还有既有Issue #16提到的页状态问题,因此前两个对照会出现moved=1。本次没有把它重新算成新发现,也没有根据moved状态推导字体变化,而是分别检查字体计数、解析属性和modified计数。
漏检发生在新增片段分支
检查comparator.py中的_diff_font_runs(),可以看到它按run下标比较。两边都存在时逐项比较字体属性;只有旧run时也会生成记录;但只有新run的情况没有对应处理。
本例中,下标0比较的是旧版普通Total和新版普通To,两者显式字体属性相同,所以没有变化。下标1只有新版加粗tal,恰好落入没有处理的情况。随后整个页面的汇总也就没有收到字体变化。
这与“同名形状匹配错误”是不同层次的问题。本例仅有一个名字唯一的文本框,不存在多个同名形状交叉配对;也与三种输出模式对共享结果消费不一致不同,因为这次共享比较结果本身就没有产生应有的字体变化。
为什么不能简单地把新run都报成修改
看见缺少分支,最直接的做法似乎是:新版多一个run,就追加一条字体修改。但这样会把第一个对照也报成变化。普通To加普通tal与普通Total的显示样式相同,结构拆分并不构成格式差异。
更稳妥的方向是先保留段落边界和完整文字,在可确定字符对应关系的区间上比较有效样式。对于文字完全相同的段落,可以按字符偏移表达样式区间,再合并相邻且样式相同的区间。片段边界不同、覆盖区间的样式相同,就不应报变化;对应字符的有效样式不同,才产生变化记录。
如果文字同时被插入、删除或替换,则需要先做文本对齐,不能直接比较相同下标的片段。空run、换行、字段和主题继承也需要单独定义口径。这里提出的是后续设计方向,不是已经完成并验证过的修复补丁。
验收应覆盖边界,也应覆盖反例
修复后至少应保留五类检查:尾部局部加粗能够检出,整段加粗仍能检出,仅拆分同样式run不误报,反向取消加粗能够检出,同一文件自比较不产生字体变化。再逐步增加中间字符、跨多个run以及文本同时修改的场景。
这次实测最有价值的地方,是把“看起来没报变化”拆成可核对的证据链:输入怎样构造,解析器读到了什么,比较函数输出什么,哪些对照通过,哪些边界仍未验证。这样既能帮助维护者定位问题,也能避免用一个简单分支修补出新的误报。
- 点赞
- 收藏
- 关注作者
评论(0)