PowerPoint差分検出

【概要】2つのPowerPointファイル間の変更を自動検出するPythonプログラム。python-pptxライブラリとMD5ハッシュを使用して、テキスト変更・位置変更・サイズ変更・書式変更を検出し、レポートを生成する。

目次

概要

差分検出技術を利用する。差分検出技術とは、二つのデータセット間の相違点を自動的に特定し、変更の種類・位置・内容を詳細に報告する計算技術である。

動作原理: 本プログラムはMD5ハッシュによる高速同一性判定とdifflibによる詳細差分表示を組み合わせる。各要素をハッシュ値で比較して変更を検出し、変更箇所では最長共通部分列(二つの系列に共通する最長の部分系列)アルゴリズムにより具体的な差分を特定する。編集距離(二つの文字列間で必要な最小編集操作数)の概念を用いて変更の複雑さを測定する。この技術は文書管理システム、バージョン管理システム、品質保証システムなどで活用される。

本プログラムを実際に実行することで、ハッシュベースの変更検出、階層的データ構造の解析、差分アルゴリズムの動作を体験し、異なる検出手法の比較実験を通じて新たな発見を得ることができる。

Python開発環境,ライブラリ類

ここでは、最低限の事前準備について説明する。機械学習や深層学習を行う場合は、NVIDIA CUDA、Visual Studio、Cursorなどを追加でインストールすると便利である。これらについては別ページ https://www.kkaneko.jp/cc/dev/aiassist.htmlで詳しく解説しているので、必要に応じて参照してください。

Python 3.12 のインストール

インストール済みの場合は実行不要。

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行する。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要である。

REM Python をシステム領域にインストール
winget install --scope machine --id Python.Python.3.12 -e --silent
REM Python のパス設定
set "PYTHON_PATH=C:\Program Files\Python312"
set "PYTHON_SCRIPTS_PATH=C:\Program Files\Python312\Scripts"
echo "%PATH%" | find /i "%PYTHON_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_PATH%" /M >nul
echo "%PATH%" | find /i "%PYTHON_SCRIPTS_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_SCRIPTS_PATH%" /M >nul

関連する外部ページ

Python の公式ページ: https://www.python.org/

AI エディタ Windsurf のインストール

Pythonプログラムの編集・実行には、AI エディタの利用を推奨する。ここでは,Windsurfのインストールを説明する。

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行して、Windsurfをシステム全体にインストールする。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要となる。

winget install --scope machine Codeium.Windsurf -e --silent

関連する外部ページ

Windsurf の公式ページ: https://windsurf.com/

必要なライブラリのインストール

コマンドプロンプトを管理者として実行(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行する。

pip install python-pptx

プログラムの詳細

概要

主要機能

使用上の注意

プログラムコード

# PowerPoint変更検証プログラム
#   2つのPowerPointファイル間の全変更を検出・報告
#   GitHub: https://github.com/scanny/python-pptx
#   特徴: python-pptxライブラリによるPowerPoint操作、MD5ハッシュとdifflibによる高精度差分検出
#         テキスト変更と非テキスト変更の分離、位置・サイズ・書式変更の詳細検出機能
#   前準備: pip install python-pptx (管理者権限のコマンドプロンプトで実行)

from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE
import hashlib
import difflib

# 設定パラメータ
POSITION_THRESHOLD = 2
SIZE_THRESHOLD = 2
TEXT_PREVIEW_LENGTH = 50

print("PowerPoint変更検証プログラム")
print("=" * 40)

# ファイルパス設定(使用時に変更してください)
file1_path = "旧版.pptx"
file2_path = "新版.pptx"

print(f"比較対象ファイル1: {file1_path}")
print(f"比較対象ファイル2: {file2_path}")
print()

# PowerPointファイル読み込み
presentation1 = Presentation(file1_path)
presentation2 = Presentation(file2_path)

# スライド数の確認
if len(presentation1.slides) != len(presentation2.slides):
    print(f"警告: スライド数が異なります({len(presentation1.slides)}枚 vs {len(presentation2.slides)}枚)")
    print()

# メイン処理
print("変更検出を実行中...")

elements1 = {}
elements2 = {}

# 全要素の抽出(プレゼンテーション1)
for slide_idx, slide in enumerate(presentation1.slides):
    slide_number = slide_idx + 1
    for shape in slide.shapes:
        if hasattr(shape, 'shape_id'):
            element_id = f"slide{slide_number}_shape{shape.shape_id}"

            # テキスト内容の抽出
            text_content = ""
            if hasattr(shape, 'has_text_frame') and shape.has_text_frame:
                text_content = shape.text.strip()
            elif shape.shape_type == MSO_SHAPE_TYPE.TABLE and hasattr(shape, 'table'):
                all_text = []
                for row in shape.table.rows:
                    for cell in row.cells:
                        cell_text = cell.text.strip()
                        if cell_text:
                            all_text.append(cell_text)
                text_content = " ".join(all_text)

            # 非テキスト要素のハッシュ計算
            non_text_data = {
                'type': str(shape.shape_type),
                'position': (shape.left, shape.top),
                'size': (shape.width, shape.height)

}

            # 要素固有の情報
            if shape.shape_type == MSO_SHAPE_TYPE.PICTURE and hasattr(shape, 'image'):
                non_text_data['image_hash'] = hashlib.md5(shape.image.blob).hexdigest()
            elif shape.shape_type == MSO_SHAPE_TYPE.CHART and hasattr(shape, 'chart'):
                chart = shape.chart
                non_text_data['chart_type'] = str(chart.chart_type)
                if hasattr(chart, '_chart_part') and hasattr(chart._chart_part, 'blob'):
                    non_text_data['chart_xml_hash'] = hashlib.md5(chart._chart_part.blob).hexdigest()

            # 書式情報
            if (hasattr(shape, 'has_text_frame') and shape.has_text_frame and
                shape.text_frame.paragraphs):
                para = shape.text_frame.paragraphs[0]
                if para.runs:
                    run = para.runs[0]
                    font = run.font
                    non_text_data['font'] = {
                        'name': font.name,
                        'size': font.size.pt if font.size and hasattr(font.size, 'pt') else None,
                        'bold': font.bold,
                        'italic': font.italic,
                        'underline': font.underline,
                        'color': (str(font.color.rgb) if font.color and hasattr(font.color, 'rgb')
                                and font.color.rgb else None)

}

            non_text_hash = hashlib.md5(str(sorted(non_text_data.items())).encode()).hexdigest()

            elements1[element_id] = {
                'element_id': element_id,
                'slide_number': slide_number,
                'element_type': str(shape.shape_type),
                'text_content': text_content,
                'text_hash': hashlib.md5(text_content.encode()).hexdigest(),
                'non_text_hash': non_text_hash,
                'position': (shape.left, shape.top),
                'size': (shape.width, shape.height)

}

# 全要素の抽出(プレゼンテーション2)
for slide_idx, slide in enumerate(presentation2.slides):
    slide_number = slide_idx + 1
    for shape in slide.shapes:
        if hasattr(shape, 'shape_id'):
            element_id = f"slide{slide_number}_shape{shape.shape_id}"

            # テキスト内容の抽出
            text_content = ""
            if hasattr(shape, 'has_text_frame') and shape.has_text_frame:
                text_content = shape.text.strip()
            elif shape.shape_type == MSO_SHAPE_TYPE.TABLE and hasattr(shape, 'table'):
                all_text = []
                for row in shape.table.rows:
                    for cell in row.cells:
                        cell_text = cell.text.strip()
                        if cell_text:
                            all_text.append(cell_text)
                text_content = " ".join(all_text)

            # 非テキスト要素のハッシュ計算
            non_text_data = {
                'type': str(shape.shape_type),
                'position': (shape.left, shape.top),
                'size': (shape.width, shape.height)

}

            # 要素固有の情報
            if shape.shape_type == MSO_SHAPE_TYPE.PICTURE and hasattr(shape, 'image'):
                non_text_data['image_hash'] = hashlib.md5(shape.image.blob).hexdigest()
            elif shape.shape_type == MSO_SHAPE_TYPE.CHART and hasattr(shape, 'chart'):
                chart = shape.chart
                non_text_data['chart_type'] = str(chart.chart_type)
                if hasattr(chart, '_chart_part') and hasattr(chart._chart_part, 'blob'):
                    non_text_data['chart_xml_hash'] = hashlib.md5(chart._chart_part.blob).hexdigest()

            # 書式情報
            if (hasattr(shape, 'has_text_frame') and shape.has_text_frame and
                shape.text_frame.paragraphs):
                para = shape.text_frame.paragraphs[0]
                if para.runs:
                    run = para.runs[0]
                    font = run.font
                    non_text_data['font'] = {
                        'name': font.name,
                        'size': font.size.pt if font.size and hasattr(font.size, 'pt') else None,
                        'bold': font.bold,
                        'italic': font.italic,
                        'underline': font.underline,
                        'color': (str(font.color.rgb) if font.color and hasattr(font.color, 'rgb')
                                and font.color.rgb else None)

}

            non_text_hash = hashlib.md5(str(sorted(non_text_data.items())).encode()).hexdigest()

            elements2[element_id] = {
                'element_id': element_id,
                'slide_number': slide_number,
                'element_type': str(shape.shape_type),
                'text_content': text_content,
                'text_hash': hashlib.md5(text_content.encode()).hexdigest(),
                'non_text_hash': non_text_hash,
                'position': (shape.left, shape.top),
                'size': (shape.width, shape.height)

}

# 変更検出
changes = []
all_element_ids = set(elements1.keys()) | set(elements2.keys())

for element_id in sorted(all_element_ids):
    elem1 = elements1.get(element_id)
    elem2 = elements2.get(element_id)

    if elem1 and elem2:
        # 既存要素の変更
        text_changed = elem1['text_hash'] != elem2['text_hash']
        non_text_changed = elem1['non_text_hash'] != elem2['non_text_hash']

        if text_changed or non_text_changed:
            non_text_details = []
            if non_text_changed:
                # 位置変更
                pos_diff = (elem2['position'][0] - elem1['position'][0],
                           elem2['position'][1] - elem1['position'][1])
                if abs(pos_diff[0]) >= POSITION_THRESHOLD or abs(pos_diff[1]) >= POSITION_THRESHOLD:
                    non_text_details.append(f"位置変更: X{pos_diff[0]:+.0f}pt, Y{pos_diff[1]:+.0f}pt")

                # サイズ変更
                size_diff = (elem2['size'][0] - elem1['size'][0],
                            elem2['size'][1] - elem1['size'][1])
                if abs(size_diff[0]) >= SIZE_THRESHOLD or abs(size_diff[1]) >= SIZE_THRESHOLD:
                    non_text_details.append(f"サイズ変更: W{size_diff[0]:+.0f}pt, H{size_diff[1]:+.0f}pt")

                if not non_text_details:
                    non_text_details.append("書式またはコンテンツ変更")

            changes.append({
                'element_id': element_id,
                'slide_number': elem1['slide_number'],
                'element_type': elem1['element_type'],
                'text_changed': text_changed,
                'text_old': elem1['text_content'],
                'text_new': elem2['text_content'],
                'non_text_changed': non_text_changed,
                'non_text_details': non_text_details

})

    elif elem1 and not elem2:
        # 削除
        changes.append({
            'element_id': element_id,
            'slide_number': elem1['slide_number'],
            'element_type': elem1['element_type'],
            'text_changed': True,
            'text_old': elem1['text_content'],
            'text_new': "",
            'non_text_changed': True,
            'non_text_details': ["要素削除"]

})

    elif not elem1 and elem2:
        # 追加
        changes.append({
            'element_id': element_id,
            'slide_number': elem2['slide_number'],
            'element_type': elem2['element_type'],
            'text_changed': True,
            'text_old': "",
            'text_new': elem2['text_content'],
            'non_text_changed': True,
            'non_text_details': ["要素追加"]

})

# 結果出力
print(f"検出された変更数: {len(changes)}件")
print()

# レポート生成
if not changes:
    report_text = "変更は検出されませんでした。"
else:
    report = []
    report.append("PowerPoint変更検出結果")
    report.append("=" * 30)
    report.append("")

    # スライド別に整理
    slide_changes = {}
    for change in changes:
        slide_num = change['slide_number']
        if slide_num not in slide_changes:
            slide_changes[slide_num] = []
        slide_changes[slide_num].append(change)

    for slide_num in sorted(slide_changes.keys()):
        report.append(f"スライド {slide_num}:")

        for change in slide_changes[slide_num]:
            report.append(f"  {change['element_id']} ({change['element_type']})")

            # テキスト変更
            if change['text_changed']:
                if change['text_old'] and change['text_new']:
                    old_preview = change['text_old'][:TEXT_PREVIEW_LENGTH]
                    new_preview = change['text_new'][:TEXT_PREVIEW_LENGTH]
                    report.append(f"    テキスト変更: '{old_preview}...' → '{new_preview}...'")

                    # 詳細差分
                    diff = list(difflib.unified_diff(
                        change['text_old'].splitlines(),
                        change['text_new'].splitlines(),
                        lineterm=''

))
                    for line in diff:
                        if line.startswith(('+', '-')) and not line.startswith(('+++', '---')):
                            report.append(f"      {line}")
                elif change['text_new']:
                    new_preview = change['text_new'][:TEXT_PREVIEW_LENGTH]
                    report.append(f"    テキスト追加: '{new_preview}...'")
                elif change['text_old']:
                    old_preview = change['text_old'][:TEXT_PREVIEW_LENGTH]
                    report.append(f"    テキスト削除: '{old_preview}...'")

            # 非テキスト変更
            if change['non_text_changed']:
                for detail in change['non_text_details']:
                    report.append(f"    {detail}")

            report.append("")

    report_text = "\n".join(report)

print(report_text)

# レポートファイル保存
report_filename = "powerpoint_change_report.txt"
with open(report_filename, 'w', encoding='utf-8') as f:
    f.write(report_text)

print(f"\nレポートを保存しました: {report_filename}")
print("処理が完了しました。")

使用方法

  1. 比較対象のPowerPointファイルを2つ準備し、プログラムと同じフォルダに配置する。
  2. プログラム内のファイルパス(file1_path、file2_path)を実際のファイル名に変更する。
  3. コマンドプロンプトでプログラムを実行する。
python powerpoint_diff_detector.py
  1. 実行結果として以下が出力される。
    • 標準出力での変更検出結果
    • powerpoint_change_report.txtファイルでの詳細レポート

実験・探求のアイデア

差分検出アルゴリズムの選択実験

現在のプログラムはMD5ハッシュとdifflibを使用している。SHA-256やSHA-1など他のハッシュアルゴリズムに変更し、処理速度と精度を比較実験できる。difflibの代替として独自の文字列比較アルゴリズムを実装し、検出精度の違いを検証できる。

検出閾値の最適化実験

POSITION_THRESHOLDとSIZE_THRESHOLDの値を変更し、検出感度の調整実験ができる。1pt、5pt、10ptなど異なる閾値で同一ファイルを比較し、検出される変更数の変化を観察できる。最適な閾値設定を発見できる。

階層的データ構造の解析実験

PowerPointの階層構造(プレゼンテーション→スライド→シェイプ)における各レベルでの変更検出効率を測定実験できる。スライドレベル、シェイプレベル、属性レベルでの処理時間を計測し、効率的な検出戦略を発見できる。

異なるファイル形式での差分検出比較

PowerPointファイル以外(Word文書、Excelファイル、PDFファイル)での類似の差分検出プログラムを作成し、各形式における検出精度と処理効率を比較実験できる。汎用的な差分検出技術の特性を理解できる。

機械学習を活用した変更分類実験

検出された変更を機械学習アルゴリズムで分類し、重要度を自動判定する実験ができる。変更の種類(テキスト、位置、サイズ、書式)に基づいて重要度スコアを算出し、優先度の高い変更から表示する機能を開発できる。