from __future__ import annotations import importlib import os import re import shutil import zipfile from dataclasses import dataclass from pathlib import Path from typing import Iterable, Protocol class TkPptxError(RuntimeError): """Base exception for tkpptx.""" class BackendUnavailableError(TkPptxError): """Raised when a requested backend is not available.""" class UnsupportedOperationError(TkPptxError): """Raised when a backend does not support the requested operation.""" class ExportError(TkPptxError): """Raised when an export operation fails.""" @dataclass(slots=True) class ExportOptions: start: int = 1 end: int | None = None width: int = 1920 height: int | None = None dpi: int = 150 overwrite: bool = False render: str = "native" keep_temporary: bool = False class Backend(Protocol): name: str def is_available(self) -> bool: ... def export_pdf( self, input_path: Path, output_path: Path, options: ExportOptions ) -> Path: ... def export_images( self, input_path: Path, output_dir: Path, options: ExportOptions ) -> list[Path]: ... def export_html( self, input_path: Path, output_path: Path, options: ExportOptions ) -> Path: ... _BACKEND_MODULES = { "com": "tkpptx_com", "officecli": "tkpptx_officecli", "libreoffice": "tkpptx_libreoffice", } def normalize_input_path(path: str | os.PathLike[str]) -> Path: p = Path(path).expanduser().resolve() if not p.exists(): raise FileNotFoundError(f"Input file was not found: {p}") if p.suffix.lower() not in {".ppt", ".pptx", ".pps", ".ppsx"}: raise ValueError(f"Unsupported PowerPoint file type: {p.suffix}") return p def prepare_output_file( path: str | os.PathLike[str], *, overwrite: bool ) -> Path: p = Path(path).expanduser().resolve() p.parent.mkdir(parents=True, exist_ok=True) if p.exists() and not overwrite: raise FileExistsError( f"Output already exists: {p}\n" "Use --overwrite to replace it." ) return p def prepare_output_dir( path: str | os.PathLike[str], *, overwrite: bool ) -> Path: p = Path(path).expanduser().resolve() p.mkdir(parents=True, exist_ok=True) if not overwrite: existing = list(p.glob("slide_*.png")) if existing: raise FileExistsError( f"Slide images already exist in: {p}\n" "Use --overwrite to replace them." ) return p def count_slides(input_path: str | os.PathLike[str]) -> int: """Count slides in a .pptx/.ppsx without requiring python-pptx.""" path = Path(input_path) if path.suffix.lower() in {".ppt", ".pps"}: raise UnsupportedOperationError( "Slide counting for legacy .ppt/.pps requires a native backend." ) pattern = re.compile(r"^ppt/slides/slide(\d+)\.xml$") with zipfile.ZipFile(path) as archive: indices = [ int(match.group(1)) for name in archive.namelist() if (match := pattern.match(name)) ] if not indices: raise ExportError(f"No slides were found in: {path}") return max(indices) def resolve_page_range( input_path: Path, options: ExportOptions ) -> tuple[int, int]: if options.start < 1: raise ValueError("--start must be 1 or greater.") if options.end is None: try: end = count_slides(input_path) except UnsupportedOperationError: end = options.start else: end = options.end if end < options.start: raise ValueError("--end must be greater than or equal to --start.") return options.start, end def load_backend(name: str) -> Backend: key = name.lower() if key not in _BACKEND_MODULES: raise ValueError( f"Unknown backend: {name}. " f"Choose from: {', '.join(_BACKEND_MODULES)} or auto." ) module = importlib.import_module(_BACKEND_MODULES[key]) backend = module.get_backend() return backend def available_backends() -> dict[str, bool]: return { name: bool(load_backend(name).is_available()) for name in _BACKEND_MODULES } def choose_backend(operation: str, requested: str = "auto") -> Backend: if requested != "auto": backend = load_backend(requested) if not backend.is_available(): raise BackendUnavailableError( f"The '{requested}' backend is not available." ) return backend priorities = { "pdf": ["com", "libreoffice"], "images": ["com", "officecli", "libreoffice"], "html": ["officecli"], } if operation not in priorities: raise ValueError(f"Unknown operation: {operation}") for name in priorities[operation]: backend = load_backend(name) if backend.is_available(): return backend raise BackendUnavailableError( f"No available backend supports '{operation}'. " f"Detected: {available_backends()}" ) def export_pdf( input_path: str | os.PathLike[str], output_path: str | os.PathLike[str], *, backend: str = "auto", options: ExportOptions | None = None, ) -> Path: options = options or ExportOptions() source = normalize_input_path(input_path) target = prepare_output_file(output_path, overwrite=options.overwrite) impl = choose_backend("pdf", backend) return impl.export_pdf(source, target, options) def export_images( input_path: str | os.PathLike[str], output_dir: str | os.PathLike[str], *, backend: str = "auto", options: ExportOptions | None = None, ) -> list[Path]: options = options or ExportOptions() source = normalize_input_path(input_path) target = prepare_output_dir(output_dir, overwrite=options.overwrite) impl = choose_backend("images", backend) return impl.export_images(source, target, options) def export_html( input_path: str | os.PathLike[str], output_path: str | os.PathLike[str], *, backend: str = "auto", options: ExportOptions | None = None, ) -> Path: options = options or ExportOptions() source = normalize_input_path(input_path) target = prepare_output_file(output_path, overwrite=options.overwrite) impl = choose_backend("html", backend) return impl.export_html(source, target, options)