MENU

問い合わせ


    【CAD図面PDFをExcelへ自動転記するツール開発 第1回】PyMuPDFで図面PDFを画面にプレビュー表示する

    前回(第0回)では、CAD図面PDFの部屋名をExcelへ自動転記するWindows向けツールの要件整理と、Python環境・tkinterの最初のウィンドウ、PDF・Excelファイルを選ぶダイアログの土台を作りました。ただしこの時点では、選んだPDFの中身はまだ画面に何も表示されません。

    第1回の今回は、選んだPDFの図面ページを、実際に画面上へプレビュー表示するところまで進めます。部屋名を自動で読み取る処理はまだ入りませんが、「どのページに何が書かれているか」を人が目で確認できるようになる、この連載で最初の目に見える一歩です。

    目次

    今回作る機能と完成イメージ

    今回できるようになることは次の2つです。

    • PDFを選ぶと、1ページ目の図面が画像としてウィンドウ内に表示される
    • 「前のページ/次のページ」ボタンでページ送り、「拡大/縮小」ボタンで表示倍率を変更できる

    部屋名らしき文字を検出してハイライトする処理は次回(第2回)以降です。今回はあくまで「人の目で図面を確認できる土台」を作ります。

    仕組みの説明:PDFをどうやって画面に映すか

    CAD図面のPDFは、多くの場合「線や文字の座標データ(ベクターデータ)」として保存されています。これは拡大しても輪郭がぼやけない一方で、tkinterの画面にそのまま貼り付けることはできません。画面に表示するには、一度「縦横に並んだ色の点(ピクセル)の集まり」であるラスタ画像に変換する必要があります。

    用語かんたんな意味
    ベクターデータ線・文字を座標や数式として持つデータ形式。拡大してもぼやけない
    ラスタ画像縦横に並んだ色の点(ピクセル)の集まり。写真やスクリーンショットと同じ形式
    dpi(dots per inch)1インチ(約2.54cm)あたり何個の点で描くか。数値が大きいほど精細だがデータ量も増える
    PixmapPyMuPDFがページをラスタ画像化した結果を表すオブジェクト
    PPM形式ラスタ画像の保存形式の一つ。tkinterが追加ライブラリなしで直接読み込める

    今回実装した処理の流れは次のとおりです。

    1. PyMuPDF(import fitz)でPDFファイルを開く。開けない(壊れている)場合はエラーとして扱う
    2. 指定したページを、指定した解像度(dpi)と拡大率でラスタ画像(Pixmap)に変換する。PyMuPDFのget_pixmap()はMatrixという行列で変換の詳細を指定でき、dpi ÷ 72 × 拡大率をスケール値として渡すと「基準解像度に拡大率をかけた」画像が得られます(PDFの座標系は1インチ=72ptが基準のため72で割ります)
    3. Pixmapをtobytes("ppm")でPPM形式のバイト列に変換する。PPMはtkinter標準のPhotoImageがそのまま読み込める形式なので、Pillow等の追加ライブラリを増やさずに済みます(exe化して配布する前提のため、依存ライブラリはできるだけ絞っています)
    4. tkinterのCanvasに画像を貼り付け、スクロール領域を画像サイズに合わせる

    この処理のうち、1〜3はGUIに一切関係しない「コア処理」として実装し、4だけがGUI(tkinter)に依存する部分です。第0回で決めた「app/core/はGUIに依存させず、pytestで自動検証する」という設計方針を、今回も踏襲しています。

    実装

    ファイル構成(第1回時点の差分)

    code/
    ├ app/
    │  ├ main.py
    │  ├ core/
    │  │  ├ file_selection.py       … (第0回)選択ファイルの検証
    │  │  └ pdf_loader.py           … 【今回追加】PDFを開き、ページを画像化する
    │  └ ui/
    │     ├ main_window.py          … 【今回変更】プレビュー部品を組み込む
    │     └ preview_canvas.py       … 【今回追加】プレビュー表示部品(tkinter Canvas)
    ├ tests/
    │  ├ test_file_selection.py
    │  └ test_pdf_loader.py         … 【今回追加】
    ├ requirements.txt              … 【今回変更】PyMuPDF==1.26.7 を追加
    ├ pytest.ini
    └ .gitignore

    コア処理:PDFを開いてページを画像化する(app/core/pdf_loader.py)

    fitz(PyMuPDF)だけに依存し、tkinterはimportしません。ファイルを開くところと、1ページを画像化するところの2つが主な役割です。

    # app/core/pdf_loader.py(抜粋)
    from __future__ import annotations
    
    from dataclasses import dataclass
    from pathlib import Path
    
    import fitz
    
    DEFAULT_DPI = 150
    MIN_ZOOM = 0.25
    MAX_ZOOM = 4.0
    
    
    class PdfLoadError(ValueError):
        """PDFを開けない、またはページ番号・拡大率が範囲外の場合に送出する例外。"""
    
    
    @dataclass(frozen=True)
    class RenderedPage:
        page_number: int
        width: int
        height: int
        dpi: int
        zoom: float
        ppm_bytes: bytes  # tkinter.PhotoImage(data=...) にそのまま渡せる
    
    
    class PdfDocument:
        def __init__(self, path: str | Path) -> None:
            resolved = Path(path)
            if not resolved.exists():
                raise PdfLoadError(f"ファイルが見つかりません: {resolved}")
            try:
                document = fitz.open(resolved)
            except Exception as exc:
                raise PdfLoadError(f"PDFとして開けませんでした: {resolved}") from exc
            if document.page_count == 0:
                document.close()
                raise PdfLoadError(f"ページが1枚もないPDFです: {resolved}")
            self._document = document
            self._path = resolved
    
        @property
        def page_count(self) -> int:
            return self._document.page_count
    
        def render_page(self, page_number: int, *, zoom: float = 1.0, dpi: int = DEFAULT_DPI) -> RenderedPage:
            if not 0 <= page_number < self.page_count:
                raise PdfLoadError(f"ページ番号が範囲外です: {page_number}")
            if not MIN_ZOOM <= zoom <= MAX_ZOOM:
                raise PdfLoadError(f"拡大率は{MIN_ZOOM}〜{MAX_ZOOM}の範囲で指定してください: {zoom}")
    
            page = self._document.load_page(page_number)
            scale = (dpi / 72.0) * zoom
            matrix = fitz.Matrix(scale, scale)
            pixmap = page.get_pixmap(matrix=matrix, colorspace=fitz.csRGB, alpha=False)
            return RenderedPage(
                page_number=page_number,
                width=pixmap.width,
                height=pixmap.height,
                dpi=dpi,
                zoom=zoom,
                ppm_bytes=pixmap.tobytes("ppm"),
            )
    
        def close(self) -> None:
            self._document.close()
    
        def __enter__(self) -> "PdfDocument":
            return self
    
        def __exit__(self, exc_type, exc, tb) -> None:
            self.close()

    拡大率(zoom)に0.25〜4.0という上限・下限を設けているのは、極端な拡大を許すとメモリ消費が急激に増え、古い図面PDF(大判用紙をスキャンしたものなど)でアプリが固まりかねないためです。壊れたPDFを開こうとした場合も、PyMuPDFが送出する内部例外をそのまま画面に見せず、PdfLoadErrorという連載共通の例外に変換してから呼び出し側(画面)に伝えています。

    GUI部品:プレビュー表示(app/ui/preview_canvas.py)

    ページ送り・拡大縮小のボタンと、画像を表示するCanvasをまとめた部品です。PdfDocument.render_page()が返したRenderedPage.ppm_bytesを、そのままtkinter.PhotoImageに渡しています。

    # app/ui/preview_canvas.py(抜粋)
    from __future__ import annotations
    
    import tkinter as tk
    from tkinter import ttk
    
    from app.core.pdf_loader import MAX_ZOOM, MIN_ZOOM, PdfDocument, PdfLoadError, load_pdf
    
    ZOOM_STEP = 0.25
    
    
    class PreviewCanvas(ttk.Frame):
        def __init__(self, master: tk.Widget) -> None:
            super().__init__(master)
            self._document: PdfDocument | None = None
            self._page_number = 0
            self._zoom = 1.0
            # PhotoImageはGC対象になるとCanvas上から消えるため、参照を保持し続ける
            self._photo_image: tk.PhotoImage | None = None
            self._build_widgets()
            self._refresh_controls()
    
        def load_pdf(self, path) -> None:
            self._close_current_document()
            self._document = load_pdf(path)
            self._page_number = 0
            self._zoom = 1.0
            self._render_current_page()
    
        def _render_current_page(self) -> None:
            if self._document is None:
                self._refresh_controls()
                return
            rendered = self._document.render_page(self._page_number, zoom=self._zoom)
            self._photo_image = tk.PhotoImage(data=rendered.ppm_bytes, format="ppm")
            self.canvas.delete("all")
            self.canvas.create_image(0, 0, anchor="nw", image=self._photo_image)
            self.canvas.configure(scrollregion=(0, 0, rendered.width, rendered.height))
            self._refresh_controls()
    
        def _on_next_page(self) -> None:
            if self._document is None or self._page_number >= self._document.page_count - 1:
                return
            self._page_number += 1
            self._render_current_page()
    
        def _on_zoom_in(self) -> None:
            self._set_zoom(self._zoom + ZOOM_STEP)

    self._photo_imageをインスタンス変数として保持しているのが地味に重要なポイントです。tkinterのPhotoImageはPythonのガベージコレクション(不要になったオブジェクトの自動破棄)の対象になるため、ローカル変数に入れただけだとメソッドを抜けた瞬間に画像がCanvas上から消えてしまうことがあります。実際、最初の実装ではこの参照保持を忘れて「一瞬だけ表示されてすぐ消える」現象にはまりました(後述のつまずきポイントでも触れます)。

    ページ送り・拡大縮小ボタンは、page_numberとzoomの範囲チェックを画面側でも行い、範囲外のときはボタン自体を無効化(state="disabled")しています。

    メイン画面への配線(app/ui/main_window.py)

    第0回で作った「PDFを選ぶ」ボタンの処理に、プレビューへの読み込みを追加しました。

    # app/ui/main_window.py(抜粋)
    from app.core.pdf_loader import PdfLoadError
    from app.ui.preview_canvas import PreviewCanvas
    
    class MainWindow(ttk.Frame):
        def _build_widgets(self) -> None:
            # ...(PDF選択・Excel選択・実行ボタンは第0回のまま)
            self.preview = PreviewCanvas(self)
            self.preview.grid(row=6, column=0, columnspan=2, sticky="nsew", pady=(28, 0))
    
        def _on_choose_pdf(self) -> None:
            path = filedialog.askopenfilename(title="CAD図面PDFを選択", filetypes=PDF_FILETYPES)
            if not path:
                return
            try:
                validated = validate_pdf_path(path)
            except FileSelectionError as exc:
                messagebox.showerror("ファイル選択エラー", str(exc))
                return
            try:
                self.preview.load_pdf(validated)
            except PdfLoadError as exc:
                messagebox.showerror("PDF読み込みエラー", str(exc))
                return
            self.selected = self.selected.with_pdf(validated)
            self.pdf_path_var.set(str(validated))
            self._refresh_run_button()

    「拡張子は.pdfだが中身が壊れている」ファイルの場合、validate_pdf_path(第0回の拡張子・存在チェック)は通過しますが、preview.load_pdf()内でPyMuPDFが実際に開こうとしてPdfLoadErrorが送出されるので、それをここで捕まえてエラーダイアログを出す二段構えにしています。

    requirements.txtへの追加

    # 第1回〜:PDFのページ画像化・ベクター文字抽出(import fitz)
    PyMuPDF==1.26.7

    pip index versions pymupdfで、このバージョンが記事の公開日(2026年1月)時点で存在することを確認しています(PyPIのパッケージ情報によると、1.26.7の公開日は2025年12月11日です)。

    動作確認の方法

    Linux開発環境で確認できたことと、Windows実機での確認が必要なことを分けて書きます。

    Linux開発環境で確認できたこと

    • pytest:合計23件(うちapp/core/pdf_loader.py向けが13件)がすべて成功。ページ数取得、画像化した際の幅・高さがdpi・拡大率の計算どおりか(PyMuPDFの内部丸めによる±1〜2px程度の誤差は許容)、ページ番号・拡大率が範囲外のときにエラーになるか、壊れたPDFやフォルダを渡したときにエラーになるか、with文を抜けたときにファイルハンドルが閉じられているかを検証しています。テスト用PDFは実在の図面ではなく、PyMuPDF自身で「寝室1」のような文字を描画してその場で生成した架空のサンプルです
    • ruff check .:エラーなし
    • ヘッドレス起動確認:tkinterとPyMuPDFの両方が使えるPython環境(このLinux環境には既定でtkinterが無いため、python3.12 -m venv --system-site-packagesで作った環境にPyMuPDFを追加インストールしたもの)で、Xvfb上にウィンドウを生成し、2ページのサンプルPDFをpreview.load_pdf()で読み込ませて、ページラベルが「1/2」→「2/2」に変わること、表示倍率のラベルが100%→125%→75%と変わること、前後ページ・拡大縮小の上限/下限でボタンが無効化されることを確認しました。例外は一切発生していません

    実際に架空のサンプルPDF(「居間」「寝室1」などの文字を描いた2ページのテスト用PDF)を「PDFを選ぶ…」ボタンから読み込ませた画面は次のとおりです。1ページ目がプレビューに表示され、ページ送り・拡大縮小のボタンが有効になっています。

    サンプルPDFを読み込んで1ページ目をプレビュー表示した画面。ページ: 1/2、表示倍率100%で、「居間」「寝室1」の文字が描かれた図面が表示されている

    ※これは開発環境(Linux/Xvfb上・Ubuntu標準のTkテーマ)で確認した画面です。Windows実機ではウィンドウの装飾や既定フォントなど見た目が異なります。

    Windows実機での確認が必要なこと(このリポジトリでは未確認)

    • 実際の画面上で図面画像が正しく(欠けたり色化けしたりせず)表示されるか
    • マウスでのボタンクリック操作、スクロールバーの操作感
    • 大判図面(A1・A2サイズなど)や複数ページのPDFを開いたときの表示速度・メモリ使用量
    • 高DPIディスプレイでの見え方

    上記のヘッドレス確認は「例外を出さずに、期待どおりの状態遷移(ページ番号・拡大率の表示、ボタンの有効/無効)が起きる」ことの確認であり、「実際に目で見て図面が正しく見える」ことの確認ではありません。この区別は第0回から一貫して意識している点です。

    つまずきやすい点・セキュリティ上の注意

    • PyMuPDFのライセンス(AGPL-3.0):第0回でも触れましたが、PyMuPDFはAGPL-3.0(GNU Affero General Public License)と、Artifex社の商用ライセンスのデュアルライセンスで提供されています。自社の内製ツールとして社内だけで使う分には基本的に問題になりませんが、このツールを他社へ配布・販売する場合は必ずライセンス条件を確認してください。 「配布先にソースコードの入手手段を提供する義務」が生じる可能性があるため、配布形態が固まった段階で法務・知財に確認を挟むことをおすすめします。条件が合わない場合の代替案として、Apache-2.0ライセンスのpypdfium2(Google PDFiumのPythonラッパー)も選択肢にあります。
    • PhotoImageの参照保持忘れ:前述のとおり、tkinter.PhotoImageをローカル変数だけに入れておくと、ガベージコレクションで画像が消えてCanvasが真っ白になることがあります。インスタンス変数として保持し続けるのを忘れないようにしてください。
    • 拡大率・解像度の上限:MAX_ZOOM(4.0倍)や基準dpi(150)を大きくしすぎると、1ページあたりの画像データが数十MB単位になることがあります。特にA1・A2サイズの大判図面では、上限を設けずに拡大を許可すると動作が重くなったりメモリ不足になったりする可能性があるため、上限値は実際の対象図面のサイズを見てから調整するのが安全です。
    • 図面ファイルの取り扱い:図面PDFには顧客の建物情報が含まれることがあります。この連載のサンプルコード・記事では、実在の図面ではなくPyMuPDFで生成した架空のテスト用PDFのみを使っています。実際の開発では、テストデータにも社内の情報管理ルールを適用してください。

    発注者向けメモ

    この回で確認しておきたいこと・工数の勘所

    図面PDFは1ファイルに複数ページ(各階の平面図、詳細図、凡例ページなど)が混ざっていることが少なくありません。「どのページの、どの部屋名を、Excelのどの欄に転記するのか」という対応関係は、開発が進んでから発覚する例外事項になりがちです。プレビュー機能ができた今の段階で、対象図面を何パターンか用意し、ページ構成を開発会社とすり合わせておくと、後の工程(文字抽出・部屋名判定)での手戻りを減らせます。

    また、PDFをその場で高解像度画像に変換する処理は、ページ数やサイズによって処理時間・メモリ使用量が変わります。「複数ページ・大判サイズの図面が多い」という前提があるなら、早い段階で開発会社に伝えておくとよいでしょう。

    • ☐ 対象図面が何ページ構成か(1ファイル1ページか、複数ページが混在するか)を確認したか
    • ☐ 各ページのおおよそのサイズ(A3・A2・A1など)を確認したか
    • ☐ 図面を外部に出す予定がある場合、含まれる建物情報の機密度を確認したか
    • ☐ プレビュー画面に求める操作感(マウスホイールでの拡大縮小、ドラッグでの移動など)の希望があれば伝えたか

    開発会社への質問例

    • 「今回のプレビュー機能で使っているPDFの画像化ライブラリのライセンス(AGPL-3.0)は、将来ツールを他社へ配布する場合に問題になりませんか?」
    • 「大判サイズの図面や、ページ数の多いPDFを開いたときの表示速度は、どの程度まで許容できる設計にしていますか?」
    • 「拡大率や表示解像度の上限は、どういう基準で決めていますか?」

    これらの質問は、今回作った「プレビュー」という一見地味な機能が、実は後続の文字抽出・OCR処理の土台になり、かつライセンス・パフォーマンスの両面で判断が必要な機能であることを確認するためのものです。

    まとめと次回予告

    第1回では、PyMuPDFでPDFのページを高解像度画像に変換するapp/core/pdf_loader.pyと、それをtkinterのCanvasに表示してページ送り・拡大縮小を行うapp/ui/preview_canvas.pyを実装し、第0回で作ったメイン画面から呼び出せるように配線しました。コア処理(PDFの画像化)は引き続きGUIに依存しない形で実装し、pytestで自動検証できる状態を保っています。

    次回(第2回)は、いよいよ図面に埋め込まれた文字を読み取ります。PyMuPDFのget_text("dict")で座標・フォントサイズ付きの単語を取得し、検出した文字の枠を今回作ったプレビュー画面の上に重ねて表示します。CAD由来のPDFは文字が最初から埋め込まれていることが多く、その場合はOCRより速く正確に部屋名候補を拾えます。

    この連載の記事一覧

    この記事は連載「CAD図面PDFをExcelへ自動転記するツール開発」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (3件)

      目次