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

概要

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

目次

関連する外部ページ

サイト内の関連情報

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

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

2. Python 3.12 のインストール

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

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

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

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

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

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

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

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

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

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

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

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

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

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

インストールの確認

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

python --version

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

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

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

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

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

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

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

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

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

インストールコマンド


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

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

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

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

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

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

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

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

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

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

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

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

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

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

pip install -U --no-user Pillow pywin32 PyMuPDF

5. プログラムコード

ソースコード

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

import tkinter as tk
from tkinter import filedialog
import os
import json
import time
import hashlib
import shutil
from PIL import Image, ImageTk
import pythoncom
import win32com.client.gencache as gencache
import pymupdf

# 設定ファイルとキャッシュディレクトリの定義
CONFIG_FILE = "ppt_config.json"
APP_NAME = "PPTStaticViewer"
CACHE_ROOT = os.path.join(os.environ["LOCALAPPDATA"], APP_NAME, "cache")
PDF_RENDER_DPI = 216  # PDFページを画像化するときの解像度(72dpiの3倍)
SLIDE_EXPORT_PIXELS = 3840  # PowerPointスライドを画像化するときの長辺の画素数

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)

        # ファイルを開くボタン
        tk.Button(button_area, text="PPTX/PDF/DOCXファイルを開く",
                  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("<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")
        # 案内モードでは軌跡以外を削除
        for item in self.canvas.find_all():
            if item not in self.draw_lines:
                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):
        # 選択範囲がある場合だけタグを追加して赤色にする
        if not self.text_widget.tag_ranges(tk.SEL):
            return
        self.text_widget.tag_add("red", tk.SEL_FIRST, tk.SEL_LAST)
        self.text_widget.tag_config("red", foreground="red")

    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 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()

    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 = round(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()

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

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

        # 時刻表示
        self.guide_time_item = self.canvas.create_text(
            w // 2, h // 2 + 120,
            text=time.strftime("%H:%M:%S"),
            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_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):
        if not os.path.exists(CONFIG_FILE):
            return
        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))

    def save_config(self):
        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)

    def open_file(self):
        filetypes = [("Supported Files", "*.pptx *.ppt *.pdf *.docx"),
                     ("PowerPoint", "*.pptx *.ppt"),
                     ("PDF", "*.pdf"),
                     ("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):
        print("プレゼンテーションビューアーを開始します")
        print("操作方法:スペース・右矢印(次へ)、左矢印・BackSpace・Delete(前へ)")
        print("注意事項:全画面モードではF11キーで切り替え可能です")

        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):
        print("前回の状態から再開します")

        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
            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:
            self.doc.Close()
            self.doc = None
        if self.word_app is not None:
            self.word_app.Quit()
            self.word_app = None

        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)

        # キャッシュディレクトリを作成
        h = hashlib.blake2b((path + str(os.path.getmtime(path))).encode(), digest_size=16).hexdigest()
        outdir = os.path.join(CACHE_ROOT, h)
        if os.path.isdir(outdir):
            shutil.rmtree(outdir)
        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を画像に変換
        pdf_doc = pymupdf.open(temp_pdf)
        page_count = len(pdf_doc)
        for i, page in enumerate(pdf_doc, 1):
            page.get_pixmap(dpi=PDF_RENDER_DPI).save(os.path.join(outdir, f"{i:04d}.png"))
        pdf_doc.close()
        os.remove(temp_pdf)  # 一時PDFを削除

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

        # パスリストを作成
        paths = []
        for i in range(1, page_count + 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

    def export_pdf_slides(self, path):
        """PDFファイルを画像にエクスポート"""
        self.show_message("PDFエクスポート開始...")
        self.root.update()

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

        # PDFを画像に変換
        pdf_doc = pymupdf.open(path)
        page_count = len(pdf_doc)
        for i, page in enumerate(pdf_doc, 1):
            page.get_pixmap(dpi=PDF_RENDER_DPI).save(os.path.join(outdir, f"{i:04d}.png"))
        pdf_doc.close()

        # パスリストを作成
        paths = []
        for i in range(1, page_count + 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

    def export_all_slides(self, path):
        """PowerPointファイルを画像にエクスポート"""
        # 既存のCOMオブジェクトをクリーンアップ
        if self.pres is not None:
            self.pres.Close()
            self.pres = None
        if self.app is not None:
            self.app.Quit()
            self.app = None

        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
        aspect_ratio = wpt / hpt
        if aspect_ratio >= 1:  # 横長または正方形
            wpx = SLIDE_EXPORT_PIXELS
            hpx = round(SLIDE_EXPORT_PIXELS / aspect_ratio)
        else:  # 縦長
            hpx = SLIDE_EXPORT_PIXELS
            wpx = round(SLIDE_EXPORT_PIXELS * aspect_ratio)

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

        # 各スライドをPNGとして保存
        page_count = self.pres.Slides.Count
        for i in range(1, page_count + 1):
            self.pres.Slides(i).Export(os.path.join(outdir, f"{i:04d}.png"), "PNG", wpx, hpx)

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

        # パスリストを作成
        paths = []
        for i in range(1, page_count + 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

    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

        with Image.open(self.slide_paths[i - 1]) as src:
            iw, ih = src.size
            scale = min(w / iw, h / ih)
            # 四捨五入を使用してより正確なサイズ計算
            nw, nh = max(1, round(iw * scale)), max(1, round(ih * scale))
            img = src.resize((nw, nh), Image.Resampling.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.update_status(f"スライド {i}/{len(self.slide_paths)}")

    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):
        print("スライドショーを終了します")

        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):
        print("PowerPoint Viewerを起動します")
        print("対応形式:PPTX、PPT、PDF、DOCX")
        print("機能:スライドショー、案内表示、メモ帳、軌跡描画")

        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:
            self.pres.Close()
        if self.app is not None:
            self.app.Quit()
        if self.doc is not None:
            self.doc.Close()
        if self.word_app is not None:
            self.word_app.Quit()
        self.root.destroy()

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

6. ユーザガイド

基本操作

「PPTX/PDF/DOCXファイルを開く」でファイルを選択し、「スライドショー開始」で表示を始める。スペースキーまたは右矢印キーで次へ、左矢印キー・BackSpaceキー・Deleteキーで前へ移動する。マウスホイールでも移動できる。F11キーで全画面表示とウィンドウ表示を切り替える。

描画とモード

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

メモ帳機能

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

その他

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

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

概要

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

主要ライブラリと前提

設定・キャッシュ

CONFIG_FILEはppt_config.jsonで、現在のファイル、スライド番号、線幅を保存・復元する。CACHE_ROOTはLOCALAPPDATA配下のアプリ専用キャッシュディレクトリである。入力ファイルパスと更新時刻のBLAKE2bハッシュでサブディレクトリを決め、ページごとの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ウィンドウの破棄を実行する。