converter.pptx2md のソースコード

"""PowerPointファイルからテキスト、数式、図を抽出し、Markdown形式に変換するツール。

このスクリプトは、指定されたPowerPointプレゼンテーションファイル(.pptx)を解析し、
スライド内のテキスト、OMML(Office Math Markup Language)形式の数式、および画像を抽出し、
対応するMarkdownファイルに出力します。数式はLaTeX形式に変換されます。
抽出プロセスでは、スライドのタイトル、メインテキスト、OMML数式(LaTeX形式)、埋め込み画像が処理されます。
数式は複数行のLaTeX式を考慮し、個別のブロックとして出力されるよう設計されています。
画像は指定されたディレクトリに保存され、Markdown内に相対パスでリンクされます。

関連リンク:
    :doc:`pptx2md_usage`
"""

import argparse
import os
import re
from pptx import Presentation
from pptx.slide import Slide
from lxml import etree
from typing import Optional, List

# 名前空間の定義: XML解析で使用される各種Office Open XMLの名前空間を定義します。
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に変換する辞書(積分の拡張・斜体補正を含む):
# OMMLから抽出されたテキストに含まれる特定のUnicode数学記号を、
# LaTeXで表示可能な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の対応:
# n-ary演算子(例: 和、積、積分)の特定のUnicode文字を、対応するLaTeXコマンドにマッピングします。
NARY_TO_LATEX = {
    '∑': '\\sum', '∏': '\\prod',
    '∫': '\\int', '∬': '\\iint', '∭': '\\iiint', '∮': '\\oint',
    '⋀': '\\bigwedge', '⋁': '\\bigvee', '⋂': '\\bigcap', '⋃': '\\bigcup',
}
# n-ary演算子として検出する文字のセット。
OPERATOR_CHARS = set(['∑', '∏', '∫', '∬', '∭', '∮'])

# プログラム終了時にユーザー入力が必要かどうかを制御するグローバルフラグ。
pause = 0

[ドキュメント] def terminate(): """プログラムの実行を終了します。 `pause` グローバル変数が1の場合、ユーザーにEnterキーの入力を求めてから終了します。 """ if pause: input("\nPress ENTER to terminate\n") exit()
[ドキュメント] def initialize(): """コマンドライン引数を解析し、プログラムの初期設定を行います。 必要な引数として入力PowerPointファイル名と出力Markdownファイル名を受け取ります。 数式の元のOMML XMLの出力、画像保存ディレクトリ、終了時のポーズ設定などのオプションも指定可能です。 :returns: 解析されたコマンドライン引数を格納する `argparse.Namespace` オブジェクト。 :rtype: argparse.Namespace """ parser = argparse.ArgumentParser(description="PowerPointファイルからテキスト、数式、図を抽出し、Markdownに出力します。") parser.add_argument("-i", "--input", required=True, help="入力するPowerPointファイル名 (例: test.pptx)") parser.add_argument("-o", "--output", required=True, help="出力するMarkdownファイル名 (例: output.md)") parser.add_argument("--xml", action="store_true", help="数式の元のOMML XMLを出力します。") parser.add_argument("--imagedir", default="images", help="画像ファイルを保存するディレクトリ (デフォルト: images)") parser.add_argument("--pause", type=int, default=0, help="終了時にENTERキー入力を要求するか (デフォルト: 0)") args = parser.parse_args() return args
[ドキュメント] def get_slide_title(slide: Slide) -> str: """スライドからタイトルを抽出します。 スライドのタイトルプレースホルダーが存在する場合はそのテキストを優先します。 タイトルプレースホルダーがない場合は、スライド上の最初のテキストフレームの最初の行をタイトルとして使用します。 どちらも見つからない場合は「無題のスライド」を返します。 :param slide: タイトルを抽出するスライドオブジェクト。 :type slide: pptx.slide.Slide :returns: 抽出されたスライドのタイトル文字列。 :rtype: 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 _safe_text_replace_math_unicode(text: str) -> str: """テキスト内のUnicode数学記号を対応するASCII/LaTeX表現に安全に置換します。 `MATH_UNICODE_MAP` 辞書に基づいて置換を行います。 :param text: 置換対象の入力文字列。 :type text: str :returns: Unicode数学記号が置換された文字列。 :rtype: str """ if not text: return "" for u, ltx in MATH_UNICODE_MAP.items(): text = text.replace(u, ltx) return text def _find_first(element: etree._Element, candidates: List[str]) -> Optional[etree._Element]: """指定された要素の子要素の中から、候補タグ名のリストに含まれる最初の要素を見つけます。 XPathで `element.find()` を使用し、候補リストの順に探索します。 :param element: 検索対象の親XML要素。 :type element: lxml.etree._Element :param candidates: 検索する子要素のタグ名のリスト(例: `['m:sub', 'm:low']`)。 :type candidates: list[str] :returns: 最初に見つかった子XML要素。見つからない場合は `None`。 :rtype: Optional[lxml.etree._Element] """ for cand in candidates: found = element.find(cand, NAMESPACES) if found is not None: return found return None def _detect_nary_op_char(element: etree._Element) -> str: """n-ary演算子の文字を検出します。 主に `<m:naryPr><m:chr m:val="..."></m:chr></m:naryPr>` から文字を読み取ります。 それが存在しない場合、演算子記号(∑, ∫など)が `<m:t>` 要素内に直接埋め込まれている `opEmu` 形式を検出し、その文字を返します。 ただし、`m:e`, `m:sub`, `m:sup` の中にあるテキストは除外されます。 :param element: `m:nary` XML要素。 :type element: lxml.etree._Element :returns: 検出されたn-ary演算子文字(例: '∑', '∫')。見つからない場合は空文字列。 :rtype: 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: etree._Element) -> str: """OMMLのXML要素を再帰的に解析し、対応するLaTeX形式の文字列に変換します。 様々なOMML構造(分数、根号、上付き/下付き、積分、n-ary演算子、デリミタなど)を処理し、 LaTeXの構文にマッピングします。`m:t` 要素内のUnicode数学記号も変換されます。 この関数は再帰的に子要素を処理し、複雑な数式構造全体をLaTeXに変換します。 :param element: 変換対象のOMML XML要素。 :type element: lxml.etree._Element :returns: 変換されたLaTeX文字列。 :rtype: str """ 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': # 下限 (lower limit) 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': # 上限 (upper limit) 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}" # 汎用 n-ary(和・積・積分など) 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): # opEmu 検出失敗時の保険:見た目が積分であることが多い 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) -> List[str]: r"""LaTeX文字列を `\\` (行区切り)で分割し、空の要素を除去します。 Pandocでのレンダリングを考慮し、複数行のLaTeX数式ブロックを個別のインラインブロックに分割するのに役立ちます。 例: `"\\frac{∂L}{∂x}=0 \\\\ \\frac{∂L}{∂y}=0"` は、`["\\frac{∂L}{∂x}=0", "\\frac{∂L}{\partial y}=0"]` に分割されます。 :param s: 分割対象のLaTeX文字列。 :type s: str :returns: 分割されたLaTeX文字列のリスト。空文字列の場合は空リストを返します。 :rtype: list[str] """ 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 extract_content_to_markdown(input_pptx: str, output_md: str, image_dir: str, include_xml: bool = False) -> None: """PowerPointファイルからテキスト、数式、図を抽出し、Markdownファイルに保存します。 各スライドからタイトル、テキスト、数式、画像を抽出し、Markdown形式で整形します。 数式はLaTeX形式に変換され、必要に応じて `$$ ... $$` ブロックに分割されます。 画像は指定されたディレクトリに保存され、Markdown内に相対パスでリンクされます。 `include_xml` がTrueの場合、数式の元のOMML XMLも出力されます。 :param input_pptx: 入力PowerPointファイルへのパス。 :type input_pptx: str :param output_md: 出力Markdownファイルへのパス。 :type output_md: str :param image_dir: 画像ファイルを保存するディレクトリのパス。 :type image_dir: str :param include_xml: 数式の元のOMML XMLを出力に含めるかどうかを示すフラグ。デフォルトは `False`。 :type include_xml: bool :returns: `None` :rtype: None """ try: presentation = Presentation(input_pptx) except Exception as e: print(f"エラー: ファイル '{input_pptx}' を開けませんでした。{e}") return os.makedirs(image_dir, exist_ok=True) markdown_output = "" for i, slide in enumerate(presentation.slides): title = get_slide_title(slide) markdown_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(): markdown_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: markdown_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要素を個別の行として出力 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): # 微分記号 d の前に半角スペースを挿入して見やすくする one_line = one_line.replace('\\mathrm{d}', '\\,\\mathrm{d}') markdown_output += f"$$ {one_line} $$\n\n" else: # oMathParaに直接数式テキストが含まれる場合のフォールバック 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}') markdown_output += f"$$ {one_line} $$\n\n" if include_xml: markdown_output += f"**元のOMML XML:**\n```xml\n{omml_string.strip()}\n```\n\n" # 単独のoMath要素の場合、そのままLaTeXに変換して出力(複数行対応も含む) 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}') markdown_output += f"$$ {one_line} $$\n\n" if include_xml: markdown_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_elements: markdown_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) # Markdownの画像リンクを生成。image_dirのベース名をパスに含める markdown_output += f"![スライド{i+1}の図{j+1}]({os.path.join(os.path.basename(image_dir), image_filename)})\n\n" except KeyError: markdown_output += f"図のリンクが見つかりませんでした (rId: {r_id})\n\n" markdown_output += "\n" markdown_output += "---\n\n" # スライド区切り # 結果をMarkdownファイルに書き込み with open(output_md, "w", encoding="utf-8") as f: f.write(markdown_output) print(f"抽出が完了しました。結果は '{output_md}' に保存されました。")
[ドキュメント] def main(): """スクリプトのメイン実行ロジックです。 コマンドライン引数を初期化・解析し、指定されたPowerPointファイルからコンテンツを抽出し、 Markdownファイルに変換して保存する主要な処理を呼び出します。 入力ファイルが見つからない場合はエラーメッセージを表示して終了します。 """ global pause args = initialize() pause = args.pause if not os.path.exists(args.input): print(f"エラー: 指定された入力ファイル '{args.input}' が見つかりません。") return extract_content_to_markdown(args.input, args.output, args.imagedir, include_xml=args.xml)
if __name__ == "__main__": main() terminate()