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:
Peter Lai
2026-07-16 19:47:37 -07:00
committed by GitHub
parent 9d2f1ae187
commit fa0fa64bdc
53 changed files with 4147 additions and 4442 deletions
+49 -27
View File
@@ -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(
[