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())