ai.pptx2md_with_image のソースコード

# -*- coding: utf-8 -*-
"""
pptx2md_with_image.py

概要: PowerPointプレゼンテーションからテキスト、数式、画像を抽出し、AIモデル(OpenAI, Gemini)を用いて解説レポートを生成するスクリプト。

詳細説明:
このスクリプトは、指定されたPowerPoint (.pptx) ファイルを処理し、各スライドから以下の情報を抽出します。
- スライドのタイトルと通常のテキストコンテンツ。
- Office Math Markup Language (OMML) 形式で記述された数式をLaTeX形式に変換。
- スライドに埋め込まれた画像を抽出し、指定された作業ディレクトリに保存。

抽出されたテキスト、変換されたLaTeX数式、および各スライドの画像は、
指定されたAIモデル (OpenAI GPT-4o/GPT-5.2またはGoogle Gemini) への入力として使用されます。
AIはこれらの情報に基づいてスライドの詳細な解説を生成し、その結果はMarkdown形式のレポートとして出力されます。

PDF変換には `pywin32`、画像抽出には `PyMuPDF`、PPTX解析には `python-pptx` と `lxml` が必要です。

関連リンク: :doc:`pptx2md_with_image_usage`
"""
import argparse
import base64
import re
import sys
import os
import time
from pathlib import Path
from typing import Dict, List, Optional

# --- ライブラリのインポートチェック ---
try:
    import win32com.client  # pywin32 (PPTX -> PDF画像化用)
except ImportError:
    print("Import error: win32com. Please install it with: pip install pywin32")
    input("\nPress ENTER to terminate>>\n")
    sys.exit(1)

try:
    import fitz  # PyMuPDF (PDF -> PNG用)
except ImportError:
    print("Import error: fitz. Please install it with: pip install pymupdf")
    input("\nPress ENTER to terminate>>\n")
    sys.exit(1)

try:
    from pptx import Presentation  # テキスト抽出用
    from lxml import etree         # 数式解析用
except ImportError:
    print("Import error: python-pptx or lxml. Please install: pip install python-pptx lxml")
    input("\nPress ENTER to terminate>>\n")
    sys.exit(1)

from openai import OpenAI
try:
    import google.generativeai as genai
except ImportError:
    print("Geminiを利用する場合は 'pip install google-generativeai' が必要です。")
    input("\nPress ENTER to terminate>>\n")
    sys.exit(1)

# プログラムの実行を一時停止するかどうかのフラグ
pause = 0


# =========================================================
# 1. Advanced Math Extraction Logic (Provided Code Integrated)
# =========================================================

# 名前空間の定義
NAMESPACES = {
    'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
    'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
    'm': 'http://schemas.openxmlformats.org/officeDocument/2006/math',
    'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
    'a14': 'http://schemas.microsoft.com/office/drawing/2010/main'
}

# Unicode 数学記号をASCII/LaTeXに変換する辞書
MATH_UNICODE_MAP = {
    '𝑷': 'P', '𝑽': 'V', '𝑹': 'R', '𝑻': 'T',
    '𝒂': 'a', '𝒃': 'b', '𝒄': 'c', '𝒅': '\\mathrm{d}', '𝒆': 'e', '𝒇': 'f', '𝒈': 'g',
    '𝒉': 'h', '𝒊': 'i', '𝒋': 'j', '𝒌': 'k', '𝒍': 'l', '𝒎': 'm', '𝒏': 'n',
    '𝒙': 'x', '𝒚': 'y', '𝒛': 'z',
    '𝟐': '2', '𝟏': '1', '𝟎': '0',
    '−': '-', '+': '+', '÷': '/', '×': '*', '⋅': '\\cdot',
    '…': '...', '∞': '\\infty',
    '∑': '\\sum', '∏': '\\prod',
    '∫': '\\int', '∬': '\\iint', '∭': '\\iiint', '∮': '\\oint',
    'α': '\\alpha', 'β': '\\beta', 'γ': '\\gamma', 'δ': '\\delta', 'ε': '\\epsilon',
    'ζ': '\\zeta', 'η': '\\eta', 'θ': '\\theta', 'ι': '\\iota', 'κ': '\\kappa',
    'λ': '\\lambda', 'μ': '\\mu', 'ν': '\\nu', 'ξ': '\\xi', 'π': '\\pi', 'ρ': '\\rho',
    'σ': '\\sigma', 'τ': '\\tau', 'υ': '\\upsilon', 'φ': '\\phi', 'χ': '\\chi',
    'ψ': '\\psi', 'ω': '\\omega',
    'Γ': '\\Gamma', 'Δ': '\\Delta', 'Θ': '\\Theta', 'Λ': '\\Lambda',
    'Ξ': '\\Xi', 'Π': '\\Pi', 'Σ': '\\Sigma', 'Φ': '\\Phi', 'Ψ': '\\Psi', 'Ω': '\\Omega',
    '′': "'", '°': '\\degree', '℃': '\\degree C'
}

# n-ary演算子のOMML文字→LaTeXの対応
NARY_TO_LATEX = {
    '∑': '\\sum', '∏': '\\prod',
    '∫': '\\int', '∬': '\\iint', '∭': '\\iiint', '∮': '\\oint',
    '⋀': '\\bigwedge', '⋁': '\\bigvee', '⋂': '\\bigcap', '⋃': '\\bigcup',
}
OPERATOR_CHARS = set(['∑', '∏', '∫', '∬', '∭', '∮'])


# AIへのプロンプトテンプレートのデフォルト値
DEFAULT_PROMPT_TEMPLATE = """
あなたはプロフェッショナルな講師です。
スライドから抽出されたテキスト情報(Markdown形式)と添付のスライド画像を統合し、
解説を作成してください。

# スライド番号: {slide_no}
# 抽出テキスト情報:
{slide_text}
# 出力言語: {lang}

# 指示:
1. 画像内の図表やレイアウトを考慮しつつ、抽出テキスト内の数式を含めて正確に解説してください。
2. 以下のフォーマットで出力してください。
3. 出力Markdown中の数式は全て、
$$改行
LaTeX改行
$$
のブロック数式で出力してください

改行
## 1. 解説
## 2. 図・グラフの分析
## 3. このスライドから読み取れる詳細な情報、データの議論・予測
改行
"""

prompt_template = DEFAULT_PROMPT_TEMPLATE
_VAR_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")


[ドキュメント] def terminate(): """ スクリプトを終了させ、必要に応じてユーザーの入力を待機します。 詳細説明: グローバル変数 `pause` が真の場合、ユーザーがEnterキーを押すまでプログラムの実行を停止します。 その後、プログラムは終了します。 :returns: None """ if pause: input("\nPress ENTER to terminate>>\n") exit()
[ドキュメント] def parse_args(): """ コマンドライン引数を解析し、プログラムの動作に必要な設定を取得します。 詳細説明: 入力ファイルパス、出力ファイル名、API選択、モデル名、言語設定、その他オプションを解析します。 OpenAIおよびGeminiのAPIキーは環境変数から取得されます。 モデル名が明示的に指定された場合、対応するAPIのモデル設定を上書きします。 :returns: tuple[argparse.ArgumentParser, argparse.Namespace] 解析器オブジェクトと引数オブジェクトのタプル。 """ parser = argparse.ArgumentParser(description="PPTX -> Text/Math -> AI Explanation") parser.add_argument("infile", type=str, help="Path to input PPTX") parser.add_argument("-o", "--output", default = None, help="出力するMarkdownファイル名") parser.add_argument("--txt", type=str, default=None, help="Optional: Path to existing text file") parser.add_argument("--ini", "-i", type=str, default=Path(__file__).stem + ".ini", help="prompt_templateを読み込むINIファイル。指定がなければスクリプト名.iniを探索。") openai_model = os.getenv("OPENAI_MODEL", "gpt-4o") openai_model5 = os.getenv("OPENAI_MODEL5", "gpt-5.2") google_model = os.getenv("GEMINI_MODEL") or os.getenv("GOOGLE_MODEL", "gemini-2.5-flash") parser.add_argument("--api", "-a", type=str, default="gemini", choices=["gemini", "google", "openai5", "openai"]) parser.add_argument("--model", default=None) parser.add_argument("--google_model", default=google_model) parser.add_argument("--openai_model", default=openai_model) parser.add_argument("--openai_model5", default=openai_model5) parser.add_argument("--visible", action="store_true") parser.add_argument("--language", type=str, default="Japanese") parser.add_argument("--pause", type=int, default=0) args = parser.parse_args() args.openai_key = os.getenv("OPENAI_API_KEY") args.gemini_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") if args.model: if args.api == 'openai5': args.openai_model5 = args.model elif args.api == 'openai': args.openai_model = args.model elif args.api in ('gemini', 'google'): args.google_model = args.model return parser, args
[ドキュメント] def search_file(infile=None): """ 指定されたファイルがカレントディレクトリまたはスクリプトディレクトリに存在するかを探索します。 :param infile: Optional[str] 探索するファイル名。指定がない場合はデフォルトのINIファイル名を探索。 :returns: Optional[str] 見つかったファイルの絶対パス、またはNone。 """ script_path = os.path.abspath(sys.argv[0]) script_dir = os.path.dirname(script_path) script_name = os.path.splitext(os.path.basename(script_path))[0] default_ini = f"{script_name}.ini" if infile is None: for path in [os.getcwd(), script_dir]: candidate = os.path.join(path, default_ini) if os.path.isfile(candidate): return candidate return None if not os.path.isfile(infile): candidate = os.path.join(script_dir, infile) if os.path.isfile(candidate): return candidate return None return infile
[ドキュメント] def read_ini(inifile=None): """ INIファイルから設定を読み込み、キーと値の辞書を返します。 詳細説明: この関数は、指定されたINIファイル(またはデフォルトのINIファイル)を解析し、設定を辞書としてロードします。 コメント行(`#` または `;` で始まる)はスキップされます。 複数行の値は、三重引用符(`\"\"\"` または `\'\'\'`)で囲むことでサポートされます。 キーと値のペア内の変数は、`$VAR_NAME` の形式で定義済み変数に展開されます。 :param inifile: Optional[str] 読み込むINIファイルのパス。 :returns: dict INIファイルから読み込まれた設定の辞書。 :raises FileNotFoundError: 指定されたINIファイルが見つからない場合。 """ path = search_file(inifile) if path is None: raise FileNotFoundError("INIファイルが見つかりませんでした") result = {} variables = {} current_key = None multiline_val = [] multiline_delim = None with open(path, 'r', encoding='utf-8') as f: for line in f: line = line.rstrip() if not line or line.startswith('#') or line.startswith(';'): continue # 複数行値の終了判定(stripで判定) if multiline_delim: if line.strip() == multiline_delim: val = '\n'.join(multiline_val) result[current_key] = val variables[current_key] = val current_key = None multiline_val = [] multiline_delim = None else: multiline_val.append(line) continue # key=val の解析 if '=' in line: key, val = map(str.strip, line.split('=', 1)) val = val.strip() # 複数行値の開始判定(空文字でも対応) if (val == '\"\"\"' or val == "\'\'\'" or (val.startswith('\"\"\"') and not val.endswith('\"\"\"')) or \ (val.startswith("\'\'\'") and not val.endswith("\'\'\'")) ): multiline_delim = val[:3] content = val[3:] multiline_val = [content] if content else [] current_key = key continue # 単一行の複数行値 if (val.startswith('\"\"\"') and val.endswith('\"\"\"')) or \ (val.startswith("\'\'\'") and val.endswith("\'\'\'")): val = val[3:-3] result[key] = val variables[key] = val # 変数展開(あとから一括処理) for key, val in result.items(): def expand_var(match): var_name = match.group(1) return variables.get(var_name, match.group(0)) result[key] = re.sub(r"\$(\w+)\b", expand_var, val) return result
[ドキュメント] def find_ini_path(ini_name: str) -> Optional[Path]: """ INIファイルのパスをカレントディレクトリ、次にスクリプトディレクトリの順に探索します。 :param ini_name: str 探索するINIファイル名。 :returns: Optional[Path] 見つかったINIファイルの `Path` オブジェクト、または `None`。 """ if not ini_name: return None p = Path(ini_name) if p.is_absolute() and p.is_file(): return p cwd_path = Path.cwd() / p.name if cwd_path.is_file(): return cwd_path script_path = Path(__file__).resolve().parent / p.name if script_path.is_file(): return script_path return None
def _safe_text_replace_math_unicode(text: str) -> str: """ 入力テキスト内の特定のUnicode数学記号を、ASCIIまたはLaTeX表現に安全に変換します。 :param text: str 変換対象の文字列。 :returns: str 変換後の文字列。 """ if not text: return "" for u, ltx in MATH_UNICODE_MAP.items(): text = text.replace(u, ltx) return text def _find_first(element, candidates): """ 指定された要素の子要素の中から、候補リスト内のタグ名に一致する最初の要素を見つけます。 :param element: etree._Element 探索対象のXML要素。 :param candidates: List[str] 探索する子要素のタグ名(OMML形式)。 :returns: Optional[etree._Element] 見つかった最初の要素、またはNone。 """ for cand in candidates: found = element.find(cand, NAMESPACES) if found is not None: return found return None def _detect_nary_op_char(element): """ n-ary演算子要素から演算子文字を検出します。 詳細説明: まず `m:naryPr/m:chr` タグの `val` 属性を探します。 見つからない場合、演算子文字として定義された文字 (`OPERATOR_CHARS`) が 要素内のテキストラン (`m:t`) に含まれていないかを探索します。 :param element: etree._Element n-ary演算子を表すXML要素。 :returns: str 検出された演算子文字、または空文字列。 """ op_tag = element.find('m:naryPr/m:chr', NAMESPACES) if op_tag is not None: val = op_tag.get(f"{{{NAMESPACES['m']}}}val", "") if val: return val ts = element.xpath('.//m:t[not(ancestor::m:e) and not(ancestor::m:sub) and not(ancestor::m:sup)]', namespaces=NAMESPACES) for t in ts: s = t.text or "" for ch in s: if ch in OPERATOR_CHARS: return ch return ""
[ドキュメント] def omml_to_latex(element): """ Office Math Markup Language (OMML) のXML要素を再帰的に解析し、対応するLaTeX形式の文字列に変換します。 詳細説明: この関数は、PowerPointスライドから抽出されたOMML形式の数式XMLを解析し、 LaTeX互換の表現に変換します。以下のOMML要素を処理します。 - `oMath`, `oMathPara`: 数式ブロック。 - `f` (Fraction): 分数。`\\frac{numerator}{denominator}` - `rad` (Root): 根号。`\\sqrt{expression}` または `\\sqrt[degree]{expression}` - `sSup` (Superscript): 上付き文字。`base^{superscript}` - `sSub` (Subscript): 下付き文字。`base_{subscript}` - `sSubSup` (Subscript and Superscript): 上下付き文字。`base_{subscript}^{superscript}` - `d` (Delimiter): 区切り文字(括弧など)。 - `r` (Run): テキストラン。 - `t` (Text): テキストコンテンツ。`MATH_UNICODE_MAP` を使用してUnicode数学記号を変換。 - `limLow` (Limit Lower): 下限。`base_{lower_limit}` - `limUpp` (Limit Upper): 上限。`base^{upper_limit}` - `int` (Integral): 積分。`\\int_{lower}^{upper}{expression}` - `nary` (N-ary Operator): n-ary演算子(Σ, Πなど)。`operator_{lower}^{upper}{expression}` :param element: etree._Element 変換対象のOMML XML要素。 :returns: str 変換されたLaTeX文字列。 """ tag = etree.QName(element).localname if tag in ('oMath', 'oMathPara'): return "".join(omml_to_latex(child) for child in element) elif tag == 'f': # Fraction num = element.find('m:num', NAMESPACES) den = element.find('m:den', NAMESPACES) return f"\\frac{{{omml_to_latex(num)}}}{{{omml_to_latex(den)}}}" elif tag == 'rad': # Root deg = element.find('m:deg', NAMESPACES) e = element.find('m:e', NAMESPACES) if deg is not None: return f"\\sqrt[{omml_to_latex(deg)}]{{{omml_to_latex(e)}}}" return f"\\sqrt{{{omml_to_latex(e)}}}" elif tag == 'sSup': # Superscript e = element.find('m:e', NAMESPACES) sup = element.find('m:sup', NAMESPACES) return f"{omml_to_latex(e)}^{{{omml_to_latex(sup)}}}" elif tag == 'sSub': # Subscript e = element.find('m:e', NAMESPACES) sub = element.find('m:sub', NAMESPACES) return f"{omml_to_latex(e)}_{{{omml_to_latex(sub)}}}" elif tag == 'sSubSup': # Subscript + Superscript e = element.find('m:e', NAMESPACES) sub = element.find('m:sub', NAMESPACES) sup = element.find('m:sup', NAMESPACES) base = omml_to_latex(e) sub_l = omml_to_latex(sub) if sub is not None else "" sup_l = omml_to_latex(sup) if sup is not None else "" return f"{base}_{{{sub_l}}}^{{{sup_l}}}" elif tag == 'd': # Delimiter beg_chr = element.find('m:dPr/m:begChr', NAMESPACES) end_chr = element.find('m:dPr/m:endChr', NAMESPACES) beg = beg_chr.get(f"{{{NAMESPACES['m']}}}val") if beg_chr is not None else "(" end = end_chr.get(f"{{{NAMESPACES['m']}}}val") if end_chr is not None else ")" content = "".join(omml_to_latex(child) for child in element if etree.QName(child).localname != 'dPr') return f"{beg}{content}{end}" elif tag == 'r': # Run return "".join(omml_to_latex(child) for child in element) elif tag == 't': # Text return _safe_text_replace_math_unicode(element.text or "") elif tag == 'limLow': base = omml_to_latex(element.find('m:e', NAMESPACES)) low = omml_to_latex(element.find('m:lim', NAMESPACES)) return f"{base}_{{{low}}}" elif tag == 'limUpp': base = omml_to_latex(element.find('m:e', NAMESPACES)) upp = omml_to_latex(element.find('m:lim', NAMESPACES)) return f"{base}^{{{upp}}}" elif tag == 'int': lower = _find_first(element, ['m:sub', 'm:low']) upper = _find_first(element, ['m:sup', 'm:up']) e = element.find('m:e', NAMESPACES) lower_ltx = omml_to_latex(lower) if lower is not None else "" upper_ltx = omml_to_latex(upper) if upper is not None else "" e_ltx = omml_to_latex(e) if e is not None else "" if lower_ltx or upper_ltx: return f"\\int_{{{lower_ltx}}}^{{{upper_ltx}}}{e_ltx}" else: return f"\\int {e_ltx}" elif tag == 'nary': op_char = _detect_nary_op_char(element) lower_tag = _find_first(element, ['m:sub', 'm:low']) upper_tag = _find_first(element, ['m:sup', 'm:up']) content_tag = element.find('m:e', NAMESPACES) lower_ltx = omml_to_latex(lower_tag) if lower_tag is not None else '' upper_ltx = omml_to_latex(upper_tag) if upper_tag is not None else '' content_ltx = omml_to_latex(content_tag) if content_tag is not None else '' op_ltx = NARY_TO_LATEX.get(op_char, _safe_text_replace_math_unicode(op_char)) if not op_ltx and (lower_ltx or upper_ltx): op_ltx = '\\int' if lower_ltx or upper_ltx: return f"{op_ltx}_{{{lower_ltx}}}^{{{upper_ltx}}}{content_ltx}" else: return f"{op_ltx} {content_ltx}".rstrip() return "".join(omml_to_latex(child) for child in element)
[ドキュメント] def split_latex_blocks(s: str): """ LaTeX文字列を `\\\\` で区切られた複数のブロックに分割します。 詳細説明: 複数行の数式が単一のLaTeX文字列に含まれている場合、各行(`\\\\` で区切られる)を 個別の数式ブロックとして扱えるように分割します。 各ブロックは前後の空白が除去され、空のブロックは結果から除外されます。 これは、Pandocなどのツールで複数行数式を正しくレンダリングするために重要です。 :param s: str 分割対象のLaTeX文字列。 :returns: List[str] 分割されたLaTeXブロックのリスト。 """ if not s: return [] parts = [p.strip() for p in re.split(r'\\\\', s) if p.strip()] return parts if parts else [s.strip()]
[ドキュメント] def get_slide_title(slide): """ PowerPointスライドからタイトルテキストを抽出します。 詳細説明: まず、スライド内のプレースホルダーのうち、タイプ1 (タイトル) のテキストフレームを検索します。 タイトルが見つかった場合、そのテキストを返します。 プレースホルダータイトルが見つからない場合、最初に見つかったテキストフレームの最初の行をタイトルとして使用します。 いずれも見つからない場合は、「無題のスライド」を返します。 :param slide: pptx.slide.Slide タイトルを抽出する対象のスライドオブジェクト。 :returns: str スライドのタイトル文字列、または「無題のスライド」。 """ for shape in slide.shapes: if getattr(shape, "has_text_frame", False) and shape.is_placeholder and shape.placeholder_format.type == 1: title_text = shape.text if title_text: return title_text.strip() for shape in slide.shapes: if getattr(shape, "has_text_frame", False): first_text = (shape.text or "").strip() if first_text: return first_text.split('\n')[0] return "無題のスライド"
[ドキュメント] def extract_content_to_markdown(pptx_path, output_md = None, image_dir = None, include_xml = False): """ PPTXファイルからコンテンツを抽出し、Markdown形式の文字列として返します。 詳細説明: 指定されたPPTXファイルを解析し、各スライドから以下の情報を抽出してMarkdown形式に変換します。 - スライド番号とタイトル。 - 各スライド内のテキストコンテンツ。 - OMML形式の数式をLaTeXに変換し、`$$ ... $$` ブロックで囲みます。 `oMathPara` 要素を優先し、その中の`oMath`を個別のブロックとして処理します。 また、複数行の数式は `\\\\` で分割して個別の `$$ ... $$` ブロックにします。 - スライドに埋め込まれた画像を抽出し、`image_dir` に保存します。 Markdown内には、保存された画像への相対パスを含むリンク `![alt_text](path/to/image.png)` を挿入します。 抽出された内容はスライドごとにMarkdown文字列として辞書に格納され、最終的にその辞書が返されます。 `output_md` が指定された場合、全コンテンツをそのファイルに書き込みます。 :param pptx_path: Path 入力PPTXファイルへのパス。 :param output_md: Optional[str] (現状未使用) 抽出結果を保存するMarkdownファイル名。 :param image_dir: Optional[Path] 抽出された画像を保存するディレクトリへのパス。`None` の場合、画像は抽出されません。 :param include_xml: bool 元のOMML XMLをMarkdownに含めるかどうか。`True` の場合、各数式の後にXMLスニペットが追加されます。 :returns: Optional[Dict[int, str]] スライド番号をキー、Markdown形式のコンテンツを値とする辞書。`output_md` が指定された場合は `None`。 """ try: presentation = Presentation(pptx_path) except Exception as e: print(f"エラー: ファイル '{pptx_path}' を開けませんでした。{e}") return if image_dir: os.makedirs(image_dir, exist_ok=True) print(f"Extracting text & math from PPTX: {pptx_path.name}...") slides_data = {} markdown_output = "" for i, slide in enumerate(presentation.slides): title = get_slide_title(slide) page_output = f"# スライド {i + 1} {title}\n\n" # スライドの生XML slide_xml = slide.part.blob root = etree.fromstring(slide_xml) # テキスト text_elements = root.xpath('//a:t', namespaces=NAMESPACES) if text_elements: slide_text = " ".join([elem.text for elem in text_elements if elem.text]) if slide_text.strip(): page_output += f"## テキスト\n\n{slide_text.strip()}\n\n" # 数式(ブロック優先: oMathPara と、oMathPara外の単独oMath) math_elements = root.xpath( '//m:oMathPara | //m:oMath[not(ancestor::m:oMathPara)]', namespaces=NAMESPACES ) if math_elements: page_output += "## 数式\n\n" seen_omml = set() for math_elem in math_elements: # 重複ガード(OMML文字列単位) omml_string = etree.tostring(math_elem, encoding='unicode') if omml_string in seen_omml: continue seen_omml.add(omml_string) tag = etree.QName(math_elem).localname # oMathPara は直下の oMath を1行ごとに出力 if tag == 'oMathPara': inner_oms = math_elem.findall('m:oMath', NAMESPACES) if inner_oms: for om in inner_oms: latex_code = omml_to_latex(om) for one_line in split_latex_blocks(latex_code): one_line = one_line.replace('\\mathrm{d}', '\\,\\mathrm{d}') page_output += f"$$ {one_line} $$\n\n" else: # フォールバック latex_code = omml_to_latex(math_elem) for one_line in split_latex_blocks(latex_code): one_line = one_line.replace('\\mathrm{d}', '\\,\\mathrm{d}') page_output += f"$$ {one_line} $$\n\n" if include_xml: page_output += f"**元のOMML XML:**\n```xml\n{omml_string.strip()}\n```\n\n" # 単独 oMath はそのまま(ただし \\ があれば分割) else: latex_code = omml_to_latex(math_elem) for one_line in split_latex_blocks(latex_code): one_line = one_line.replace('\\mathrm{d}', '\\,\\mathrm{d}') page_output += f"$$ {one_line} $$\n\n" if include_xml: page_output += f"**元のOMML XML:**\n```xml\n{omml_string.strip()}\n```\n\n" # 画像 image_elements = root.xpath('//a:blip[@r:embed]', namespaces=NAMESPACES) if image_dir and image_elements: page_output += "## 図\n\n" for j, image_elem in enumerate(image_elements): r_id = image_elem.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed') try: image_part = slide.part.rels[r_id].target_part image_data = image_part.blob image_ext = image_part.content_type.split('/')[-1] image_filename = f"slide{i+1}_image{j+1}.{image_ext}" image_path = os.path.join(image_dir, image_filename) with open(image_path, 'wb') as f: f.write(image_data) page_output += f"![スライド{i+1}の図{j+1}]({os.path.join(os.path.basename(image_dir), image_filename)})\n\n" except KeyError: page_output += f"図のリンクが見つかりませんでした (rId: {r_id})\n\n" page_output += "\n" page_output += "\n---\n\n" slides_data[i] = page_output markdown_output += page_output # print("page_output:", page_output) if output_md: with open(output_md, "w", encoding="utf-8") as f: f.write(markdown_output) print(f"抽出が完了しました。結果は '{output_md}' に保存されました。") else: # for key, val in slides_data.items(): # print(f"{key}: {val}") return slides_data
# ========================================================= # 2. Text Parsing (Existing Markdown File Support) # ========================================================= SLIDE_HEADER_RE = re.compile(r"^\s*#\s*Slide\s+(\d+)", re.IGNORECASE)
[ドキュメント] def load_slide_texts(txt_path: Path) -> Dict[int, str]: """ 既存のMarkdownファイルからスライドごとのテキストコンテンツを読み込みます。 詳細説明: 入力ファイル (`txt_path`) を読み込み、`# Slide N` の形式のヘッダーを検出して スライドの区切りとします。各スライドのコンテンツを抽出し、スライド番号 (1始まり) を キーとする辞書として返します。これにより、PPTXからのテキスト抽出ではなく、 既存のテキストファイルを利用してAI処理を進めることができます。 :param txt_path: Path 読み込むテキストファイルへのパス。 :returns: Dict[int, str] スライド番号をキー、対応するテキストコンテンツを値とする辞書。 ファイルが存在しない場合は空の辞書を返します。 """ slides: Dict[int, str] = {} if not txt_path or not txt_path.exists(): return {} print(f"Loading text from file: {txt_path.name}") current_no: Optional[int] = None buf: List[str] = [] with txt_path.open("r", encoding="utf-8") as f: for line in f: m = SLIDE_HEADER_RE.match(line) if m: if current_no is not None: slides[current_no] = "".join(buf).strip() current_no = int(m.group(1)); buf = [] else: buf.append(line) if current_no is not None: slides[current_no] = "".join(buf).strip() return slides
# ========================================================= # 3. PDF/Image Conversion # =========================================================
[ドキュメント] def pptx_to_pdf(pptx_path: Path, pdf_path: Path, visible: bool = False) -> None: """ PowerPointプレゼンテーションファイルをPDF形式に変換します。 詳細説明: `win32com.client` を使用してPowerPointアプリケーションを起動し、 指定されたPPTXファイル (`pptx_path`) をPDF形式 (`pdf_path`) で保存します。 `visible` が `False` の場合、PowerPointアプリケーションはバックグラウンドで実行され、 アラートも表示されません。PDFファイルが既に存在する場合は、変換をスキップします。 :param pptx_path: Path 入力PPTXファイルへのパス。 :param pdf_path: Path 出力PDFファイルへのパス。 :param visible: bool PowerPointアプリケーションのウィンドウを表示するかどうか。デフォルトは `False`。 :returns: None :raises Exception: PowerPointアプリケーションの操作中にエラーが発生した場合。 """ abs_pptx = str(pptx_path.resolve()) abs_pdf = str(pdf_path.resolve()) if pdf_path.exists(): return print(f"Converting PPTX to PDF...") try: app = win32com.client.Dispatch("PowerPoint.Application") if not visible: app.DisplayAlerts = 0 presentation = app.Presentations.Open(abs_pptx, ReadOnly=True, Untitled=False, WithWindow=visible) try: presentation.SaveAs(abs_pdf, 32) # 32はPDF形式のファイルフォーマットを表す finally: presentation.Close() if not visible: app.Quit() except Exception as e: print(f"PPTX->PDF Error: {e}"); raise e
[ドキュメント] def convert_pdf_to_images(pdf_path: Path, out_dir: Path) -> List[Path]: """ PDFファイルを各ページごとにPNG画像に変換します。 詳細説明: `PyMuPDF` (fitz) ライブラリを使用して、指定されたPDFファイル (`pdf_path`) の 各ページをPNG画像として変換します。画像は `out_dir` ディレクトリに保存されます。 変換時には、高解像度 (2.0倍) でレンダリングされます。 出力ディレクトリが存在しない場合は作成されます。 :param pdf_path: Path 入力PDFファイルへのパス。 :param out_dir: Path 出力画像を保存するディレクトリへのパス。 :returns: List[Path] 生成されたPNG画像のファイルパスのリスト。 """ if not out_dir.exists(): out_dir.mkdir(parents=True, exist_ok=True) doc = fitz.open(str(pdf_path.resolve())) paths = [] print(f"Converting PDF to Images...") for i in range(len(doc)): mat = fitz.Matrix(2.0, 2.0) # 高解像度化 pix = doc.load_page(i).get_pixmap(matrix=mat) p = out_dir / f"slide_{i+1:03d}.png" pix.save(str(p)) paths.append(p) doc.close() return paths
# ========================================================= # 3. AI 呼び出し部 # =========================================================
[ドキュメント] def call_gemini(model_name, slide_no, slide_text, image_path, lang): """ Google Gemini APIを呼び出して、スライドの解説を生成します。 詳細説明: グローバルで定義された `prompt_template` を使用し、スライド番号、抽出テキスト、 出力言語を組み込んだプロンプトを作成します。 スライド画像はバイナリデータとして読み込み、プロンプトと共にGeminiモデルに送信します。 APIからの応答(解説テキスト)を返します。 :param model_name: str 使用するGeminiモデルの名前(例: "gemini-pro-vision")。 :param slide_no: int 処理中のスライド番号。 :param slide_text: str スライドから抽出されたテキスト情報。 :param image_path: Path スライド画像のファイルパス。 :param lang: str 出力言語。 :returns: str Geminiモデルが生成した解説テキスト、またはエラーメッセージ。 """ prompt = prompt_template\ .replace("{slide_no}", f"{slide_no}")\ .replace("{slide_text}", slide_text)\ .replace("{lang}", lang)\ .strip() print(f"\nprompt:", prompt) model = genai.GenerativeModel(model_name) img_data = {'mime_type': 'image/png', 'data': image_path.read_bytes()} try: res = model.generate_content([prompt, img_data]) return res.text except Exception as e: return f"Gemini Error: {e}"
[ドキュメント] def call_openai(client, model, slide_no, slide_text, image_path, lang): """ OpenAI APIを呼び出して、スライドの解説を生成します。 詳細説明: グローバルで定義された `prompt_template` を使用し、スライド番号、抽出テキスト、 出力言語を組み込んだプロンプトを作成します。 スライド画像はBase64エンコードされたデータURL形式に変換され、 プロンプトと共にOpenAIモデルに送信されます。 APIからの応答(解説テキスト)を返します。 :param client: openai.OpenAI OpenAI APIクライアントインスタンス。 :param model: str 使用するOpenAIモデルの名前(例: "gpt-4o")。 :param slide_no: int 処理中のスライド番号。 :param slide_text: str スライドから抽出されたテキスト情報。 :param image_path: Path スライド画像のファイルパス。 :param lang: str 出力言語。 :returns: str OpenAIモデルが生成した解説テキスト。 """ b64 = base64.b64encode(image_path.read_bytes()).decode('utf-8') prompt = prompt_template\ .replace("{slide_no}", f"{slide_no}")\ .replace("{slide_text}", slide_text)\ .replace("{lang}", lang)\ .strip() print(f"\nprompt:", prompt) res = client.chat.completions.create( model=model, messages=[{"role": "user", "content": [ {"type": "text", "text": prompt}, {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}} ]}] ) return res.choices[0].message.content
[ドキュメント] def main(): """ スクリプトの主要な実行ロジックをカプセル化します。 詳細説明: この関数は、コマンドライン引数の解析から始まり、以下の主要なステップを実行します。 1. INIファイルからプロンプトテンプレートを読み込みます(もしあれば)。 2. 入力ファイル (PPTXまたはPDF) に基づいて作業ディレクトリを設定します。 3. PPTXファイルの場合、テキスト、数式、画像を抽出し、Markdown形式でコンテンツを準備します。 既存のテキストファイルが指定された場合は、そこからテキストを読み込みます。 4. PPTXファイルの場合、PowerPointをPDFに変換し、さらに各PDFページをPNG画像に変換します。 PDFファイルが直接入力された場合は、それを画像に変換します。 5. 選択されたAIモデル(OpenAIまたはGemini)をAPIキーでセットアップします。 6. 各スライドの画像と抽出されたテキストを用いてAIを呼び出し、詳細な解説を生成します。 7. 生成された解説は、指定された出力Markdownファイルにスライドごとに追記されます。 最後に、処理の完了メッセージと出力ファイルのパスが表示されます。 :returns: None """ global prompt_template, pause print() parser, args = parse_args() if args.pause: pause = 1 infile_path = Path(args.infile) # infile_pathをここで定義 if args.output: out_md = args.output else: out_md = infile_path.with_name(infile_path.stem + ".md") ini_path = find_ini_path(args.ini) if ini_path: try: ini_data = read_ini(ini_path) if "prompt_template" in ini_data: prompt_template = ini_data["prompt_template"] print(f"Loaded prompt_template from {ini_path}") else: print(f"prompt_template key not found in {ini_path}, using default.") except Exception as e: print(f"Warning: failed to read ini ({e}), using default prompt.") else: print(f"INI not found ({args.ini}), using default prompt.") # print("prompt_template:", prompt_template) print(f"infile: {args.infile}") work_dir = infile_path.parent / (infile_path.stem + "_work") img_dir = work_dir / "slides_png" work_dir.mkdir(exist_ok=True) # 1. Text/Math Extraction slide_texts = {} if args.txt and Path(args.txt).exists(): slide_texts = load_slide_texts(Path(args.txt)) elif infile_path.suffix.lower() == ".pptx": # PPTXから高度な数式抽出を実行 slide_texts = extract_content_to_markdown(infile_path, image_dir=img_dir) # 画像抽出もここで行う # 2. PDF/Image Conversion pdf_path: Path if infile_path.suffix.lower() == ".pptx": pdf_path = work_dir / (infile_path.stem + ".pdf") pptx_to_pdf(infile_path, pdf_path, visible=args.visible) else: # PDF入力の場合、入力ファイル自体がPDF pdf_path = infile_path # PDFが存在しない場合はエラー、またはPPTXの場合のみPDF作成と画像変換を行う if not pdf_path.exists(): print(f"Error: PDF file not found at {pdf_path}. Cannot convert to images.") terminate() images = convert_pdf_to_images(pdf_path, img_dir) # 3. AI Setup model: str if args.api == "openai5": model = args.openai_model5 elif args.api == "openai": model = args.openai_model else: model = args.google_model print(f"Using API: {args.api} / Model: {model}") client_openai = None if args.api == "openai" or args.api == "openai5": if args.openai_key is None: print("Error: OPENAI_API_KEY missing.") sys.exit(1) client_openai = OpenAI() elif args.api == "gemini": if args.gemini_key is None: print("Error: GEMINI_KEY and GOOGLE_API_KEY missing.") sys.exit(1) genai.configure(api_key=args.gemini_key) # 4. Processing with open(out_md, "w", encoding="utf-8") as f: f.write(f"# Analysis Report: {infile_path.name}\n\n---\n") for i, img_p in enumerate(images, start=1): print() print(f"Processing Slide {i}/{len(images)}...", end=" ", flush=True) # slide_textsは0-indexedだが、スライド番号は1-indexed txt = slide_texts.get(i-1, "(テキスト情報なし)") try: if args.api in ("openai5", "openai"): content = call_openai(client_openai, model, i, txt, img_p, args.language) else: content = call_gemini(model, i, txt, img_p, args.language) chunk = f"\n# Slide {i}\n\n{content}\n\n---\n" print("Done.") time.sleep(1) except Exception as e: print(f"Error: {e}") chunk = f"# Slide {i}\n\nError: {e}\n\n---\n" # ✅ 毎回 append モードで開いて書き込む with open(out_md, "a", encoding="utf-8") as f: f.write(chunk) print(f"\nSaved to: {out_md}")
if __name__ == "__main__": main() terminate()