PowerPointファイルビューワ・Windows用(動作にPowerPointが必要)

【概要】PowerPoint、PDF文書を画像変換してスライド表示するWindowsデスクトップアプリケーション。スライド表示中にマウスで赤い線を描画でき、案内表示モードやメモ帳モードを搭載。全画面対応、プレゼンテーションタイマー付き。

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

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

Python 3.12 のインストール

インストール済みの場合は実行不要。

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行する。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要である。

REM Python をシステム領域にインストール
winget install --scope machine --id Python.Python.3.12 -e --silent
REM Python のパス設定
set "PYTHON_PATH=C:\Program Files\Python312"
set "PYTHON_SCRIPTS_PATH=C:\Program Files\Python312\Scripts"
echo "%PATH%" | find /i "%PYTHON_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_PATH%" /M >nul
echo "%PATH%" | find /i "%PYTHON_SCRIPTS_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_SCRIPTS_PATH%" /M >nul

関連する外部ページ

Python の公式ページ: https://www.python.org/

AI エディタ Windsurf のインストール

Pythonプログラムの編集・実行には、AI エディタの利用を推奨する。ここでは,Windsurfのインストールを説明する。

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行して、Windsurfをシステム全体にインストールする。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要となる。

winget install --scope machine Codeium.Windsurf -e --silent

関連する外部ページ

Windsurf の公式ページ: https://windsurf.com/

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

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


pip install Pillow pywin32 PyMuPDF

PowerPointファイルビューワ・Windows用(動作にPowerPointが必要)

ソースコード


import tkinter as tk
from tkinter import filedialog, font
import os
import json
import time
import hashlib
import shutil
from PIL import Image, ImageTk, ImageDraw
import pythoncom
import win32com.client.gencache as gencache
try:
    import fitz  # PyMuPDF
    PDF_SUPPORT = True
except ImportError:
    PDF_SUPPORT = False

# 設定ファイルとキャッシュディレクトリの定義
CONFIG_FILE = "ppt_config.json"
APP_NAME = "PPTStaticViewer"
CACHE_ROOT = os.path.join(os.getenv("LOCALAPPDATA", "."), APP_NAME, "cache")

class PPTViewer:
    def __init__(self):
        self.root = tk.Tk()
        self.root.title("PowerPoint Viewer")
        self.root.geometry("1200x600")

        self.current_file = ""
        self.current_slide = 1
        self.total_slides = 0
        self.slide_paths = []
        self.tkimg = None
        self.pres = None
        self.app = None
        self.word_app = None
        self.doc = None

        # マウス軌跡描画機能の変数
        self.drawing = False
        self.draw_lines = []
        self.line_width = 15

        # 表示モードの状態変数
        self.current_mode = "slideshow"  # "slideshow" or "guide" or "notepad"
        self.guide_text_item = None
        self.guide_text2_item = None
        self.guide_time_item = None
        self.time_update_id = None

        # プレゼンテーションタイマー用
        self.presentation_start_time = None
        self.timer_update_id = None

        # コントロールパネル自動隠し用
        self.panel_visible = True
        self.hide_timer_id = None

        # 全画面モード用
        self.is_fullscreen = False
        self.previous_geometry = None

        # メモ帳モード用
        self.notepad_font_size = 16
        self.backup_timer_id = None

        os.makedirs(CACHE_ROOT, exist_ok=True)
        self.load_config()
        self.setup_ui()

    def setup_ui(self):
        # メインコンテナ
        self.main_container = tk.Frame(self.root)
        self.main_container.pack(fill=tk.BOTH, expand=True)

        # 右側:コントロールパネル(先にpack)
        self.right_frame = tk.Frame(self.main_container, width=250, bg='#f0f0f0')
        self.right_frame.pack(side=tk.RIGHT, fill=tk.Y)
        self.right_frame.pack_propagate(False)

        # 左側:スライド表示エリア(後でpack)
        self.left_frame = tk.Frame(self.main_container, bg='black')
        self.left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)

        self.canvas = tk.Canvas(self.left_frame, bg="black", highlightthickness=0)
        self.canvas.pack(fill=tk.BOTH, expand=True)

        # メモ帳モード用フレーム(初期は非表示)
        self.notepad_frame = tk.Frame(self.left_frame, bg='white')

        # Textウィジェット(太字の等幅フォント)
        self.text_widget = tk.Text(
            self.notepad_frame,
            font=("MS Gothic", 16, "bold"),
            wrap=tk.WORD,
            bg="white",
            fg="black"
        )

        # 垂直スクロールバー
        v_scrollbar = tk.Scrollbar(self.notepad_frame, orient=tk.VERTICAL)
        self.text_widget.config(yscrollcommand=v_scrollbar.set)
        v_scrollbar.config(command=self.text_widget.yview)

        # 配置
        self.text_widget.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)

        # ボタンエリア
        button_area = tk.Frame(self.right_frame, bg='#f0f0f0')
        button_area.pack(side=tk.TOP, fill=tk.X, padx=5, pady=2)

        # ファイルを開くボタン
        open_btn_text = "PPTX/PDF/DOCXファイルを開く" if PDF_SUPPORT else "PPTX/DOCXファイルを開く"
        tk.Button(button_area, text=open_btn_text,
                  command=self.open_file, width=25).pack(pady=1)
        tk.Button(button_area, text="スライドショー開始",
                  command=self.start_show, width=25).pack(pady=1)
        tk.Button(button_area, text="再開",
                  command=self.resume_show, width=25).pack(pady=1)
        tk.Button(button_area, text="スライドショー終了",
                  command=self.end_show, width=25).pack(pady=1)

        # 区切り線
        tk.Frame(self.right_frame, height=1, bg='#cccccc').pack(fill=tk.X, padx=5, pady=2)

        # 全画面モード切り替えボタン
        fullscreen_frame = tk.Frame(self.right_frame, bg='#f0f0f0')
        fullscreen_frame.pack(fill=tk.X, padx=5, pady=1)

        tk.Button(fullscreen_frame, text="全画面表示",
                  command=self.enter_fullscreen, width=12).pack(side=tk.LEFT, padx=1)
        tk.Button(fullscreen_frame, text="ウィンドウ表示",
                  command=self.exit_fullscreen, width=12).pack(side=tk.LEFT, padx=1)

        # 区切り線
        tk.Frame(self.right_frame, height=1, bg='#cccccc').pack(fill=tk.X, padx=5, pady=2)

        # ステータス表示
        tk.Label(self.right_frame, text="ステータス:", bg='#f0f0f0').pack(anchor=tk.W, padx=5, pady=(2,0))
        self.status_entry = tk.Entry(self.right_frame, width=30, state='readonly',
                                     readonlybackground='white', relief=tk.FLAT)
        self.status_entry.pack(padx=5, pady=1)
        self.status_entry.config(state='normal')
        self.status_entry.insert(0, "ファイル未選択")
        self.status_entry.config(state='readonly')

        # メッセージ表示エリア
        tk.Label(self.right_frame, text="メッセージ:", bg='#f0f0f0').pack(anchor=tk.W, padx=5, pady=(2,0))
        self.message_text = tk.Text(self.right_frame, height=2, width=30, wrap=tk.WORD,
                                   state='disabled', bg='white', relief=tk.FLAT)
        self.message_text.pack(padx=5, pady=1)

        # 区切り線
        tk.Frame(self.right_frame, height=1, bg='#cccccc').pack(fill=tk.X, padx=5, pady=2)

        # 軌跡太さ調整
        tk.Label(self.right_frame, text="軌跡の太さ:", bg='#f0f0f0').pack(anchor=tk.W, padx=5, pady=(2,0))
        line_frame = tk.Frame(self.right_frame, bg='#f0f0f0')
        line_frame.pack(fill=tk.X, padx=5, pady=1)

        self.line_slider = tk.Scale(line_frame, from_=5, to=50, orient=tk.HORIZONTAL,
                                   command=self.update_line_width,
                                   bg='#f0f0f0', length=180)
        self.line_slider.set(self.line_width)
        self.line_slider.pack(side=tk.LEFT)

        self.line_label = tk.Label(line_frame, text=str(self.line_width), width=5, bg='#f0f0f0')
        self.line_label.pack(side=tk.LEFT, padx=2)

        # 区切り線
        tk.Frame(self.right_frame, height=1, bg='#cccccc').pack(fill=tk.X, padx=5, pady=2)

        # モード切り替えボタン
        mode_frame = tk.Frame(self.right_frame, bg='#f0f0f0')
        mode_frame.pack(fill=tk.X, padx=5, pady=1)

        tk.Button(mode_frame, text="スライドショー",
                  command=self.set_slideshow_mode, width=8).pack(side=tk.LEFT, padx=1)
        tk.Button(mode_frame, text="案内",
                  command=self.set_guide_mode, width=8).pack(side=tk.LEFT, padx=1)
        tk.Button(mode_frame, text="メモ帳",
                  command=self.set_notepad_mode, width=8).pack(side=tk.LEFT, padx=1)

        # 案内入力フォーム1
        tk.Label(self.right_frame, text="案内:", bg='#f0f0f0').pack(anchor=tk.W, padx=5, pady=(2,0))
        self.guide_entry = tk.Entry(self.right_frame, width=30)
        self.guide_entry.pack(padx=5, pady=1)
        self.guide_entry.insert(0, "お知らせ")

        # 案内入力フォーム2(赤文字用)
        tk.Label(self.right_frame, text="案内(赤文字):", bg='#f0f0f0').pack(anchor=tk.W, padx=5, pady=(2,0))
        self.guide_entry2 = tk.Entry(self.right_frame, width=30)
        self.guide_entry2.pack(padx=5, pady=1)
        self.guide_entry2.insert(0, "重要")

        # 区切り線
        tk.Frame(self.right_frame, height=1, bg='#cccccc').pack(fill=tk.X, padx=5, pady=2)

        # メモ帳モード用コントロール(常時表示)
        self.notepad_controls = tk.Frame(self.right_frame, bg='#f0f0f0')
        self.notepad_controls.pack(fill=tk.X, padx=5, pady=1)

        # フォントサイズボタン
        tk.Label(self.notepad_controls, text="フォントサイズ:", bg='#f0f0f0').pack(anchor=tk.W, pady=(1,0))
        font_size_frame = tk.Frame(self.notepad_controls, bg='#f0f0f0')
        font_size_frame.pack(fill=tk.X, pady=1)

        tk.Button(font_size_frame, text="16", width=5,
                  command=lambda: self.set_notepad_font_size(16)).pack(side=tk.LEFT, padx=1)
        tk.Button(font_size_frame, text="32", width=5,
                  command=lambda: self.set_notepad_font_size(32)).pack(side=tk.LEFT, padx=1)
        tk.Button(font_size_frame, text="48", width=5,
                  command=lambda: self.set_notepad_font_size(48)).pack(side=tk.LEFT, padx=1)
        tk.Button(font_size_frame, text="64", width=5,
                  command=lambda: self.set_notepad_font_size(64)).pack(side=tk.LEFT, padx=1)

        # テキスト装飾ボタン
        tk.Button(self.notepad_controls, text="赤くする", width=25,
                  command=self.make_text_red).pack(pady=1)

        # 保存ボタン
        tk.Button(self.notepad_controls, text="保存 (result.txt)", width=25,
                  command=self.save_notepad).pack(pady=1)

        # プレゼンテーションタイマー表示
        timer_frame = tk.Frame(self.right_frame, bg='#f0f0f0')
        timer_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=5)

        tk.Label(timer_frame, text="プレゼンテーションタイマー:", bg='#f0f0f0').pack(anchor=tk.W)
        self.timer_label = tk.Label(timer_frame, text="00分00秒", font=("Arial", 14, "bold"), bg='#f0f0f0')
        self.timer_label.pack(anchor=tk.W)

        # マウスイベントバインド
        self.canvas.bind("<Motion>", self.on_mouse_move)
        self.canvas.bind("<ButtonPress-1>", self.on_mouse_press)
        self.canvas.bind("<B1-Motion>", self.on_mouse_drag)
        self.canvas.bind("<ButtonRelease-1>", self.on_mouse_release)
        self.canvas.bind("<MouseWheel>", self.on_mouse_wheel)

        # メモ帳用バインド
        self.text_widget.bind("<MouseWheel>", self.on_notepad_mousewheel)
        self.text_widget.bind("<Control-c>", lambda e: self.text_widget.event_generate("<<Copy>>"))
        self.text_widget.bind("<Control-v>", lambda e: self.text_widget.event_generate("<<Paste>>"))

        # キーバインド
        self.root.bind("<space>", self.on_key_press)
        self.root.bind("<Right>", self.on_key_press)
        self.root.bind("<Left>", self.on_key_press)
        self.root.bind("<BackSpace>", self.on_key_press)
        self.root.bind("<Delete>", self.on_key_press)
        self.root.bind("<Escape>", lambda e: self.on_closing())
        self.root.bind("<F11>", lambda e: self.toggle_fullscreen())
        self.root.bind("<Configure>", self.on_resize)
        self.root.protocol("WM_DELETE_WINDOW", self.on_closing)

        # コントロールパネル自動隠しのためのバインド
        self.root.bind("<Motion>", self.on_root_motion)

    def on_notepad_mousewheel(self, event):
        if self.current_mode == "notepad":
            # 上下スクロール(3行単位)
            if event.delta > 0:
                self.text_widget.yview_scroll(-3, "units")
            else:
                self.text_widget.yview_scroll(3, "units")

    def set_notepad_mode(self):
        self.current_mode = "notepad"

        # 他のモードのタイマーを停止
        if self.time_update_id:
            self.root.after_cancel(self.time_update_id)
            self.time_update_id = None

        # キャンバスを隠してメモ帳フレームを表示
        self.canvas.pack_forget()
        self.notepad_frame.pack(fill=tk.BOTH, expand=True)

        # 自動バックアップ開始
        self.start_auto_backup()

        self.update_status("メモ帳モード")

    def set_slideshow_mode(self):
        # 自動バックアップ停止
        self.stop_auto_backup()

        # 軌跡をクリア
        self.clear_drawings()

        self.current_mode = "slideshow"
        if self.time_update_id:
            self.root.after_cancel(self.time_update_id)
            self.time_update_id = None
        if self.guide_text_item:
            self.canvas.delete(self.guide_text_item)
            self.guide_text_item = None
        if self.guide_text2_item:
            self.canvas.delete(self.guide_text2_item)
            self.guide_text2_item = None
        if self.guide_time_item:
            self.canvas.delete(self.guide_time_item)
            self.guide_time_item = None

        # メモ帳フレームを隠してキャンバスを表示
        self.notepad_frame.pack_forget()
        self.canvas.pack(fill=tk.BOTH, expand=True)

        self.canvas.config(bg="black")
        if self.slide_paths:
            self.show_current()

    def set_guide_mode(self):
        # 自動バックアップ停止
        self.stop_auto_backup()

        # 軌跡をクリア
        self.clear_drawings()

        self.current_mode = "guide"

        # メモ帳フレームを隠してキャンバスを表示
        self.notepad_frame.pack_forget()
        self.canvas.pack(fill=tk.BOTH, expand=True)

        self.canvas.config(bg="white")
        # 案内モードでは軌跡以外を削除
        items_to_delete = []
        for item in self.canvas.find_all():
            if item not in self.draw_lines:
                items_to_delete.append(item)
        for item in items_to_delete:
            self.canvas.delete(item)
        self.show_guide()

    def set_notepad_font_size(self, size):
        self.notepad_font_size = size
        self.text_widget.config(font=("MS Gothic", size, "bold"))

    def make_text_red(self):
        try:
            # 選択範囲を取得
            sel_start = self.text_widget.index(tk.SEL_FIRST)
            sel_end = self.text_widget.index(tk.SEL_LAST)
            # タグを追加して赤色に
            self.text_widget.tag_add("red", sel_start, sel_end)
            self.text_widget.tag_config("red", foreground="red")
        except tk.TclError:
            pass  # 選択範囲なし

    def save_notepad(self):
        content = self.text_widget.get("1.0", tk.END)
        with open("result.txt", "w", encoding="utf-8") as f:
            f.write(content)
        self.show_message("保存完了:\nresult.txt")

    def start_auto_backup(self):
        # 1分後に最初のバックアップを設定
        self.backup_timer_id = self.root.after(60000, self.auto_backup)

    def auto_backup(self):
        if self.current_mode == "notepad":
            timestamp = time.strftime("BACKUP%Y-%m-%d-%H-%M-%S.txt")
            content = self.text_widget.get("1.0", tk.END)
            with open(timestamp, "w", encoding="utf-8") as f:
                f.write(content)
            self.show_message(f"バックアップ作成:\n{timestamp}")
            # 次の1分後に再実行
            self.backup_timer_id = self.root.after(60000, self.auto_backup)

    def stop_auto_backup(self):
        if hasattr(self, 'backup_timer_id') and self.backup_timer_id:
            self.root.after_cancel(self.backup_timer_id)
            self.backup_timer_id = None

    def enter_fullscreen(self):
        if not self.is_fullscreen:
            self.previous_geometry = self.root.geometry()
            self.root.attributes("-fullscreen", True)
            self.is_fullscreen = True
            self.hide_panel()

    def exit_fullscreen(self):
        if self.is_fullscreen:
            self.root.attributes("-fullscreen", False)
            if self.previous_geometry:
                self.root.geometry(self.previous_geometry)
            self.is_fullscreen = False
            self.show_panel()

    def toggle_fullscreen(self):
        if self.is_fullscreen:
            self.exit_fullscreen()
        else:
            self.enter_fullscreen()

    def on_root_motion(self, event):
        # マウスが右端近くにある場合、パネルを表示
        if event.x_root > self.root.winfo_screenwidth() - 300:
            self.show_panel()
            self.reset_hide_timer()
        else:
            self.reset_hide_timer()

    def show_panel(self):
        if not self.panel_visible:
            if self.current_mode == "notepad":
                # メモ帳モードの場合、notepad_frameも再配置が必要
                self.notepad_frame.pack_forget()
                self.left_frame.pack_forget()
                self.right_frame.pack(side=tk.RIGHT, fill=tk.Y)
                self.left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
                self.notepad_frame.pack(fill=tk.BOTH, expand=True)
            else:
                # 通常モードの処理
                self.left_frame.pack_forget()
                self.right_frame.pack(side=tk.RIGHT, fill=tk.Y)
                self.left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
            self.panel_visible = True

    def hide_panel(self):
        if self.panel_visible and self.is_fullscreen:
            if self.current_mode == "notepad":
                # メモ帳モードの場合
                self.notepad_frame.pack_forget()
            self.right_frame.pack_forget()
            self.left_frame.pack_forget()
            self.left_frame.pack(fill=tk.BOTH, expand=True)
            if self.current_mode == "notepad":
                self.notepad_frame.pack(fill=tk.BOTH, expand=True)
            self.panel_visible = False

    def reset_hide_timer(self):
        if self.hide_timer_id:
            self.root.after_cancel(self.hide_timer_id)
        if self.is_fullscreen:
            self.hide_timer_id = self.root.after(3000, self.hide_panel)

    def update_timer(self):
        if self.presentation_start_time is not None:
            elapsed = int(time.time() - self.presentation_start_time)
            minutes = elapsed // 60
            seconds = elapsed % 60
            self.timer_label.config(text=f"{minutes:02d}分{seconds:02d}秒")
            self.timer_update_id = self.root.after(1000, self.update_timer)

    def start_timer(self):
        self.presentation_start_time = time.time()
        self.update_timer()

    def stop_timer(self):
        if self.timer_update_id:
            self.root.after_cancel(self.timer_update_id)
            self.timer_update_id = None
        self.presentation_start_time = None
        self.timer_label.config(text="00分00秒")

    def on_key_press(self, event):
        if self.current_mode == "slideshow":
            if event.keysym in ["space", "Right"]:
                self.next_slide()
            elif event.keysym in ["Left", "BackSpace", "Delete"]:
                self.prev_slide()

    def on_mouse_wheel(self, event):
        if self.current_mode == "slideshow":
            if event.delta > 0:
                self.prev_slide()
            else:
                self.next_slide()

    def show_guide(self):
        if self.current_mode != "guide":
            return

        # 古い案内テキストのみ削除(軌跡は保持)
        if self.guide_text_item:
            self.canvas.delete(self.guide_text_item)
        if self.guide_text2_item:
            self.canvas.delete(self.guide_text2_item)
        if self.guide_time_item:
            self.canvas.delete(self.guide_time_item)

        w = self.canvas.winfo_width()
        h = self.canvas.winfo_height()

        guide_text = self.guide_entry.get()
        guide_text2 = self.guide_entry2.get()

        # 案内テキスト表示(黒文字)
        self.guide_text_item = self.canvas.create_text(
            w // 2, h // 2 - 120,
            text=guide_text,
            font=("Arial", 72, "bold"),
            fill="black",
            anchor="center"
        )

        # 案内テキスト2表示(赤文字)
        self.guide_text2_item = self.canvas.create_text(
            w // 2, h // 2,
            text=guide_text2,
            font=("Arial", 72, "bold"),
            fill="red",
            anchor="center"
        )

        # 時刻表示
        current_time = time.strftime("%H:%M:%S")
        self.guide_time_item = self.canvas.create_text(
            w // 2, h // 2 + 120,
            text=current_time,
            font=("Arial", 36),
            fill="black",
            anchor="center"
        )

        # 軌跡を最前面に
        for line in self.draw_lines:
            self.canvas.tag_raise(line)

        # 1秒後に更新
        self.time_update_id = self.root.after(1000, self.show_guide)

    def update_line_width(self, value):
        self.line_width = int(value)
        self.line_label.config(text=str(self.line_width))

    def show_message(self, message):
        self.message_text.config(state='normal')
        self.message_text.delete(1.0, tk.END)
        self.message_text.insert(1.0, message)
        self.message_text.config(state='disabled')

    def on_mouse_move(self, event):
        # マウス移動イベント(軌跡描画用の座標更新のみ)
        pass

    def on_mouse_press(self, event):
        # 描画開始(メモ帳モード以外)
        if self.current_mode != "notepad":
            self.drawing = True
            self.last_x = event.x
            self.last_y = event.y

    def on_mouse_drag(self, event):
        # マウスドラッグで軌跡を描画(メモ帳モード以外)
        if self.drawing and self.current_mode != "notepad":
            line = self.canvas.create_line(self.last_x, self.last_y,
                                          event.x, event.y,
                                          fill='#ff0000', width=self.line_width,
                                          capstyle=tk.ROUND, joinstyle=tk.ROUND,
                                          stipple='gray50')
            self.draw_lines.append(line)
            self.last_x = event.x
            self.last_y = event.y

    def on_mouse_release(self, event):
        self.drawing = False

    def clear_drawings(self):
        for line in self.draw_lines:
            self.canvas.delete(line)
        self.draw_lines = []

    def update_status(self, text, color="black"):
        self.status_entry.config(state='normal', fg=color)
        self.status_entry.delete(0, tk.END)
        self.status_entry.insert(0, text)
        self.status_entry.config(state='readonly')

    def load_config(self):
        try:
            with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
                c = json.load(f)
                self.current_file = c.get('file', '')
                self.current_slide = int(c.get('slide', 1))
                self.line_width = int(c.get('line_width', 15))
        except:
            pass

    def save_config(self):
        try:
            with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
                json.dump({
                    'file': self.current_file,
                    'slide': self.current_slide,
                    'line_width': self.line_width
                }, f, ensure_ascii=False)
        except:
            pass

    def open_file(self):
        if PDF_SUPPORT:
            filetypes = [("Supported Files", "*.pptx *.ppt *.pdf *.docx"),
                        ("PowerPoint", "*.pptx *.ppt"),
                        ("PDF", "*.pdf"),
                        ("Word", "*.docx")]
        else:
            filetypes = [("Supported Files", "*.pptx *.ppt *.docx"),
                        ("PowerPoint", "*.pptx *.ppt"),
                        ("Word", "*.docx")]

        file_path = filedialog.askopenfilename(filetypes=filetypes)
        if file_path:
            self.current_file = file_path
            self.current_slide = 1
            self.update_status(os.path.basename(file_path))
            self.save_config()
            self.show_message(f"ファイル選択:\n{os.path.basename(file_path)}")

    def start_show(self):
        if not self.current_file:
            self.update_status("エラー: ファイルを選択してください", "red")
            return
        if not os.path.exists(self.current_file):
            self.update_status("エラー: ファイルが見つかりません", "red")
            self.show_message("エラー:\nファイルが見つかりません")
            return

        # ファイル拡張子を確認
        ext = os.path.splitext(self.current_file)[1].lower()
        if ext == '.pdf':
            ok = self.export_pdf_slides(self.current_file)
        elif ext == '.docx':
            ok = self.export_docx_slides(self.current_file)
        else:
            ok = self.export_all_slides(self.current_file)

        if ok:
            self.current_slide = 1
            self.total_slides = len(self.slide_paths)
            self.clear_drawings()
            self.root.after_idle(self.show_current)
            self.update_status(f"スライド {self.current_slide}/{self.total_slides}")
            self.start_timer()

    def resume_show(self):
        self.show_message("再開準備中...")
        self.root.update()

        if not self.current_file:
            self.load_config()
            if not self.current_file:
                self.update_status("エラー: 前回のファイルがありません", "red")
                self.show_message("エラー:\n前回のファイルがありません")
                return
            if hasattr(self, 'line_slider'):
                self.line_slider.set(self.line_width)
                self.line_label.config(text=str(self.line_width))

        if not os.path.exists(self.current_file):
            self.update_status("エラー: ファイルが見つかりません", "red")
            self.show_message("エラー:\nファイルが見つかりません")
            return

        self.show_message("再開開始...")
        self.root.update()

        # ファイル拡張子を確認
        ext = os.path.splitext(self.current_file)[1].lower()
        if ext == '.pdf':
            ok = self.export_pdf_slides(self.current_file)
        elif ext == '.docx':
            ok = self.export_docx_slides(self.current_file)
        else:
            ok = self.export_all_slides(self.current_file)

        if ok:
            self.total_slides = len(self.slide_paths)
            if self.total_slides == 0:
                self.update_status("エラー: スライドが見つかりません", "red")
                return
            self.current_slide = max(1, min(self.current_slide, self.total_slides))
            self.clear_drawings()
            self.root.after_idle(self.show_current)
            self.update_status(f"スライド {self.current_slide}/{self.total_slides}")
            self.show_message(f"再開完了:\nスライド {self.current_slide}/{self.total_slides}")
            self.start_timer()

    def export_docx_slides(self, path):
        """DOCXファイルを画像にエクスポート"""
        # 既存のWordオブジェクトをクリーンアップ
        if self.doc is not None:
            try:
                self.doc.Close()
            except:
                pass
            self.doc = None
        if self.word_app is not None:
            try:
                self.word_app.Quit()
            except:
                pass
            self.word_app = None

        try:
            self.show_message("DOCXエクスポート開始...")
            self.root.update()

            pythoncom.CoInitialize()
            self.word_app = gencache.EnsureDispatch("Word.Application")
            self.word_app.Visible = False

            # 文書を開く
            self.doc = self.word_app.Documents.Open(path)

            # ページ数を取得(wdStatisticPages = 2)
            pages = self.doc.ComputeStatistics(2)

            # キャッシュディレクトリを作成
            h = hashlib.sha1((path + str(os.path.getmtime(path))).encode()).hexdigest()[:16]
            outdir = os.path.join(CACHE_ROOT, h)
            if os.path.isdir(outdir):
                shutil.rmtree(outdir, ignore_errors=True)
            os.makedirs(outdir, exist_ok=True)

            # 一時的にPDFとして保存してから画像化
            temp_pdf = os.path.join(outdir, "temp.pdf")
            self.doc.SaveAs2(temp_pdf, FileFormat=17)  # wdFormatPDF = 17

            # PDFを画像に変換
            if PDF_SUPPORT:
                pdf_doc = fitz.open(temp_pdf)
                for i in range(len(pdf_doc)):
                    page = pdf_doc[i]
                    mat = fitz.Matrix(3.0, 3.0)  # 解像度設定
                    pix = page.get_pixmap(matrix=mat)
                    fname = f"{i+1:04d}.png"
                    pix.save(os.path.join(outdir, fname))
                    pix = None
                pdf_doc.close()
                os.remove(temp_pdf)  # 一時PDFを削除
            else:
                # PyMuPDFがない場合のメッセージ
                self.show_message("PDF変換ライブラリが\nありません")

            # クリーンアップ
            self.doc.Close()
            self.doc = None
            self.word_app.Quit()
            self.word_app = None
            pythoncom.CoUninitialize()

            # パスリストを作成
            paths = []
            for i in range(1, pages + 1):
                p = os.path.join(outdir, f"{i:04d}.png")
                if os.path.exists(p):
                    paths.append(p)

            self.slide_paths = paths
            self.show_message(f"エクスポート完了:\n{len(paths)}枚")
            self.update_status(f"{os.path.basename(path)} ({len(paths)}枚)")
            return len(paths) > 0

        except Exception as e:
            # エラー時のクリーンアップ
            if self.doc is not None:
                try:
                    self.doc.Close()
                except:
                    pass
                finally:
                    self.doc = None
            if self.word_app is not None:
                try:
                    self.word_app.Quit()
                except:
                    pass
                finally:
                    self.word_app = None
            try:
                pythoncom.CoUninitialize()
            except:
                pass
            self.update_status("エラー: DOCXエクスポート失敗", "red")
            self.show_message(f"エラー:\n{str(e)}")
            return False

    def export_pdf_slides(self, path):
        """PDFファイルを画像にエクスポート"""
        if not PDF_SUPPORT:
            self.update_status("エラー: PDF未サポート", "red")
            self.show_message("エラー:\nPyMuPDFがインストールされていません")
            return False

        try:
            self.show_message("PDFエクスポート開始...")
            self.root.update()

            # PDFを開く
            pdf_doc = fitz.open(path)
            n = len(pdf_doc)

            # キャッシュディレクトリを作成
            h = hashlib.sha1((path + str(os.path.getmtime(path))).encode()).hexdigest()[:16]
            outdir = os.path.join(CACHE_ROOT, h)
            if os.path.isdir(outdir):
                shutil.rmtree(outdir, ignore_errors=True)
            os.makedirs(outdir, exist_ok=True)

            # 各ページを画像として保存
            for i in range(n):
                page = pdf_doc[i]
                # 解像度設定(3倍)
                mat = fitz.Matrix(3.0, 3.0)
                pix = page.get_pixmap(matrix=mat)
                fname = f"{i+1:04d}.png"
                pix.save(os.path.join(outdir, fname))
                pix = None

            pdf_doc.close()

            # パスリストを作成
            paths = []
            for i in range(1, n + 1):
                p = os.path.join(outdir, f"{i:04d}.png")
                if os.path.exists(p):
                    paths.append(p)

            self.slide_paths = paths
            self.show_message(f"エクスポート完了:\n{len(paths)}枚")
            self.update_status(f"{os.path.basename(path)} ({len(paths)}枚)")
            return len(paths) > 0

        except Exception as e:
            self.update_status("エラー: PDFエクスポート失敗", "red")
            self.show_message(f"エラー:\n{str(e)}")
            return False

    def export_all_slides(self, path):
        # 既存のCOMオブジェクトをクリーンアップ
        if self.pres is not None:
            try:
                self.pres.Close()
            except:
                pass
            self.pres = None
        if self.app is not None:
            try:
                self.app.Quit()
            except:
                pass
            self.app = None

        try:
            self.show_message("エクスポート開始...")
            self.root.update()
            pythoncom.CoInitialize()
            self.app = gencache.EnsureDispatch("PowerPoint.Application")
            self.pres = self.app.Presentations.Open(path, True, False, False)

            wpt, hpt = self.pres.PageSetup.SlideWidth, self.pres.PageSetup.SlideHeight
            if wpt >= hpt:
                wpx = 3840
                hpx = round(3840 * hpt / wpt)
            else:
                hpx = 3840
                wpx = round(3840 * wpt / hpt)

            n = self.pres.Slides.Count
            h = hashlib.sha1((path + str(os.path.getmtime(path))).encode()).hexdigest()[:16]
            outdir = os.path.join(CACHE_ROOT, h)
            if os.path.isdir(outdir):
                shutil.rmtree(outdir, ignore_errors=True)
            os.makedirs(outdir, exist_ok=True)

            for i in range(1, n + 1):
                fname = f"{i:04d}.png"
                self.pres.Slides(i).Export(os.path.join(outdir, fname), "PNG", wpx, hpx)

            # COMオブジェクトを適切に解放
            self.pres.Close()
            self.pres = None
            self.app.Quit()
            self.app = None
            pythoncom.CoUninitialize()

            paths = []
            for i in range(1, n + 1):
                p = os.path.join(outdir, f"{i:04d}.png")
                if os.path.exists(p):
                    paths.append(p)

            self.slide_paths = paths
            self.show_message(f"エクスポート完了:\n{len(paths)}枚")
            self.update_status(f"{os.path.basename(path)} ({len(paths)}枚)")
            return len(paths) > 0
        except Exception as e:
            # エラー時のクリーンアップ
            if self.pres is not None:
                try:
                    self.pres.Close()
                except:
                    pass
                finally:
                    self.pres = None
            if self.app is not None:
                try:
                    self.app.Quit()
                except:
                    pass
                finally:
                    self.app = None
            try:
                pythoncom.CoUninitialize()
            except:
                pass
            self.update_status("エラー: エクスポート失敗", "red")
            self.show_message(f"エラー:\n{str(e)}")
            return False

    def show_current(self):
        if not self.slide_paths or self.current_mode != "slideshow":
            return
        i = max(1, min(self.current_slide, len(self.slide_paths)))
        self.canvas.update_idletasks()
        w = self.canvas.winfo_width()
        h = self.canvas.winfo_height()
        if w < 2 or h < 2:
            self.root.after(50, self.show_current)
            return
        try:
            with Image.open(self.slide_paths[i - 1]) as src:
                iw, ih = src.size
                scale = min(w / iw, h / ih)
                nw, nh = max(1, int(iw * scale)), max(1, int(ih * scale))
                # Pillow 10.0.0以降の推奨記法
                try:
                    img = src.resize((nw, nh), Image.Resampling.LANCZOS)
                except AttributeError:
                    # 古いバージョンのPillowの場合
                    img = src.resize((nw, nh), Image.LANCZOS)
            self.tkimg = ImageTk.PhotoImage(img)

            for item in self.canvas.find_all():
                if item not in self.draw_lines:
                    self.canvas.delete(item)

            self.canvas.create_image(w // 2, h // 2, image=self.tkimg, anchor="center")
            self.canvas.tag_lower("all")

            for line in self.draw_lines:
                self.canvas.tag_raise(line)

            self.canvas.image = self.tkimg
            self.update_status(f"スライド {i}/{len(self.slide_paths)}")
        except Exception as e:
            self.show_message(f"表示エラー:\n{str(e)}")

    def next_slide(self):
        if self.slide_paths and self.current_slide < len(self.slide_paths):
            self.current_slide += 1
            self.clear_drawings()
            self.show_current()
            self.save_config()

    def prev_slide(self):
        if self.slide_paths and self.current_slide > 1:
            self.current_slide -= 1
            self.clear_drawings()
            self.show_current()
            self.save_config()

    def on_resize(self, event):
        if self.current_mode == "guide":
            self.show_guide()
        elif self.slide_paths and self.current_mode == "slideshow":
            self.show_current()

    def end_show(self):
        self.slide_paths = []
        self.tkimg = None
        self.clear_drawings()
        self.canvas.delete("all")
        self.update_status("スライドショー終了")
        self.show_message("スライドショー終了")
        self.stop_timer()
        if self.time_update_id:
            self.root.after_cancel(self.time_update_id)
            self.time_update_id = None

    def run(self):
        self.root.mainloop()

    def on_closing(self):
        self.save_config()
        self.stop_timer()
        self.stop_auto_backup()
        if self.time_update_id:
            self.root.after_cancel(self.time_update_id)
        if self.hide_timer_id:
            self.root.after_cancel(self.hide_timer_id)
        if self.pres is not None:
            try:
                self.pres.Close()
            except:
                pass
        if self.app is not None:
            try:
                self.app.Quit()
            except:
                pass
        if self.doc is not None:
            try:
                self.doc.Close()
            except:
                pass
        if self.word_app is not None:
            try:
                self.word_app.Quit()
            except:
                pass
        self.root.destroy()

if __name__ == "__main__":
    PPTViewer().run()

ユーザガイド

基本操作

「PPTX/PDF/DOCXファイルを開く」でファイル選択後、「スライドショー開始」で表示開始。スペース/右矢印で次へ、左矢印/BackSpaceで前へ移動。マウスホイールでも操作可能。F11で全画面切替。

描画とモード

スライド上でマウスドラッグすると赤い線を描画できる。「案内」ボタンで案内表示モード(編集可能な2行テキストと時刻表示)、「メモ帳」ボタンでテキスト編集モードに切替。モード切替時に描画は消去される。

メモ帳機能

メモ帳モードではテキスト入力、フォントサイズ変更(16/32/48/64)、選択文字の赤色化が可能。「保存」でresult.txtに出力。1分ごとに自動バックアップされる。

その他

「再開」で前回のファイルとページから継続。全画面時は右端にマウス移動でメニュー表示。プレゼンテーションタイマーが自動計測される。

プログラムの構造と挙動の要点

概要

PowerPoint、PDF、Word(docx)を画像に変換して表示するデスクトップビューアである。表示モードは3種(slideshow、guide、notepad)である。キャンバスにスライド画像を表示し、描画(赤い軌跡)や案内表示、メモ作成を行う。右側のコントロールパネルは固定幅250ピクセルで、全画面時は自動的に隠れ、マウスを右端に移動すると再表示される。

主要ライブラリと前提

設定・キャッシュ

CONFIG_FILEはppt_config.jsonに現在ファイル、スライド番号、線幅を保存・復元する。CACHE_ROOTはLOCALAPPDATA配下にアプリ専用キャッシュディレクトリである。入力ファイルパスと更新時刻のSHA1ハッシュでサブディレクトリを決定し、ページごとのPNG画像を保存する。

UI構成

main_container内に左右2分割である。

notepad_frameはTextウィジェット+垂直スクロールバーである。メモ帳モードでCanvasの代わりにpackする。

動作モード

  1. slideshow:スライド画像をCanvas中央にアスペクト比を保持してフィット表示する。左右キー・スペース・マウスホイールで前後移動する。描画可能である。
  2. guide:背景白で案内テキスト2行(黒と赤)と現在時刻をCanvasに表示する。1秒ごとに時刻を更新する。モード切替時に軌跡をクリアする。
  3. notepad:Textウィジェットでメモ編集する。フォントサイズボタン(16/32/48/64)、選択文字の赤色化、result.txt保存、自動バックアップ(1分ごと)がある。

スライドの取り込み(エクスポート)

取り込み後、slide_pathsに画像パスリストを設定する。show_currentでリサイズ表示する。

描画機能

Canvas上でマウスドラッグすると赤色の線を描画する(capstyle/joinstyle=ROUND、stipple='gray50')。線の太さは右パネルのスライダーで5~50の範囲で変更可能である。notepadモードでは描画しない。スライド切替時とモード切替時(slideshow⇔guide)に軌跡をクリアする。

全画面と右パネルの自動表示

F11キーで全画面トグルする。enter_fullscreen時はパネルを隠す。on_root_motionでマウスが画面右端から300ピクセル以内に入るとshow_panel、離れると3秒後にhide_panelする。show_panel/hide_panelはnotepadモードか否かで再pack手順を分岐する。right_frame→left_frame→notepad_frameの順に再配置して右パネルを表示させる。

タイマーとバックアップ

start_show/resume_showでプレゼンテーションタイマー開始、1秒ごとに経過時間を表示更新する。end_showで停止する。notepadモード中は60秒間隔で自動バックアップファイル(BACKUP+タイムスタンプ.txt)を作成する。

状態管理と終了処理

状態はcurrent_modecurrent_filecurrent_slideslide_pathsline_width等である。終了時に設定保存、タイマーやafterイベントのキャンセル、COMオブジェクトのClose/Quit、pythoncom.CoUninitialize、Tkウィンドウ破棄を実行する。