如何使用 python 比较两个 draw.io XML 文件并生成差异区域的前后对比图片?
可以用 Python 解析 draw.io XML、识别对象变化,再调用渲染工具,裁剪出更新前后的对比图片。复杂流程图建议结合两种比较方式:
- 比较 XML,判断哪些节点、连线或属性发生了变化。
- 比较渲染后的图片,确定变化实际出现在哪些区域。
XML 有助于解释修改内容,图片比较则能覆盖连线路由、文字换行、阴影等最终显示效果。仅比较 XML 文本,无法直接确定可靠的截图范围。
比较前需要满足的两个条件
更新前后的对象能够对应
draw.io 通常用 mxCell 表示节点和连线,通过 id 标识对象。同一张图的不同版本,可以尝试按 id 匹配,识别新增、删除和修改的对象。
如果两个版本来自复制页面、重新导入或重新生成的文件,对象 ID 可能不同。按 ID 比较时,大量对象可能会被判定为删除和新增。这时需要另建业务标识,或根据文字、连接关系和位置匹配。
两张图片使用相同的坐标系
比较图片时,同一个图形位置必须对应同一个像素位置,因此两次导出需要保持以下设置一致:
- 缩放比例和画布原点。
- 字体、背景和渲染环境。
- 画布范围和导出边距。
自动裁剪尤其容易影响对齐。例如,在左侧新增一个节点,可能改变整张导出图片的起点,让原本没有修改的内容也出现像素差异。即使图片尺寸相同,也不能证明两张图已经对齐。
一个可行的办法是,在用于导出的临时副本中,给两个页面各加一个位置、尺寸完全一致的白色背景矩形,放到底层,完整包住两个版本的所有内容。然后统一缩放和边距,导出整张图。矩形之外不能有文字、连线或阴影,否则导出范围仍可能改变。
需要更精确地控制导出时,可以使用 diagrams.net/mxGraph 渲染环境,为两个版本设置相同的视口和变换参数。
第一步:解析页面并比较 XML 对象
draw.io 文件可能直接包含 <mxGraphModel>,也可能把页面内容放在 <diagram> 中。常见的压缩页面内容需要依次执行:
Base64 解码 → raw DEFLATE 解压 → URL 解码 → XML 解析
下面的代码支持这两种常见形式,也可以从同一个文件中选择不同页面。页面索引从 0 开始。
import base64
import zlib
from urllib.parse import unquote
import xml.etree.ElementTree as ET
def load_model(filename, page_index=0):
root = ET.parse(filename).getroot()
if root.tag == "mxGraphModel":
if page_index != 0:
raise IndexError("该文件只有一个 mxGraphModel")
return root
if root.tag == "diagram":
if page_index != 0:
raise IndexError("该文件只有一个 diagram")
diagram = root
elif root.tag == "mxfile":
diagrams = root.findall("diagram")
if not 0 <= page_index < len(diagrams):
raise IndexError("页面索引超出范围")
diagram = diagrams[page_index]
else:
raise ValueError(f"不支持的根元素:{root.tag}")
model = diagram.find("mxGraphModel")
if model is not None:
return model
payload = (diagram.text or "").strip()
if not payload:
raise ValueError("页面内容为空")
# 兼容以转义文本形式保存的未压缩 XML。
if payload.startswith("<"):
model = ET.fromstring(payload)
else:
compressed = base64.b64decode(payload)
decoded = zlib.decompress(compressed, -15).decode("utf-8")
model = ET.fromstring(unquote(decoded))
if model.tag != "mxGraphModel":
raise ValueError("页面内容不是 mxGraphModel")
return model
def signature(element):
"""忽略属性排列和 XML 排版空白,保留子元素顺序。"""
return (
element.tag,
tuple(sorted(element.attrib.items())),
(element.text or "").strip(),
tuple(signature(child) for child in element),
)
def index_objects(model):
graph_root = model.find("root")
if graph_root is None:
raise ValueError("缺少 mxGraphModel/root")
objects = {}
for element in graph_root:
# 自定义属性对象可能用 object 等元素包裹 mxCell。
cell = (
element
if element.tag == "mxCell"
else element.find("mxCell")
)
if cell is None:
continue
object_id = element.get("id") or cell.get("id")
if object_id:
objects[object_id] = signature(element)
return objects
def compare_models(before, after):
old = index_objects(before)
new = index_objects(after)
return {
"added": sorted(new.keys() - old.keys()),
"removed": sorted(old.keys() - new.keys()),
"modified": sorted(
object_id
for object_id in old.keys() & new.keys()
if old[object_id] != new[object_id]
),
}
比较两个文件:
before = load_model("before.drawio", page_index=0)
after = load_model("after.drawio", page_index=0)
changes = compare_models(before, after)
print(changes)
如果两个版本位于同一个文件的两个页面中:
before = load_model("sop.drawio", page_index=0)
after = load_model("sop.drawio", page_index=1)
这段代码比较对象结构。文字、样式、几何信息,以及连线的 source、target 等属性发生变化时,对象都可能进入 modified。
不过,结构比较没有完整实现 draw.io 的显示语义。例如,样式字符串中的属性顺序变化可能被报告为修改;父分组移动时,子节点自身的 XML 却可能保持不变。因此,这份变化列表不能直接用来确定最终截图范围。
第二步:用 draw.io 渲染页面
复杂流程图适合交给 draw.io 自身渲染。如果用 Pillow 根据 XML 重新绘制节点和连线,就需要额外处理形状、HTML 标签、分组变换、连线路由等规则,工作量很大。
安装 draw.io Desktop 并配置命令行入口后,可以用类似下面的命令导出 PNG:
drawio --export --format png --page-index 0 --scale 1 --border 0 --output before.png before.drawio
drawio --export --format png --page-index 0 --scale 1 --border 0 --output after.png after.drawio
drawio 的可执行文件路径取决于安装方式,可用参数请通过所安装版本的 --help 核对。比较同一文件中的两个页面时,调整输入路径和 --page-index 即可。
运行这些命令前,仍需按前面的方法统一导出范围。自动化时,可以用 Python 的 subprocess.run(..., check=True) 调用命令,等待导出完成后再比较图片。
第三步:检测差异并生成前后对比图片
下面的示例使用已经对齐的 before.png 和 after.png,按以下步骤处理:
- 计算每个像素的颜色差异。
- 用阈值过滤较小的差异。
- 通过膨胀操作,将相邻的差异像素合成区域。
- 在两张原图中裁剪相同范围,生成并排图片。
安装依赖:
python -m pip install pillow numpy opencv-python
from pathlib import Path
import cv2
import numpy as np
from PIL import Image, ImageDraw
def load_rgb(filename):
# 将透明背景统一合成到白色背景上。
with Image.open(filename) as source:
rgba = source.convert("RGBA")
background = Image.new("RGBA", rgba.size, "white")
return Image.alpha_composite(background, rgba).convert("RGB")
def export_diff_regions(
before_file,
after_file,
output_dir="diff_output",
threshold=12,
merge_radius=10,
padding=24,
):
before = load_rgb(before_file)
after = load_rgb(after_file)
if before.size != after.size:
raise ValueError("图片尺寸不同,请先统一画布范围和缩放比例")
output = Path(output_dir)
output.mkdir(parents=True, exist_ok=True)
a = np.asarray(before)
b = np.asarray(after)
difference = cv2.absdiff(a, b)
mask = (
difference.max(axis=2) > threshold
).astype(np.uint8) * 255
Image.fromarray(mask).save(output / "diff_mask.png")
if not np.any(mask):
return []
kernel_size = 2 * merge_radius + 1
kernel = np.ones(
(kernel_size, kernel_size),
dtype=np.uint8,
)
grouped = cv2.dilate(mask, kernel, iterations=1)
count, _, stats, _ = cv2.connectedComponentsWithStats(
grouped,
connectivity=8,
)
# 第 0 个连通域是背景。
regions = sorted(stats[1:count], key=lambda row: (row[1], row[0]))
width, height = before.size
boxes = []
for index, (x, y, w, h, _) in enumerate(regions, start=1):
box = (
max(0, int(x) - padding),
max(0, int(y) - padding),
min(width, int(x + w) + padding),
min(height, int(y + h) + padding),
)
old_crop = before.crop(box)
new_crop = after.crop(box)
old_crop.save(output / f"region_{index:03d}_before.png")
new_crop.save(output / f"region_{index:03d}_after.png")
gap = 16
header = 28
crop_width, crop_height = old_crop.size
comparison = Image.new(
"RGB",
(crop_width * 2 + gap, crop_height + header),
"white",
)
comparison.paste(old_crop, (0, header))
comparison.paste(new_crop, (crop_width + gap, header))
draw = ImageDraw.Draw(comparison)
draw.text((8, 7), "Before", fill="black")
draw.text((crop_width + gap + 8, 7), "After", fill="black")
comparison.save(output / f"region_{index:03d}.png")
boxes.append(box)
return boxes
boxes = export_diff_regions("before.png", "after.png")
print(f"生成了 {len(boxes)} 个差异区域")
输出目录中会包含:
diff_output/
diff_mask.png
region_001_before.png
region_001_after.png
region_001.png
...
region_001.png 的左侧是更新前,右侧是更新后,两侧使用相同的裁剪坐标。新增内容在更新前的对应位置可能是空白;删除内容则可能在更新后留下空白。
threshold=12、merge_radius=10 和 padding=24 都是供调整的示例值,具体取值需要按图片情况确定:
- 降低
threshold可以捕捉更细微的变化,也更容易受到抗锯齿差异影响。 - 增大
merge_radius可以合并相邻改动,但可能把不同修改合成一个大区域。 - 增大
padding可以保留更多上下文,但不保证完整包含整个节点。
复杂流程图还需要处理的情况
截取完整节点时,使用渲染后的边界
像素差异可能只覆盖几个修改过的文字。如果希望截图包含完整节点,需要将差异范围扩展到对应节点的渲染边界。
不能直接把 mxGeometry 中的 x、y、width、height 当成截图边界。分组内的坐标可能相对于父对象,连线还涉及端点、折点和自动路由,标签也可能超出形状本身。
需要精确截图时,应从渲染环境取得节点、连线和标签的实际显示边界,再映射到图片坐标:
pixel_x = scale * graph_x + offset_x
pixel_y = scale * graph_y + offset_y
其中,graph_x、graph_y 必须已经转换到共同的图坐标系。对象发生移动时,截图范围需要同时覆盖旧位置和新位置,也要包含节点移动引起的连线路由变化。
大范围重新排版会产生大量视觉差异
整个流程图执行自动布局后,即使流程内容没有变化,大量节点和连线的位置也会改变,像素比较会检测到这些变化。
如果只关心 SOP 内容修改,需要先定义结构比较规则,例如忽略纯位置变化,保留文字和连接关系变化。单靠图片差异无法作出这种区分。
提前确定蓝色高亮是否算修改
如果蓝色只是人工添加的修订标记,图片比较也会识别出这部分颜色变化。
需要区分“业务内容修改”和“修订标记”时,可以在 XML 比较规则中排除约定的颜色属性,或在临时副本中统一样式后再渲染。直接过滤所有蓝色像素,可能漏掉原本就使用蓝色的流程元素。
大图可以分块比较
高分辨率 PNG 会占用较多内存。流程图特别大时,可以分块计算差异,为相邻块保留重叠区域,最后合并跨块的差异范围。无论采用哪种优化方式,都应先确认未修改的节点在两张图中准确重合。
备注:内容仅供参考。
