現新比較テストのためのPDFコンペアツールをつくった


# はじめに

- 背景
    - 基盤更改などの案件では、現新比較テスト（差分がないこと）の確認が求められる
    - 特に一部の帳票は、「差分がないこと」の厳密性が要件に含まれることが多い
        - レイアウト位置、フォントの指定、文字サイズなど

- 課題
    - 厳密な比較は、👀で確認は厳しい
        - 確認結果の妥当性をステークスホルダに示すことも難しい
    - PDFコンペアツールがお手頃なものがない
        - 多数のPDFを一気に確認できたり、エビデンスを残せる機能がほしい

# やったこと
PythonのGUIアプリを自作しました。


# ツールの操作方法

### 1.ディレクトリ選択

２つのディレクトリを選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348593/674dc000-b88d-9ee3-27ed-08152db348fb.png)

### 2.比較の実行

「比較開始」ボタンを押します。

すると、ディレクトリ内のすべてのPDFファイルを画像に変換され、ページごとの比較結果、差分の有無、差分の%が表示されます。スクロールで、全ページが確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348593/e4bf210c-9368-6a50-55d6-d32ca799f699.png)

### 3.比較レポートの出力

「結果の保存」ボタンを押します。
すると、比較結果レポートをHTMLファイルとして保存され、エビデンスとして残すことができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348593/32684800-11cf-00bb-582c-b9b21d6becb1.png)

**HTMLファイル**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348593/5a165552-6562-307d-29cf-6ef0fb1c8d89.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348593/42bd1842-d5fc-bb8a-8799-79dd4ebd64ab.png)


### Ex.(オプション)パラメータ設定

「パラメータ設定」ボタンを押すと、ポップアップで設定画面が開きます。
比較の厳密さなどを変更できるようにしており、各種数値を調整できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348593/f7d93e68-ab0d-e06a-df62-6bcf69ebb1d9.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348593/63514e75-0faf-eee2-feee-c77896753bda.png)

３種類のプリセットからの選択も可能。設定のインポート、エクスポートも可能。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348593/a76b2cde-80c7-b896-f800-6aa76c8d235b.png)

ヘルプから、各種パラメータの説明を確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348593/42e1ae4e-806c-739a-0e5d-654d88cfce0e.png)

# 処理内容

処理は大きく２つあります。

**1.PDFを画像に変換して比較**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348593/5519f419-ff3d-57a2-d5ce-0d0c4b40263e.png)

**2.HTMLレポートの保存**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348593/08ed4c00-e032-6b35-7cc0-062848d61eca.png)

順に処理を解説します。

## 1.PDFを画像に変換して比較

行っていることは非常にシンプルで、大きく２つです。
- PyMuPDFを使ったPDFファイルの画像変換
- OpenCVを使った２画像の差分検知

```python
# pdf_processor.py

import fitz  # PyMuPDFライブラリをインポート
import os
import numpy as np
import cv2  # OpenCVライブラリをインポート
from pathlib import Path  # パス操作のためのライブラリをインポート

class PDFProcessor:
    def __init__(self, temp_dir):
        self.temp_dir = temp_dir  # 一時ディレクトリのパスを設定

    def get_pdf_page_count(self, pdf_path):
        """PDFのページ数を取得"""
        doc = fitz.open(pdf_path)  # PDFを開く
        page_count = len(doc)  # ページ数を取得
        doc.close()  # PDFを閉じる
        return page_count  # ページ数を返す

    def convert_pdf_to_images(self, pdf_path):
        """PDFの全ページを画像に変換"""
        try:
            doc = fitz.open(pdf_path)  # PDFを開く
            image_paths = []  # 画像パスのリストを初期化
            
            for page_num in range(len(doc)):  # 各ページを処理
                page = doc[page_num]  # ページを取得
                pix = page.get_pixmap()  # ページを画像に変換
                img_path = os.path.join(
                    self.temp_dir, 
                    f"temp_{os.path.basename(pdf_path)}_{page_num}.png"
                )  # 画像の保存パスを設定
                pix.save(img_path)  # 画像を保存
                image_paths.append(img_path)  # 画像パスをリストに追加
            
            doc.close()  # PDFを閉じる
            return image_paths  # 画像パスのリストを返す
        except Exception as e:
            raise Exception(f"PDF変換エラー: {str(e)}")  # エラーをキャッチして例外を投げる

    def compare_images(self, image_path1, image_path2, settings):
        """2つの画像を比較して差分を検出（高度な設定に対応）"""
        try:
            # 画像を読み込み
            img1 = cv2.imread(image_path1)
            img2 = cv2.imread(image_path2)

            # 同じサイズにリサイズ
            height = min(img1.shape[0], img2.shape[0])
            width = min(img1.shape[1], img2.shape[1])
            img1 = cv2.resize(img1, (width, height))
            img2 = cv2.resize(img2, (width, height))

            # ノイズ軽減処理
            if settings["noise_reduction"] > 0:
                kernel_size = 2 * settings["noise_reduction"] + 1
                img1 = cv2.medianBlur(img1, kernel_size)
                img2 = cv2.medianBlur(img2, kernel_size)

            # ぼかし処理
            if settings["blur_radius"] > 0:
                kernel_size = 2 * settings["blur_radius"] + 1
                img1 = cv2.GaussianBlur(img1, (kernel_size, kernel_size), 0)
                img2 = cv2.GaussianBlur(img2, (kernel_size, kernel_size), 0)

            # エッジ検出
            if settings["edge_detection"]:
                img1 = cv2.Canny(img1, 100, 200)
                img2 = cv2.Canny(img2, 100, 200)
        
            # 色差感度に基づく処理
            if settings["color_sensitivity"] == "high":
                color_space = cv2.COLOR_BGR2Lab
            elif settings["color_sensitivity"] == "low":
                color_space = cv2.COLOR_BGR2HSV
            else:  # medium
                color_space = cv2.COLOR_BGR2RGB

            if not settings["edge_detection"]:
                img1 = cv2.cvtColor(img1, color_space)
                img2 = cv2.cvtColor(img2, color_space)

            # 差分計算
            if settings["edge_detection"]:
                diff = cv2.absdiff(img1, img2)
            else:
                diff = cv2.absdiff(img1, img2)
                diff = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY)

            # 閾値処理
            _, thresh = cv2.threshold(
                diff, 
                settings["pixel_threshold"], 
                255, 
                cv2.THRESH_BINARY
            )

            # 差分領域の強調表示
            diff_img = cv2.imread(image_path1)  # 元画像を再度読み込み
            diff_img = cv2.resize(diff_img, (width, height))
            diff_img[thresh > 0] = [0, 0, 255]  # 差分箇所を赤色で表示

            # 差分の程度を計算
            diff_percentage = (np.count_nonzero(thresh) / thresh.size) * 100

            # 結果を保存
            diff_path = os.path.join(
                self.temp_dir,
                f"diff_{os.path.basename(image_path1)}"
            )
            cv2.imwrite(diff_path, diff_img)

            return diff_path, diff_percentage  # 差分画像のパスと差分率を返す
        except Exception as e:
            raise Exception(f"画像比較エラー: {str(e)}")  # エラーをキャッチして例外を投げる

    def get_pdf_files(self, directory):
        """ディレクトリ内のPDFファイルを取得"""
        return sorted([f for f in Path(directory).glob("*.pdf")])  # PDFファイルを取得してソートして返す

    def cleanup_temp_files(self):
        """一時ファイルの削除"""
        for file in os.listdir(self.temp_dir):  # 一時ディレクトリ内のファイルを取得
            os.remove(os.path.join(self.temp_dir, file))  # ファイルを削除
```

## 2.HTMLレポートの保存

HTMLレポートのテンプレート定義をあらかじめ定義しておき、そのテンプレートにコンテンツを格納してHTMLを作成しています。


```python
# report_generator.py

import os
import logging
from datetime import datetime
import base64
from PIL import Image
import traceback
from string import Template
from report_template import ReportTemplate

class ReportGenerator:
    def __init__(self):
        self.template = ReportTemplate()  # レポートテンプレートのインスタンスを作成
        self.setup_logging()  # ロギングの設定を行う
        self.logger = logging.getLogger('ReportGenerator')  # ロガーを取得

    def generate_report(self, comparison_results, settings, output_dir):
        """比較結果をHTMLレポートとして生成"""
        try:
            self.settings = settings  # 設定を保存
            self.logger.info("レポート生成開始")
            self.logger.debug(f"出力ディレクトリ: {output_dir}")
            self.logger.debug(f"設定内容: {settings}")

            # タイムスタンプを生成してレポートディレクトリを作成
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            report_dir = os.path.join(output_dir, f"comparison_report_{timestamp}")
            os.makedirs(report_dir, exist_ok=True)
            
            # 画像ディレクトリを作成
            images_dir = os.path.join(report_dir, "images")
            os.makedirs(images_dir, exist_ok=True)
            
            self.logger.info("コンテンツ生成開始")
            content_parts = []  # コンテンツ部分を格納するリスト
            
            # ヘッダー情報
            self.logger.debug("ヘッダー生成")
            current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            content_parts.append(self.template.get_header(current_time, settings))
            
            # サマリー情報
            self.logger.debug("サマリー生成")
            total_pages = len(comparison_results)  # 総ページ数を取得
            pages_with_diff = sum(1 for r in comparison_results if r['diff_percentage'] > settings['diff_threshold'])  # 差分のあるページ数を計算
            avg_diff = sum(r['diff_percentage'] for r in comparison_results) / total_pages if total_pages > 0 else 0  # 平均差分率を計算
            content_parts.append(self.template.get_summary(total_pages, pages_with_diff, avg_diff))
            
            # 比較結果
            self.logger.info(f"比較結果の生成開始 (全{len(comparison_results)}件)")
            current_files = None  # 現在のファイルペアを初期化
            
            for i, result in enumerate(comparison_results, 1):
                try:
                    self.logger.debug(f"結果 {i}/{len(comparison_results)} の処理")
                    if current_files != (result['file1'], result['file2']):
                        if current_files is not None:
                            content_parts.append(self.template.get_file_comparison_end())  # ファイル比較終了テンプレートを追加
                        current_files = (result['file1'], result['file2'])
                        content_parts.append(self.template.get_file_comparison_start(result['file1'], result['file2']))  # ファイル比較開始テンプレートを追加
                    
                    # 画像の処理
                    images = self._process_images(i-1, result, images_dir)
                    
                    # 差分状態の決定
                    diff_class = "diff_high" if result['diff_percentage'] > settings['diff_threshold'] else "diff_low"
                    diff_status = "差分あり" if result['diff_percentage'] > settings['diff_threshold'] else "差分なし"
                    
                    comparison_item = self.template.get_comparison_item(
                        index=i-1,
                        page=result['page'],
                        images=images,
                        diff_percentage=result['diff_percentage'],
                        diff_class=diff_class,
                        diff_status=diff_status
                    )
                    content_parts.append(comparison_item)  # 比較アイテムテンプレートを追加
                    
                except Exception as e:
                    self.logger.error(f"比較結果 {i} の生成中にエラー: {str(e)}")
                    self.logger.debug(f"処理中の結果データ: {result}")
                    raise
            
            if content_parts:
                content_parts.append(self.template.get_file_comparison_end())  # 最後のファイル比較終了テンプレートを追加
            
            # レポート生成
            self.logger.info("HTMLファイルの生成開始")
            try:
                template = Template(self.template.get_html_template())
                html_content = template.safe_substitute(content='\n'.join(content_parts))  # HTMLコンテンツを生成
                
                # デバッグ用にHTMLの一部を出力
                self.logger.debug("生成されたHTMLの最初の1000文字:")
                self.logger.debug(html_content[:1000])
                
                report_path = os.path.join(report_dir, "report.html")
                with open(report_path, "w", encoding="utf-8") as f:
                    f.write(html_content)  # HTMLファイルを書き込み
                
                self.logger.info(f"レポート生成完了: {report_path}")
                return report_path  # レポートのパスを返す
                
            except Exception as e:
                self.logger.error("HTML生成中にエラー発生")
                self.logger.error(f"エラー詳細: {str(e)}")
                self.logger.debug(f"スタックトレース: {traceback.format_exc()}")
                raise
            
        except Exception as e:
            self.logger.error(f"レポート生成中に重大なエラーが発生: {str(e)}")
            self.logger.debug(f"スタックトレース: {traceback.format_exc()}")
            raise

    def _process_images(self, index, result, images_dir):
        """画像の処理とbase64エンコード"""
        images = {}
        for key in ['img1', 'img2', 'diff']:
            try:
                img_name = f"comparison_{index}_{key}.png"
                img_path = os.path.join(images_dir, img_name)
                self.logger.debug(f"画像処理: {img_path}")
                
                img = Image.open(result[key])  # 画像を開く
                img.save(img_path)  # 画像を保存
                with open(img_path, 'rb') as img_file:
                    img_data = base64.b64encode(img_file.read()).decode()  # 画像をbase64エンコード
                images[key] = f"data:image/png;base64,{img_data}"  # エンコードしたデータを辞書に追加
            except Exception as e:
                self.logger.error(f"画像処理エラー ({key}): {str(e)}")
                raise
                
        return images  # 画像データの辞書を返す
```

```python
# report_template.py

import os
import logging
from string import Template

class ReportTemplate:
    def __init__(self):
        self.logger = logging.getLogger(__name__)  # ロガーを取得
        self.template_dir = self._get_template_dir()  # テンプレートディレクトリのパスを取得

    def _get_template_dir(self):
        """テンプレートディレクトリのパスを取得"""
        return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'templates')

    def _load_resource(self, filename):
        """リソースファイルを読み込む"""
        try:
            file_path = os.path.join(self.template_dir, filename)  # ファイルパスを生成
            self.logger.debug(f"リソース読み込み: {file_path}")
            with open(file_path, 'r', encoding='utf-8') as f:
                return f.read()  # ファイル内容を読み込んで返す
        except Exception as e:
            self.logger.error(f"リソースファイルの読み込みエラー ({filename}): {str(e)}")
            raise

    def get_html_template(self):
        """メインのHTMLテンプレートを取得"""
        try:
            template_content = self._load_resource('report_template.html')  # メインテンプレートを読み込む
            css_content = self._load_resource('../styles/report_styles.css')  # CSSを読み込む
            js_content = self._load_resource('../scripts/report_scripts.js')  # JavaScriptを読み込む
            
            template = Template(template_content)  # テンプレートを作成
            return template.safe_substitute(
                css=css_content,
                javascript=js_content,
                content='${content}'  # 後で置換するためのプレースホルダー
            )
        except Exception as e:
            self.logger.error(f"HTMLテンプレート生成エラー: {str(e)}")
            raise

    def get_header(self, timestamp, settings):
        """ヘッダー部分を生成"""
        try:
            template_content = self._load_resource('components/header.html')  # ヘッダーテンプレートを読み込む
            template = Template(template_content)  # テンプレートを作成
            
            noise_levels = ['なし', '弱', '中', '強']  # ノイズレベルのマッピング
            sensitivity_map = {'low': '低', 'medium': '中', 'high': '高'}  # 色感度のマッピング
            
            return template.safe_substitute(
                timestamp=timestamp,
                diff_threshold=settings["diff_threshold"],
                pixel_threshold=settings["pixel_threshold"],
                noise_reduction=noise_levels[settings["noise_reduction"]],
                blur_radius=settings["blur_radius"],
                edge_detection='有効' if settings["edge_detection"] else '無効',
                color_sensitivity=sensitivity_map.get(settings["color_sensitivity"], '中')
            )
        except Exception as e:
            self.logger.error(f"ヘッダー生成エラー: {str(e)}")
            raise

    def get_summary(self, total_pages, pages_with_diff, avg_diff):
        """サマリー部分を生成"""
        try:
            template_content = self._load_resource('components/summary.html')  # サマリーテンプレートを読み込む
            template = Template(template_content)  # テンプレートを作成
            return template.safe_substitute(
                total_pages=total_pages,
                pages_with_diff=pages_with_diff,
                avg_diff=f"{avg_diff:.2f}"
            )
        except Exception as e:
            self.logger.error(f"サマリー生成エラー: {str(e)}")
            raise

    def get_file_comparison_start(self, file1, file2):
        """ファイル比較セクションの開始部分を生成"""
        try:
            template_content = self._load_resource('components/file_comparison.html')  # ファイル比較テンプレートを読み込む
            template = Template(template_content)  # テンプレートを作成
            return template.safe_substitute(
                file1=file1,
                file2=file2,
                content='${content}'
            )
        except Exception as e:
            self.logger.error(f"ファイル比較開始部分生成エラー: {str(e)}")
            raise

    def get_file_comparison_end(self):
        """ファイル比較セクションの終了部分を生成"""
        return "</div>"

    def get_comparison_item(self, index, page, images, diff_percentage, diff_class, diff_status):
        """比較アイテムを生成"""
        try:
            template_content = self._load_resource('components/comparison_item.html')  # 比較アイテムテンプレートを読み込む
            template = Template(template_content)  # テンプレートを作成
            return template.safe_substitute(
                index=index,
                page=page,
                img1=images['img1'],
                img2=images['img2'],
                diff=images['diff'],
                diff_percentage=f"{diff_percentage:.2f}",
                diff_class=diff_class,
                diff_status=diff_status
            )
        except Exception as e:
            self.logger.error(f"比較アイテム生成エラー: {str(e)}")
            raise
```

**HTMLテンプレート**

HTMLの各部、CSS,javascriptをそれぞれテンプレートとしてあらかじめ定義して、それらを組み合わせてレポートを生成しています。

全体のディレクトリは以下のようになります。
``` markdown
- main.py: メインのPythonスクリプト。GUIの設定や操作が含まれています。
- pdf_processor.py: PDF処理
- settings_dialog.py: 設定ダイアログ
- report_generator.py: レポート生成
- report_template.py: レポートのテンプレートに関するスクリプト
- scripts:
    - report_scripts.js: レポートに関するJavaScriptファイル
- styles: 
    - report_styles.css: レポートのスタイルシート
- templates: テンプレートファイルが保存されるディレクトリ
    - components/: 
        - comparison_item.html: 比較アイテムのテンプレート
        - file_comparison.html: ファイル比較のテンプレート
        - header.html: ヘッダーのテンプレート
        - summary.html: サマリーのテンプレート
    - report_template.html: レポートのメインテンプレート

```

## GUI部分

GUIにはtkinterを使用し、シンプルなツール操作を実現しています。

```python
# main.py

import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from PIL import Image, ImageTk
import tempfile
import os
from datetime import datetime
import webbrowser
from pdf_processor import PDFProcessor
from settings_dialog import SettingsDialog
from report_generator import ReportGenerator

class PDFComparisonGUI:
    def __init__(self, root):
        self.root = root
        self.root.title("PDF比較ツール")
        self.root.state('zoomed')
        
        # 一時ディレクトリの作成
        self.temp_dir = tempfile.mkdtemp()
        
        # 各クラスのインスタンス化
        self.pdf_processor = PDFProcessor(self.temp_dir)
        self.report_generator = ReportGenerator()
        
        # 変数の初期化
        self.dir1_path = tk.StringVar()
        self.dir2_path = tk.StringVar()
        self.image_size = (400, 1)  # 高さは自動計算
        self.comparison_results = []
        
        # 設定パラメータの初期化
        self.settings = {
            "diff_threshold": 1.0,
            "pixel_threshold": 30,
            "noise_reduction": 2,
            "blur_radius": 1,
            "edge_detection": False,
            "color_sensitivity": "medium"
        }
        
        self.setup_ui()

    def setup_ui(self):
        # メインフレーム
        main_frame = ttk.Frame(self.root)
        main_frame.pack(fill='both', expand=True, padx=10, pady=5)
        
        # 上部コントロールエリア
        self.setup_control_area(main_frame)
        
        # 中央の画像表示エリア（スクロール可能）
        self.setup_scroll_area(main_frame)
        
        # ステータスバー
        self.status_bar = ttk.Label(self.root, text="準備完了", relief=tk.SUNKEN)
        self.status_bar.pack(fill='x', side='bottom', padx=5, pady=2)

    def setup_control_area(self, parent):
        control_frame = ttk.LabelFrame(parent, text="コントロールパネル", padding=(5, 5, 5, 5))
        control_frame.pack(fill='x', padx=5, pady=5)
        
        # ディレクトリ選択部分
        dir_frame = ttk.Frame(control_frame)
        dir_frame.pack(fill='x')
        
        # 左側のディレクトリ選択
        left_frame = ttk.Frame(dir_frame)
        left_frame.pack(side='left', padx=5)
        ttk.Label(left_frame, text="ディレクトリ1:").pack(side='left')
        ttk.Entry(left_frame, textvariable=self.dir1_path, width=50).pack(side='left', padx=5)
        ttk.Button(left_frame, text="参照", command=lambda: self.select_directory(1)).pack(side='left')
        
        # 右側のディレクトリ選択
        right_frame = ttk.Frame(dir_frame)
        right_frame.pack(side='left', padx=5)
        ttk.Label(right_frame, text="ディレクトリ2:").pack(side='left')
        ttk.Entry(right_frame, textvariable=self.dir2_path, width=50).pack(side='left', padx=5)
        ttk.Button(right_frame, text="参照", command=lambda: self.select_directory(2)).pack(side='left')
        
        # 設定とコントロールのフレーム
        settings_frame = ttk.Frame(control_frame)
        settings_frame.pack(fill='x', pady=5)
        
        # 左側：操作ボタン
        button_frame = ttk.Frame(settings_frame)
        button_frame.pack(side='left', padx=5)
        
        # 各種操作ボタン
        ttk.Button(button_frame, text="比較開始", command=self.start_comparison).pack(side='left', padx=5)
        ttk.Button(button_frame, text="画像サイズ調整", command=self.adjust_image_size).pack(side='left', padx=5)
        ttk.Button(button_frame, text="パラメータ設定", command=self.show_settings_dialog).pack(side='left', padx=5)
        ttk.Button(button_frame, text="結果を保存", command=self.save_results).pack(side='left', padx=5)
        
        # 現在の設定値を表示
        self.settings_label = ttk.Label(button_frame, 
            text=f"差分許容値: {self.settings['diff_threshold']}% / ピクセル閾値: {self.settings['pixel_threshold']}")
        self.settings_label.pack(side='left', padx=20)
        
        # 右側：プログレスバー
        self.progress_var = tk.DoubleVar()
        self.progress_bar = ttk.Progressbar(settings_frame, variable=self.progress_var, maximum=100)
        self.progress_bar.pack(side='right', fill='x', expand=True, padx=5)

    def setup_scroll_area(self, parent):
        # スクロール可能なキャンバスの作成
        self.canvas = tk.Canvas(parent)
        scrollbar = ttk.Scrollbar(parent, orient="vertical", command=self.canvas.yview)
        self.scrollable_frame = ttk.Frame(self.canvas)
        
        self.scrollable_frame.bind(
            "<Configure>",
            lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))
        )
        
        self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw")
        self.canvas.configure(yscrollcommand=scrollbar.set)
        
        # マウスホイールでスクロール
        self.canvas.bind_all("<MouseWheel>", self._on_mousewheel)
        
        # パッキング
        self.canvas.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")

    def _on_mousewheel(self, event):
        self.canvas.yview_scroll(int(-1*(event.delta/120)), "units")

    def select_directory(self, dir_num):
        directory = filedialog.askdirectory()
        if directory:
            if dir_num == 1:
                self.dir1_path.set(directory)
            else:
                self.dir2_path.set(directory)

    def adjust_image_size(self):
        dialog = tk.Toplevel(self.root)
        dialog.title("画像サイズ調整")
        dialog.geometry("300x120")
        dialog.transient(self.root)
        dialog.grab_set()
        
        ttk.Label(dialog, text="基準幅:").pack(pady=5)
        width_entry = ttk.Entry(dialog)
        width_entry.insert(0, str(self.image_size[0]))
        width_entry.pack()
        
        def apply_size():
            try:
                width = int(width_entry.get())
                self.image_size = (width, 1)  # 高さは自動計算
                dialog.destroy()
                if self.comparison_results:
                    self.display_results()
            except ValueError:
                messagebox.showerror("エラー", "有効な数値を入力してください")
        
        ttk.Button(dialog, text="適用", command=apply_size).pack(pady=10)

    def show_settings_dialog(self):
        def on_settings_changed(new_settings):
            self.settings.update(new_settings)
            self.settings_label.config(
                text=f"差分許容値: {self.settings['diff_threshold']}% / "
                     f"ピクセル閾値: {self.settings['pixel_threshold']} / "
                     f"詳細設定: 使用中"
            )
            
            if self.comparison_results:
                if messagebox.askyesno("確認", "設定を変更しました。比較結果を更新しますか？"):
                    self.start_comparison()

        SettingsDialog(
            self.root,
            self.settings,
            on_settings_changed
        )

    
    def save_results(self):
        """比較結果をレポートとして保存"""
        if not self.comparison_results:
            messagebox.showwarning("警告", "保存する比較結果がありません")
            return
        
        # 保存先ディレクトリの選択
        save_dir = filedialog.askdirectory(
            title="レポートの保存先を選択"
        )
        
        if save_dir:
            try:
                # 進捗状態の更新
                self.status_bar.config(text="レポート生成中...")
                self.root.update()
                
                # レポートの生成
                report_path = self.report_generator.generate_report(
                    self.comparison_results,
                    self.settings,
                    save_dir
                )
                
                # 成功メッセージの表示
                message = f"レポートを保存しました:\n{report_path}"
                messagebox.showinfo("成功", message)
                
                # レポートを自動で開く
                if messagebox.askyesno("確認", "保存したレポートを開きますか？"):
                    webbrowser.open(f"file://{report_path}")
                
            except Exception as e:
                # エラーログの確認
                log_file = os.path.join('logs', f'report_generator_{datetime.now().strftime("%Y%m%d")}.log')
                error_message = f"レポートの保存中にエラーが発生しました:\n{str(e)}"
                if os.path.exists('logs'):
                    error_message += f"\n\n詳細なエラー情報は以下のログファイルを確認してください:\n{log_file}"
                
                messagebox.showerror("エラー", error_message)
            finally:
                self.status_bar.config(text="準備完了")
                self.root.update()

    def start_comparison(self):
        try:
            # 既存の結果をクリア
            for widget in self.scrollable_frame.winfo_children():
                widget.destroy()
            
            self.comparison_results = []
            
            # PDFファイルの取得
            pdf_files1 = self.pdf_processor.get_pdf_files(self.dir1_path.get())
            pdf_files2 = self.pdf_processor.get_pdf_files(self.dir2_path.get())
            
            if not pdf_files1 or not pdf_files2:
                messagebox.showwarning("警告", "PDFファイルが見つかりません")
                return
            
            total_files = min(len(pdf_files1), len(pdf_files2))
            
            # 全ファイルのページ数を計算
            total_pages = 0
            for i in range(total_files):
                pages1 = self.pdf_processor.get_pdf_page_count(str(pdf_files1[i]))
                pages2 = self.pdf_processor.get_pdf_page_count(str(pdf_files2[i]))
                total_pages += min(pages1, pages2)
            
            current_progress = 0
            
            for i in range(total_files):
                self.status_bar.config(text=f"ファイル処理中... {i+1}/{total_files}")
                self.root.update()
                
                # 各PDFの全ページを画像に変換
                images1 = self.pdf_processor.convert_pdf_to_images(str(pdf_files1[i]))
                images2 = self.pdf_processor.convert_pdf_to_images(str(pdf_files2[i]))
                
                # 各ページを比較
                pages_to_compare = min(len(images1), len(images2))
                
                for page in range(pages_to_compare):
                    # プログレスバーの更新
                    current_progress += 1
                    self.progress_var.set((current_progress / total_pages) * 100)
                    self.root.update()
                    
                    # 差分検出
                    diff_path, diff_percentage = self.pdf_processor.compare_images(
                        images1[page], 
                        images2[page],
                        self.settings
                    )
                    
                    self.comparison_results.append({
                        'img1': images1[page],
                        'img2': images2[page],
                        'diff': diff_path,
                        'diff_percentage': diff_percentage,
                        'file1': pdf_files1[i].name,
                        'file2': pdf_files2[i].name,
                        'page': page + 1
                    })
            
            self.display_results()
            self.progress_var.set(100)
            self.status_bar.config(text="比較完了")
            
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def display_results(self):
        # 既存の結果をクリア
        for widget in self.scrollable_frame.winfo_children():
            widget.destroy()
        
        current_file = None
        file_frame = None
        
        for i, result in enumerate(self.comparison_results):
            # ファイルが変わったら新しいフレームを作成
            if current_file != (result['file1'], result['file2']):
                current_file = (result['file1'], result['file2'])
                file_frame = ttk.LabelFrame(
                    self.scrollable_frame,
                    text=f"ファイル比較: {result['file1']} vs {result['file2']}"
                )
                file_frame.pack(fill='x', padx=5, pady=5)
            
            # ページごとの結果フレーム
            result_frame = ttk.LabelFrame(
                file_frame,
                text=f"ページ {result['page']}"
            )
            result_frame.pack(fill='x', padx=5, pady=5)
            
            # 画像を横に並べて表示するフレーム
            image_frame = ttk.Frame(result_frame)
            image_frame.pack(fill='x', padx=5, pady=5)
            
            # 各画像のコンテナを作成（均等な幅で配置）
            containers_frame = ttk.Frame(image_frame)
            containers_frame.pack(fill='x', expand=True)
            
            # 元の画像1のコンテナ
            img1_container = ttk.Frame(containers_frame)
            img1_container.pack(side='left', expand=True, fill='both', padx=5)
            ttk.Label(img1_container, text="元画像1").pack()
            img1_label = ttk.Label(img1_container)
            img1_label.pack(expand=True)
            self.display_image(result['img1'], img1_label)
            
            # 元の画像2のコンテナ
            img2_container = ttk.Frame(containers_frame)
            img2_container.pack(side='left', expand=True, fill='both', padx=5)
            ttk.Label(img2_container, text="元画像2").pack()
            img2_label = ttk.Label(img2_container)
            img2_label.pack(expand=True)
            self.display_image(result['img2'], img2_label)
            
            # 差分画像のコンテナ
            diff_container = ttk.Frame(containers_frame)
            diff_container.pack(side='left', expand=True, fill='both', padx=5)
            ttk.Label(diff_container, text="差分").pack()
            diff_label = ttk.Label(diff_container)
            diff_label.pack(expand=True)
            self.display_image(result['diff'], diff_label)
            
            # 差分情報フレーム
            diff_info_frame = ttk.Frame(result_frame)
            diff_info_frame.pack(fill='x', padx=5, pady=5)
            
            # 差分のパーセンテージ表示
            diff_style = 'error' if result['diff_percentage'] > self.settings['diff_threshold'] else 'success'
            diff_info = ttk.Label(
                diff_info_frame,
                text=f"差分: {result['diff_percentage']:.2f}%",
                foreground='red' if diff_style == 'error' else 'green'
            )
            diff_info.pack(side='left')
            
            # 差分の有無の表示
            status_label = ttk.Label(
                diff_info_frame,
                text="差分あり" if diff_style == 'error' else "差分なし",
                foreground='red' if diff_style == 'error' else 'green'
            )
            status_label.pack(side='left', padx=10)

    def display_image(self, image_path, label):
        """画像をラベルに表示（アスペクト比を保持）"""
        try:
            img = Image.open(image_path)
            # 元の画像のアスペクト比を計算
            original_ratio = img.width / img.height
            
            # 表示サイズの基準値
            base_width = self.image_size[0]
            
            # アスペクト比を維持しながら新しいサイズを計算
            new_width = base_width
            new_height = int(base_width / original_ratio)
            
            # リサイズ（アスペクト比を保持）
            img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
            photo = ImageTk.PhotoImage(img)
            label.configure(image=photo)
            label.image = photo
        except Exception as e:
            error_msg = f"画像表示エラー: {str(e)}"
            messagebox.showerror("エラー", error_msg)

    def cleanup(self):
        """アプリケーション終了時のクリーンアップ"""
        try:
            self.pdf_processor.cleanup_temp_files()
            os.rmdir(self.temp_dir)
        except Exception as e:
            print(f"クリーンアップエラー: {str(e)}")

def main():
    root = tk.Tk()
    app = PDFComparisonGUI(root)
    root.protocol("WM_DELETE_WINDOW", lambda: (app.cleanup(), root.destroy()))
    root.mainloop()

if __name__ == "__main__":
    main()
```

# さいごに

現新比較テストのためのPDFコンペアツールをつくりました。
ガンガン使っていきます。