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

pdf2pptx.py をダウンロード

pdf2pptx.py
pdf2pptx.py
  1#!/usr/bin/env python
  2# -*- coding: utf-8 -*-
  3"""
  4概要:
  5    PDFの各ページを画像としてPowerPointスライドに貼り込む変換モジュールです。
  6詳細説明:
  7    convert.pyからインポートして使えるように、convert関数を公開しています。
  8    必要ライブラリとして pymupdf と python-pptx が必要です。
  9主な機能:
 10    単独実行例として python pdf2pptx.py input.pdf output.pptx や、
 11    オプション付きで python pdf2pptx.py input.pdf output.pptx --dpi 250 のように実行可能です。
 12"""
 13
 14from __future__ import annotations
 15
 16import argparse
 17from io import BytesIO
 18from pathlib import Path
 19from typing import Iterable, List, Tuple
 20
 21import fitz  # PyMuPDF
 22from pptx import Presentation
 23from pptx.util import Inches
 24
 25
 26SLIDE_SIZE_CHOICES = ("pdf", "wide", "standard")
 27
 28
 29def _as_bool(value) -> bool:
 30    """
 31    概要:
 32        文字列や数値や真偽値をbool型に変換します。
 33    引数:
 34        :param value: 変換対象の値
 35        :type value: Any
 36    戻り値:
 37        :returns: 変換後の真偽値
 38        :rtype: bool
 39    """
 40    if isinstance(value, bool):
 41        return value
 42    if value is None:
 43        return False
 44    if isinstance(value, (int, float)):
 45        return bool(value)
 46    text = str(value).strip().lower()
 47    return text in ("1", "true", "yes", "y", "on")
 48
 49
 50def _get_first(kwargs, names, default=None):
 51    """
 52    概要:
 53        キーワード引数から複数候補名のうち最初に存在する値を取り出します。
 54    引数:
 55        :param kwargs: キーワード引数を格納した辞書
 56        :type kwargs: dict
 57        :param names: 検索するキー名のリストまたはタプル
 58        :type names: Iterable
 59        :param default: 見つからなかった場合に返すデフォルト値
 60        :type default: Any
 61    戻り値:
 62        :returns: 見つかった値またはデフォルト値
 63        :rtype: Any
 64    """
 65    for name in names:
 66        if name in kwargs and kwargs[name] is not None:
 67            return kwargs[name]
 68    return default
 69
 70
 71def fit_image_to_slide(img_w: int, img_h: int, slide_w: int, slide_h: int, margin: int = 0):
 72    """
 73    概要:
 74        画像をスライド内にアスペクト比を保って収める位置とサイズを計算します。
 75    詳細説明:
 76        img_w と img_h はピクセル単位、slide_w と slide_h と margin は PowerPoint の EMU 単位として計算します。
 77        戻り値の left と top と width と height も EMU 単位となります。
 78    引数:
 79        :param img_w: 画像の幅
 80        :type img_w: int
 81        :param img_h: 画像の高さ
 82        :type img_h: int
 83        :param slide_w: スライドの幅
 84        :type slide_w: int
 85        :param slide_h: スライドの高さ
 86        :type slide_h: int
 87        :param margin: スライドの余白
 88        :type margin: int
 89    戻り値:
 90        :returns: left、top、width、heightのタプル
 91        :rtype: tuple
 92    例外:
 93        :raises ValueError: 選択されたスライドサイズに対してmarginが大きすぎる場合に発生します。
 94    """
 95    available_w = slide_w - 2 * margin
 96    available_h = slide_h - 2 * margin
 97
 98    if available_w <= 0 or available_h <= 0:
 99        raise ValueError("Margin is too large for the selected slide size.")
100
101    img_ratio = img_w / img_h
102    slide_ratio = available_w / available_h
103
104    if img_ratio > slide_ratio:
105        width = available_w
106        height = int(width / img_ratio)
107    else:
108        height = available_h
109        width = int(height * img_ratio)
110
111    left = int((slide_w - width) / 2)
112    top = int((slide_h - height) / 2)
113
114    return left, top, width, height
115
116
117def set_slide_size(
118    prs: Presentation,
119    pdf_doc: fitz.Document,
120    slide_size: str = "pdf",
121    split_vertical_half: bool = False,
122):
123    """
124    概要:
125        Presentationのスライドサイズを設定します。
126    詳細説明:
127        指定された slide_size に応じてサイズを設定し、縦長PDFを上下半分に分割する場合はスライド高さも半分に調整します。
128    引数:
129        :param prs: PowerPointのPresentationオブジェクト
130        :type prs: pptx.presentation.Presentation
131        :param pdf_doc: PyMuPDFのDocumentオブジェクト
132        :type pdf_doc: fitz.Document
133        :param slide_size: スライドサイズを指定する文字列
134        :type slide_size: str
135        :param split_vertical_half: 縦長PDFを上下半分に分割する場合はTrue
136        :type split_vertical_half: bool
137    戻り値:
138        :returns: なし
139        :rtype: None
140    例外:
141        :raises ValueError: 不明なslide_sizeが指定された場合に発生します。
142    """
143    slide_size = str(slide_size).lower()
144
145    if slide_size == "wide":
146        # 16:9 widescreen
147        prs.slide_width = Inches(13.333)
148        prs.slide_height = Inches(7.5)
149        return
150
151    if slide_size == "standard":
152        # 4:3 standard
153        prs.slide_width = Inches(10)
154        prs.slide_height = Inches(7.5)
155        return
156
157    if slide_size == "pdf":
158        page = pdf_doc[0]
159        rect = page.rect
160
161        width_pt = rect.width
162        height_pt = rect.height
163
164        # 縦長PDFを上下半分に分割するときは、スライド高さも半分にする。
165        if split_vertical_half:
166            height_pt /= 2.0
167
168        prs.slide_width = Inches(width_pt / 72.0)
169        prs.slide_height = Inches(height_pt / 72.0)
170        return
171
172    raise ValueError(f"Unknown slide_size: {slide_size}. Choose from {SLIDE_SIZE_CHOICES}.")
173
174
175def get_page_clips(page: fitz.Page, split_vertical_half: bool = False) -> List[Tuple[fitz.Rect, str]]:
176    """
177    概要:
178        1ページをそのまま使うか、上下2分割して使うかを決定して矩形のリストを返します。
179    詳細説明:
180        split_vertical_half が True の場合、戻り値は上半分と下半分の矩形およびそれぞれの名称のタプルになります。
181    引数:
182        :param page: 対象のPDFページオブジェクト
183        :type page: fitz.Page
184        :param split_vertical_half: 上下2分割するかどうかを指定するフラグ
185        :type split_vertical_half: bool
186    戻り値:
187        :returns: 矩形オブジェクトと名称のタプルのリスト
188        :rtype: list
189    """
190    rect = page.rect
191
192    if not split_vertical_half:
193        return [(rect, "full")]
194
195    mid_y = (rect.y0 + rect.y1) / 2.0
196    upper = fitz.Rect(rect.x0, rect.y0, rect.x1, mid_y)
197    lower = fitz.Rect(rect.x0, mid_y, rect.x1, rect.y1)
198
199    return [(upper, "upper"), (lower, "lower")]
200
201
202def convert_pdf_to_pptx(
203    pdf_path: str | Path,
204    pptx_path: str | Path,
205    dpi: int = 200,
206    slide_size: str = "pdf",
207    margin_inch: float = 0.0,
208    split_vertical_half: bool = False,
209    verbose: bool = True,
210) -> bool:
211    """
212    概要:
213        PDFをPPTXへ変換する本体関数です。
214    引数:
215        :param pdf_path: 入力PDFのファイルパス
216        :type pdf_path: str または pathlib.Path
217        :param pptx_path: 出力PPTXのファイルパス
218        :type pptx_path: str または pathlib.Path
219        :param dpi: PDFを画像化するときの解像度
220        :type dpi: int
221        :param slide_size: PPTXのスライドサイズ指定文字列
222        :type slide_size: str
223        :param margin_inch: スライド内のインチ単位の余白
224        :type margin_inch: float
225        :param split_vertical_half: 各PDFページを上半分と下半分に分けて2枚のスライドにする場合はTrue
226        :type split_vertical_half: bool
227        :param verbose: 変換中の進捗メッセージを表示する場合はTrue
228        :type verbose: bool
229    戻り値:
230        :returns: 変換に成功した場合はTrue
231        :rtype: bool
232    例外:
233        :raises FileNotFoundError: 入力PDFファイルが見つからない場合に発生します。
234        :raises ValueError: 入力ファイルがPDFでない場合やDPIが正の整数でない場合などに発生します。
235    """
236    pdf_path = Path(pdf_path)
237    pptx_path = Path(pptx_path)
238
239    if not pdf_path.exists():
240        raise FileNotFoundError(f"PDF file not found: {pdf_path}")
241    if pdf_path.suffix.lower() != ".pdf":
242        raise ValueError(f"Input file is not a PDF: {pdf_path}")
243    if dpi <= 0:
244        raise ValueError("dpi must be a positive integer.")
245
246    slide_size = str(slide_size).lower()
247    if slide_size not in SLIDE_SIZE_CHOICES:
248        raise ValueError(f"slide_size must be one of {SLIDE_SIZE_CHOICES}: {slide_size}")
249
250    pptx_path.parent.mkdir(parents=True, exist_ok=True)
251
252    doc = fitz.open(str(pdf_path))
253    try:
254        if len(doc) == 0:
255            raise ValueError("PDF has no pages.")
256
257        prs = Presentation()
258        set_slide_size(
259            prs,
260            doc,
261            slide_size=slide_size,
262            split_vertical_half=split_vertical_half,
263        )
264
265        blank_layout = prs.slide_layouts[6]
266        zoom = dpi / 72.0
267        matrix = fitz.Matrix(zoom, zoom)
268        margin = Inches(margin_inch)
269
270        slide_count = 0
271
272        for page_index, page in enumerate(doc):
273            clips = get_page_clips(page, split_vertical_half=split_vertical_half)
274
275            for clip, part_name in clips:
276                slide_count += 1
277                if verbose:
278                    print(
279                        f"Rendering PDF page {page_index + 1}/{len(doc)} "
280                        f"({part_name}) -> slide {slide_count}"
281                    )
282
283                pix = page.get_pixmap(matrix=matrix, clip=clip, alpha=False)
284                png_bytes = pix.tobytes("png")
285
286                image_stream = BytesIO(png_bytes)
287                image_stream.seek(0)
288
289                slide = prs.slides.add_slide(blank_layout)
290                left, top, width, height = fit_image_to_slide(
291                    img_w=pix.width,
292                    img_h=pix.height,
293                    slide_w=prs.slide_width,
294                    slide_h=prs.slide_height,
295                    margin=margin,
296                )
297
298                slide.shapes.add_picture(
299                    image_stream,
300                    left,
301                    top,
302                    width=width,
303                    height=height,
304                )
305
306        prs.save(str(pptx_path))
307    finally:
308        doc.close()
309
310    if verbose:
311        print(f"Saved: {pptx_path}")
312
313    return True
314
315
316def convert(input_path: str, output_path: str | None = None, **kwargs) -> bool:
317    """
318    概要:
319        外部スクリプトから呼ばれるためのラッパー関数です。
320    詳細説明:
321        キーワード引数 kwargs として、pdf_dpi や pdf_slide_size や pdf_margin や pdf_split_vertical_half などを受け付けます。
322    引数:
323        :param input_path: 入力ファイルのパス
324        :type input_path: str
325        :param output_path: 出力ファイルのパス
326        :type output_path: str または None
327        :param kwargs: オプション引数を格納する辞書
328        :type kwargs: dict
329    戻り値:
330        :returns: 変換に成功した場合はTrue
331        :rtype: bool
332    """
333    input_path = Path(input_path)
334    if output_path is None:
335        output_path = input_path.with_suffix(".pptx")
336
337    dpi = int(_get_first(kwargs, ("pdf_dpi", "dpi"), 200))
338    slide_size = _get_first(kwargs, ("pdf_slide_size", "slide_size"), "pdf")
339    margin_inch = float(_get_first(kwargs, ("pdf_margin", "margin", "margin_inch"), 0.0))
340    split_vertical_half = _as_bool(_get_first(
341        kwargs,
342        ("pdf_split_vertical_half", "split_vertical_half"),
343        False,
344    ))
345    verbose = _as_bool(_get_first(kwargs, ("verbose",), True))
346
347    return convert_pdf_to_pptx(
348        pdf_path=input_path,
349        pptx_path=output_path,
350        dpi=dpi,
351        slide_size=slide_size,
352        margin_inch=margin_inch,
353        split_vertical_half=split_vertical_half,
354        verbose=verbose,
355    )
356
357
358def parse_args():
359    """
360    概要:
361        コマンドライン引数をパースします。
362    戻り値:
363        :returns: パースされたコマンドライン引数を格納したオブジェクト
364        :rtype: argparse.Namespace
365    """
366    parser = argparse.ArgumentParser(
367        description="Convert PDF pages to PowerPoint slides as images."
368    )
369    parser.add_argument("pdf_path", help="Input PDF file")
370    parser.add_argument("pptx_path", nargs="?", default=None, help="Output PPTX file")
371    parser.add_argument("--dpi", type=int, default=200, help="Rendering DPI. Default: 200")
372    parser.add_argument(
373        "--slide-size",
374        choices=SLIDE_SIZE_CHOICES,
375        default="pdf",
376        help="Slide size: pdf, wide, or standard. Default: pdf",
377    )
378    parser.add_argument("--margin", type=float, default=0.0, help="Margin in inches. Default: 0.0")
379    parser.add_argument(
380        "--split-vertical-half",
381        action="store_true",
382        help="Split each PDF page into upper and lower halves.",
383    )
384    parser.add_argument("--quiet", action="store_true", help="Suppress progress messages")
385    parser.add_argument("--pause", action="store_true", help="Pause before terminate")
386    return parser.parse_args()
387
388
389def main() -> int:
390    """
391    概要:
392        コマンドラインからの実行時のメイン処理を行います。
393    戻り値:
394        :returns: 終了コード
395        :rtype: int
396    """
397    args = parse_args()
398    output_path = args.pptx_path or str(Path(args.pdf_path).with_suffix(".pptx"))
399    convert_pdf_to_pptx(
400        pdf_path=args.pdf_path,
401        pptx_path=output_path,
402        dpi=args.dpi,
403        slide_size=args.slide_size,
404        margin_inch=args.margin,
405        split_vertical_half=args.split_vertical_half,
406        verbose=not args.quiet,
407    )
408    
409    if args.pause:
410        input("\nPress ENTER to terminate>>\n")
411        
412    return 0
413
414
415if __name__ == "__main__":
416    raise SystemExit(main())