PAYATHON 2026

如何使用 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,按以下步骤处理:

  1. 计算每个像素的颜色差异。
  2. 用阈值过滤较小的差异。
  3. 通过膨胀操作,将相邻的差异像素合成区域。
  4. 在两张原图中裁剪相同范围,生成并排图片。

安装依赖:

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 会占用较多内存。流程图特别大时,可以分块计算差异,为相邻块保留重叠区域,最后合并跨块的差异范围。无论采用哪种优化方式,都应先确认未修改的节点在两张图中准确重合。

备注:内容仅供参考。