1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-11-27

はじめに

  • 背景

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

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

やったこと

PythonのGUIアプリを自作しました。

ツールの操作方法

1.ディレクトリ選択

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

image.png

2.比較の実行

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

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

image.png

3.比較レポートの出力

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

image.png

HTMLファイル

image.png

image.png

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

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

image.png

image.png

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

image.png

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

image.png

処理内容

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

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

image.png

2.HTMLレポートの保存

image.png

順に処理を解説します。

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

行っていることは非常にシンプルで、大きく2つです。

  • PyMuPDFを使ったPDFファイルの画像変換
  • OpenCVを使った2画像の差分検知
# 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を作成しています。

# 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  # 画像データの辞書を返す
# 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をそれぞれテンプレートとしてあらかじめ定義して、それらを組み合わせてレポートを生成しています。

全体のディレクトリは以下のようになります。

- 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を使用し、シンプルなツール操作を実現しています。

# 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コンペアツールをつくりました。
ガンガン使っていきます。

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?