pptx2md_with_image.py ダウンロード/コピー

pptx2md_with_image.py をダウンロード

pptx2md_with_image.py
pptx2md_with_image.py
  1# -*- coding: utf-8 -*-
  2"""
  3pptx2md_with_image.py
  4
  5概要: PowerPointプレゼンテーションからテキスト、数式、画像を抽出し、AIモデル(OpenAI, Gemini)を用いて解説レポートを生成するスクリプト。
  6
  7詳細説明:
  8このスクリプトは、指定されたPowerPoint (.pptx) ファイルを処理し、各スライドから以下の情報を抽出します。
  9- スライドのタイトルと通常のテキストコンテンツ。
 10- Office Math Markup Language (OMML) 形式で記述された数式をLaTeX形式に変換。
 11- スライドに埋め込まれた画像を抽出し、指定された作業ディレクトリに保存。
 12
 13抽出されたテキスト、変換されたLaTeX数式、および各スライドの画像は、
 14指定されたAIモデル (OpenAI GPT-4o/GPT-5.2またはGoogle Gemini) への入力として使用されます。
 15AIはこれらの情報に基づいてスライドの詳細な解説を生成し、その結果はMarkdown形式のレポートとして出力されます。
 16
 17PDF変換には `pywin32`、画像抽出には `PyMuPDF`、PPTX解析には `python-pptx` と `lxml` が必要です。
 18
 19関連リンク: :doc:`pptx2md_with_image_usage`
 20"""
 21import argparse
 22import base64
 23import re
 24import sys
 25import os
 26import time
 27from pathlib import Path
 28from typing import Dict, List, Optional
 29
 30# --- ライブラリのインポートチェック ---
 31try:
 32    import win32com.client  # pywin32 (PPTX -> PDF画像化用)
 33except ImportError:
 34    print("Import error: win32com. Please install it with: pip install pywin32")
 35    input("\nPress ENTER to terminate>>\n")
 36    sys.exit(1)
 37
 38try:
 39    import fitz  # PyMuPDF (PDF -> PNG用)
 40except ImportError:
 41    print("Import error: fitz. Please install it with: pip install pymupdf")
 42    input("\nPress ENTER to terminate>>\n")
 43    sys.exit(1)
 44
 45try:
 46    from pptx import Presentation  # テキスト抽出用
 47    from lxml import etree         # 数式解析用
 48except ImportError:
 49    print("Import error: python-pptx or lxml. Please install: pip install python-pptx lxml")
 50    input("\nPress ENTER to terminate>>\n")
 51    sys.exit(1)
 52
 53from openai import OpenAI
 54try:
 55    import google.generativeai as genai
 56except ImportError:
 57    print("Geminiを利用する場合は 'pip install google-generativeai' が必要です。")
 58    input("\nPress ENTER to terminate>>\n")
 59    sys.exit(1)
 60
 61# プログラムの実行を一時停止するかどうかのフラグ
 62pause = 0
 63
 64
 65# =========================================================
 66# 1. Advanced Math Extraction Logic (Provided Code Integrated)
 67# =========================================================
 68
 69# 名前空間の定義
 70NAMESPACES = {
 71    'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
 72    'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
 73    'm': 'http://schemas.openxmlformats.org/officeDocument/2006/math',
 74    'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
 75    'a14': 'http://schemas.microsoft.com/office/drawing/2010/main'
 76}
 77
 78# Unicode 数学記号をASCII/LaTeXに変換する辞書
 79MATH_UNICODE_MAP = {
 80    '𝑷': 'P', '𝑽': 'V', '𝑹': 'R', '𝑻': 'T',
 81    '𝒂': 'a', '𝒃': 'b', '𝒄': 'c', '𝒅': '\\mathrm{d}', '𝒆': 'e', '𝒇': 'f', '𝒈': 'g',
 82    '𝒉': 'h', '𝒊': 'i', '𝒋': 'j', '𝒌': 'k', '𝒍': 'l', '𝒎': 'm', '𝒏': 'n',
 83    '𝒙': 'x', '𝒚': 'y', '𝒛': 'z',
 84    '𝟐': '2', '𝟏': '1', '𝟎': '0',
 85    '−': '-', '+': '+', '÷': '/', '×': '*', '⋅': '\\cdot',
 86    '…': '...', '∞': '\\infty',
 87    '∑': '\\sum', '∏': '\\prod',
 88    '∫': '\\int', '∬': '\\iint', '∭': '\\iiint', '∮': '\\oint',
 89    'α': '\\alpha', 'β': '\\beta', 'γ': '\\gamma', 'δ': '\\delta', 'ε': '\\epsilon',
 90    'ζ': '\\zeta', 'η': '\\eta', 'θ': '\\theta', 'ι': '\\iota', 'κ': '\\kappa',
 91    'λ': '\\lambda', 'μ': '\\mu', 'ν': '\\nu', 'ξ': '\\xi', 'π': '\\pi', 'ρ': '\\rho',
 92    'σ': '\\sigma', 'τ': '\\tau', 'υ': '\\upsilon', 'φ': '\\phi', 'χ': '\\chi',
 93    'ψ': '\\psi', 'ω': '\\omega',
 94    'Γ': '\\Gamma', 'Δ': '\\Delta', 'Θ': '\\Theta', 'Λ': '\\Lambda',
 95    'Ξ': '\\Xi', 'Π': '\\Pi', 'Σ': '\\Sigma', 'Φ': '\\Phi', 'Ψ': '\\Psi', 'Ω': '\\Omega',
 96    '′': "'", '°': '\\degree', '℃': '\\degree C'
 97}
 98
 99# n-ary演算子のOMML文字→LaTeXの対応
100NARY_TO_LATEX = {
101    '∑': '\\sum', '∏': '\\prod',
102    '∫': '\\int', '∬': '\\iint', '∭': '\\iiint', '∮': '\\oint',
103    '⋀': '\\bigwedge', '⋁': '\\bigvee', '⋂': '\\bigcap', '⋃': '\\bigcup',
104}
105OPERATOR_CHARS = set(['∑', '∏', '∫', '∬', '∭', '∮'])
106
107
108# AIへのプロンプトテンプレートのデフォルト値
109DEFAULT_PROMPT_TEMPLATE = """
110あなたはプロフェッショナルな講師です。
111スライドから抽出されたテキスト情報(Markdown形式)と添付のスライド画像を統合し、
112解説を作成してください。
113
114# スライド番号: {slide_no}
115# 抽出テキスト情報:
116{slide_text}
117# 出力言語: {lang}
118
119# 指示:
1201. 画像内の図表やレイアウトを考慮しつつ、抽出テキスト内の数式を含めて正確に解説してください。
1212. 以下のフォーマットで出力してください。
1223. 出力Markdown中の数式は全て、
123$$改行
124LaTeX改行
125$$
126のブロック数式で出力してください
127
128改行
129## 1. 解説
130## 2. 図・グラフの分析
131## 3. このスライドから読み取れる詳細な情報、データの議論・予測
132改行
133"""
134
135prompt_template = DEFAULT_PROMPT_TEMPLATE
136_VAR_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
137
138
139def terminate():
140    """
141    スクリプトを終了させ、必要に応じてユーザーの入力を待機します。
142
143    詳細説明:
144    グローバル変数 `pause` が真の場合、ユーザーがEnterキーを押すまでプログラムの実行を停止します。
145    その後、プログラムは終了します。
146
147    :returns: None
148    """
149    if pause:
150        input("\nPress ENTER to terminate>>\n")
151    exit()
152
153
154def parse_args():
155    """
156    コマンドライン引数を解析し、プログラムの動作に必要な設定を取得します。
157
158    詳細説明:
159    入力ファイルパス、出力ファイル名、API選択、モデル名、言語設定、その他オプションを解析します。
160    OpenAIおよびGeminiのAPIキーは環境変数から取得されます。
161    モデル名が明示的に指定された場合、対応するAPIのモデル設定を上書きします。
162
163    :returns: tuple[argparse.ArgumentParser, argparse.Namespace] 解析器オブジェクトと引数オブジェクトのタプル。
164    """
165    parser = argparse.ArgumentParser(description="PPTX -> Text/Math -> AI Explanation")
166    parser.add_argument("infile", type=str, help="Path to input PPTX")
167    parser.add_argument("-o", "--output", default = None, help="出力するMarkdownファイル名")
168    parser.add_argument("--txt", type=str, default=None, help="Optional: Path to existing text file")
169    parser.add_argument("--ini", "-i", type=str, default=Path(__file__).stem + ".ini",
170                        help="prompt_templateを読み込むINIファイル。指定がなければスクリプト名.iniを探索。")
171
172    openai_model = os.getenv("OPENAI_MODEL", "gpt-4o")
173    openai_model5 = os.getenv("OPENAI_MODEL5", "gpt-5.2")
174    google_model = os.getenv("GEMINI_MODEL") or os.getenv("GOOGLE_MODEL", "gemini-2.5-flash")
175
176    parser.add_argument("--api", "-a", type=str, default="gemini",
177                        choices=["gemini", "google", "openai5", "openai"])
178    parser.add_argument("--model", default=None)
179    parser.add_argument("--google_model", default=google_model)
180    parser.add_argument("--openai_model", default=openai_model)
181    parser.add_argument("--openai_model5", default=openai_model5)
182    parser.add_argument("--visible", action="store_true")
183    parser.add_argument("--language", type=str, default="Japanese")
184    parser.add_argument("--pause", type=int, default=0)
185
186    args = parser.parse_args()
187
188    args.openai_key = os.getenv("OPENAI_API_KEY")
189    args.gemini_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
190
191    if args.model:
192        if args.api == 'openai5': args.openai_model5 = args.model
193        elif args.api == 'openai': args.openai_model = args.model
194        elif args.api in ('gemini', 'google'): args.google_model = args.model
195
196    return parser, args
197
198
199def search_file(infile=None):
200    """
201    指定されたファイルがカレントディレクトリまたはスクリプトディレクトリに存在するかを探索します。
202
203    :param infile: Optional[str] 探索するファイル名。指定がない場合はデフォルトのINIファイル名を探索。
204    :returns: Optional[str] 見つかったファイルの絶対パス、またはNone。
205    """
206    script_path = os.path.abspath(sys.argv[0])
207    script_dir = os.path.dirname(script_path)
208    script_name = os.path.splitext(os.path.basename(script_path))[0]
209    default_ini = f"{script_name}.ini"
210
211    if infile is None:
212        for path in [os.getcwd(), script_dir]:
213            candidate = os.path.join(path, default_ini)
214            if os.path.isfile(candidate):
215                return candidate
216        return None
217
218    if not os.path.isfile(infile):
219        candidate = os.path.join(script_dir, infile)
220        if os.path.isfile(candidate):
221            return candidate
222        return None
223
224    return infile
225
226def read_ini(inifile=None):
227    """
228    INIファイルから設定を読み込み、キーと値の辞書を返します。
229
230    詳細説明:
231    この関数は、指定されたINIファイル(またはデフォルトのINIファイル)を解析し、設定を辞書としてロードします。
232    コメント行(`#` または `;` で始まる)はスキップされます。
233    複数行の値は、三重引用符(`\"\"\"` または `\'\'\'`)で囲むことでサポートされます。
234    キーと値のペア内の変数は、`$VAR_NAME` の形式で定義済み変数に展開されます。
235
236    :param inifile: Optional[str] 読み込むINIファイルのパス。
237    :returns: dict INIファイルから読み込まれた設定の辞書。
238    :raises FileNotFoundError: 指定されたINIファイルが見つからない場合。
239    """
240    path = search_file(inifile)
241    if path is None:
242        raise FileNotFoundError("INIファイルが見つかりませんでした")
243
244    result = {}
245    variables = {}
246    current_key = None
247    multiline_val = []
248    multiline_delim = None
249
250    with open(path, 'r', encoding='utf-8') as f:
251        for line in f:
252            line = line.rstrip()
253
254            if not line or line.startswith('#') or line.startswith(';'):
255                continue
256
257            # 複数行値の終了判定(stripで判定)
258            if multiline_delim:
259                if line.strip() == multiline_delim:
260                    val = '\n'.join(multiline_val)
261                    result[current_key] = val
262                    variables[current_key] = val
263                    current_key = None
264                    multiline_val = []
265                    multiline_delim = None
266                else:
267                    multiline_val.append(line)
268                continue
269
270            # key=val の解析
271            if '=' in line:
272                key, val = map(str.strip, line.split('=', 1))
273                val = val.strip()
274
275                # 複数行値の開始判定(空文字でも対応)
276                if (val == '\"\"\"' or val == "\'\'\'" or
277                   (val.startswith('\"\"\"') and not val.endswith('\"\"\"')) or \
278                   (val.startswith("\'\'\'") and not val.endswith("\'\'\'")) ):
279                    multiline_delim = val[:3]
280                    content = val[3:]
281                    multiline_val = [content] if content else []
282                    current_key = key
283                    continue
284
285                # 単一行の複数行値
286                if (val.startswith('\"\"\"') and val.endswith('\"\"\"')) or \
287                   (val.startswith("\'\'\'") and val.endswith("\'\'\'")):
288                    val = val[3:-3]
289
290                result[key] = val
291                variables[key] = val
292
293    # 変数展開(あとから一括処理)
294    for key, val in result.items():
295        def expand_var(match):
296            var_name = match.group(1)
297            return variables.get(var_name, match.group(0))
298        result[key] = re.sub(r"\$(\w+)\b", expand_var, val)
299
300    return result
301
302def find_ini_path(ini_name: str) -> Optional[Path]:
303    """
304    INIファイルのパスをカレントディレクトリ、次にスクリプトディレクトリの順に探索します。
305
306    :param ini_name: str 探索するINIファイル名。
307    :returns: Optional[Path] 見つかったINIファイルの `Path` オブジェクト、または `None`。
308    """
309    if not ini_name:
310        return None
311    p = Path(ini_name)
312    if p.is_absolute() and p.is_file():
313        return p
314    cwd_path = Path.cwd() / p.name
315    if cwd_path.is_file():
316        return cwd_path
317    script_path = Path(__file__).resolve().parent / p.name
318    if script_path.is_file():
319        return script_path
320    return None
321
322def _safe_text_replace_math_unicode(text: str) -> str:
323    """
324    入力テキスト内の特定のUnicode数学記号を、ASCIIまたはLaTeX表現に安全に変換します。
325
326    :param text: str 変換対象の文字列。
327    :returns: str 変換後の文字列。
328    """
329    if not text:
330        return ""
331    for u, ltx in MATH_UNICODE_MAP.items():
332        text = text.replace(u, ltx)
333    return text
334
335def _find_first(element, candidates):
336    """
337    指定された要素の子要素の中から、候補リスト内のタグ名に一致する最初の要素を見つけます。
338
339    :param element: etree._Element 探索対象のXML要素。
340    :param candidates: List[str] 探索する子要素のタグ名(OMML形式)。
341    :returns: Optional[etree._Element] 見つかった最初の要素、またはNone。
342    """
343    for cand in candidates:
344        found = element.find(cand, NAMESPACES)
345        if found is not None:
346            return found
347    return None
348
349def _detect_nary_op_char(element):
350    """
351    n-ary演算子要素から演算子文字を検出します。
352
353    詳細説明:
354    まず `m:naryPr/m:chr` タグの `val` 属性を探します。
355    見つからない場合、演算子文字として定義された文字 (`OPERATOR_CHARS`) が
356    要素内のテキストラン (`m:t`) に含まれていないかを探索します。
357
358    :param element: etree._Element n-ary演算子を表すXML要素。
359    :returns: str 検出された演算子文字、または空文字列。
360    """
361    op_tag = element.find('m:naryPr/m:chr', NAMESPACES)
362    if op_tag is not None:
363        val = op_tag.get(f"{{{NAMESPACES['m']}}}val", "")
364        if val:
365            return val
366    ts = element.xpath('.//m:t[not(ancestor::m:e) and not(ancestor::m:sub) and not(ancestor::m:sup)]', namespaces=NAMESPACES)
367    for t in ts:
368        s = t.text or ""
369        for ch in s:
370            if ch in OPERATOR_CHARS:
371                return ch
372    return ""
373
374def omml_to_latex(element):
375    """
376    Office Math Markup Language (OMML) のXML要素を再帰的に解析し、対応するLaTeX形式の文字列に変換します。
377
378    詳細説明:
379    この関数は、PowerPointスライドから抽出されたOMML形式の数式XMLを解析し、
380    LaTeX互換の表現に変換します。以下のOMML要素を処理します。
381    - `oMath`, `oMathPara`: 数式ブロック。
382    - `f` (Fraction): 分数。`\\frac{numerator}{denominator}`
383    - `rad` (Root): 根号。`\\sqrt{expression}` または `\\sqrt[degree]{expression}`
384    - `sSup` (Superscript): 上付き文字。`base^{superscript}`
385    - `sSub` (Subscript): 下付き文字。`base_{subscript}`
386    - `sSubSup` (Subscript and Superscript): 上下付き文字。`base_{subscript}^{superscript}`
387    - `d` (Delimiter): 区切り文字(括弧など)。
388    - `r` (Run): テキストラン。
389    - `t` (Text): テキストコンテンツ。`MATH_UNICODE_MAP` を使用してUnicode数学記号を変換。
390    - `limLow` (Limit Lower): 下限。`base_{lower_limit}`
391    - `limUpp` (Limit Upper): 上限。`base^{upper_limit}`
392    - `int` (Integral): 積分。`\\int_{lower}^{upper}{expression}`
393    - `nary` (N-ary Operator): n-ary演算子(Σ, Πなど)。`operator_{lower}^{upper}{expression}`
394
395    :param element: etree._Element 変換対象のOMML XML要素。
396    :returns: str 変換されたLaTeX文字列。
397    """
398    tag = etree.QName(element).localname
399
400    if tag in ('oMath', 'oMathPara'):
401        return "".join(omml_to_latex(child) for child in element)
402
403    elif tag == 'f':  # Fraction
404        num = element.find('m:num', NAMESPACES)
405        den = element.find('m:den', NAMESPACES)
406        return f"\\frac{{{omml_to_latex(num)}}}{{{omml_to_latex(den)}}}"
407
408    elif tag == 'rad':  # Root
409        deg = element.find('m:deg', NAMESPACES)
410        e = element.find('m:e', NAMESPACES)
411        if deg is not None:
412            return f"\\sqrt[{omml_to_latex(deg)}]{{{omml_to_latex(e)}}}"
413        return f"\\sqrt{{{omml_to_latex(e)}}}"
414
415    elif tag == 'sSup':  # Superscript
416        e = element.find('m:e', NAMESPACES)
417        sup = element.find('m:sup', NAMESPACES)
418        return f"{omml_to_latex(e)}^{{{omml_to_latex(sup)}}}"
419
420    elif tag == 'sSub':  # Subscript
421        e = element.find('m:e', NAMESPACES)
422        sub = element.find('m:sub', NAMESPACES)
423        return f"{omml_to_latex(e)}_{{{omml_to_latex(sub)}}}"
424
425    elif tag == 'sSubSup':  # Subscript + Superscript
426        e = element.find('m:e', NAMESPACES)
427        sub = element.find('m:sub', NAMESPACES)
428        sup = element.find('m:sup', NAMESPACES)
429        base = omml_to_latex(e)
430        sub_l = omml_to_latex(sub) if sub is not None else ""
431        sup_l = omml_to_latex(sup) if sup is not None else ""
432        return f"{base}_{{{sub_l}}}^{{{sup_l}}}"
433
434    elif tag == 'd':  # Delimiter
435        beg_chr = element.find('m:dPr/m:begChr', NAMESPACES)
436        end_chr = element.find('m:dPr/m:endChr', NAMESPACES)
437        beg = beg_chr.get(f"{{{NAMESPACES['m']}}}val") if beg_chr is not None else "("
438        end = end_chr.get(f"{{{NAMESPACES['m']}}}val") if end_chr is not None else ")"
439        content = "".join(omml_to_latex(child) for child in element if etree.QName(child).localname != 'dPr')
440        return f"{beg}{content}{end}"
441
442    elif tag == 'r':  # Run
443        return "".join(omml_to_latex(child) for child in element)
444
445    elif tag == 't':  # Text
446        return _safe_text_replace_math_unicode(element.text or "")
447
448    elif tag == 'limLow':
449        base = omml_to_latex(element.find('m:e', NAMESPACES))
450        low = omml_to_latex(element.find('m:lim', NAMESPACES))
451        return f"{base}_{{{low}}}"
452
453    elif tag == 'limUpp':
454        base = omml_to_latex(element.find('m:e', NAMESPACES))
455        upp = omml_to_latex(element.find('m:lim', NAMESPACES))
456        return f"{base}^{{{upp}}}"
457
458    elif tag == 'int':
459        lower = _find_first(element, ['m:sub', 'm:low'])
460        upper = _find_first(element, ['m:sup', 'm:up'])
461        e = element.find('m:e', NAMESPACES)
462        lower_ltx = omml_to_latex(lower) if lower is not None else ""
463        upper_ltx = omml_to_latex(upper) if upper is not None else ""
464        e_ltx = omml_to_latex(e) if e is not None else ""
465        if lower_ltx or upper_ltx:
466            return f"\\int_{{{lower_ltx}}}^{{{upper_ltx}}}{e_ltx}"
467        else:
468            return f"\\int {e_ltx}"
469
470    elif tag == 'nary':
471        op_char = _detect_nary_op_char(element)
472        lower_tag = _find_first(element, ['m:sub', 'm:low'])
473        upper_tag = _find_first(element, ['m:sup', 'm:up'])
474        content_tag = element.find('m:e', NAMESPACES)
475
476        lower_ltx = omml_to_latex(lower_tag) if lower_tag is not None else ''
477        upper_ltx = omml_to_latex(upper_tag) if upper_tag is not None else ''
478        content_ltx = omml_to_latex(content_tag) if content_tag is not None else ''
479
480        op_ltx = NARY_TO_LATEX.get(op_char, _safe_text_replace_math_unicode(op_char))
481        if not op_ltx and (lower_ltx or upper_ltx):
482            op_ltx = '\\int'
483
484        if lower_ltx or upper_ltx:
485            return f"{op_ltx}_{{{lower_ltx}}}^{{{upper_ltx}}}{content_ltx}"
486        else:
487            return f"{op_ltx} {content_ltx}".rstrip()
488
489    return "".join(omml_to_latex(child) for child in element)
490
491def split_latex_blocks(s: str):
492    """
493    LaTeX文字列を `\\\\` で区切られた複数のブロックに分割します。
494
495    詳細説明:
496    複数行の数式が単一のLaTeX文字列に含まれている場合、各行(`\\\\` で区切られる)を
497    個別の数式ブロックとして扱えるように分割します。
498    各ブロックは前後の空白が除去され、空のブロックは結果から除外されます。
499    これは、Pandocなどのツールで複数行数式を正しくレンダリングするために重要です。
500
501    :param s: str 分割対象のLaTeX文字列。
502    :returns: List[str] 分割されたLaTeXブロックのリスト。
503    """
504    if not s:
505        return []
506    parts = [p.strip() for p in re.split(r'\\\\', s) if p.strip()]
507    return parts if parts else [s.strip()]
508
509def get_slide_title(slide):
510    """
511    PowerPointスライドからタイトルテキストを抽出します。
512
513    詳細説明:
514    まず、スライド内のプレースホルダーのうち、タイプ1 (タイトル) のテキストフレームを検索します。
515    タイトルが見つかった場合、そのテキストを返します。
516    プレースホルダータイトルが見つからない場合、最初に見つかったテキストフレームの最初の行をタイトルとして使用します。
517    いずれも見つからない場合は、「無題のスライド」を返します。
518
519    :param slide: pptx.slide.Slide タイトルを抽出する対象のスライドオブジェクト。
520    :returns: str スライドのタイトル文字列、または「無題のスライド」。
521    """
522    for shape in slide.shapes:
523        if getattr(shape, "has_text_frame", False) and shape.is_placeholder and shape.placeholder_format.type == 1:
524            title_text = shape.text
525            if title_text:
526                return title_text.strip()
527    for shape in slide.shapes:
528        if getattr(shape, "has_text_frame", False):
529            first_text = (shape.text or "").strip()
530            if first_text:
531                return first_text.split('\n')[0]
532    return "無題のスライド"
533
534def extract_content_to_markdown(pptx_path, output_md = None, image_dir = None, include_xml = False):
535    """
536    PPTXファイルからコンテンツを抽出し、Markdown形式の文字列として返します。
537
538    詳細説明:
539    指定されたPPTXファイルを解析し、各スライドから以下の情報を抽出してMarkdown形式に変換します。
540    - スライド番号とタイトル。
541    - 各スライド内のテキストコンテンツ。
542    - OMML形式の数式をLaTeXに変換し、`$$ ... $$` ブロックで囲みます。
543      `oMathPara` 要素を優先し、その中の`oMath`を個別のブロックとして処理します。
544      また、複数行の数式は `\\\\` で分割して個別の `$$ ... $$` ブロックにします。
545    - スライドに埋め込まれた画像を抽出し、`image_dir` に保存します。
546      Markdown内には、保存された画像への相対パスを含むリンク `![alt_text](path/to/image.png)` を挿入します。
547    抽出された内容はスライドごとにMarkdown文字列として辞書に格納され、最終的にその辞書が返されます。
548    `output_md` が指定された場合、全コンテンツをそのファイルに書き込みます。
549
550    :param pptx_path: Path 入力PPTXファイルへのパス。
551    :param output_md: Optional[str] (現状未使用) 抽出結果を保存するMarkdownファイル名。
552    :param image_dir: Optional[Path] 抽出された画像を保存するディレクトリへのパス。`None` の場合、画像は抽出されません。
553    :param include_xml: bool 元のOMML XMLをMarkdownに含めるかどうか。`True` の場合、各数式の後にXMLスニペットが追加されます。
554    :returns: Optional[Dict[int, str]] スライド番号をキー、Markdown形式のコンテンツを値とする辞書。`output_md` が指定された場合は `None`。
555    """
556
557    try:
558        presentation = Presentation(pptx_path)
559    except Exception as e:
560        print(f"エラー: ファイル '{pptx_path}' を開けませんでした。{e}")
561        return
562
563    if image_dir:
564        os.makedirs(image_dir, exist_ok=True)
565
566    print(f"Extracting text & math from PPTX: {pptx_path.name}...")
567    slides_data = {}
568    markdown_output = ""
569    for i, slide in enumerate(presentation.slides):
570        title = get_slide_title(slide)
571        page_output = f"# スライド {i + 1} {title}\n\n"
572
573        # スライドの生XML
574        slide_xml = slide.part.blob
575        root = etree.fromstring(slide_xml)
576
577        # テキスト
578        text_elements = root.xpath('//a:t', namespaces=NAMESPACES)
579        if text_elements:
580            slide_text = " ".join([elem.text for elem in text_elements if elem.text])
581            if slide_text.strip():
582                page_output += f"## テキスト\n\n{slide_text.strip()}\n\n"
583
584        # 数式(ブロック優先: oMathPara と、oMathPara外の単独oMath)
585        math_elements = root.xpath(
586            '//m:oMathPara | //m:oMath[not(ancestor::m:oMathPara)]',
587            namespaces=NAMESPACES
588        )
589        if math_elements:
590            page_output += "## 数式\n\n"
591            seen_omml = set()
592            for math_elem in math_elements:
593                # 重複ガード(OMML文字列単位)
594                omml_string = etree.tostring(math_elem, encoding='unicode')
595                if omml_string in seen_omml:
596                    continue
597                seen_omml.add(omml_string)
598
599                tag = etree.QName(math_elem).localname
600
601                # oMathPara は直下の oMath を1行ごとに出力
602                if tag == 'oMathPara':
603                    inner_oms = math_elem.findall('m:oMath', NAMESPACES)
604                    if inner_oms:
605                        for om in inner_oms:
606                            latex_code = omml_to_latex(om)
607                            for one_line in split_latex_blocks(latex_code):
608                                one_line = one_line.replace('\\mathrm{d}', '\\,\\mathrm{d}')
609                                page_output += f"$$ {one_line} $$\n\n"
610                    else:
611                        # フォールバック
612                        latex_code = omml_to_latex(math_elem)
613                        for one_line in split_latex_blocks(latex_code):
614                            one_line = one_line.replace('\\mathrm{d}', '\\,\\mathrm{d}')
615                            page_output += f"$$ {one_line} $$\n\n"
616
617                    if include_xml:
618                        page_output += f"**元のOMML XML:**\n```xml\n{omml_string.strip()}\n```\n\n"
619
620                # 単独 oMath はそのまま(ただし \\ があれば分割)
621                else:
622                    latex_code = omml_to_latex(math_elem)
623                    for one_line in split_latex_blocks(latex_code):
624                        one_line = one_line.replace('\\mathrm{d}', '\\,\\mathrm{d}')
625                        page_output += f"$$ {one_line} $$\n\n"
626                    if include_xml:
627                        page_output += f"**元のOMML XML:**\n```xml\n{omml_string.strip()}\n```\n\n"
628
629        # 画像
630        image_elements = root.xpath('//a:blip[@r:embed]', namespaces=NAMESPACES)
631        if image_dir and image_elements:
632            page_output += "## 図\n\n"
633            for j, image_elem in enumerate(image_elements):
634                r_id = image_elem.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed')
635                try:
636                    image_part = slide.part.rels[r_id].target_part
637                    image_data = image_part.blob
638                    image_ext = image_part.content_type.split('/')[-1]
639                    image_filename = f"slide{i+1}_image{j+1}.{image_ext}"
640                    image_path = os.path.join(image_dir, image_filename)
641                    with open(image_path, 'wb') as f:
642                        f.write(image_data)
643                    page_output += f"![スライド{i+1}の図{j+1}]({os.path.join(os.path.basename(image_dir), image_filename)})\n\n"
644                except KeyError:
645                    page_output += f"図のリンクが見つかりませんでした (rId: {r_id})\n\n"
646            page_output += "\n"
647
648        page_output += "\n---\n\n"
649        slides_data[i] = page_output
650        markdown_output += page_output
651#        print("page_output:", page_output)
652
653    if output_md:
654        with open(output_md, "w", encoding="utf-8") as f:
655            f.write(markdown_output)
656        print(f"抽出が完了しました。結果は '{output_md}' に保存されました。")
657    else:
658#        for key, val in slides_data.items():
659#            print(f"{key}: {val}")
660        return slides_data
661
662# =========================================================
663# 2. Text Parsing (Existing Markdown File Support)
664# =========================================================
665SLIDE_HEADER_RE = re.compile(r"^\s*#\s*Slide\s+(\d+)", re.IGNORECASE)
666
667def load_slide_texts(txt_path: Path) -> Dict[int, str]:
668    """
669    既存のMarkdownファイルからスライドごとのテキストコンテンツを読み込みます。
670
671    詳細説明:
672    入力ファイル (`txt_path`) を読み込み、`# Slide N` の形式のヘッダーを検出して
673    スライドの区切りとします。各スライドのコンテンツを抽出し、スライド番号 (1始まり) を
674    キーとする辞書として返します。これにより、PPTXからのテキスト抽出ではなく、
675    既存のテキストファイルを利用してAI処理を進めることができます。
676
677    :param txt_path: Path 読み込むテキストファイルへのパス。
678    :returns: Dict[int, str] スライド番号をキー、対応するテキストコンテンツを値とする辞書。
679                            ファイルが存在しない場合は空の辞書を返します。
680    """
681    slides: Dict[int, str] = {}
682    if not txt_path or not txt_path.exists():
683        return {}
684
685    print(f"Loading text from file: {txt_path.name}")
686    current_no: Optional[int] = None
687    buf: List[str] = []
688    
689    with txt_path.open("r", encoding="utf-8") as f:
690        for line in f:
691            m = SLIDE_HEADER_RE.match(line)
692            if m:
693                if current_no is not None: slides[current_no] = "".join(buf).strip()
694                current_no = int(m.group(1)); buf = []
695            else:
696                buf.append(line)
697    if current_no is not None: slides[current_no] = "".join(buf).strip()
698    return slides
699
700
701# =========================================================
702# 3. PDF/Image Conversion
703# =========================================================
704def pptx_to_pdf(pptx_path: Path, pdf_path: Path, visible: bool = False) -> None:
705    """
706    PowerPointプレゼンテーションファイルをPDF形式に変換します。
707
708    詳細説明:
709    `win32com.client` を使用してPowerPointアプリケーションを起動し、
710    指定されたPPTXファイル (`pptx_path`) をPDF形式 (`pdf_path`) で保存します。
711    `visible` が `False` の場合、PowerPointアプリケーションはバックグラウンドで実行され、
712    アラートも表示されません。PDFファイルが既に存在する場合は、変換をスキップします。
713
714    :param pptx_path: Path 入力PPTXファイルへのパス。
715    :param pdf_path: Path 出力PDFファイルへのパス。
716    :param visible: bool PowerPointアプリケーションのウィンドウを表示するかどうか。デフォルトは `False`。
717    :returns: None
718    :raises Exception: PowerPointアプリケーションの操作中にエラーが発生した場合。
719    """
720    abs_pptx = str(pptx_path.resolve())
721    abs_pdf = str(pdf_path.resolve())
722    if pdf_path.exists(): return
723
724    print(f"Converting PPTX to PDF...")
725    try:
726        app = win32com.client.Dispatch("PowerPoint.Application")
727        if not visible: app.DisplayAlerts = 0
728        presentation = app.Presentations.Open(abs_pptx, ReadOnly=True, Untitled=False, WithWindow=visible)
729        try:
730            presentation.SaveAs(abs_pdf, 32) # 32はPDF形式のファイルフォーマットを表す
731        finally:
732            presentation.Close()
733            if not visible: app.Quit()
734    except Exception as e:
735        print(f"PPTX->PDF Error: {e}"); raise e
736
737def convert_pdf_to_images(pdf_path: Path, out_dir: Path) -> List[Path]:
738    """
739    PDFファイルを各ページごとにPNG画像に変換します。
740
741    詳細説明:
742    `PyMuPDF` (fitz) ライブラリを使用して、指定されたPDFファイル (`pdf_path`) の
743    各ページをPNG画像として変換します。画像は `out_dir` ディレクトリに保存されます。
744    変換時には、高解像度 (2.0倍) でレンダリングされます。
745    出力ディレクトリが存在しない場合は作成されます。
746
747    :param pdf_path: Path 入力PDFファイルへのパス。
748    :param out_dir: Path 出力画像を保存するディレクトリへのパス。
749    :returns: List[Path] 生成されたPNG画像のファイルパスのリスト。
750    """
751    if not out_dir.exists(): out_dir.mkdir(parents=True, exist_ok=True)
752    doc = fitz.open(str(pdf_path.resolve()))
753    paths = []
754    print(f"Converting PDF to Images...")
755    for i in range(len(doc)):
756        mat = fitz.Matrix(2.0, 2.0) # 高解像度化
757        pix = doc.load_page(i).get_pixmap(matrix=mat)
758        p = out_dir / f"slide_{i+1:03d}.png"
759        pix.save(str(p))
760        paths.append(p)
761    doc.close()
762    return paths
763
764# =========================================================
765# 3. AI 呼び出し部
766# =========================================================
767def call_gemini(model_name, slide_no, slide_text, image_path, lang):
768    """
769    Google Gemini APIを呼び出して、スライドの解説を生成します。
770
771    詳細説明:
772    グローバルで定義された `prompt_template` を使用し、スライド番号、抽出テキスト、
773    出力言語を組み込んだプロンプトを作成します。
774    スライド画像はバイナリデータとして読み込み、プロンプトと共にGeminiモデルに送信します。
775    APIからの応答(解説テキスト)を返します。
776
777    :param model_name: str 使用するGeminiモデルの名前(例: "gemini-pro-vision")。
778    :param slide_no: int 処理中のスライド番号。
779    :param slide_text: str スライドから抽出されたテキスト情報。
780    :param image_path: Path スライド画像のファイルパス。
781    :param lang: str 出力言語。
782    :returns: str Geminiモデルが生成した解説テキスト、またはエラーメッセージ。
783    """
784    prompt = prompt_template\
785        .replace("{slide_no}", f"{slide_no}")\
786        .replace("{slide_text}", slide_text)\
787        .replace("{lang}", lang)\
788        .strip()
789
790    print(f"\nprompt:", prompt)
791
792    model = genai.GenerativeModel(model_name)
793    img_data = {'mime_type': 'image/png', 'data': image_path.read_bytes()}
794
795    try:
796        res = model.generate_content([prompt, img_data])
797        return res.text
798    except Exception as e:
799        return f"Gemini Error: {e}"
800
801
802def call_openai(client, model, slide_no, slide_text, image_path, lang):
803    """
804    OpenAI APIを呼び出して、スライドの解説を生成します。
805
806    詳細説明:
807    グローバルで定義された `prompt_template` を使用し、スライド番号、抽出テキスト、
808    出力言語を組み込んだプロンプトを作成します。
809    スライド画像はBase64エンコードされたデータURL形式に変換され、
810    プロンプトと共にOpenAIモデルに送信されます。
811    APIからの応答(解説テキスト)を返します。
812
813    :param client: openai.OpenAI OpenAI APIクライアントインスタンス。
814    :param model: str 使用するOpenAIモデルの名前(例: "gpt-4o")。
815    :param slide_no: int 処理中のスライド番号。
816    :param slide_text: str スライドから抽出されたテキスト情報。
817    :param image_path: Path スライド画像のファイルパス。
818    :param lang: str 出力言語。
819    :returns: str OpenAIモデルが生成した解説テキスト。
820    """
821    b64 = base64.b64encode(image_path.read_bytes()).decode('utf-8')
822    prompt = prompt_template\
823        .replace("{slide_no}", f"{slide_no}")\
824        .replace("{slide_text}", slide_text)\
825        .replace("{lang}", lang)\
826        .strip()
827
828    print(f"\nprompt:", prompt)
829
830    res = client.chat.completions.create(
831        model=model,
832        messages=[{"role": "user", "content": [
833            {"type": "text", "text": prompt},
834            {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
835        ]}]
836    )
837    return res.choices[0].message.content
838
839
840def main():
841    """
842    スクリプトの主要な実行ロジックをカプセル化します。
843
844    詳細説明:
845    この関数は、コマンドライン引数の解析から始まり、以下の主要なステップを実行します。
846    1.  INIファイルからプロンプトテンプレートを読み込みます(もしあれば)。
847    2.  入力ファイル (PPTXまたはPDF) に基づいて作業ディレクトリを設定します。
848    3.  PPTXファイルの場合、テキスト、数式、画像を抽出し、Markdown形式でコンテンツを準備します。
849        既存のテキストファイルが指定された場合は、そこからテキストを読み込みます。
850    4.  PPTXファイルの場合、PowerPointをPDFに変換し、さらに各PDFページをPNG画像に変換します。
851        PDFファイルが直接入力された場合は、それを画像に変換します。
852    5.  選択されたAIモデル(OpenAIまたはGemini)をAPIキーでセットアップします。
853    6.  各スライドの画像と抽出されたテキストを用いてAIを呼び出し、詳細な解説を生成します。
854    7.  生成された解説は、指定された出力Markdownファイルにスライドごとに追記されます。
855    最後に、処理の完了メッセージと出力ファイルのパスが表示されます。
856
857    :returns: None
858    """
859    global prompt_template, pause
860
861    print()
862    parser, args = parse_args()
863    if args.pause: pause = 1
864    
865    infile_path = Path(args.infile) # infile_pathをここで定義
866
867    if args.output:
868        out_md = args.output
869    else:
870        out_md = infile_path.with_name(infile_path.stem + ".md")
871
872    ini_path = find_ini_path(args.ini)
873    if ini_path:
874        try:
875            ini_data = read_ini(ini_path)
876            if "prompt_template" in ini_data:
877                prompt_template = ini_data["prompt_template"]
878                print(f"Loaded prompt_template from {ini_path}")
879            else:
880                print(f"prompt_template key not found in {ini_path}, using default.")
881        except Exception as e:
882            print(f"Warning: failed to read ini ({e}), using default prompt.")
883    else:
884        print(f"INI not found ({args.ini}), using default prompt.")
885
886#    print("prompt_template:", prompt_template)
887    print(f"infile: {args.infile}")
888    
889    
890    work_dir = infile_path.parent / (infile_path.stem + "_work")
891    img_dir = work_dir / "slides_png"
892    work_dir.mkdir(exist_ok=True)
893
894    # 1. Text/Math Extraction
895    slide_texts = {}
896    if args.txt and Path(args.txt).exists():
897        slide_texts = load_slide_texts(Path(args.txt))
898    elif infile_path.suffix.lower() == ".pptx":
899        # PPTXから高度な数式抽出を実行
900        slide_texts = extract_content_to_markdown(infile_path, image_dir=img_dir) # 画像抽出もここで行う
901    
902    # 2. PDF/Image Conversion
903    pdf_path: Path
904    if infile_path.suffix.lower() == ".pptx":
905        pdf_path = work_dir / (infile_path.stem + ".pdf")
906        pptx_to_pdf(infile_path, pdf_path, visible=args.visible)
907    else:
908        # PDF入力の場合、入力ファイル自体がPDF
909        pdf_path = infile_path
910
911    # PDFが存在しない場合はエラー、またはPPTXの場合のみPDF作成と画像変換を行う
912    if not pdf_path.exists():
913        print(f"Error: PDF file not found at {pdf_path}. Cannot convert to images.")
914        terminate()
915
916    images = convert_pdf_to_images(pdf_path, img_dir)
917
918    # 3. AI Setup
919    model: str
920    if args.api == "openai5":
921        model = args.openai_model5
922    elif args.api == "openai":
923        model = args.openai_model
924    else:
925        model = args.google_model
926    print(f"Using API: {args.api} / Model: {model}")
927
928    client_openai = None
929    if args.api == "openai" or args.api == "openai5":
930        if args.openai_key is None:
931            print("Error: OPENAI_API_KEY missing.")
932            sys.exit(1)
933        client_openai = OpenAI()
934    elif args.api == "gemini":
935        if args.gemini_key is None:
936            print("Error: GEMINI_KEY and GOOGLE_API_KEY missing.")
937            sys.exit(1)
938        genai.configure(api_key=args.gemini_key)
939
940    # 4. Processing
941    with open(out_md, "w", encoding="utf-8") as f:
942        f.write(f"# Analysis Report: {infile_path.name}\n\n---\n")
943
944    for i, img_p in enumerate(images, start=1):
945        print()
946        print(f"Processing Slide {i}/{len(images)}...", end=" ", flush=True)
947        # slide_textsは0-indexedだが、スライド番号は1-indexed
948        txt = slide_texts.get(i-1, "(テキスト情報なし)")
949
950        try:
951            if args.api in ("openai5", "openai"):
952                content = call_openai(client_openai, model, i, txt, img_p, args.language)
953            else:
954                content = call_gemini(model, i, txt, img_p, args.language)
955
956            chunk = f"\n# Slide {i}\n\n{content}\n\n---\n"
957            print("Done.")
958            time.sleep(1)
959
960        except Exception as e:
961            print(f"Error: {e}")
962            chunk = f"# Slide {i}\n\nError: {e}\n\n---\n"
963
964    # ✅ 毎回 append モードで開いて書き込む
965        with open(out_md, "a", encoding="utf-8") as f:
966            f.write(chunk)
967
968    print(f"\nSaved to: {out_md}")
969
970
971if __name__ == "__main__":
972    main()
973    terminate()