PowerPoint差分検出

概要

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

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

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

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

目次

関連する外部ページ

サイト内の関連情報

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

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

2. Python 3.12 のインストール

Pythonのインストールを行い、Pythonのプログラムを実行する環境を整える。扱う環境は、Windows搭載パソコンである。金子研究室では、Python 3.12.10を推奨する。

[Windows での Python 3.12 のインストール手順を見るには、ここをクリック]

Windows での Python 3.12 のインストール

以下のいずれかの方法でPython 3.12をインストールする。Pythonがインストール済みの場合、この手順は不要である。

方法 1:winget によるインストール

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windowsキーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして、コマンド全体をコマンドプロンプトにコピー&ペーストする。

--scope machine を指定することで、システム全体(全ユーザー向け)にインストールされる。このオプションの実行には管理者権限が必要である。インストール完了後、コマンドプロンプトを再起動するとPATHが反映される。

REM Python 3.12 をシステム領域にインストール
winget install --id Python.Python.3.12 -e --scope machine --silent --accept-source-agreements --accept-package-agreements --override "/quiet InstallAllUsers=1 PrependPath=1 Include_test=0 Include_pip=1 Include_launcher=1 InstallLauncherAllUsers=1 TargetDir=\"C:\Program Files\Python312\""
if not "%ERRORLEVEL%"=="0" ( color 0c & echo Python 3.12 のインストールに失敗しました & ping 127.0.0.1 -n 6 >nul & color )

REM Python と Scripts を PATH 先頭に追加
powershell -NoProfile -Command "$p='C:\Program Files\Python312'; $s=\"$p\Scripts\"; if(Test-Path $p){$k=[Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\Session Manager\Environment',$true); $c=$k.GetValue('Path','',[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames); $t=$k.GetValueKind('Path'); $new=$c; if((';'+$new+';') -notlike \"*;$p;*\"){$new=$p+';'+$new}; if((';'+$new+';') -notlike \"*;$s;*\"){$new=$s+';'+$new}; if($new -ne $c){$k.SetValue('Path',$new,$t)}; $k.Close()}"

REM 現在のセッションにも反映(システムPATHを再取得して連結)
for /f "usebackq tokens=2,*" %A in (`reg query "HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment" /v Path`) do set "PATH=%B"

REM pip / wheel の更新
python -m pip install --no-user -U pip wheel
if not "%ERRORLEVEL%"=="0" ( color 0c & echo pip / wheel の更新に失敗しました & ping 127.0.0.1 -n 6 >nul & color )

方法 2:インストーラーによるインストール

  1. Python公式サイト(https://www.python.org/downloads/)にアクセスし、「Download Python 3.x.x」ボタンからWindows用インストーラーをダウンロードする。
  2. ダウンロードしたインストーラーを実行する。
  3. 初期画面の下部に表示される「Add python.exe to PATH」にチェックを入れてから「Customize installation」を選択する。このチェックを入れ忘れると、コマンドプロンプトから python コマンドを実行できない。
  4. 「Install Python 3.xx for all users」にチェックを入れ、「Install」をクリックする。

インストールの確認

コマンドプロンプトで以下を実行する。

python --version

バージョン番号(例:Python 3.12.x)が表示されればインストール成功である。「'python' は、内部コマンドまたは外部コマンドとして認識されていません。」と表示される場合は、インストールが正常に完了していない。

3. Python の開発環境 Visual Studio Code のインストールと Python 用の設定

Python の開発環境Visual Studio Code(プログラムを編集するソフトウェア。以下、VS Code)を整える。

[Windows での Visual Studio Code のインストールと Python 用の設定手順を見るには、ここをクリック]

Windows での Visual Studio Code のインストールと Python 用の設定手順

1. VS Code と拡張機能のインストール

以下のコマンドにより,既存の VS Code を削除し,全ユーザー共有の設定で再インストールしたうえで,拡張機能(VS Code に機能を追加するソフトウェア)をまとめて導入する.

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windows キーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして,コマンド全体をコマンドプロンプトにコピー&ペーストする。

インストールコマンド


REM ============================================================
REM Microsoft Visual Studio Code
REM ============================================================
REM Build Tools + Desktop development with C++(VCTools)+ 追加コンポーネント(一括)
REM 未インストール時: winget で新規インストール
REM インストール済み時: setup.exe modify でコンポーネント追加(バージョンは変更しない)
winget list --id Microsoft.VisualStudio.BuildTools 2>nul | findstr /i "BuildTools" >nul 2>&1
if %ERRORLEVEL% EQU 0 (
    for /f "usebackq delims=" %P in (`"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -products Microsoft.VisualStudio.Product.BuildTools -property installationPath`) do start /wait "" "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" modify --installPath "%P" --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Workload.MSBuildTools --add Microsoft.VisualStudio.Component.VC.CMake.Project --add Microsoft.VisualStudio.Component.VC.Llvm.Clang --add Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --add Microsoft.VisualStudio.Component.Windows11SDK.26100 --add Microsoft.VisualStudio.Component.VC.v143.x86.x64 --includeRecommended --quiet --norestart --nocache
    if not "%ERRORLEVEL%"=="0" ( color 0c & echo Build Tools のコンポーネント追加に失敗しました & ping 127.0.0.1 -n 6 >nul & color )
) else (
    winget install --scope machine --id Microsoft.VisualStudio.BuildTools -e --silent --disable-interactivity --force --accept-source-agreements --accept-package-agreements --override "--quiet --wait --norestart --nocache --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended --add Microsoft.VisualStudio.Workload.MSBuildTools --add Microsoft.VisualStudio.Component.VC.CMake.Project --add Microsoft.VisualStudio.Component.VC.Llvm.Clang --add Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --add Microsoft.VisualStudio.Component.Windows11SDK.26100 --add Microsoft.VisualStudio.Component.VC.v143.x86.x64"
    if not "%ERRORLEVEL%"=="0" ( color 0c & echo Build Tools のインストールに失敗しました & ping 127.0.0.1 -n 6 >nul & color )
)

REM 全ユーザー共有の拡張機能フォルダ
if not exist "C:\ProgramData\vscode-extensions" mkdir "C:\ProgramData\vscode-extensions"
icacls "C:\ProgramData\vscode-extensions" /grant "Everyone:(OI)(CI)M" /T

REM スタートメニューのショートカットを --extensions-dir 付きで再作成
if exist "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code" rmdir /s /q "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code"
if exist "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk" del "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk"
powershell -NoProfile -Command "$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk'); $lnk.TargetPath='C:\Program Files\Microsoft VS Code\Code.exe'; $lnk.Arguments='--extensions-dir \"C:\ProgramData\vscode-extensions\"'; $lnk.Save()"
REM ショートカットの検証
powershell -NoProfile -Command "$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk'); Write-Host 'TargetPath:' $lnk.TargetPath; Write-Host 'Arguments:' $lnk.Arguments"

REM ファイル / フォルダ右クリックの「Code で開く」を登録
reg add "HKLM\SOFTWARE\Classes\*\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%1\"" /f
reg add "HKLM\SOFTWARE\Classes\Directory\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%1\"" /f
reg add "HKLM\SOFTWARE\Classes\Directory\Background\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%V\"" /f

REM --extensions-dir 付きで起動する code.cmd ラッパを作成
REM (%* を echo で書くと対話的 cmd で失われるため、PowerShell で [char]37+'*' を書き出す)
powershell -NoProfile -Command "$pct=[char]37; $q=[char]34; $c='@echo off'+[char]13+[char]10+$q+'C:\Program Files\Microsoft VS Code\bin\code.cmd'+$q+' --extensions-dir '+$q+'C:\ProgramData\vscode-extensions'+$q+' '+$pct+'*'+[char]13+[char]10; [IO.File]::WriteAllText('C:\ProgramData\vscode-extensions\vscode.cmd',$c,[Text.Encoding]::ASCII)"

REM 拡張機能のインストール
set "CODE=C:\Program Files\Microsoft VS Code\bin\code.cmd"
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --uninstall-extension GitHub.copilot
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --uninstall-extension GitHub.copilot-chat
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.python
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.vscode-pylance
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.debugpy
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension MS-CEINTL.vscode-language-pack-ja
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension saoudrizwan.claude-dev
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension rust-lang.rust-analyzer
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension tamasfe.even-better-toml
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension anthropic.claude-code
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension almenon.arepl
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --list-extensions --show-versions

REM settings.json を作成(自動更新オフ、Python、Claude Code 設定)
if not exist "%APPDATA%\Code\User" mkdir "%APPDATA%\Code\User"
python -c "import json,os;data={'update.mode':'none','update.enableWindowsBackgroundUpdates':False,'extensions.autoUpdate':False,'python.defaultInterpreterPath':r'C:\Program Files\Python312\python.exe','claudeCode.environmentVariables':[{'name':'ANTHROPIC_API_KEY','value':'not-needed'},{'name':'ANTHROPIC_AUTH_TOKEN','value':'ollama'},{'name':'ANTHROPIC_BASE_URL','value':'http://localhost:11434'},{'name':'ANTHROPIC_MODEL','value':'glm-4.7-flash'},{'name':'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC','value':'1'}]};p=os.path.join(os.environ['APPDATA'],'Code','User','settings.json');open(p,'w',encoding='utf-8').write(json.dumps(data,indent=4));print('Done:',p)"

REM 自動更新の抑止ポリシー(settings.json に加えて、レジストリ側でも明示的にオフ)
reg add "HKLM\SOFTWARE\Policies\Microsoft\VSCode" /v "UpdateMode" /t REG_SZ /d "none" /f
echo === セットアップ完了 ===

2. Python インタプリタの選択

同一マシンに複数の Python がインストールされている場合,VS Code で使用する Python 本体(インタプリタ:Python プログラムを解釈・実行するソフトウェア)を選択する必要がある.

  1. コマンドパレット(コマンド名で機能を呼び出す VS Code の入力欄)を開く(Ctrl+Shift+P
  2. Python: Select Interpreter と入力する
  3. 表示される一覧から,使用する Python(例:C:\Program Files\Python312\python.exe)を選択する.

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

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

続けて、次のコマンドを実行する(--no-user は、ユーザ領域ではなくシステム領域へインストールするためのオプションである)。

pip install -U --no-user python-pptx

5. プログラムの詳細

概要

主要機能

使用上の注意

6. プログラムコード

ソースコード

以下のコードをpowerpoint_diff_detector.pyとして保存する。

# PowerPoint変更検証プログラム
#   2つのPowerPointファイル間の全変更を検出・報告
#   GitHub: https://github.com/scanny/python-pptx
#   特徴: python-pptxライブラリによるPowerPoint操作、BLAKE2bハッシュとdifflibによる差分検出
#         テキスト変更と非テキスト変更の分離、位置・サイズ・書式変更の詳細検出機能
#   前準備: pip install -U --no-user python-pptx

from pptx import Presentation
from pptx.enum.dml import MSO_COLOR_TYPE
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

# 設定パラメータ
POSITION_THRESHOLD = 2      # 位置変更の検出閾値(pt)
SIZE_THRESHOLD = 2          # サイズ変更の検出閾値(pt)
TEXT_PREVIEW_LENGTH = 50
SIMILARITY_THRESHOLD = 0.8
EMU_PER_POINT = 12700       # Office Open XMLの長さ単位。1pt = 12700 EMU
EMU_PER_INCH = 914400       # 1インチ = 914400 EMU

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文字列にしてからハッシュ化する
    json_str = json.dumps(data, sort_keys=True, ensure_ascii=False)
    return hashlib.blake2b(json_str.encode('utf-8'), digest_size=16).hexdigest()

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

    # テキスト内容
    text_content = ""
    if shape.has_text_frame:
        text_content = normalize_text(shape.text)
    elif shape.has_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 or 0) / EMU_PER_INCH, 3),
        'top_ratio': round((shape.top or 0) / EMU_PER_INCH, 3)
    }
    signature_data['relative_size'] = {
        'width_ratio': round((shape.width or 0) / EMU_PER_INCH, 3),
        'height_ratio': round((shape.height or 0) / EMU_PER_INCH, 3)
    }

    # 特殊要素の特徴
    if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
        signature_data['image_hash'] = hashlib.blake2b(shape.image.blob, digest_size=16).hexdigest()

    return create_stable_hash(signature_data)

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

    for slide_number, slide in enumerate(presentation.slides, 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 shape.has_text_frame:
                text_content = normalize_text(shape.text)
            elif shape.has_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 or 0, shape.top or 0),
                'size': (shape.width or 0, shape.height or 0)
            }

            # 要素固有の情報
            if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
                non_text_data['image_hash'] = hashlib.blake2b(shape.image.blob, digest_size=16).hexdigest()
            elif shape.has_chart:
                chart = shape.chart
                non_text_data['chart_type'] = str(chart.chart_type)
                non_text_data['chart_xml_hash'] = hashlib.blake2b(chart.part.blob, digest_size=16).hexdigest()

            # 書式情報
            if shape.has_text_frame and shape.text_frame.paragraphs:
                para = shape.text_frame.paragraphs[0]
                if para.runs:
                    font = para.runs[0].font
                    font_size = font.size.pt if font.size is not None else None
                    font_color = str(font.color.rgb) if font.color.type == MSO_COLOR_TYPE.RGB else 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.blake2b(text_content.encode('utf-8'), digest_size=16).hexdigest(),
                'non_text_hash': non_text_hash,
                'position': (shape.left or 0, shape.top or 0),
                'size': (shape.width or 0, shape.height or 0),
                'signature': shape_signature
            }

    return elements

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

    # テキスト類似度
    if elem1['text_content'] or elem2['text_content']:
        if elem1['text_content'] == elem2['text_content']:
            text_similarity = 1.0
        else:
            # 編集距離ベースの類似度
            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 / EMU_PER_INCH)  # 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 / EMU_PER_INCH)

    # 総合類似度(重み付き平均)
    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:
                # 位置変更(EMUをptに換算して判定)
                pos_diff = ((elem2['position'][0] - elem1['position'][0]) / EMU_PER_POINT,
                            (elem2['position'][1] - elem1['position'][1]) / EMU_PER_POINT)
                if abs(pos_diff[0]) >= POSITION_THRESHOLD or abs(pos_diff[1]) >= POSITION_THRESHOLD:
                    non_text_details.append(f"位置変更: X{pos_diff[0]:+.1f}pt, Y{pos_diff[1]:+.1f}pt")

                # サイズ変更(EMUをptに換算して判定)
                size_diff = ((elem2['size'][0] - elem1['size'][0]) / EMU_PER_POINT,
                             (elem2['size'][1] - elem1['size'][1]) / EMU_PER_POINT)
                if abs(size_diff[0]) >= SIZE_THRESHOLD or abs(size_diff[1]) >= SIZE_THRESHOLD:
                    non_text_details.append(f"サイズ変更: W{size_diff[0]:+.1f}pt, H{size_diff[1]:+.1f}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):
    """レポートをファイルに保存する"""
    with open(filename, 'w', encoding='utf-8') as f:
        f.write(report_text)

# メイン処理
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ファイル読み込み
    print("ファイルを読み込み中...")
    presentation1 = Presentation(file1_path)
    presentation2 = Presentation(file2_path)

    # スライド数の確認
    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"
    save_report(report_text, report_filename)
    print(f"\nレポートを保存しました: {report_filename}")

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

if __name__ == "__main__":
    main()

7. 使用方法

  1. 比較対象のPowerPointファイル(.pptx)を2つ用意する。
  2. コマンドプロンプトで、保存したプログラムを実行する。
python powerpoint_diff_detector.py
  1. ファイル選択ダイアログが2回表示される。1回目で比較元(旧版)、2回目で比較先(新版)を選ぶ。
  2. 実行結果として以下が出力される。
    • 標準出力での変更検出結果
    • powerpoint_change_report.txtファイルでの詳細レポート(プログラムを実行したフォルダに作成される)

8. 実験・探求のアイデア

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

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

検出閾値の最適化実験

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

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

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

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

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

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

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