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
import os
import sys
import re
import json
import tkinter as tk
from tkinter import filedialog, messagebox

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

def select_files():
    """tkinterを使用してファイルを選択する"""
    root = tk.Tk()
    root.withdraw()  # メインウィンドウを非表示

    print("ファイル選択ダイアログを開いています...")

    # 1つ目のファイル選択
    file1_path = filedialog.askopenfilename(
        title="比較元ファイル(旧版)を選択してください",
        filetypes=[
            ("PowerPoint files", "*.pptx"),
            ("All files", "*.*")
        ],
        initialdir=os.getcwd()
    )

    if not file1_path:
        print("ファイル選択がキャンセルされました。")
        root.destroy()
        return None, None

    # 2つ目のファイル選択
    file2_path = filedialog.askopenfilename(
        title="比較先ファイル(新版)を選択してください",
        filetypes=[
            ("PowerPoint files", "*.pptx"),
            ("All files", "*.*")
        ],
        initialdir=os.path.dirname(file1_path)
    )

    if not file2_path:
        print("ファイル選択がキャンセルされました。")
        root.destroy()
        return None, None

    root.destroy()
    return file1_path, file2_path

def normalize_text(text):
    """テキストを正規化する"""
    if not text:
        return ""
    # 改行・タブ・連続スペースを統一
    normalized = re.sub(r'\s+', ' ', text.strip())
    return normalized

def create_stable_hash(data):
    """安定したハッシュを生成する"""
    # 辞書を再帰的にソートしてJSON文字列化
    def sort_dict_recursive(obj):
        if isinstance(obj, dict):
            return {k: sort_dict_recursive(v) for k, v in sorted(obj.items())}
        elif isinstance(obj, list):
            return [sort_dict_recursive(item) for item in obj]
        else:
            return obj

    sorted_data = sort_dict_recursive(data)
    json_str = json.dumps(sorted_data, sort_keys=True, ensure_ascii=False)
    return hashlib.md5(json_str.encode('utf-8')).hexdigest()

def extract_shape_signature(shape, slide_number):
    """図形の特徴量を抽出してシグネチャを生成する"""
    signature_data = {
        'shape_type': str(shape.shape_type),
        'slide_number': slide_number
    }

    # テキスト内容
    text_content = ""
    if hasattr(shape, 'has_text_frame') and shape.has_text_frame:
        text_content = normalize_text(shape.text)
    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 = normalize_text(cell.text)
                if cell_text:
                    all_text.append(cell_text)
        text_content = " ".join(all_text)

    signature_data['text_content'] = text_content

    # 位置・サイズ(相対的な特徴として使用)
    signature_data['relative_position'] = {
        'left_ratio': round(shape.left / 914400, 3) if shape.left else 0,  # EMU to inches
        'top_ratio': round(shape.top / 914400, 3) if shape.top else 0
    }
    signature_data['relative_size'] = {
        'width_ratio': round(shape.width / 914400, 3) if shape.width else 0,
        'height_ratio': round(shape.height / 914400, 3) if shape.height else 0
    }

    # 特殊要素の特徴
    if shape.shape_type == MSO_SHAPE_TYPE.PICTURE and hasattr(shape, 'image'):
        try:
            signature_data['image_hash'] = hashlib.md5(shape.image.blob).hexdigest()
        except Exception:
            signature_data['image_hash'] = 'error'

    return create_stable_hash(signature_data)

def extract_elements_from_presentation(presentation):
    """プレゼンテーションから全要素を抽出する"""
    elements = {}

    for slide_idx, slide in enumerate(presentation.slides):
        slide_number = slide_idx + 1
        for shape_idx, shape in enumerate(slide.shapes):
            # 内容ベースの一意識別子を生成
            shape_signature = extract_shape_signature(shape, slide_number)
            element_id = f"slide{slide_number}_sig{shape_signature[:8]}_idx{shape_idx}"

            # テキスト内容の抽出
            text_content = ""
            if hasattr(shape, 'has_text_frame') and shape.has_text_frame:
                text_content = normalize_text(shape.text)
            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 = normalize_text(cell.text)
                        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'):
                try:
                    non_text_data['image_hash'] = hashlib.md5(shape.image.blob).hexdigest()
                except Exception:
                    non_text_data['image_hash'] = 'error'
            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'):
                    try:
                        non_text_data['chart_xml_hash'] = hashlib.md5(chart._chart_part.blob).hexdigest()
                    except Exception:
                        non_text_data['chart_xml_hash'] = 'error'

            # 書式情報
            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
                    font_size = None
                    if font.size is not None:
                        try:
                            font_size = font.size.pt
                        except (AttributeError, TypeError):
                            font_size = None

                    font_color = None
                    if font.color is not None:
                        try:
                            if hasattr(font.color, 'rgb') and font.color.rgb is not None:
                                font_color = str(font.color.rgb)
                        except (AttributeError, TypeError):
                            font_color = None

                    non_text_data['font'] = {
                        'name': font.name,
                        'size': font_size,
                        'bold': font.bold,
                        'italic': font.italic,
                        'underline': font.underline,
                        'color': font_color
                    }

            non_text_hash = create_stable_hash(non_text_data)

            elements[element_id] = {
                'element_id': element_id,
                'slide_number': slide_number,
                'shape_index': shape_idx,
                'element_type': str(shape.shape_type),
                'text_content': text_content,
                'text_hash': hashlib.md5(text_content.encode('utf-8')).hexdigest(),
                'non_text_hash': non_text_hash,
                'position': (shape.left, shape.top),
                'size': (shape.width, shape.height),
                'signature': shape_signature
            }

    return elements

def calculate_similarity(elem1, elem2):
    """2つの要素間の類似度を計算する"""
    if elem1['element_type'] != elem2['element_type']:
        return 0.0

    # テキスト類似度
    text_similarity = 0.0
    if elem1['text_content'] or elem2['text_content']:
        if elem1['text_content'] == elem2['text_content']:
            text_similarity = 1.0
        else:
            # 編集距離ベースの類似度
            import difflib
            text_similarity = difflib.SequenceMatcher(None, elem1['text_content'], elem2['text_content']).ratio()
    else:
        text_similarity = 1.0  # 両方空の場合は同一

    # 位置類似度
    pos1, pos2 = elem1['position'], elem2['position']
    max_distance = max(abs(pos1[0] - pos2[0]), abs(pos1[1] - pos2[1]))
    position_similarity = max(0, 1 - max_distance / 914400)  # EMU単位での正規化

    # サイズ類似度
    size1, size2 = elem1['size'], elem2['size']
    size_diff = max(abs(size1[0] - size2[0]), abs(size1[1] - size2[1]))
    size_similarity = max(0, 1 - size_diff / 914400)

    # 総合類似度(重み付き平均)
    total_similarity = (text_similarity * 0.6 + position_similarity * 0.2 + size_similarity * 0.2)
    return total_similarity

def find_best_matches(elements1, elements2):
    """最適なマッチングを見つける"""
    matches = {}
    used_elem2 = set()

    # 完全一致を最初に探す
    for elem1_id, elem1 in elements1.items():
        for elem2_id, elem2 in elements2.items():
            if elem2_id in used_elem2:
                continue
            if elem1['signature'] == elem2['signature']:
                matches[elem1_id] = elem2_id
                used_elem2.add(elem2_id)
                break

    # 類似度ベースのマッチング
    unmatched_elem1 = {k: v for k, v in elements1.items() if k not in matches}
    unmatched_elem2 = {k: v for k, v in elements2.items() if k not in used_elem2}

    for elem1_id, elem1 in unmatched_elem1.items():
        best_match = None
        best_similarity = 0

        for elem2_id, elem2 in unmatched_elem2.items():
            if elem2_id in used_elem2:
                continue

            similarity = calculate_similarity(elem1, elem2)
            if similarity > best_similarity and similarity >= SIMILARITY_THRESHOLD:
                best_similarity = similarity
                best_match = elem2_id

        if best_match:
            matches[elem1_id] = best_match
            used_elem2.add(best_match)

    return matches

def detect_changes(elements1, elements2):
    """2つの要素辞書間の変更を検出する"""
    changes = []

    # 最適マッチングを見つける
    matches = find_best_matches(elements1, elements2)

    # マッチした要素の変更検出
    for elem1_id, elem2_id in matches.items():
        elem1 = elements1[elem1_id]
        elem2 = elements2[elem2_id]

        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 elem1['slide_number'] != elem2['slide_number']:
                    non_text_details.append(f"スライド移動: {elem1['slide_number']} → {elem2['slide_number']}")

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

            changes.append({
                'element_id': elem1_id,
                'matched_id': elem2_id,
                'slide_number': elem1['slide_number'],
                'element_type': elem1['element_type'],
                'change_type': 'modified',
                '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
            })

    # 削除された要素
    for elem1_id, elem1 in elements1.items():
        if elem1_id not in matches:
            changes.append({
                'element_id': elem1_id,
                'matched_id': None,
                'slide_number': elem1['slide_number'],
                'element_type': elem1['element_type'],
                'change_type': 'deleted',
                'text_changed': True,
                'text_old': elem1['text_content'],
                'text_new': "",
                'non_text_changed': True,
                'non_text_details': ["要素削除"]
            })

    # 追加された要素
    matched_elem2_ids = set(matches.values())
    for elem2_id, elem2 in elements2.items():
        if elem2_id not in matched_elem2_ids:
            changes.append({
                'element_id': elem2_id,
                'matched_id': None,
                'slide_number': elem2['slide_number'],
                'element_type': elem2['element_type'],
                'change_type': 'added',
                'text_changed': True,
                'text_old': "",
                'text_new': elem2['text_content'],
                'non_text_changed': True,
                'non_text_details': ["要素追加"]
            })

    return changes

def generate_report(changes):
    """変更検出結果のレポートを生成する"""
    if not changes:
        return "変更は検出されませんでした。"

    report = []
    report.append("PowerPoint変更検出結果")
    report.append("=" * 30)
    report.append("")

    # 変更種別ごとの統計
    modified_count = sum(1 for c in changes if c['change_type'] == 'modified')
    added_count = sum(1 for c in changes if c['change_type'] == 'added')
    deleted_count = sum(1 for c in changes if c['change_type'] == 'deleted')

    report.append(f"変更統計: 修正{modified_count}件, 追加{added_count}件, 削除{deleted_count}件")
    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]:
            change_type_label = {'modified': '修正', 'added': '追加', 'deleted': '削除'}[change['change_type']]
            report.append(f"  {change['element_id']} ({change['element_type']}) - {change_type_label}")

            # テキスト変更
            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("")

    return "\n".join(report)

def save_report(report_text, filename):
    """レポートをファイルに保存する"""
    try:
        with open(filename, 'w', encoding='utf-8') as f:
            f.write(report_text)
        return True
    except (IOError, OSError) as e:
        print(f"レポート保存エラー: {e}")
        return False

# メイン処理
def main():
    print("PowerPoint変更検証プログラム")
    print("概要: 2つのPowerPointファイル間の全変更を検出・報告")
    print("操作方法: ファイル選択ダイアログで比較対象ファイルを選択")
    print("注意事項: python-pptxライブラリが必要です")
    print("=" * 40)

    # ファイル選択
    file1_path, file2_path = select_files()

    if not file1_path or not file2_path:
        print("プログラムを終了します。")
        sys.exit(0)

    print(f"比較対象ファイル1(旧版): {os.path.basename(file1_path)}")
    print(f"比較対象ファイル2(新版): {os.path.basename(file2_path)}")
    print()

    # PowerPointファイル読み込み
    try:
        print("ファイルを読み込み中...")
        presentation1 = Presentation(file1_path)
        presentation2 = Presentation(file2_path)
    except Exception as e:
        print(f"ファイル読み込みエラー: {e}")
        sys.exit(1)

    # スライド数の確認
    slide_count1 = len(presentation1.slides)
    slide_count2 = len(presentation2.slides)

    if slide_count1 != slide_count2:
        print(f"警告: スライド数が異なります({slide_count1}枚 vs {slide_count2}枚)")
        if slide_count1 > slide_count2:
            print(f"スライド{slide_count2 + 1}以降は削除として扱われます")
        else:
            print(f"スライド{slide_count1 + 1}以降は追加として扱われます")
        print()

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

    # 要素抽出
    elements1 = extract_elements_from_presentation(presentation1)
    elements2 = extract_elements_from_presentation(presentation2)

    # 変更検出
    changes = detect_changes(elements1, elements2)

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

    # レポート生成
    report_text = generate_report(changes)
    print(report_text)

    # レポートファイル保存
    report_filename = "powerpoint_change_report.txt"
    if save_report(report_text, report_filename):
        print(f"\nレポートを保存しました: {report_filename}")

    print("処理が完了しました。")

if __name__ == "__main__":
    main()

使用方法

  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ファイル)での類似の差分検出プログラムを作成し、各形式における検出精度と処理効率を比較実験できる。汎用的な差分検出技術の特性を理解できる。

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

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