hermes-agent/skills/productivity/powerpoint/scripts/thumbnail.py
Teknium afb7bf6a5a
feat(skills): bundle docx, xlsx, and pdf office skills; refresh powerpoint (#68595)
Non-technical users asking for Word docs, spreadsheets, or PDF work had
no bundled skill coverage — docx/xlsx creation required discovering and
installing hub skills, and PDF manipulation had no skill at all beyond
OCR extraction and nano-pdf edits.

- skills/productivity/docx: create (docx-js), edit (unzip -> XML -> zip),
  tracked changes, comments, validation. Adapted from anthropics/skills.
- skills/productivity/xlsx: openpyxl creation/editing, mandatory
  LibreOffice recalc gate, formula-compatibility rules, financial-model
  conventions. Points at optional excel-author for finance-grade work.
- skills/productivity/pdf: merge/split/rotate/watermark/encrypt, form
  filling (AcroForm + flat overlay scripts), text/table extraction,
  reportlab creation, forms.md + reference.md companions.
- skills/productivity/powerpoint: synced to current upstream pptx skill —
  richer pptxgenjs corruption footguns, template workflow, validate.py +
  validators + thumbnail.py, font-substitution QA guidance; drops the
  stale pack.py/editing.md/pptxgenjs.md workflow files.
- Cross-linked ocr-and-documents, nano-pdf, excel-author via
  related_skills so each office skill routes to its siblings.
- deliverable-mode docs mention the new skills; regenerated per-skill
  docs pages, catalogs, and sidebar.
- tests/skills/test_office_document_skills.py: frontmatter contracts,
  referenced-script existence, schema-map integrity, cross-link
  resolution, script compilation.

E2E validated: docx create->render->edit->validate, xlsx recalc
(SUM + _xlfn.TEXTJOIN evaluate correctly), pdf create->merge->extract,
pptx generate->validate->thumbnail.
2026-07-21 05:39:23 -07:00

311 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()