mirror of
https://github.com/anthropics/skills.git
synced 2026-08-02 13:05:28 +08:00
Update docx, pptx, and xlsx skills (#1447)
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.
This commit is contained in:
@@ -16,6 +16,7 @@ Examples:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import posixpath
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -23,9 +24,12 @@ import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import defusedxml.minidom
|
||||
from office.soffice import get_soffice_env
|
||||
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
|
||||
@@ -92,28 +96,45 @@ def main():
|
||||
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_slide = {}
|
||||
rid_to_part = {}
|
||||
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/", "")
|
||||
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"):
|
||||
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})
|
||||
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
|
||||
|
||||
@@ -123,6 +144,15 @@ def build_slide_list(
|
||||
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
|
||||
@@ -133,15 +163,15 @@ def build_slide_list(
|
||||
visible_idx = 0
|
||||
|
||||
for info in slide_info:
|
||||
if info["hidden"]:
|
||||
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:
|
||||
if visible_idx < len(visible_images):
|
||||
slides.append((visible_images[visible_idx], info["name"]))
|
||||
visible_idx += 1
|
||||
label = f"{info['name']} (hidden)" if info["hidden"] else info["name"]
|
||||
slides.append((visible_images[visible_idx], label))
|
||||
visible_idx += 1
|
||||
|
||||
return slides
|
||||
|
||||
@@ -158,22 +188,14 @@ def create_hidden_placeholder(size: tuple[int, int]) -> Image.Image:
|
||||
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),
|
||||
],
|
||||
result = 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")
|
||||
detail = (result.stderr or result.stdout or "").strip()
|
||||
raise RuntimeError(f"PDF conversion failed: {detail}" if detail else "PDF conversion failed")
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user