mirror of
https://github.com/anthropics/skills.git
synced 2026-08-02 21:15:27 +08:00
fa0fa64bdc
Add support for template formats (.dotx, .potx, .xltx) across validation and the helper scripts. Consolidate the shared office helpers into a single module, replacing the pack/unpack pipeline with explicit zip/unzip steps. Extraction now rejects symlink and path-traversal archive entries. Move run merging to a standalone docx script, since it only ever applied to Word documents. Fix the redlining validator so it compares against the original even when a document has no tracked changes, which is when an untracked edit would otherwise go unreported. Provision a LibreOffice user profile per invocation so conversions work in sandboxed environments. Trim the skill docs to the guidance that earns its place.
312 lines
9.6 KiB
Python
Executable File
312 lines
9.6 KiB
Python
Executable File
"""Create thumbnail grids from PowerPoint presentation slides.
|
|
|
|
Creates a grid layout of slide thumbnails for quick visual analysis.
|
|
Labels each thumbnail with its XML filename (e.g., slide1.xml).
|
|
Hidden slides are shown with a placeholder pattern.
|
|
|
|
Usage:
|
|
python thumbnail.py input.pptx [output_prefix] [--cols N]
|
|
|
|
Examples:
|
|
python thumbnail.py presentation.pptx
|
|
# Creates: thumbnails.jpg
|
|
|
|
python thumbnail.py template.pptx grid --cols 4
|
|
# Creates: grid.jpg (or grid-1.jpg, grid-2.jpg for large decks)
|
|
"""
|
|
|
|
import argparse
|
|
import posixpath
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import defusedxml.minidom
|
|
from defusedxml import ElementTree
|
|
from office.helpers import SLIDE_REL_TYPE, opc_target
|
|
from office.soffice import run_soffice
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
|
|
THUMBNAIL_WIDTH = 300
|
|
CONVERSION_DPI = 100
|
|
MAX_COLS = 6
|
|
DEFAULT_COLS = 3
|
|
JPEG_QUALITY = 95
|
|
GRID_PADDING = 20
|
|
BORDER_WIDTH = 2
|
|
FONT_SIZE_RATIO = 0.10
|
|
LABEL_PADDING_RATIO = 0.4
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Create thumbnail grids from PowerPoint slides."
|
|
)
|
|
parser.add_argument("input", help="Input PowerPoint file (.pptx)")
|
|
parser.add_argument(
|
|
"output_prefix",
|
|
nargs="?",
|
|
default="thumbnails",
|
|
help="Output prefix for image files (default: thumbnails)",
|
|
)
|
|
parser.add_argument(
|
|
"--cols",
|
|
type=int,
|
|
default=DEFAULT_COLS,
|
|
help=f"Number of columns (default: {DEFAULT_COLS}, max: {MAX_COLS})",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
cols = min(args.cols, MAX_COLS)
|
|
if args.cols > MAX_COLS:
|
|
print(f"Warning: Columns limited to {MAX_COLS}")
|
|
|
|
input_path = Path(args.input)
|
|
if not input_path.exists() or input_path.suffix.lower() != ".pptx":
|
|
print(f"Error: Invalid PowerPoint file: {args.input}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
output_path = Path(f"{args.output_prefix}.jpg")
|
|
|
|
try:
|
|
slide_info = get_slide_info(input_path)
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp_path = Path(temp_dir)
|
|
visible_images = convert_to_images(input_path, temp_path)
|
|
|
|
if not visible_images and not any(s["hidden"] for s in slide_info):
|
|
print("Error: No slides found", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
slides = build_slide_list(slide_info, visible_images, temp_path)
|
|
|
|
grid_files = create_grids(slides, cols, THUMBNAIL_WIDTH, output_path)
|
|
|
|
print(f"Created {len(grid_files)} grid(s):")
|
|
for grid_file in grid_files:
|
|
print(f" {grid_file}")
|
|
|
|
except Exception as e:
|
|
print(f"Error: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def _is_hidden(zf: zipfile.ZipFile, part: str) -> bool:
|
|
try:
|
|
with zf.open(part) as f:
|
|
for _, root in ElementTree.iterparse(f, events=("start",)):
|
|
return root.get("show") in ("0", "false")
|
|
except (KeyError, ElementTree.ParseError):
|
|
return False
|
|
return False
|
|
|
|
|
|
def get_slide_info(pptx_path: Path) -> list[dict]:
|
|
with zipfile.ZipFile(pptx_path, "r") as zf:
|
|
rels_content = zf.read("ppt/_rels/presentation.xml.rels").decode("utf-8")
|
|
rels_dom = defusedxml.minidom.parseString(rels_content)
|
|
|
|
rid_to_part = {}
|
|
for rel in rels_dom.getElementsByTagName("Relationship"):
|
|
if rel.getAttribute("Type") != SLIDE_REL_TYPE:
|
|
continue
|
|
part = opc_target(
|
|
rel.getAttribute("Target"),
|
|
"ppt/presentation.xml",
|
|
rel.getAttribute("TargetMode"),
|
|
)
|
|
if part is not None:
|
|
rid_to_part[rel.getAttribute("Id")] = part
|
|
|
|
pres_content = zf.read("ppt/presentation.xml").decode("utf-8")
|
|
pres_dom = defusedxml.minidom.parseString(pres_content)
|
|
|
|
present = set(zf.namelist())
|
|
|
|
slides = []
|
|
for sld_id in pres_dom.getElementsByTagName("p:sldId"):
|
|
part = rid_to_part.get(sld_id.getAttribute("r:id"))
|
|
if part is not None and part in present:
|
|
slides.append(
|
|
{"name": posixpath.basename(part), "hidden": _is_hidden(zf, part)}
|
|
)
|
|
|
|
return slides
|
|
|
|
|
|
def build_slide_list(
|
|
slide_info: list[dict],
|
|
visible_images: list[Path],
|
|
temp_dir: Path,
|
|
) -> list[tuple[Path, str]]:
|
|
visible_count = sum(1 for info in slide_info if not info["hidden"])
|
|
rendered_hidden = len(visible_images) == len(slide_info) != visible_count
|
|
|
|
if not rendered_hidden and visible_count != len(visible_images):
|
|
raise ValueError(
|
|
f"LibreOffice rendered {len(visible_images)} page(s) for {visible_count} "
|
|
f"visible slide(s) of {len(slide_info)}; thumbnails would be mislabeled"
|
|
)
|
|
|
|
if visible_images:
|
|
with Image.open(visible_images[0]) as img:
|
|
placeholder_size = img.size
|
|
else:
|
|
placeholder_size = (1920, 1080)
|
|
|
|
slides = []
|
|
visible_idx = 0
|
|
|
|
for info in slide_info:
|
|
if info["hidden"] and not rendered_hidden:
|
|
placeholder_path = temp_dir / f"hidden-{info['name']}.jpg"
|
|
placeholder_img = create_hidden_placeholder(placeholder_size)
|
|
placeholder_img.save(placeholder_path, "JPEG")
|
|
slides.append((placeholder_path, f"{info['name']} (hidden)"))
|
|
else:
|
|
label = f"{info['name']} (hidden)" if info["hidden"] else info["name"]
|
|
slides.append((visible_images[visible_idx], label))
|
|
visible_idx += 1
|
|
|
|
return slides
|
|
|
|
|
|
def create_hidden_placeholder(size: tuple[int, int]) -> Image.Image:
|
|
img = Image.new("RGB", size, color="#F0F0F0")
|
|
draw = ImageDraw.Draw(img)
|
|
line_width = max(5, min(size) // 100)
|
|
draw.line([(0, 0), size], fill="#CCCCCC", width=line_width)
|
|
draw.line([(size[0], 0), (0, size[1])], fill="#CCCCCC", width=line_width)
|
|
return img
|
|
|
|
|
|
def convert_to_images(pptx_path: Path, temp_dir: Path) -> list[Path]:
|
|
pdf_path = temp_dir / f"{pptx_path.stem}.pdf"
|
|
|
|
result = run_soffice(
|
|
["--headless", "--convert-to", "pdf", "--outdir", str(temp_dir), str(pptx_path)],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0 or not pdf_path.exists():
|
|
detail = (result.stderr or result.stdout or "").strip()
|
|
raise RuntimeError(f"PDF conversion failed: {detail}" if detail else "PDF conversion failed")
|
|
|
|
result = subprocess.run(
|
|
[
|
|
"pdftoppm",
|
|
"-jpeg",
|
|
"-r",
|
|
str(CONVERSION_DPI),
|
|
str(pdf_path),
|
|
str(temp_dir / "slide"),
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError("Image conversion failed")
|
|
|
|
return sorted(temp_dir.glob("slide-*.jpg"))
|
|
|
|
|
|
def create_grids(
|
|
slides: list[tuple[Path, str]],
|
|
cols: int,
|
|
width: int,
|
|
output_path: Path,
|
|
) -> list[str]:
|
|
max_per_grid = cols * (cols + 1)
|
|
grid_files = []
|
|
|
|
for chunk_idx, start_idx in enumerate(range(0, len(slides), max_per_grid)):
|
|
end_idx = min(start_idx + max_per_grid, len(slides))
|
|
chunk_slides = slides[start_idx:end_idx]
|
|
|
|
grid = create_grid(chunk_slides, cols, width)
|
|
|
|
if len(slides) <= max_per_grid:
|
|
grid_filename = output_path
|
|
else:
|
|
stem = output_path.stem
|
|
suffix = output_path.suffix
|
|
grid_filename = output_path.parent / f"{stem}-{chunk_idx + 1}{suffix}"
|
|
|
|
grid_filename.parent.mkdir(parents=True, exist_ok=True)
|
|
grid.save(str(grid_filename), quality=JPEG_QUALITY)
|
|
grid_files.append(str(grid_filename))
|
|
|
|
return grid_files
|
|
|
|
|
|
def create_grid(
|
|
slides: list[tuple[Path, str]],
|
|
cols: int,
|
|
width: int,
|
|
) -> Image.Image:
|
|
font_size = int(width * FONT_SIZE_RATIO)
|
|
label_padding = int(font_size * LABEL_PADDING_RATIO)
|
|
|
|
with Image.open(slides[0][0]) as img:
|
|
aspect = img.height / img.width
|
|
height = int(width * aspect)
|
|
|
|
rows = (len(slides) + cols - 1) // cols
|
|
grid_w = cols * width + (cols + 1) * GRID_PADDING
|
|
grid_h = rows * (height + font_size + label_padding * 2) + (rows + 1) * GRID_PADDING
|
|
|
|
grid = Image.new("RGB", (grid_w, grid_h), "white")
|
|
draw = ImageDraw.Draw(grid)
|
|
|
|
try:
|
|
font = ImageFont.load_default(size=font_size)
|
|
except Exception:
|
|
font = ImageFont.load_default()
|
|
|
|
for i, (img_path, slide_name) in enumerate(slides):
|
|
row, col = i // cols, i % cols
|
|
x = col * width + (col + 1) * GRID_PADDING
|
|
y_base = (
|
|
row * (height + font_size + label_padding * 2) + (row + 1) * GRID_PADDING
|
|
)
|
|
|
|
label = slide_name
|
|
bbox = draw.textbbox((0, 0), label, font=font)
|
|
text_w = bbox[2] - bbox[0]
|
|
draw.text(
|
|
(x + (width - text_w) // 2, y_base + label_padding),
|
|
label,
|
|
fill="black",
|
|
font=font,
|
|
)
|
|
|
|
y_thumbnail = y_base + label_padding + font_size + label_padding
|
|
|
|
with Image.open(img_path) as img:
|
|
img.thumbnail((width, height), Image.Resampling.LANCZOS)
|
|
w, h = img.size
|
|
tx = x + (width - w) // 2
|
|
ty = y_thumbnail + (height - h) // 2
|
|
grid.paste(img, (tx, ty))
|
|
|
|
if BORDER_WIDTH > 0:
|
|
draw.rectangle(
|
|
[
|
|
(tx - BORDER_WIDTH, ty - BORDER_WIDTH),
|
|
(tx + w + BORDER_WIDTH - 1, ty + h + BORDER_WIDTH - 1),
|
|
],
|
|
outline="gray",
|
|
width=BORDER_WIDTH,
|
|
)
|
|
|
|
return grid
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|