|
14 | 14 | import pptx |
15 | 15 | from click import Context, Parameter |
16 | 16 | from lxml import etree |
17 | | -from pydantic import BaseModel, FilePath, PositiveInt, ValidationError |
| 17 | +from PIL import Image |
| 18 | +from pydantic import BaseModel, FilePath, PositiveFloat, PositiveInt, ValidationError |
18 | 19 | from tqdm import tqdm |
19 | 20 |
|
20 | 21 | from . import data |
@@ -75,6 +76,7 @@ def from_string(cls, s: str) -> Type["Converter"]: |
75 | 76 | """Returns the appropriate converter from a string name.""" |
76 | 77 | return { |
77 | 78 | "html": RevealJS, |
| 79 | + "pdf": PDF, |
78 | 80 | "pptx": PowerPoint, |
79 | 81 | }[s] |
80 | 82 |
|
@@ -367,6 +369,62 @@ def convert_to(self, dest: Path) -> None: |
367 | 369 | f.write(content) |
368 | 370 |
|
369 | 371 |
|
| 372 | +class FrameIndex(str, Enum): |
| 373 | + first = "first" |
| 374 | + last = "last" |
| 375 | + |
| 376 | + |
| 377 | +class PDF(Converter): |
| 378 | + frame_index: FrameIndex = FrameIndex.last |
| 379 | + resolution: PositiveFloat = 100.0 |
| 380 | + |
| 381 | + class Config: |
| 382 | + use_enum_values = True |
| 383 | + extra = "forbid" |
| 384 | + |
| 385 | + def open(self, file: Path) -> None: |
| 386 | + return open_with_default(file) |
| 387 | + |
| 388 | + def convert_to(self, dest: Path) -> None: |
| 389 | + """Converts this configuration into a PDF presentation, saved to DEST.""" |
| 390 | + |
| 391 | + def read_image_from_video_file(file: Path, frame_index: FrameIndex) -> Image: |
| 392 | + cap = cv2.VideoCapture(str(file)) |
| 393 | + |
| 394 | + if frame_index == FrameIndex.last: |
| 395 | + index = cap.get(cv2.CAP_PROP_FRAME_COUNT) |
| 396 | + cap.set(cv2.CAP_PROP_POS_FRAMES, index - 1) |
| 397 | + |
| 398 | + ret, frame = cap.read() |
| 399 | + |
| 400 | + if ret: |
| 401 | + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) |
| 402 | + return Image.fromarray(frame) |
| 403 | + else: |
| 404 | + raise ValueError("Failed to read {image_index} image from video file") |
| 405 | + |
| 406 | + images = [] |
| 407 | + |
| 408 | + for i, presentation_config in enumerate(self.presentation_configs): |
| 409 | + presentation_config.concat_animations() |
| 410 | + for slide_config in tqdm( |
| 411 | + presentation_config.slides, |
| 412 | + desc=f"Generating video slides for config {i + 1}", |
| 413 | + leave=False, |
| 414 | + ): |
| 415 | + file = presentation_config.files[slide_config.start_animation] |
| 416 | + |
| 417 | + images.append(read_image_from_video_file(file, self.frame_index)) |
| 418 | + |
| 419 | + images[0].save( |
| 420 | + dest, |
| 421 | + "PDF", |
| 422 | + resolution=self.resolution, |
| 423 | + save_all=True, |
| 424 | + append_images=images[1:], |
| 425 | + ) |
| 426 | + |
| 427 | + |
370 | 428 | class PowerPoint(Converter): |
371 | 429 | left: PositiveInt = 0 |
372 | 430 | top: PositiveInt = 0 |
@@ -513,7 +571,7 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None: |
513 | 571 | @click.argument("dest", type=click.Path(dir_okay=False, path_type=Path)) |
514 | 572 | @click.option( |
515 | 573 | "--to", |
516 | | - type=click.Choice(["html", "pptx"], case_sensitive=False), |
| 574 | + type=click.Choice(["html", "pdf", "pptx"], case_sensitive=False), |
517 | 575 | default="html", |
518 | 576 | show_default=True, |
519 | 577 | help="Set the conversion format to use.", |
|
0 commit comments