mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
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.
311 lines
9.6 KiB
Python
Executable file
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()
|