Files
goclaw/skills/pptx/scripts/thumbnail.py
T
Viet Tran ace07509b7 feat(skills): system skills integration — toggle, dep checking, per-item install (#161)
* feat(infra): add runtime package support for skills

Install nodejs, npm, pandoc, github-cli + pre-install Python packages
(openpyxl, pandas, python-pptx, markitdown) and Node packages
(docx, pptxgenjs). Configure runtime dirs for agent pip/npm installs
with PIP_TARGET, NPM_CONFIG_PREFIX, NODE_PATH to enable dynamic
package installation in read-only container environment.

* feat(infra): add bundled skills with runtime package support

- Add 5 bundled skills: docx, pdf, pptx, xlsx, skill-creator from container skills-store
- Wire GOCLAW_BUILTIN_SKILLS_DIR env var in gateway and CLI
- Support optional runtime packages alongside dynamic skill loading
- Update Dockerfile to COPY bundled-skills at /app/bundled-skills/
- Add PIP_CACHE_DIR in docker-entrypoint.sh for clean pip installs
- Document bundled skills in 14-skills-runtime.md section 6

* feat(infra): remove ai-multimodal skill directory from bundled skills

Remove the ai-multimodal skill package as part of consolidating runtime
package support for bundled skills. This directory is no longer needed
in the bundled skills structure.

* feat(ci): add semantic release and Docker Hub publishing

Add go-semantic-release workflow to auto-create semver tags on merge to
main. Extend docker-publish to push all variants to both GHCR and
Docker Hub (digitop/goclaw).

* feat(skills): add system skills infrastructure with is_system column, dep scanning, and seeder

- Migration 000017: add is_system boolean column with partial index
- Store layer: UpsertSystemSkill, delete protection, IsSystemSkill
- ListAccessible auto-includes system skills (no grants needed)
- ListWithGrantStatus returns is_system field
- Dependency scanner: auto-detect deps from scripts/ or skill-manifest.json
- Dependency checker: verify system binaries, Python/Node packages
- Seeder: seed bundled skills into DB on startup (idempotent via hash)
- Gateway wiring: GOCLAW_BUNDLED_SKILLS_DIR env for bundled skills
- HTTP: delete guard (403), slug conflict check (409), rescan-deps endpoint
- UI: System badge, hide delete for system skills, rescan deps button
- Agent skills tab: "Always available" for system skills
- i18n: en/vi/zh keys for system skills, deps scanning

* feat(skills): conditional system prompt, skill manifests, and Zip Slip fix

- System prompt: only show package list when python3/node are available
- Add skill-manifest.json for pdf, docx, xlsx, pptx bundled skills
- Fix Zip Slip vulnerability in office/unpack.py (all 3 copies)

* refactor(skills): extract shared office code to _shared/ and deduplicate

Move office scripts (pack, unpack, validate, schemas, validators) from
duplicated copies in docx/xlsx/pptx to skills/_shared/office/ with
symlinks. Remove soffice.py (non-functional in containers) and update
SKILL.md references to use soffice binary directly. Update seeder
copyDir to follow symlinks.

Removes ~45K lines of duplicate code across 3 skills.

* fix(skills): address code review findings for system skills integration

- H1: Remove dead symlink branch in copyDir (filepath.Walk follows symlinks)
- H3: Fix rescan-deps to query ALL skills (including archived) and re-activate
  when deps become available; add ListAllSkills() + Status field to SkillInfo
- H4: Add Status field to SkillCreateParams, stop overloading Visibility
- M1: Batch Python/Node dep checks into single subprocess per runtime
- M4: Add rows.Err() check in ListSkills to prevent caching partial results

* feat(skills): async dep checking with realtime WS events

Split Seed() into sync DB upsert + async CheckDepsAsync() goroutine.
Gateway startup no longer blocks on Python/Node subprocess dep checks.

- Seed() returns seeded skills list, all initially status="active"
- CheckDepsAsync() runs in background, emits skill.deps.checked per-skill
- skill.deps.complete event emitted when all checks finish
- Each failed dep check: archives skill + BumpVersion() for immediate
  cache invalidation so next agent turn picks up the change
- UI: use-query-invalidation listens to skill.deps.* events → auto-refresh
  skills list in realtime

* feat(skills): system skills integration with toggle, dep checking, and per-item install

- Add is_system, deps, enabled columns to skills table (migration 017)
- Seed bundled core skills (pdf, docx, pptx, xlsx, skill-creator) on startup
- PYTHONPATH-based dep detection — eliminates false positives from local modules
- Per-item dep install UI with individual status (installing/success/error)
- Enable/disable toggle for core and custom skills (independent of dep status)
- Re-run dep check when skill is toggled back on
- Inline skill thresholds: 40 skills / 5000 tokens before switching to search mode
- Fix UpsertSystemSkill: backfill null file_hash without bumping DB version
- Remove redundant skill-manifest.json files (replaced by deps JSONB column)
- Show author from frontmatter in custom skills tab
- Runtime checker for python3/pip3/node/npm availability
- WS events for dep checking/installing progress
- docs: add 15-core-skills-system.md, 16-skill-publishing.md

---------

Co-authored-by: Goon <duy@wearetopgroup.com>
2026-03-12 09:20:41 +07:00

290 lines
8.6 KiB
Python

"""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 subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
import defusedxml.minidom
from office.soffice import get_soffice_env
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 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_slide = {}
for rel in rels_dom.getElementsByTagName("Relationship"):
rid = rel.getAttribute("Id")
target = rel.getAttribute("Target")
rel_type = rel.getAttribute("Type")
if "slide" in rel_type and target.startswith("slides/"):
rid_to_slide[rid] = target.replace("slides/", "")
pres_content = zf.read("ppt/presentation.xml").decode("utf-8")
pres_dom = defusedxml.minidom.parseString(pres_content)
slides = []
for sld_id in pres_dom.getElementsByTagName("p:sldId"):
rid = sld_id.getAttribute("r:id")
if rid in rid_to_slide:
hidden = sld_id.getAttribute("show") == "0"
slides.append({"name": rid_to_slide[rid], "hidden": hidden})
return slides
def build_slide_list(
slide_info: list[dict],
visible_images: list[Path],
temp_dir: Path,
) -> list[tuple[Path, str]]:
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"]:
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:
if visible_idx < len(visible_images):
slides.append((visible_images[visible_idx], info["name"]))
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 = subprocess.run(
[
"soffice",
"--headless",
"--convert-to",
"pdf",
"--outdir",
str(temp_dir),
str(pptx_path),
],
capture_output=True,
text=True,
env=get_soffice_env(),
)
if result.returncode != 0 or not pdf_path.exists():
raise RuntimeError("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()