Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Create, edit, and inspect PowerPoint presentations with professional design and automated visual QA
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/thumbnail.py
1"""Create thumbnail grids from PowerPoint presentation slides.23Creates a grid layout of slide thumbnails for quick visual analysis.4Labels each thumbnail with its XML filename (e.g., slide1.xml).5Hidden slides are shown with a placeholder pattern.67Usage:8python thumbnail.py input.pptx [output_prefix] [--cols N]910Examples:11python thumbnail.py presentation.pptx12# Creates: thumbnails.jpg1314python thumbnail.py template.pptx grid --cols 415# Creates: grid.jpg (or grid-1.jpg, grid-2.jpg for large decks)16"""1718import argparse19import subprocess20import sys21import tempfile22import zipfile23from pathlib import Path2425import defusedxml.minidom26from office.soffice import get_soffice_env27from PIL import Image, ImageDraw, ImageFont2829THUMBNAIL_WIDTH = 30030CONVERSION_DPI = 10031MAX_COLS = 632DEFAULT_COLS = 333JPEG_QUALITY = 9534GRID_PADDING = 2035BORDER_WIDTH = 236FONT_SIZE_RATIO = 0.1037LABEL_PADDING_RATIO = 0.4383940def main():41parser = argparse.ArgumentParser(42description="Create thumbnail grids from PowerPoint slides."43)44parser.add_argument("input", help="Input PowerPoint file (.pptx)")45parser.add_argument(46"output_prefix",47nargs="?",48default="thumbnails",49help="Output prefix for image files (default: thumbnails)",50)51parser.add_argument(52"--cols",53type=int,54default=DEFAULT_COLS,55help=f"Number of columns (default: {DEFAULT_COLS}, max: {MAX_COLS})",56)5758args = parser.parse_args()5960cols = min(args.cols, MAX_COLS)61if args.cols > MAX_COLS:62print(f"Warning: Columns limited to {MAX_COLS}")6364input_path = Path(args.input)65if not input_path.exists() or input_path.suffix.lower() != ".pptx":66print(f"Error: Invalid PowerPoint file: {args.input}", file=sys.stderr)67sys.exit(1)6869output_path = Path(f"{args.output_prefix}.jpg")7071try:72slide_info = get_slide_info(input_path)7374with tempfile.TemporaryDirectory() as temp_dir:75temp_path = Path(temp_dir)76visible_images = convert_to_images(input_path, temp_path)7778if not visible_images and not any(s["hidden"] for s in slide_info):79print("Error: No slides found", file=sys.stderr)80sys.exit(1)8182slides = build_slide_list(slide_info, visible_images, temp_path)8384grid_files = create_grids(slides, cols, THUMBNAIL_WIDTH, output_path)8586print(f"Created {len(grid_files)} grid(s):")87for grid_file in grid_files:88print(f" {grid_file}")8990except Exception as e:91print(f"Error: {e}", file=sys.stderr)92sys.exit(1)939495def get_slide_info(pptx_path: Path) -> list[dict]:96with zipfile.ZipFile(pptx_path, "r") as zf:97rels_content = zf.read("ppt/_rels/presentation.xml.rels").decode("utf-8")98rels_dom = defusedxml.minidom.parseString(rels_content)99100rid_to_slide = {}101for rel in rels_dom.getElementsByTagName("Relationship"):102rid = rel.getAttribute("Id")103target = rel.getAttribute("Target")104rel_type = rel.getAttribute("Type")105if "slide" in rel_type and target.startswith("slides/"):106rid_to_slide[rid] = target.replace("slides/", "")107108pres_content = zf.read("ppt/presentation.xml").decode("utf-8")109pres_dom = defusedxml.minidom.parseString(pres_content)110111slides = []112for sld_id in pres_dom.getElementsByTagName("p:sldId"):113rid = sld_id.getAttribute("r:id")114if rid in rid_to_slide:115hidden = sld_id.getAttribute("show") == "0"116slides.append({"name": rid_to_slide[rid], "hidden": hidden})117118return slides119120121def build_slide_list(122slide_info: list[dict],123visible_images: list[Path],124temp_dir: Path,125) -> list[tuple[Path, str]]:126if visible_images:127with Image.open(visible_images[0]) as img:128placeholder_size = img.size129else:130placeholder_size = (1920, 1080)131132slides = []133visible_idx = 0134135for info in slide_info:136if info["hidden"]:137placeholder_path = temp_dir / f"hidden-{info['name']}.jpg"138placeholder_img = create_hidden_placeholder(placeholder_size)139placeholder_img.save(placeholder_path, "JPEG")140slides.append((placeholder_path, f"{info['name']} (hidden)"))141else:142if visible_idx < len(visible_images):143slides.append((visible_images[visible_idx], info["name"]))144visible_idx += 1145146return slides147148149def create_hidden_placeholder(size: tuple[int, int]) -> Image.Image:150img = Image.new("RGB", size, color="#F0F0F0")151draw = ImageDraw.Draw(img)152line_width = max(5, min(size) // 100)153draw.line([(0, 0), size], fill="#CCCCCC", width=line_width)154draw.line([(size[0], 0), (0, size[1])], fill="#CCCCCC", width=line_width)155return img156157158def convert_to_images(pptx_path: Path, temp_dir: Path) -> list[Path]:159pdf_path = temp_dir / f"{pptx_path.stem}.pdf"160161result = subprocess.run(162[163"soffice",164"--headless",165"--convert-to",166"pdf",167"--outdir",168str(temp_dir),169str(pptx_path),170],171capture_output=True,172text=True,173env=get_soffice_env(),174)175if result.returncode != 0 or not pdf_path.exists():176raise RuntimeError("PDF conversion failed")177178result = subprocess.run(179[180"pdftoppm",181"-jpeg",182"-r",183str(CONVERSION_DPI),184str(pdf_path),185str(temp_dir / "slide"),186],187capture_output=True,188text=True,189)190if result.returncode != 0:191raise RuntimeError("Image conversion failed")192193return sorted(temp_dir.glob("slide-*.jpg"))194195196def create_grids(197slides: list[tuple[Path, str]],198cols: int,199width: int,200output_path: Path,201) -> list[str]:202max_per_grid = cols * (cols + 1)203grid_files = []204205for chunk_idx, start_idx in enumerate(range(0, len(slides), max_per_grid)):206end_idx = min(start_idx + max_per_grid, len(slides))207chunk_slides = slides[start_idx:end_idx]208209grid = create_grid(chunk_slides, cols, width)210211if len(slides) <= max_per_grid:212grid_filename = output_path213else:214stem = output_path.stem215suffix = output_path.suffix216grid_filename = output_path.parent / f"{stem}-{chunk_idx + 1}{suffix}"217218grid_filename.parent.mkdir(parents=True, exist_ok=True)219grid.save(str(grid_filename), quality=JPEG_QUALITY)220grid_files.append(str(grid_filename))221222return grid_files223224225def create_grid(226slides: list[tuple[Path, str]],227cols: int,228width: int,229) -> Image.Image:230font_size = int(width * FONT_SIZE_RATIO)231label_padding = int(font_size * LABEL_PADDING_RATIO)232233with Image.open(slides[0][0]) as img:234aspect = img.height / img.width235height = int(width * aspect)236237rows = (len(slides) + cols - 1) // cols238grid_w = cols * width + (cols + 1) * GRID_PADDING239grid_h = rows * (height + font_size + label_padding * 2) + (rows + 1) * GRID_PADDING240241grid = Image.new("RGB", (grid_w, grid_h), "white")242draw = ImageDraw.Draw(grid)243244try:245font = ImageFont.load_default(size=font_size)246except Exception:247font = ImageFont.load_default()248249for i, (img_path, slide_name) in enumerate(slides):250row, col = i // cols, i % cols251x = col * width + (col + 1) * GRID_PADDING252y_base = (253row * (height + font_size + label_padding * 2) + (row + 1) * GRID_PADDING254)255256label = slide_name257bbox = draw.textbbox((0, 0), label, font=font)258text_w = bbox[2] - bbox[0]259draw.text(260(x + (width - text_w) // 2, y_base + label_padding),261label,262fill="black",263font=font,264)265266y_thumbnail = y_base + label_padding + font_size + label_padding267268with Image.open(img_path) as img:269img.thumbnail((width, height), Image.Resampling.LANCZOS)270w, h = img.size271tx = x + (width - w) // 2272ty = y_thumbnail + (height - h) // 2273grid.paste(img, (tx, ty))274275if BORDER_WIDTH > 0:276draw.rectangle(277[278(tx - BORDER_WIDTH, ty - BORDER_WIDTH),279(tx + w + BORDER_WIDTH - 1, ty + h + BORDER_WIDTH - 1),280],281outline="gray",282width=BORDER_WIDTH,283)284285return grid286287288if __name__ == "__main__":289main()290