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内には、保存された画像への相対パスを含むリンク `` を挿入します。
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", 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()