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

convert.py をダウンロード

convert.py
convert.py
   1"""
   2概要:
   3    各種ファイル形式の変換処理を統一的に管理・実行するスクリプトです。
   4詳細説明:
   5    Word、Excel、PowerPoint、MarkdownなどのファイルをPDFなどの目的の形式に変換します。
   6    変換器はレジストリに登録され、ディレクトリ内の再帰的な一括変換や単一ファイルの変換をサポートします。
   7"""
   8import os
   9import sys
  10import fnmatch
  11import argparse
  12from dataclasses import dataclass, field
  13from typing import Callable, Dict, List, Optional, Tuple, Any
  14import tempfile
  15import shutil
  16
  17
  18OUTPUT_MODE_FILE = "file"
  19OUTPUT_MODE_DIR = "dir"
  20
  21
  22@dataclass
  23class ConverterSpec:
  24    """
  25    概要:
  26        変換器の仕様を定義するデータクラスです。
  27    詳細説明:
  28        入力と出力の拡張子、実行する変換関数、およびその他のメタデータを保持します。
  29    引数:
  30        :param input_ext: 入力ファイルの拡張子
  31        :type input_ext: str
  32        :param output_ext: 出力ファイルの拡張子
  33        :type output_ext: str
  34        :param converter: 変換処理を実行する関数
  35        :type converter: Callable
  36        :param description: 変換器の説明文
  37        :type description: str
  38        :param ignore_temp_prefixes: 無視する一時ファイルの接頭辞のタプル
  39        :type ignore_temp_prefixes: tuple
  40        :param output_mode: 出力モードを示す文字列
  41        :type output_mode: str
  42        :param options: 変換器に渡す追加オプションの辞書
  43        :type options: dict
  44    """
  45    input_ext: str
  46    output_ext: str
  47    converter: Callable[..., Any]
  48    description: str = ""
  49    ignore_temp_prefixes: Tuple[str, ...] = ()
  50    output_mode: str = OUTPUT_MODE_FILE
  51    options: Dict[str, Any] = field(default_factory=dict)
  52
  53    def __post_init__(self):
  54        """
  55        概要:
  56            インスタンス生成後の初期化処理を行います。
  57        詳細説明:
  58            拡張子を正規化し、出力モードの値が正しいか検証します。
  59        例外:
  60            :raises ValueError: 無効な output_mode が指定された場合
  61        """
  62        self.input_ext = normalize_ext(self.input_ext)
  63        self.output_ext = normalize_ext(self.output_ext)
  64        if self.output_mode not in (OUTPUT_MODE_FILE, OUTPUT_MODE_DIR):
  65            raise ValueError(f"Invalid output_mode: {self.output_mode}")
  66
  67
  68class ConverterRegistry:
  69    """
  70    概要:
  71        変換器を管理するレジストリクラスです。
  72    詳細説明:
  73        入出力の拡張子に応じた適切な変換器を登録および取得する機能を提供します。
  74    """
  75    def __init__(self):
  76        """
  77        概要:
  78            レジストリの初期化を行います。
  79        """
  80        self._specs: Dict[Tuple[str, str], ConverterSpec] = {}
  81        self._import_errors: Dict[str, Exception] = {}
  82
  83    def register(self, spec: ConverterSpec):
  84        """
  85        概要:
  86            変換器の仕様をレジストリに登録します。
  87        引数:
  88            :param spec: 登録する変換器の仕様
  89            :type spec: ConverterSpec
  90        """
  91        key = (spec.input_ext, spec.output_ext)
  92        self._specs[key] = spec
  93
  94    def set_import_error(self, module_name: str, error: Exception):
  95        """
  96        概要:
  97            モジュールのインポート時に発生したエラーを記録します。
  98        引数:
  99            :param module_name: インポートに失敗したモジュール名
 100            :type module_name: str
 101            :param error: 発生した例外オブジェクト
 102            :type error: Exception
 103        """
 104        self._import_errors[module_name] = error
 105
 106    def get(self, input_ext: str, output_ext: str) -> Optional[ConverterSpec]:
 107        """
 108        概要:
 109            指定された拡張子の組み合わせに対応する変換器を取得します。
 110        引数:
 111            :param input_ext: 入力ファイルの拡張子
 112            :type input_ext: str
 113            :param output_ext: 出力ファイルの拡張子
 114            :type output_ext: str
 115        戻り値:
 116            :returns: 変換器の仕様オブジェクト、見つからない場合はNone
 117            :rtype: ConverterSpec
 118        """
 119        return self._specs.get((normalize_ext(input_ext), normalize_ext(output_ext)))
 120
 121    def has(self, input_ext: str, output_ext: str) -> bool:
 122        """
 123        概要:
 124            指定された拡張子の組み合わせに対応する変換器が存在するか判定します。
 125        引数:
 126            :param input_ext: 入力ファイルの拡張子
 127            :type input_ext: str
 128            :param output_ext: 出力ファイルの拡張子
 129            :type output_ext: str
 130        戻り値:
 131            :returns: 変換器が存在する場合はTrue、それ以外はFalse
 132            :rtype: bool
 133        """
 134        return self.get(input_ext, output_ext) is not None
 135
 136    def list_specs(self):
 137        """
 138        概要:
 139            登録されているすべての変換器の仕様をリストとして返します。
 140        戻り値:
 141            :returns: 変換器仕様オブジェクトのリスト
 142            :rtype: list
 143        """
 144        return list(self._specs.values())
 145
 146    def list_output_exts(self):
 147        """
 148        概要:
 149            登録されているすべての出力拡張子をソートされたリストとして返します。
 150        戻り値:
 151            :returns: 出力拡張子のリスト
 152            :rtype: list
 153        """
 154        return sorted({spec.output_ext for spec in self._specs.values()})
 155
 156    def print_available_converters(self):
 157        """
 158        概要:
 159            利用可能な変換器の一覧と、利用できないモジュールを標準出力に表示します。
 160        """
 161        print("Available converters:")
 162        for spec in sorted(self._specs.values(), key=lambda s: (s.output_ext, s.input_ext, s.output_mode)):
 163            desc = f" ({spec.description})" if spec.description else ""
 164            mode = f" [{spec.output_mode}]" if spec.output_mode != OUTPUT_MODE_FILE else ""
 165            print(f"  {spec.input_ext} -> {spec.output_ext}{mode}{desc}")
 166
 167        if self._import_errors:
 168            print("\nUnavailable modules:")
 169            for module_name, err in self._import_errors.items():
 170                print(f"  {module_name}: {err}")
 171
 172
 173def normalize_ext(ext: str) -> str:
 174    """
 175    概要:
 176        拡張子を正規化します。
 177    詳細説明:
 178        小文字に変換し、先頭にピリオドがない場合は付与します。
 179    引数:
 180        :param ext: 元の拡張子
 181        :type ext: str
 182    戻り値:
 183        :returns: 正規化された拡張子
 184        :rtype: str
 185    """
 186    ext = ext.strip().lower()
 187    if not ext.startswith("."):
 188        ext = "." + ext
 189    return ext
 190
 191
 192def build_output_path(input_path: str, output_ext: str, output_mode: str = OUTPUT_MODE_FILE) -> str:
 193    """
 194    概要:
 195        出力先のファイルまたはディレクトリパスを構築します。
 196    詳細説明:
 197        出力モードがディレクトリの場合は拡張子に s を付与した名前を返します。
 198    引数:
 199        :param input_path: 入力ファイルのパス
 200        :type input_path: str
 201        :param output_ext: 対象の出力拡張子
 202        :type output_ext: str
 203        :param output_mode: 出力モード
 204        :type output_mode: str
 205    戻り値:
 206        :returns: 構築された出力パス
 207        :rtype: str
 208    """
 209    stem = os.path.splitext(input_path)[0]
 210    if output_mode == OUTPUT_MODE_DIR:
 211        return stem + normalize_ext(output_ext) + "s"
 212    return stem + normalize_ext(output_ext)
 213
 214
 215def should_ignore_file(filename: str, spec: ConverterSpec) -> bool:
 216    """
 217    概要:
 218        ファイルが一時ファイルとして無視すべき対象かどうかを判定します。
 219    引数:
 220        :param filename: 判定するファイル名
 221        :type filename: str
 222        :param spec: 適用する変換器の仕様
 223        :type spec: ConverterSpec
 224    戻り値:
 225        :returns: 無視すべきファイルの場合はTrue、それ以外はFalse
 226        :rtype: bool
 227    """
 228    return any(filename.startswith(prefix) for prefix in spec.ignore_temp_prefixes)
 229
 230
 231def path_exists_for_mode(path: str, output_mode: str) -> bool:
 232    """
 233    概要:
 234        指定された出力モードにおいてパスが有効に存在するかを確認します。
 235    詳細説明:
 236        ディレクトリモードの場合はディレクトリが存在し、かつ空でないことを確認します。
 237    引数:
 238        :param path: 確認するパス
 239        :type path: str
 240        :param output_mode: 出力モード
 241        :type output_mode: str
 242    戻り値:
 243        :returns: パスが存在し条件を満たす場合はTrue、それ以外はFalse
 244        :rtype: bool
 245    """
 246    if output_mode == OUTPUT_MODE_DIR:
 247        return os.path.isdir(path) and any(True for _ in os.scandir(path))
 248    return os.path.exists(path)
 249
 250
 251def get_latest_mtime(path: str, output_mode: str) -> Optional[float]:
 252    """
 253    概要:
 254        指定されたパスの最新の更新日時を取得します。
 255    詳細説明:
 256        ディレクトリモードの場合は再帰的にファイルを走査し、最も新しい更新日時を返します。
 257    引数:
 258        :param path: 対象のパス
 259        :type path: str
 260        :param output_mode: 出力モード
 261        :type output_mode: str
 262    戻り値:
 263        :returns: 最新の更新日時を表すタイムスタンプ、存在しない場合はNone
 264        :rtype: float
 265    """
 266    if output_mode == OUTPUT_MODE_FILE:
 267        if not os.path.exists(path):
 268            return None
 269        return os.path.getmtime(path)
 270
 271    if not os.path.isdir(path):
 272        return None
 273
 274    latest = os.path.getmtime(path)
 275    found_any = False
 276    for dirpath, dirnames, filenames in os.walk(path):
 277        for name in dirnames + filenames:
 278            found_any = True
 279            candidate = os.path.join(dirpath, name)
 280            try:
 281                latest = max(latest, os.path.getmtime(candidate))
 282            except OSError:
 283                pass
 284    return latest if found_any else None
 285
 286
 287def should_convert(input_path: str, output_path: str, output_mode: str, update: bool, overwrite: bool) -> Tuple[bool, str]:
 288    """
 289    概要:
 290        変換を実行すべきかどうかとその理由を判定します。
 291    詳細説明:
 292        上書き設定やファイルの更新日時を比較して、変換の要否を決定します。
 293    引数:
 294        :param input_path: 入力ファイルのパス
 295        :type input_path: str
 296        :param output_path: 出力ファイルのパス
 297        :type output_path: str
 298        :param output_mode: 出力モード
 299        :type output_mode: str
 300        :param update: 更新のみ行うかどうかのフラグ
 301        :type update: bool
 302        :param overwrite: 強制的に上書きするかどうかのフラグ
 303        :type overwrite: bool
 304    戻り値:
 305        :returns: 変換実行の要否と判定理由の文字列のタプル
 306        :rtype: tuple
 307    """
 308    if overwrite:
 309        return True, "overwrite=1"
 310
 311    exists = path_exists_for_mode(output_path, output_mode)
 312    if not exists:
 313        return True, "output does not exist"
 314
 315    if not update:
 316        return False, "output exists and update=0"
 317
 318    output_mtime = get_latest_mtime(output_path, output_mode)
 319    if output_mtime is None:
 320        return True, "output exists but is empty/incomplete"
 321
 322    input_mtime = os.path.getmtime(input_path)
 323    if input_mtime > output_mtime:
 324        return True, "source is newer than output"
 325
 326    return False, "up to date"
 327
 328
 329def parse_target_patterns(target_text: Optional[str]) -> Optional[List[str]]:
 330    """
 331    概要:
 332        セミコロン区切りの対象ファイルパターンの文字列をリストにパースします。
 333    引数:
 334        :param target_text: 対象パターンの文字列
 335        :type target_text: str
 336    戻り値:
 337        :returns: パースされたパターンのリスト、入力が空の場合はNone
 338        :rtype: list
 339    """
 340    if not target_text:
 341        return None
 342    patterns = [p.strip() for p in target_text.split(";") if p.strip()]
 343    return patterns or None
 344
 345
 346def matches_target(filename: str, target_patterns: Optional[List[str]]) -> bool:
 347    """
 348    概要:
 349        ファイル名が対象パターンのいずれかに一致するか判定します。
 350    引数:
 351        :param filename: 判定するファイル名
 352        :type filename: str
 353        :param target_patterns: 対象パターンのリスト
 354        :type target_patterns: list
 355    戻り値:
 356        :returns: 一致する場合はTrue、それ以外はFalse
 357        :rtype: bool
 358    """
 359    if not target_patterns:
 360        return True
 361
 362    lower_name = filename.lower()
 363    for pattern in target_patterns:
 364        if fnmatch.fnmatch(lower_name, pattern.lower()):
 365            return True
 366    return False
 367
 368
 369# ---------- importable wrappers ----------
 370def wrap_simple(converter: Callable[[str, Optional[str]], Any]) -> Callable[..., bool]:
 371    """
 372    概要:
 373        シンプルな変換関数を標準的なインターフェースでラップします。
 374    引数:
 375        :param converter: 元の変換関数
 376        :type converter: Callable
 377    戻り値:
 378        :returns: ラップされた関数
 379        :rtype: Callable
 380    """
 381    def _wrapped(input_path: str, output_path: str, **kwargs) -> bool:
 382        result = converter(input_path, output_path)
 383        return bool(result is not None)
 384    return _wrapped
 385
 386
 387def wrap_image_to_dir(converter: Callable[..., Any], rename_func: Optional[Callable[[str], Any]] = None) -> Callable[..., bool]:
 388    """
 389    概要:
 390        画像をディレクトリに出力する変換関数をラップします。
 391    引数:
 392        :param converter: 元の変換関数
 393        :type converter: Callable
 394        :param rename_func: 出力後のリネーム処理を行う関数
 395        :type rename_func: Callable
 396    戻り値:
 397        :returns: ラップされた関数
 398        :rtype: Callable
 399    """
 400    def _wrapped(input_path: str, output_path: str, **kwargs) -> bool:
 401        os.makedirs(output_path, exist_ok=True)
 402        converter(input_path, output_path, "png")
 403        if rename_func is not None:
 404            rename_func(output_path)
 405        return True
 406    return _wrapped
 407
 408
 409def wrap_docx_to_png(docx_to_pdf: Callable[[str, str], Any], pdf_to_images: Callable[..., Any]) -> Callable[..., bool]:
 410    """
 411    概要:
 412        WordファイルをPDF経由でPNG画像のディレクトリに変換する処理をラップします。
 413    引数:
 414        :param docx_to_pdf: WordからPDFへの変換関数
 415        :type docx_to_pdf: Callable
 416        :param pdf_to_images: PDFから画像への変換関数
 417        :type pdf_to_images: Callable
 418    戻り値:
 419        :returns: ラップされた関数
 420        :rtype: Callable
 421    """
 422    def _wrapped(input_path: str, output_path: str, **kwargs) -> bool:
 423        os.makedirs(output_path, exist_ok=True)
 424        tmp_pdf = os.path.splitext(input_path)[0] + ".pdf"
 425        docx_to_pdf(input_path, tmp_pdf)
 426        pdf_to_images(tmp_pdf, output_path, "png")
 427        return True
 428    return _wrapped
 429
 430
 431def wrap_md_with_images(convert_func: Callable[..., Any]) -> Callable[..., bool]:
 432    """
 433    概要:
 434        画像ディレクトリを伴うMarkdown変換関数をラップします。
 435    引数:
 436        :param convert_func: 元の変換関数
 437        :type convert_func: Callable
 438    戻り値:
 439        :returns: ラップされた関数
 440        :rtype: Callable
 441    """
 442    def _wrapped(input_path: str, output_path: str, **kwargs) -> bool:
 443        image_dir = os.path.splitext(output_path)[0] + ".images"
 444        result = convert_func(input_path, output_path, image_dir)
 445        return bool(result is not None or os.path.exists(output_path))
 446    return _wrapped
 447
 448
 449def wrap_pdf2md(convert_func: Callable[[str, Optional[str]], Any]) -> Callable[..., bool]:
 450    """
 451    概要:
 452        PDFからMarkdownへの変換関数をラップします。
 453    引数:
 454        :param convert_func: 元の変換関数
 455        :type convert_func: Callable
 456    戻り値:
 457        :returns: ラップされた関数
 458        :rtype: Callable
 459    """
 460    def _wrapped(input_path: str, output_path: str, **kwargs) -> bool:
 461        result = convert_func(input_path, output_path)
 462        return bool(result is not None or os.path.exists(output_path))
 463    return _wrapped
 464
 465
 466def wrap_pandoc(target: str, default_template: Optional[str] = None) -> Callable[..., bool]:
 467    """
 468    概要:
 469        Pandocを使用した変換関数をラップします。
 470    引数:
 471        :param target: 変換先のフォーマット指定
 472        :type target: str
 473        :param default_template: デフォルトのテンプレートパス
 474        :type default_template: str
 475    戻り値:
 476        :returns: ラップされた関数
 477        :rtype: Callable
 478    """
 479    def _wrapped(input_path: str, output_path: str, **kwargs) -> bool:
 480        from pandoc import convert_md, find_template
 481
 482        pandoc_path = kwargs.get("pandoc_path") or "pandoc"
 483        template = kwargs.get("template") or default_template
 484        if template:
 485            template = find_template(template)
 486
 487        return bool(convert_md(
 488            infile_md=input_path,
 489            target=target,
 490            pandoc_path=pandoc_path,
 491            outfile=output_path,
 492            template=template,
 493            toc=bool(kwargs.get("toc", 0)),
 494            css=kwargs.get("css"),
 495            mathml=bool(kwargs.get("mathml", False)),
 496            no_yaml=bool(kwargs.get("no_yaml", False)),
 497            verbose=bool(kwargs.get("verbose", False)),
 498            smart_conversion=bool(kwargs.get("smart_conversion", False)),
 499        ))
 500    return _wrapped
 501
 502
 503def wrap_ipynb_json_to_md(convert_func: Callable[..., Any]) -> Callable[..., bool]:
 504    """
 505    概要:
 506        Jupyter NotebookまたはJSONファイルからMarkdownへの変換関数をラップします。
 507    引数:
 508        :param convert_func: 元の変換関数
 509        :type convert_func: Callable
 510    戻り値:
 511        :returns: ラップされた関数
 512        :rtype: Callable
 513    """
 514    def _wrapped(input_path: str, output_path: str, **kwargs) -> bool:
 515        result = convert_func(input_path, output_path)
 516        return bool(result is not None or os.path.exists(output_path))
 517    return _wrapped
 518
 519
 520def wrap_ipynb_json_to_pdf(ipynb_or_json_to_md: Callable[..., Any], md_to_pdf: Callable[..., Any]) -> Callable[..., bool]:
 521    """
 522    概要:
 523        Jupyter NotebookまたはJSONファイルをMarkdown経由でPDFに変換する関数をラップします。
 524    詳細説明:
 525        一時ディレクトリを作成し、Markdownへ変換したのちPDFへの変換を実行します。
 526    引数:
 527        :param ipynb_or_json_to_md: Markdownへの変換関数
 528        :type ipynb_or_json_to_md: Callable
 529        :param md_to_pdf: MarkdownからPDFへの変換関数
 530        :type md_to_pdf: Callable
 531    戻り値:
 532        :returns: ラップされた関数
 533        :rtype: Callable
 534    """
 535    def _wrapped(input_path: str, output_path: str, **kwargs) -> bool:
 536        tmp_dir = tempfile.mkdtemp(prefix="convert_pipeline_")
 537        tmp_md = os.path.join(tmp_dir, os.path.splitext(os.path.basename(input_path))[0] + ".md")
 538        try:
 539            md_result = ipynb_or_json_to_md(input_path, tmp_md)
 540            if md_result is False or not os.path.exists(tmp_md):
 541                return False
 542            pdf_result = md_to_pdf(tmp_md, output_path)
 543            return bool(pdf_result is not None or os.path.exists(output_path))
 544        finally:
 545            shutil.rmtree(tmp_dir, ignore_errors=True)
 546    return _wrapped
 547
 548
 549# ---------- registry ----------
 550def safe_import_registry(registry: ConverterRegistry):
 551    """
 552    概要:
 553        利用可能な各種モジュールを安全にインポートし、レジストリに登録します。
 554    詳細説明:
 555        インポートに失敗した場合はエラー情報をレジストリに記録し、処理を続行します。
 556    引数:
 557        :param registry: 登録先のレジストリインスタンス
 558        :type registry: ConverterRegistry
 559    """
 560    try:
 561        from docx2pdf import docx_to_pdf
 562    except Exception as e:
 563        registry.set_import_error("docx2pdf", e)
 564    else:
 565        registry.register(ConverterSpec(
 566            input_ext=".docx",
 567            output_ext=".pdf",
 568            converter=wrap_simple(docx_to_pdf),
 569            description="Word to PDF",
 570        ))
 571
 572    try:
 573        from xlsx2pdf import xlsx_to_pdf
 574    except Exception as e:
 575        registry.set_import_error("xlsx2pdf", e)
 576    else:
 577        registry.register(ConverterSpec(
 578            input_ext=".xlsx",
 579            output_ext=".pdf",
 580            converter=wrap_simple(xlsx_to_pdf),
 581            description="Excel to PDF",
 582            ignore_temp_prefixes=("~$",),
 583        ))
 584
 585    try:
 586        from pptx2pdf import pptx_to_pdf
 587    except Exception as e:
 588        registry.set_import_error("pptx2pdf", e)
 589    else:
 590        registry.register(ConverterSpec(
 591            input_ext=".pptx",
 592            output_ext=".pdf",
 593            converter=wrap_simple(pptx_to_pdf),
 594            description="PowerPoint to PDF",
 595        ))
 596
 597    try:
 598        from pptx2pdf_with_notes_importable import pptx_to_pdf_with_notes
 599    except Exception as e:
 600        registry.set_import_error("pptx2pdf_with_notes_importable", e)
 601    else:
 602        registry.register(ConverterSpec(
 603            input_ext=".pptx",
 604            output_ext=".notes.pdf",
 605            converter=wrap_simple(pptx_to_pdf_with_notes),
 606            description="PowerPoint to PDF with speaker notes",
 607        ))
 608
 609    try:
 610        from html2pdf_importable import html_to_pdf
 611    except Exception as e:
 612        registry.set_import_error("html2pdf_importable", e)
 613    else:
 614        registry.register(ConverterSpec(
 615            input_ext=".html",
 616            output_ext=".pdf",
 617            converter=wrap_simple(html_to_pdf),
 618            description="HTML to PDF",
 619        ))
 620        registry.register(ConverterSpec(
 621            input_ext=".htm",
 622            output_ext=".pdf",
 623            converter=wrap_simple(html_to_pdf),
 624            description="HTML to PDF",
 625        ))
 626
 627    try:
 628        from md2pdf_importable import md_to_pdf
 629    except Exception as e:
 630        registry.set_import_error("md2pdf_importable", e)
 631    else:
 632        registry.register(ConverterSpec(
 633            input_ext=".md",
 634            output_ext=".pdf",
 635            converter=wrap_simple(md_to_pdf),
 636            description="Markdown to PDF",
 637        ))
 638
 639    try:
 640        from txt2pdf_importable import txt_to_pdf
 641    except Exception as e:
 642        registry.set_import_error("txt2pdf_importable", e)
 643    else:
 644        registry.register(ConverterSpec(
 645            input_ext=".txt",
 646            output_ext=".pdf",
 647            converter=wrap_simple(txt_to_pdf),
 648            description="Text to PDF",
 649        ))
 650
 651    try:
 652        from img2pdf_importable import image_to_pdf
 653    except Exception as e:
 654        registry.set_import_error("img2pdf_importable", e)
 655    else:
 656        for ext in (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp"):
 657            registry.register(ConverterSpec(
 658                input_ext=ext,
 659                output_ext=".pdf",
 660                converter=wrap_simple(image_to_pdf),
 661                description="Image to PDF",
 662            ))
 663
 664    # Markdown converters with formula/image extraction
 665    try:
 666        import docx2md
 667    except Exception as e:
 668        registry.set_import_error("docx2md", e)
 669    else:
 670        registry.register(ConverterSpec(
 671            input_ext=".docx",
 672            output_ext=".md",
 673            converter=wrap_md_with_images(docx2md.convert),
 674            description="Word to Markdown with equations/images",
 675        ))
 676
 677    try:
 678        import pptx2md2
 679    except Exception as e:
 680        registry.set_import_error("pptx2md2", e)
 681    else:
 682        registry.register(ConverterSpec(
 683            input_ext=".pptx",
 684            output_ext=".md",
 685            converter=wrap_md_with_images(pptx2md2.extract_content_to_markdown),
 686            description="PowerPoint to Markdown with equations/images",
 687        ))
 688
 689    try:
 690        import pdf2md
 691    except Exception as e:
 692        registry.set_import_error("pdf2md", e)
 693    else:
 694        registry.register(ConverterSpec(
 695            input_ext=".pdf",
 696            output_ext=".md",
 697            converter=wrap_pdf2md(pdf2md.convert),
 698            description="PDF to Markdown",
 699        ))
 700
 701    try:
 702        import pdf2pptx
 703    except Exception as e:
 704        registry.set_import_error("pdf2pptx", e)
 705    else:
 706        registry.register(ConverterSpec(
 707            input_ext=".pdf",
 708            output_ext=".pptx",
 709            converter=pdf2pptx.convert,
 710            description="PDF to PowerPoint slides as images",
 711        ))
 712
 713    try:
 714        import pptx2img
 715    except Exception as e:
 716        registry.set_import_error("pptx2img", e)
 717    else:
 718        registry.register(ConverterSpec(
 719            input_ext=".pptx",
 720            output_ext=".png",
 721            converter=wrap_image_to_dir(pptx2img.export_img, rename_func=getattr(pptx2img, "rename_img", None)),
 722            description="PowerPoint to PNG images",
 723            output_mode=OUTPUT_MODE_DIR,
 724        ))
 725
 726    try:
 727        import docx2img
 728    except Exception as e:
 729        registry.set_import_error("docx2img", e)
 730    else:
 731        registry.register(ConverterSpec(
 732            input_ext=".docx",
 733            output_ext=".png",
 734            converter=wrap_docx_to_png(docx2img.convert_word_to_pdf, docx2img.convert_pdf_to_images),
 735            description="Word to PNG images",
 736            output_mode=OUTPUT_MODE_DIR,
 737        ))
 738
 739    try:
 740        import pdf2img
 741    except Exception as e:
 742        registry.set_import_error("pdf2img", e)
 743    else:
 744        registry.register(ConverterSpec(
 745            input_ext=".pdf",
 746            output_ext=".png",
 747            converter=wrap_image_to_dir(pdf2img.convert_pdf_to_images),
 748            description="PDF to PNG images",
 749            output_mode=OUTPUT_MODE_DIR,
 750        ))
 751
 752    try:
 753        import ipynb2md
 754    except Exception as e:
 755        registry.set_import_error("ipynb2md", e)
 756    else:
 757        registry.register(ConverterSpec(
 758            input_ext=".ipynb",
 759            output_ext=".md",
 760            converter=wrap_ipynb_json_to_md(ipynb2md.convert_ipynb_to_md),
 761            description="Jupyter Notebook to Markdown",
 762        ))
 763        registry.register(ConverterSpec(
 764            input_ext=".json",
 765            output_ext=".md",
 766            converter=wrap_ipynb_json_to_md(ipynb2md.convert_json_to_md),
 767            description="JSON to Markdown",
 768        ))
 769
 770        try:
 771            from md2pdf_importable import md_to_pdf as md_to_pdf_for_pipeline
 772        except Exception:
 773            try:
 774                from md2pdf import md_to_pdf as md_to_pdf_for_pipeline
 775            except Exception as e:
 776                registry.set_import_error("md2pdf pipeline backend", e)
 777            else:
 778                registry.register(ConverterSpec(
 779                    input_ext=".ipynb",
 780                    output_ext=".pdf",
 781                    converter=wrap_ipynb_json_to_pdf(ipynb2md.convert_ipynb_to_md, md_to_pdf_for_pipeline),
 782                    description="Jupyter Notebook to PDF via Markdown pipeline",
 783                ))
 784                registry.register(ConverterSpec(
 785                    input_ext=".json",
 786                    output_ext=".pdf",
 787                    converter=wrap_ipynb_json_to_pdf(ipynb2md.convert_json_to_md, md_to_pdf_for_pipeline),
 788                    description="JSON to PDF via Markdown pipeline",
 789                ))
 790        else:
 791            registry.register(ConverterSpec(
 792                input_ext=".ipynb",
 793                output_ext=".pdf",
 794                converter=wrap_ipynb_json_to_pdf(ipynb2md.convert_ipynb_to_md, md_to_pdf_for_pipeline),
 795                description="Jupyter Notebook to PDF via Markdown pipeline",
 796            ))
 797            registry.register(ConverterSpec(
 798                input_ext=".json",
 799                output_ext=".pdf",
 800                converter=wrap_ipynb_json_to_pdf(ipynb2md.convert_json_to_md, md_to_pdf_for_pipeline),
 801                description="JSON to PDF via Markdown pipeline",
 802            ))
 803
 804    # pandoc.py 由来の追加変換
 805    try:
 806        import pandoc as pandoc_module
 807        _ = pandoc_module.find_pandoc
 808        _ = pandoc_module.convert_md
 809    except Exception as e:
 810        registry.set_import_error("pandoc", e)
 811    else:
 812        registry.register(ConverterSpec(
 813            input_ext=".md",
 814            output_ext=".docx",
 815            converter=wrap_pandoc("docx"),
 816            description="Markdown to Word via Pandoc",
 817        ))
 818        registry.register(ConverterSpec(
 819            input_ext=".md",
 820            output_ext=".pptx",
 821            converter=wrap_pandoc("pptx"),
 822            description="Markdown to PowerPoint via Pandoc",
 823        ))
 824        registry.register(ConverterSpec(
 825            input_ext=".md",
 826            output_ext=".html",
 827            converter=wrap_pandoc("html"),
 828            description="Markdown to HTML via Pandoc",
 829        ))
 830
 831
 832def convert_file(
 833    input_path: str,
 834    output_ext: str,
 835    registry: ConverterRegistry,
 836    update: bool = True,
 837    overwrite: bool = False,
 838    converter_kwargs: Optional[Dict[str, Any]] = None,
 839) -> bool:
 840    """
 841    概要:
 842        単一のファイルを変換します。
 843    詳細説明:
 844        指定されたファイルに対してレジストリから適切な変換器を取得し、変換を実行します。
 845    引数:
 846        :param input_path: 入力ファイルのパス
 847        :type input_path: str
 848        :param output_ext: 対象の出力拡張子
 849        :type output_ext: str
 850        :param registry: 変換器レジストリ
 851        :type registry: ConverterRegistry
 852        :param update: 出力ファイルが存在し、入力より新しい場合はスキップするかのフラグ
 853        :type update: bool
 854        :param overwrite: 常に変換を実行するかのフラグ
 855        :type overwrite: bool
 856        :param converter_kwargs: 変換器に渡す追加引数
 857        :type converter_kwargs: dict
 858    戻り値:
 859        :returns: 変換に成功した場合はTrue、それ以外はFalse
 860        :rtype: bool
 861    """
 862    input_path = os.path.abspath(input_path)
 863    input_ext = normalize_ext(os.path.splitext(input_path)[1])
 864    converter_kwargs = converter_kwargs or {}
 865
 866    spec = registry.get(input_ext, output_ext)
 867    if spec is None:
 868        print(f"Skipping unsupported file: '{input_path}'")
 869        return False
 870
 871    filename = os.path.basename(input_path)
 872    if should_ignore_file(filename, spec):
 873        print(f"Skipping temporary file: '{input_path}'")
 874        return False
 875
 876    output_path = build_output_path(input_path, output_ext, spec.output_mode)
 877    do_convert, reason = should_convert(
 878        input_path=input_path,
 879        output_path=output_path,
 880        output_mode=spec.output_mode,
 881        update=update,
 882        overwrite=overwrite,
 883    )
 884
 885    if do_convert:
 886        print(f"Converting: '{input_path}' -> '{output_path}' ({reason})")
 887        kwargs = dict(spec.options)
 888        kwargs.update(converter_kwargs)
 889        result = spec.converter(input_path, output_path, **kwargs)
 890        return bool(result)
 891
 892    print(f"Skipping: '{input_path}' ({reason})")
 893    return False
 894
 895
 896def walk_and_convert(
 897    root_dir: str,
 898    output_ext: str,
 899    registry: ConverterRegistry,
 900    max_level: int = -1,
 901    update: bool = True,
 902    overwrite: bool = False,
 903    target_patterns: Optional[List[str]] = None,
 904    converter_kwargs: Optional[Dict[str, Any]] = None,
 905):
 906    """
 907    概要:
 908        ディレクトリツリーを走査し、条件に一致するファイルを一括で変換します。
 909    詳細説明:
 910        再帰的にディレクトリを辿り、指定された対象パターンや更新条件を満たすファイルを変換します。
 911    引数:
 912        :param root_dir: 走査を開始するルートディレクトリ
 913        :type root_dir: str
 914        :param output_ext: 対象の出力拡張子
 915        :type output_ext: str
 916        :param registry: 変換器レジストリ
 917        :type registry: ConverterRegistry
 918        :param max_level: 最大の再帰深度であり、-1の場合は制限なし
 919        :type max_level: int
 920        :param update: 更新対象のみ変換するかのフラグ
 921        :type update: bool
 922        :param overwrite: 常に上書き変換するかのフラグ
 923        :type overwrite: bool
 924        :param target_patterns: 変換対象とするファイル名のパターンのリスト
 925        :type target_patterns: list
 926        :param converter_kwargs: 変換器に渡す追加引数
 927        :type converter_kwargs: dict
 928    戻り値:
 929        :returns: 走査した対象ファイル数と変換されたファイル数のタプル
 930        :rtype: tuple
 931    """
 932    if not os.path.isdir(root_dir):
 933        print(f"Error: Root directory '{root_dir}' does not exist.")
 934        return 0, 0
 935
 936    root_dir_abs = os.path.abspath(root_dir)
 937    output_ext = normalize_ext(output_ext)
 938
 939    print(f"Searching in '{root_dir_abs}'")
 940    print(f"Target output format: {output_ext}")
 941    print(f"Max level: {max_level}")
 942    print(f"update: {int(update)}")
 943    print(f"overwrite: {int(overwrite)}")
 944    if converter_kwargs:
 945        for key, value in sorted(converter_kwargs.items()):
 946            if value not in (None, ""):
 947                print(f"{key}: {value}")
 948    if target_patterns:
 949        print(f"Target patterns: {';'.join(target_patterns)}")
 950    else:
 951        print("Target patterns: (all matching input files)")
 952    print("")
 953
 954    converted = 0
 955    scanned = 0
 956
 957    for dirpath, dirnames, filenames in os.walk(root_dir_abs):
 958        current_level = dirpath.count(os.sep) - root_dir_abs.count(os.sep)
 959
 960        if max_level != -1 and current_level >= max_level:
 961            del dirnames[:]
 962
 963        for filename in filenames:
 964            if not matches_target(filename, target_patterns):
 965                continue
 966
 967            input_path = os.path.join(dirpath, filename)
 968            input_ext = normalize_ext(os.path.splitext(filename)[1])
 969
 970            if registry.has(input_ext, output_ext):
 971                scanned += 1
 972                if convert_file(
 973                    input_path=input_path,
 974                    output_ext=output_ext,
 975                    registry=registry,
 976                    update=update,
 977                    overwrite=overwrite,
 978                    converter_kwargs=converter_kwargs,
 979                ):
 980                    converted += 1
 981
 982    print("\nConversion process finished.")
 983    print(f"Scanned supported files: {scanned}")
 984    print(f"Converted files: {converted}")
 985    return scanned, converted
 986
 987
 988def parse_args():
 989    """
 990    概要:
 991        コマンドライン引数をパースします。
 992    戻り値:
 993        :returns: パース結果を格納した名前空間オブジェクト
 994        :rtype: argparse.Namespace
 995    """
 996    parser = argparse.ArgumentParser(
 997        description="Convert files in a directory tree using registered converters."
 998    )
 999    parser.add_argument("root_dir", nargs="?", default=".", help="Root directory to scan")
1000    parser.add_argument("--infile", default="", help="Convert only this file and skip recursive search when specified")
1001    parser.add_argument("output_ext", nargs="?", default=".pdf", help="Target output extension, e.g. pdf or .notes.pdf")
1002    parser.add_argument("--max_level", nargs="?", default="-1", help="Maximum recursion depth (-1 means unlimited)")
1003    parser.add_argument("--target", default=None, help='Filename patterns separated by ";" , e.g. --target="*.pptx;*.docx"')
1004    parser.add_argument("--update", type=int, choices=[0, 1], default=1, help="If 0, skip when output already exists even if source is newer")
1005    parser.add_argument("--overwrite", type=int, choices=[0, 1], default=0, help="If 1, always convert regardless of timestamps")
1006    parser.add_argument("--pandoc_path", default=None, help="Path to pandoc executable for Pandoc-based conversions")
1007    parser.add_argument("--template_docx", default=None, help="Pandoc reference doc/template for md -> docx")
1008    parser.add_argument("--template_pptx", default=None, help="Pandoc reference doc/template for md -> pptx")
1009    parser.add_argument("--css", default=None, help="CSS for md -> html via Pandoc")
1010    parser.add_argument("--toc", type=int, choices=[0, 1], default=0, help="Enable table of contents for Pandoc docx output")
1011    parser.add_argument("--mathml", action="store_true", help="Use MathML for md -> html via Pandoc")
1012    parser.add_argument("--no_yaml", action="store_true", help="Pass no_yaml option to Pandoc conversion")
1013    parser.add_argument("--verbose", action="store_true", help="Verbose mode for Pandoc conversion")
1014    parser.add_argument("--smart_conversion", type=int, choices=[0, 1], default=0, help="Enable smart_conversion in pandoc.py")
1015    parser.add_argument("--pdf-dpi", type=int, default=None, help="DPI for PDF -> PPTX image rendering. Default is pdf2pptx.py default, usually 200")
1016    parser.add_argument("--pdf-slide-size", choices=["pdf", "wide", "standard"], default=None, help="Slide size for PDF -> PPTX: pdf, wide, or standard")
1017    parser.add_argument("--pdf-margin", type=float, default=None, help="Margin in inches for PDF -> PPTX")
1018    parser.add_argument("--pdf-split-vertical-half", action="store_true", help="Split each PDF page into upper/lower halves for PDF -> PPTX")
1019    parser.add_argument("--list", action="store_true", help="List available converters and exit")
1020    parser.add_argument("--no-pause", action="store_true", help="Do not wait for ENTER before exit")
1021    return parser.parse_args()
1022
1023
1024def build_converter_kwargs(args, output_ext: str) -> Dict[str, Any]:
1025    """
1026    概要:
1027        コマンドライン引数から変換器に渡すキーワード引数を構築します。
1028    引数:
1029        :param args: パース済みのコマンドライン引数
1030        :type args: argparse.Namespace
1031        :param output_ext: 対象の出力拡張子
1032        :type output_ext: str
1033    戻り値:
1034        :returns: 変換器用の追加引数が格納された辞書
1035        :rtype: dict
1036    """
1037    output_ext = normalize_ext(output_ext)
1038    kwargs: Dict[str, Any] = {}
1039
1040    if args.pandoc_path:
1041        kwargs["pandoc_path"] = args.pandoc_path
1042    if output_ext == ".docx" and args.template_docx:
1043        kwargs["template"] = args.template_docx
1044    elif output_ext == ".pptx" and args.template_pptx:
1045        kwargs["template"] = args.template_pptx
1046    elif output_ext == ".html" and args.css:
1047        kwargs["css"] = args.css
1048
1049    kwargs["toc"] = bool(args.toc)
1050    kwargs["mathml"] = bool(args.mathml)
1051    kwargs["no_yaml"] = bool(args.no_yaml)
1052    kwargs["verbose"] = bool(args.verbose)
1053    kwargs["smart_conversion"] = bool(args.smart_conversion)
1054
1055    # Options used by pdf2pptx.convert when input is PDF and output is PPTX.
1056    # They are harmless for other .pptx converters because wrappers ignore unknown kwargs.
1057    if args.pdf_dpi is not None:
1058        kwargs["pdf_dpi"] = args.pdf_dpi
1059    if args.pdf_slide_size is not None:
1060        kwargs["pdf_slide_size"] = args.pdf_slide_size
1061    if args.pdf_margin is not None:
1062        kwargs["pdf_margin"] = args.pdf_margin
1063    if args.pdf_split_vertical_half:
1064        kwargs["pdf_split_vertical_half"] = True
1065
1066    return kwargs
1067
1068
1069def main():
1070    """
1071    概要:
1072        スクリプトのメイン処理を実行します。
1073    詳細説明:
1074        コマンドライン引数の解析、レジストリの初期化、指定されたモードに応じた変換処理を行います。
1075    戻り値:
1076        :returns: 終了コード
1077        :rtype: int
1078    """
1079    args = parse_args()
1080
1081    try:
1082        max_level = int(args.max_level)
1083    except ValueError:
1084        print(f"Error: Invalid max_level '{args.max_level}'.")
1085        return 1
1086
1087    registry = ConverterRegistry()
1088    safe_import_registry(registry)
1089
1090    if args.list:
1091        registry.print_available_converters()
1092        return 0
1093
1094    output_ext = normalize_ext(args.output_ext)
1095    available_outputs = registry.list_output_exts()
1096
1097    if output_ext not in available_outputs:
1098        print(f"Error: No converters registered for output format '{output_ext}'.")
1099        if available_outputs:
1100            print("Registered output formats:", ", ".join(available_outputs))
1101        registry.print_available_converters()
1102        return 1
1103
1104    target_patterns = parse_target_patterns(args.target)
1105    converter_kwargs = build_converter_kwargs(args, output_ext)
1106
1107    if args.infile:
1108        infile = os.path.abspath(args.infile)
1109        if not os.path.exists(infile):
1110            print(f"Error: infile '{args.infile}' does not exist.")
1111            return 1
1112        print(f"Single-file mode: {infile}")
1113        converted = convert_file(
1114            input_path=infile,
1115            output_ext=output_ext,
1116            registry=registry,
1117            update=bool(args.update),
1118            overwrite=bool(args.overwrite),
1119            converter_kwargs=converter_kwargs,
1120        )
1121        print("\nConversion process finished.")
1122        print("Scanned supported files: 1" if registry.has(os.path.splitext(infile)[1], output_ext) else "Scanned supported files: 0")
1123        print(f"Converted files: {1 if converted else 0}")
1124        return 0 if converted or registry.has(os.path.splitext(infile)[1], output_ext) else 1
1125
1126    walk_and_convert(
1127        root_dir=args.root_dir,
1128        output_ext=output_ext,
1129        registry=registry,
1130        max_level=max_level,
1131        update=bool(args.update),
1132        overwrite=bool(args.overwrite),
1133        target_patterns=target_patterns,
1134        converter_kwargs=converter_kwargs,
1135    )
1136    return 0
1137
1138
1139if __name__ == "__main__":
1140    rc = main()
1141    print("\nProgram execution completed.")
1142    if "--no-pause" not in sys.argv:
1143        input("\nPress ENTER to terminate>>\n")
1144    raise SystemExit(rc)