wechat-robot-skills/skills/pptx/scripts/render_presentation.py

225 lines
8.1 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import math
import shutil
import tempfile
from pathlib import Path
from typing import Any, Optional
from _pptx_common import (
OOXML_PRESENTATION_SUFFIXES,
SkillArgumentParser,
find_program,
input_file,
output_directory,
publish_file,
run_cli,
run_program,
run_soffice_convert,
)
def build_parser() -> argparse.ArgumentParser:
parser = SkillArgumentParser(
description="通过 LibreOffice 和 Poppler 把演示文稿渲染为逐页 PNG。"
)
parser.add_argument("--input", required=True)
parser.add_argument("--output-dir", required=True)
parser.add_argument("--start-slide", type=int, default=1)
parser.add_argument("--end-slide", type=int)
parser.add_argument("--max-slides", type=int, default=30)
parser.add_argument("--dpi", type=int, default=150)
parser.add_argument("--timeout", type=int, default=180)
parser.add_argument("--include-pdf", action="store_true")
parser.add_argument("--contact-sheet", action="store_true")
parser.add_argument("--overwrite", action="store_true")
return parser
def _contact_sheet(
images: list[Path],
slide_numbers: list[int],
destination: Path,
) -> None:
from PIL import Image, ImageDraw, ImageFont
columns = min(4, max(1, len(images)))
rows = math.ceil(len(images) / columns)
thumb_width = 420
label_height = 34
gap = 18
opened: list[Image.Image] = []
try:
for path in images:
opened.append(Image.open(path).convert("RGB"))
aspect = opened[0].height / opened[0].width
thumb_height = max(1, round(thumb_width * aspect))
canvas_width = columns * thumb_width + (columns + 1) * gap
canvas_height = rows * (thumb_height + label_height) + (rows + 1) * gap
canvas = Image.new("RGB", (canvas_width, canvas_height), "white")
draw = ImageDraw.Draw(canvas)
try:
font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
20,
)
except OSError:
font = ImageFont.load_default()
for index, (image, slide_number) in enumerate(zip(opened, slide_numbers)):
row, column = divmod(index, columns)
x = gap + column * (thumb_width + gap)
y = gap + row * (thumb_height + label_height)
thumbnail = image.copy()
thumbnail.thumbnail((thumb_width, thumb_height))
paste_x = x + (thumb_width - thumbnail.width) // 2
paste_y = y + (thumb_height - thumbnail.height) // 2
canvas.paste(thumbnail, (paste_x, paste_y))
label = f"Slide {slide_number}"
label_box = draw.textbbox((0, 0), label, font=font)
label_width = label_box[2] - label_box[0]
draw.text(
(x + (thumb_width - label_width) // 2, y + thumb_height + 6),
label,
fill="black",
font=font,
)
canvas.save(destination, format="PNG", optimize=True)
finally:
for image in opened:
image.close()
def main() -> dict[str, Any]:
from pypdf import PdfReader
args = build_parser().parse_args()
if args.start_slide < 1:
raise ValueError("start-slide 必须大于 0")
if args.end_slide is not None and args.end_slide < args.start_slide:
raise ValueError("end-slide 不能小于 start-slide")
if args.max_slides < 1 or args.max_slides > 100:
raise ValueError("max-slides 必须在 1 到 100 之间")
if args.dpi < 72 or args.dpi > 300:
raise ValueError("dpi 必须在 72 到 300 之间")
source = input_file(args.input, OOXML_PRESENTATION_SUFFIXES)
destination_dir = output_directory(args.output_dir)
with tempfile.TemporaryDirectory(prefix="pptx-render-") as temp_name:
temp_dir = Path(temp_name)
staged_input = temp_dir / f"presentation{source.suffix.lower()}"
shutil.copy2(source, staged_input)
pdf_path, office_output = run_soffice_convert(
staged_input,
target_format="pdf",
output_dir=temp_dir / "pdf",
timeout=args.timeout,
)
slide_count = len(PdfReader(str(pdf_path)).pages)
if slide_count < 1:
raise ValueError("LibreOffice 生成的 PDF 没有页面")
if args.start_slide > slide_count:
raise ValueError(f"start-slide 超出页面总数 {slide_count}")
requested_end = slide_count if args.end_slide is None else args.end_slide
if requested_end > slide_count:
raise ValueError(f"end-slide 超出页面总数 {slide_count}")
actual_end = min(
requested_end,
args.start_slide + args.max_slides - 1,
)
raw_prefix = temp_dir / "raw-slide"
run_program(
[
find_program("pdftoppm"),
"-png",
"-r",
str(args.dpi),
"-f",
str(args.start_slide),
"-l",
str(actual_end),
str(pdf_path),
str(raw_prefix),
],
timeout=args.timeout,
)
raw_pages = sorted(
temp_dir.glob("raw-slide-*.png"),
key=lambda path: int(path.stem.rsplit("-", 1)[1]),
)
expected_count = actual_end - args.start_slide + 1
if len(raw_pages) != expected_count:
raise RuntimeError(
f"Poppler 应生成 {expected_count} 页,实际生成 {len(raw_pages)} 页"
)
slide_numbers = list(range(args.start_slide, actual_end + 1))
destinations = [
destination_dir / f"slide-{number:04d}.png"
for number in slide_numbers
]
contact_destination = destination_dir / (
f"contact-sheet-{args.start_slide:04d}-{actual_end:04d}.png"
)
if args.contact_sheet:
destinations.append(contact_destination)
if args.include_pdf:
destinations.append(destination_dir / "presentation.pdf")
if not args.overwrite:
existing = [str(path) for path in destinations if path.exists()]
if existing:
raise FileExistsError("以下渲染目标已存在:" + "、".join(existing))
output_paths: list[str] = []
published_pages: list[Path] = []
for slide_number, raw_page in zip(slide_numbers, raw_pages):
destination = destination_dir / f"slide-{slide_number:04d}.png"
publish_file(raw_page, destination, overwrite=args.overwrite)
output_paths.append(str(destination))
published_pages.append(destination)
contact_sheet_path: Optional[str] = None
if args.contact_sheet:
staged_contact = temp_dir / "contact-sheet.png"
_contact_sheet(
published_pages,
slide_numbers,
staged_contact,
)
publish_file(
staged_contact,
contact_destination,
overwrite=args.overwrite,
)
contact_sheet_path = str(contact_destination)
pdf_output: Optional[str] = None
if args.include_pdf:
destination_pdf = destination_dir / "presentation.pdf"
staged_pdf = temp_dir / "publish.pdf"
shutil.copy2(pdf_path, staged_pdf)
publish_file(staged_pdf, destination_pdf, overwrite=args.overwrite)
pdf_output = str(destination_pdf)
next_slide = actual_end + 1 if actual_end < requested_end else None
return {
"source": str(source),
"slide_count": slide_count,
"start_slide": args.start_slide,
"end_slide": actual_end,
"rendered_slides": output_paths,
"contact_sheet": contact_sheet_path,
"pdf": pdf_output,
"has_more": next_slide is not None,
"next_slide": next_slide,
"dpi": args.dpi,
"office_stdout": office_output["stdout"],
"office_stderr": office_output["stderr"],
}
if __name__ == "__main__":
raise SystemExit(run_cli(main))