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

pptx2md2.py をダウンロード

pptx2md2.py
pptx2md2.py
  1"""
  2概要: PowerPointファイルからテキスト、数式、図を抽出し、Markdown形式で出力するスクリプトです。
  3
  4詳細説明:
  5このスクリプトは、`python-pptx`と`lxml`ライブラリを使用して、PPTXファイルの内部構造を解析します。
  6各スライドの内容を、Markdown形式の出力ファイルに変換します。
  7具体的には、以下の要素を抽出してMarkdownに変換します。
  8- スライドのタイトル
  9- テキストボックス内のテキスト(箇条書きのレベルと改行を保持)
 10- Office Math Markup Language (OMML) で記述された数式(LaTeX形式に変換)
 11- スライドに埋め込まれた画像(指定されたディレクトリに保存し、Markdownからリンク)
 12
 13変換されたMarkdownファイルは、プレゼンテーションのコンテンツを再利用しやすい形で提供します。
 14
 15関連リンク:
 16:doc:`pptx2md2_usage`
 17"""
 18
 19import argparse
 20import os
 21import re
 22from pptx import Presentation
 23from lxml import etree
 24
 25# 名前空間の定義
 26NAMESPACES = {
 27    'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
 28    'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
 29    'm': 'http://schemas.openxmlformats.org/officeDocument/2006/math',
 30    'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
 31    'a14': 'http://schemas.microsoft.com/office/drawing/2010/main'
 32}
 33
 34# Unicode数式文字とLaTeXコマンドのマッピング
 35MATH_UNICODE_MAP = {
 36    '𝑷': 'P', '𝑽': 'V', '𝑹': 'R', '𝑻': 'T',
 37    '𝒂': 'a', '𝒃': 'b', '𝒄': 'c', '𝒅': '\\mathrm{d}', '𝒆': 'e', '𝒇': 'f', '𝒈': 'g',
 38    '𝒉': 'h', '𝒊': 'i', '𝒋': 'j', '𝒌': 'k', '𝒍': 'l', '𝒎': 'm', '𝒏': 'n',
 39    '𝒙': 'x', '𝒚': 'y', '𝒛': 'z',
 40    '𝟐': '2', '𝟏': '1', '𝟎': '0',
 41    '−': '-', '+': '+', '÷': '/', '×': '*', '⋅': '\\cdot',
 42    '…': '...', '∞': '\\infty',
 43    '∑': '\\sum', '∏': '\\prod',
 44    '∫': '\\int', '∬': '\\iint', '∭': '\\iiint', '∮': '\\oint',
 45    'α': '\\alpha', 'β': '\\beta', 'γ': '\\gamma', 'δ': '\\delta', 'ε': '\\epsilon',
 46    'ζ': '\\zeta', 'η': '\\eta', 'θ': '\\theta', 'ι': '\\iota', 'κ': '\\kappa',
 47    'λ': '\\lambda', 'μ': '\\mu', 'ν': '\\nu', 'ξ': '\\xi', 'π': '\\pi', 'ρ': '\\rho',
 48    'σ': '\\sigma', 'τ': '\\tau', 'υ': '\\upsilon', 'φ': '\\phi', 'χ': '\\chi',
 49    'ψ': '\\psi', 'ω': '\\omega',
 50    'Γ': '\\Gamma', 'Δ': '\\Delta', 'Θ': '\\Theta', 'Λ': '\\Lambda',
 51    'Ξ': '\\Xi', 'Π': '\\Pi', 'Σ': '\\Sigma', 'Φ': '\\Phi', 'Ψ': '\\Psi', 'Ω': '\\Omega',
 52    '′': "'", '°': '\\degree', '℃': '\\degree C'
 53}
 54
 55# 多項演算子とLaTeXコマンドのマッピング
 56NARY_TO_LATEX = {
 57    '∑': '\\sum', '∏': '\\prod',
 58    '∫': '\\int', '∬': '\\iint', '∭': '\\iiint', '∮': '\\oint',
 59    '⋀': '\\bigwedge', '⋁': '\\bigvee', '⋂': '\\bigcap', '⋃': '\\bigcup',
 60}
 61OPERATOR_CHARS = set(['∑', '∏', '∫', '∬', '∭', '∮'])
 62
 63pause = 0
 64
 65def terminate():
 66    """
 67    概要: プログラムを終了します。
 68
 69    詳細説明:
 70    `pause` グローバル変数が1に設定されている場合、ユーザーがEnterキーを押すまで待機してからプログラムを終了します。
 71    これにより、コマンドプロンプトのウィンドウがすぐに閉じられるのを防ぎます。
 72
 73    :returns: None
 74    """
 75    if pause:
 76        input("\nPress ENTER to terminate\n")
 77    exit()
 78
 79def initialize():
 80    """
 81    概要: コマンドライン引数を解析し、設定を初期化します。
 82
 83    詳細説明:
 84    `argparse` モジュールを使用して、以下のコマンドライン引数を定義および解析します。
 85    - `-i` または `--input`: 入力するPowerPointファイル名 (必須)
 86    - `-o` または `--output`: 出力するMarkdownファイル名 (必須)
 87    - `--xml`: 数式の元のOMML XMLを出力するかどうかを示すフラグ
 88    - `--imagedir`: 画像を保存するディレクトリ名 (デフォルトは"images")
 89    - `--pause`: 終了時に待機するかどうかを示すフラグ (デフォルトは0)
 90    解析された引数は `argparse.Namespace` オブジェクトとして返されます。
 91
 92    :returns: argparse.Namespace
 93        解析されたコマンドライン引数を含むオブジェクト。
 94    """
 95    parser = argparse.ArgumentParser(description="PowerPointファイルからテキスト、数式、図を抽出し、Markdownに出力します。")
 96    parser.add_argument("-i", "--input", required=True, help="入力するPowerPointファイル名")
 97    parser.add_argument("-o", "--output", required=True, help="出力するMarkdownファイル名")
 98    parser.add_argument("--xml", action="store_true", help="数式の元のOMML XMLを出力します。")
 99    parser.add_argument("--imagedir", default="images", help="画像ディレクトリ")
100    parser.add_argument("--pause", type=int, default=0, help="終了時待機")
101    args = parser.parse_args()
102    return args
103
104def get_slide_title(slide):
105    """
106    概要: スライドからタイトルを抽出します。
107
108    詳細説明:
109    指定されたスライドオブジェクトにタイトルプレースホルダーが存在する場合、そのテキストコンテンツを返します。
110    タイトルプレースホルダーが見つからない場合は、「無題のスライド」というデフォルト文字列を返します。
111    抽出されたテキストは前後の空白が取り除かれます。
112
113    :param slide: pptx.slide.Slide
114        タイトルを抽出する対象のスライドオブジェクト。
115    :returns: str
116        スライドのタイトル文字列。
117    """
118    if slide.shapes.title:
119        return slide.shapes.title.text.strip()
120    return "無題のスライド"
121
122def _safe_text_replace_math_unicode(text: str) -> str:
123    """
124    概要: テキスト内のUnicode数式文字を対応するLaTeXコマンドに置換します。
125
126    詳細説明:
127    `MATH_UNICODE_MAP` に定義されているUnicode文字(例: 数学記号、特殊なアルファベットなど)を、
128    対応するLaTeXコマンドの文字列に置換します。
129    入力テキストが空の場合は、空文字列をそのまま返します。
130
131    :param text: str
132        置換処理を行う入力テキスト。
133    :returns: str
134        Unicode数式文字がLaTeXコマンドに置換されたテキスト。
135    """
136    if not text: return ""
137    for u, ltx in MATH_UNICODE_MAP.items():
138        text = text.replace(u, ltx)
139    return text
140
141def _find_first(element, candidates):
142    """
143    概要: 指定された要素の子孫から、候補タグのいずれかに最初に一致する要素を見つけます。
144
145    詳細説明:
146    `candidates` リスト内のタグ名を順番に試行し、`element` の直下の子要素の中から、
147    最初に見つかったタグ名の要素を返します。いずれの候補も見つからなかった場合はNoneを返します。
148
149    :param element: lxml.etree._Element
150        検索を開始する親要素。
151    :param candidates: list[str]
152        検索するタグ名のリスト。
153    :returns: lxml.etree._Element or None
154        最初に見つかった子要素、または見つからなかった場合はNone。
155    """
156    for cand in candidates:
157        found = element.find(cand, NAMESPACES)
158        if found is not None: return found
159    return None
160
161def _detect_nary_op_char(element):
162    """
163    概要: Nary(多項演算子)要素から演算子文字を検出します。
164
165    詳細説明:
166    OMMLのNary要素から、総和記号などの演算子文字を特定します。
167    まず `m:naryPr/m:chr` タグの `val` 属性を探します。
168    見つからない場合は、要素内の `m:t` タグ(下付きや上付きの子孫ではないもの)を走査し、
169    `OPERATOR_CHARS` に含まれる文字を検出します。
170
171    :param element: lxml.etree._Element
172        Nary要素。
173    :returns: str
174        検出された演算子文字、または見つからなかった場合は空文字列。
175    """
176    op_tag = element.find('m:naryPr/m:chr', NAMESPACES)
177    if op_tag is not None:
178        val = op_tag.get(f"{{{NAMESPACES['m']}}}val", "")
179        if val: return val
180    ts = element.xpath('.//m:t[not(ancestor::m:e) and not(ancestor::m:sub) and not(ancestor::m:sup)]', namespaces=NAMESPACES)
181    for t in ts:
182        s = t.text or ""
183        for ch in s:
184            if ch in OPERATOR_CHARS: return ch
185    return ""
186
187def omml_to_latex(element):
188    """
189    概要: Office Math Markup Language (OMML) 要素をLaTeX形式に変換します。
190
191    詳細説明:
192    OMMLのXML要素を再帰的に走査し、対応するLaTeX表現を生成します。
193    以下のような主要なOMML構造を扱います。
194    - `m:f`: 分数 (`\\frac`)
195    - `m:rad`: 根号 (`\\sqrt`)
196    - `m:sSup`: 上付き文字 (`^`)
197    - `m:sSub`: 下付き文字 (`_`)
198    - `m:sSubSup`: 下付き上付き文字 (`_`, `^`)
199    - `m:d`: 区切り文字 (括弧など)
200    - `m:r`: テキストのまとまり
201    - `m:t`: テキストコンテンツ (`_safe_text_replace_math_unicode`で処理)
202    - `m:limLow`, `m:limUpp`: 限界式の下限、上限
203    - `m:int`, `m:nary`: 積分、多項演算子 (総和、総乗など)
204
205    未知のタグや処理されないタグについては、その子要素を再帰的に処理して結合します。
206
207    :param element: lxml.etree._Element
208        変換するOMML要素。
209    :returns: str
210        変換されたLaTeX文字列。
211    """
212    tag = etree.QName(element).localname
213    if tag in ('oMath', 'oMathPara'):
214        return "".join(omml_to_latex(child) for child in element)
215    elif tag == 'f':
216        num = element.find('m:num', NAMESPACES); den = element.find('m:den', NAMESPACES)
217        return f"\\frac{{{omml_to_latex(num)}}}{{{omml_to_latex(den)}}}"
218    elif tag == 'rad':
219        deg = element.find('m:deg', NAMESPACES); e = element.find('m:e', NAMESPACES)
220        if deg is not None: return f"\\sqrt[{omml_to_latex(deg)}]{{{omml_to_latex(e)}}}"
221        return f"\\sqrt{{{omml_to_latex(e)}}}"
222    elif tag == 'sSup':
223        e = element.find('m:e', NAMESPACES); sup = element.find('m:sup', NAMESPACES)
224        return f"{omml_to_latex(e)}^{{{omml_to_latex(sup)}}}"
225    elif tag == 'sSub':
226        e = element.find('m:e', NAMESPACES); sub = element.find('m:sub', NAMESPACES)
227        return f"{omml_to_latex(e)}_{{{omml_to_latex(sub)}}}"
228    elif tag == 'sSubSup':
229        e = element.find('m:e', NAMESPACES); sub = element.find('m:sub', NAMESPACES); sup = element.find('m:sup', NAMESPACES)
230        return f"{omml_to_latex(e)}_{{{omml_to_latex(sub)}}}^{{{omml_to_latex(sup)}}}"
231    elif tag == 'd':
232        beg_chr = element.find('m:dPr/m:begChr', NAMESPACES); end_chr = element.find('m:dPr/m:endChr', NAMESPACES)
233        beg = beg_chr.get(f"{{{NAMESPACES['m']}}}val") if beg_chr is not None else "("
234        end = end_chr.get(f"{{{NAMESPACES['m']}}}val") if end_chr is not None else ")"
235        content = "".join(omml_to_latex(child) for child in element if etree.QName(child).localname != 'dPr')
236        return f"{beg}{content}{end}"
237    elif tag == 'r': return "".join(omml_to_latex(child) for child in element)
238    elif tag == 't': return _safe_text_replace_math_unicode(element.text or "")
239    elif tag == 'limLow':
240        base = omml_to_latex(element.find('m:e', NAMESPACES)); low = omml_to_latex(element.find('m:lim', NAMESPACES))
241        return f"{base}_{{{low}}}"
242    elif tag == 'limUpp':
243        base = omml_to_latex(element.find('m:e', NAMESPACES)); upp = omml_to_latex(element.find('m:lim', NAMESPACES))
244        return f"{base}^{{{upp}}}"
245    elif tag == 'int':
246        lower = _find_first(element, ['m:sub', 'm:low']); upper = _find_first(element, ['m:sup', 'm:up']); e = element.find('m:e', NAMESPACES)
247        l_ltx = omml_to_latex(lower) if lower is not None else ""; u_ltx = omml_to_latex(upper) if upper is not None else ""; e_ltx = omml_to_latex(e) if e is not None else ""
248        return f"\\int_{{{l_ltx}}}^{{{u_ltx}}}{e_ltx}" if (l_ltx or u_ltx) else f"\\int {e_ltx}"
249    elif tag == 'nary':
250        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)
251        l_ltx = omml_to_latex(lower_tag) if lower_tag is not None else ''; u_ltx = omml_to_latex(upper_tag) if upper_tag is not None else ''; c_ltx = omml_to_latex(content_tag) if content_tag is not None else ''
252        op_ltx = NARY_TO_LATEX.get(op_char, _safe_text_replace_math_unicode(op_char))
253        if lower_tag is not None or upper_tag is not None: return f"{op_ltx}_{{{l_ltx}}}^{{{u_ltx}}}{c_ltx}"
254        return f"{op_ltx} {c_ltx}".rstrip()
255    return "".join(omml_to_latex(child) for child in element)
256
257def split_latex_blocks(s: str):
258    """
259    概要: LaTeX文字列を改行コード `\\\\` で分割し、個別のブロックのリストを生成します。
260
261    詳細説明:
262    入力されたLaTeX文字列を正規表現 `r'\\\\'` (すなわち `\\` という文字列) で分割します。
263    分割された各パーツは前後の空白が取り除かれ、空文字列ではないものだけがリストに含まれます。
264    入力文字列が空の場合や、分割後に有効なパーツがない場合は空のリストを返します。
265
266    :param s: str
267        分割するLaTeX文字列。
268    :returns: list[str]
269        分割されたLaTeXブロックのリスト。
270    """
271    if not s: return []
272    parts = [p.strip() for p in re.split(r'\\\\', s) if p.strip()]
273    return parts if parts else [s.strip()]
274
275def extract_content_to_markdown(input_pptx, output_md, image_dir, include_xml=False):
276    """
277    概要: PowerPointファイルからコンテンツを抽出し、Markdownファイルに変換して保存します。
278
279    詳細説明:
280    `python-pptx` を使用して指定されたPPTXファイルを開き、各スライドを順番に処理します。
281    各スライドから以下のコンテンツを抽出し、Markdown形式で出力ファイルに書き込みます。
282    1. スライドタイトル: Markdownのヘッダーとして追加されます。
283    2. テキスト: スライド内のすべての段落 (a:p) を走査し、テキスト、改行 (a:br)、
284       および数式 (m:oMath, m:oMathPara) を結合して、Markdownの箇条書きとして出力します。
285       箇条書きのレベルは `a:pPr/@lvl` 属性に基づいて適切にインデントされます。
286    3. 数式: OMML形式の数式を `omml_to_latex` 関数でLaTeX形式に変換し、
287       Markdownの数式ブロック (`$$ ... $$`) として出力します。重複する数式は一度だけ処理されます。
288    4. 図: スライドに埋め込まれた画像を抽出し、指定された `image_dir` に保存します。
289       保存された画像はMarkdownの画像リンク (`![alt_text](path/to/image.png)`) として埋め込まれます。
290
291    処理中にファイル読み込みや画像保存に関するエラーが発生した場合は、コンソールにエラーメッセージが表示されます。
292
293    :param input_pptx: str
294        入力するPowerPoint (.pptx) ファイルのパス。
295    :param output_md: str
296        出力するMarkdown (.md) ファイルのパス。
297    :param image_dir: str
298        抽出された画像を保存するディレクトリのパス。存在しない場合は作成されます。
299    :param include_xml: bool
300        数式の元のOMML XMLを出力するかどうかを示すフラグ。(この関数内では現在未使用)
301    :returns: None
302    """
303    try:
304        presentation = Presentation(input_pptx)
305    except Exception as e:
306        print(f"エラー: {e}")
307        return
308
309    os.makedirs(image_dir, exist_ok=True)
310    markdown_output = ""
311
312    for i, slide in enumerate(presentation.slides):
313        title = get_slide_title(slide)
314        markdown_output += f"# スライド {i + 1} {title}\n\n"
315
316        # スライドのXMLルートを取得
317        slide_xml = slide.part.blob
318        root = etree.fromstring(slide_xml)
319
320        # --- 1. テキストセクション (全テキスト・改行・箇条書きの完全復元) ---
321        markdown_output += "## テキスト\n\n"
322        
323        # 段落 (a:p) を順番にすべて取得する
324        paragraphs = root.xpath('//a:p', namespaces=NAMESPACES)
325        has_text = False
326
327        for p in paragraphs:
328            para_text_parts = []
329            
330            # 箇条書きレベルの取得
331            lvl_attr = p.xpath('./a:pPr/@lvl', namespaces=NAMESPACES)
332            level = int(lvl_attr[0]) if lvl_attr else 0
333            indent = "  " * level
334            
335            # 段落内の「直下の子要素」だけを順番に走査する(入れ子を深く見すぎない)
336            for child in p.iterchildren():
337                local_name = etree.QName(child).localname
338                
339                # 1. 通常のテキストラン (a:r)
340                if local_name == 'r':
341                    # 数式の中の a:r ではなく、段落直下の a:r だけを処理
342                    for t in child.findall('.//a:t', NAMESPACES):
343                        if t.text: para_text_parts.append(t.text)
344                
345                # 2. 改行 (a:br)
346                elif local_name == 'br':
347                    para_text_parts.append("\n" + indent + "  ")
348                
349                # 3. 数式 (m:oMath, m:oMathPara)
350                elif local_name in ('oMath', 'oMathPara'):
351                    latex = omml_to_latex(child)
352                    if latex.strip():
353                        para_text_parts.append(f" ${latex}$ ")
354
355            full_para_text = "".join(para_text_parts).strip()
356            # ... (以下、Markdownへの書き出し処理)            
357            if full_para_text:
358                markdown_output += f"{indent}* {full_para_text}\n"
359                has_text = True
360
361        if not has_text:
362            markdown_output += "(テキストなし)\n\n"
363        else:
364            markdown_output += "\n"
365
366        # --- 2. 数式セクション (元のXPathロジックをそのまま使用) ---
367        math_elements = root.xpath(
368            '//m:oMathPara | //m:oMath[not(ancestor::m:oMathPara)]',
369            namespaces=NAMESPACES
370        )
371        if math_elements:
372            markdown_output += "## 数式\n\n"
373            seen_omml = set()
374            for math_elem in math_elements:
375                omml_string = etree.tostring(math_elem, encoding='unicode')
376                if omml_string in seen_omml: continue
377                seen_omml.add(omml_string)
378
379                latex_code = omml_to_latex(math_elem)
380                for one_line in split_latex_blocks(latex_code):
381                    one_line = one_line.replace('\\mathrm{d}', '\\,\\mathrm{d}')
382                    markdown_output += f"$$ {one_line} $$\n\n"
383
384        # --- 3. 図セクション (元のXPathロジックをそのまま使用) ---
385        image_elements = root.xpath('//a:blip[@r:embed]', namespaces=NAMESPACES)
386        if image_elements:
387            markdown_output += "## 図\n\n"
388            for j, image_elem in enumerate(image_elements):
389                r_id = image_elem.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed')
390                try:
391                    image_part = slide.part.rels[r_id].target_part
392                    ext = image_part.content_type.split('/')[-1].replace('x-', '')
393                    image_filename = f"slide{i+1}_image{j+1}.{ext}"
394                    image_path = os.path.join(image_dir, image_filename)
395                    with open(image_path, 'wb') as f:
396                        f.write(image_part.blob)
397                    markdown_output += f"![スライド{i+1}の図{j+1}]({image_dir}/{image_filename})\n\n"
398                except: continue
399
400        markdown_output += "---\n\n"
401
402    with open(output_md, "w", encoding="utf-8") as f:
403        f.write(markdown_output)
404    print(f"変換完了: {output_md}")
405
406def main():
407    """
408    概要: スクリプトの主要な実行フローを制御します。
409
410    詳細説明:
411    コマンドライン引数を初期化し、入力PowerPointファイルの存在を確認します。
412    ファイルが存在しない場合はエラーメッセージを表示して終了します。
413    その後、`extract_content_to_markdown` 関数を呼び出して、PowerPointからMarkdownへの変換処理を実行します。
414    終了時に`pause`フラグが設定されている場合は、ユーザーの入力を待ちます。
415
416    :returns: None
417    """
418    args = initialize()
419    global pause
420    pause = args.pause
421    if not os.path.exists(args.input):
422        print("入力ファイルが見つかりません"); return
423    extract_content_to_markdown(args.input, args.output, args.imagedir, include_xml=args.xml)
424
425if __name__ == "__main__":
426    main()
427    terminate()