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)