fad88cf130
Same clean-room discipline as the initial rewrite (isolated subagents, functional specs only, predecessor content banned including via git history; transcripts retained). All additions test-proven. docx (13->29 tests): - docx_revisions.py: tracked changes list/accept/reject (all or by id), incl. tables and headers/footers, via direct oxml manipulation - docx_comments.py: list/add/delete comments (native python-docx >=1.2 API with XML fallback), anchored-text extraction - docx_validate.py: package health check (rels, images, styles, CRC) with JSON severity report — explicitly not XSD validation - docx_edit.py: run normalization; TOC + PAGE/NUMPAGES field insertion xlsx (5->12 tests): - xlsx_restructure.py: reference-aware insert/delete rows/cols — rewrites formulas on all sheets (absolute refs, ranges, cross-sheet, quoted names), shifts merges/autofilter/freeze/validation/CF ranges, tables, defined names; JSON report incl. honest not_shifted list - native Excel tables, named ranges, hyperlinks, cell notes, sheet protection (documented as strippable, not security) - xlsx_recalc.py: headless LibreOffice recalc with graceful degrade powerpoint (11->21 tests): - pptx_render.py: all slides -> PNGs (soffice + pdftoppm/pdftocairo), wired to vision_analyze review loop in SKILL.md - run-merge normalize before replace (identical-format splits lossless) - surgical chart ops (series/category/title) wrapping replace_data - slide duplication with rel remap (clean refusal on chart slides) - backgrounds, hyperlinks, slide numbers, footers, notes editing pdf (8->21 tests): - pdf_make_form.py: JSON spec -> AcroForm (text/checkbox/radio/dropdown) - pdf_form_layout.py: pre-build layout lint (bounds/overlap/pairing) + rendered box overlay for vision_analyze review - pdf_page_image.py + shared _raster.py: pypdfium2 -> pdftoppm chain, graceful degrade; connected to scanned-PDF triage flow - pdf_stamp.py: text/image stamps at coordinates (rotation/opacity) - pdf_meta.py: DocInfo metadata + attachments round-trip Gates re-verified independently: 83 skill tests green under LC_ALL=C, repo invariant suite 29/29, SkillEvaluator pii+unicode+lint 3/3 x4.
94 lines
3.5 KiB
Python
94 lines
3.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Render every slide of a .pptx to per-slide PNG images.
|
|
|
|
Pipeline: LibreOffice (soffice --headless --convert-to pdf) turns the deck
|
|
into a PDF, then poppler (pdftoppm, or pdftocairo as an alternate) splits
|
|
the PDF into one PNG per slide.
|
|
|
|
Output is JSON. When both tools are present:
|
|
{"rendered": true, "files": ["render/slide-1.png", ...]}
|
|
When either tool is missing the script still exits 0 and reports:
|
|
{"rendered": false, "missing": ["soffice"], "guidance": "..."}
|
|
so callers can degrade gracefully (fall back to pptx_read.py --outline).
|
|
"""
|
|
import argparse
|
|
import glob
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
|
|
def find_tools():
|
|
"""Return (soffice, splitter, missing) using shutil.which."""
|
|
soffice = shutil.which("soffice")
|
|
splitter = shutil.which("pdftoppm") or shutil.which("pdftocairo")
|
|
missing = []
|
|
if not soffice:
|
|
missing.append("soffice")
|
|
if not splitter:
|
|
missing.append("pdftoppm (or pdftocairo)")
|
|
return soffice, splitter, missing
|
|
|
|
|
|
def render(pptx_path, out_dir, prefix, dpi):
|
|
soffice, splitter, missing = find_tools()
|
|
if missing:
|
|
return {
|
|
"rendered": False, "missing": missing,
|
|
"guidance": "Install LibreOffice (soffice) and poppler-utils "
|
|
"(pdftoppm/pdftocairo) to render slides. Without "
|
|
"them, verify decks with pptx_read.py --outline "
|
|
"instead.",
|
|
}
|
|
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
proc = subprocess.run(
|
|
[soffice, "--headless", "--convert-to", "pdf",
|
|
"--outdir", tmp, pptx_path],
|
|
capture_output=True, text=True, encoding="utf-8",
|
|
errors="replace", timeout=300)
|
|
pdfs = glob.glob(os.path.join(tmp, "*.pdf"))
|
|
if proc.returncode != 0 or not pdfs:
|
|
raise SystemExit(f"soffice PDF conversion failed: {proc.stderr}")
|
|
pdf = pdfs[0]
|
|
out_prefix = os.path.join(out_dir, prefix)
|
|
proc = subprocess.run(
|
|
[splitter, "-png", "-r", str(dpi), pdf, out_prefix],
|
|
capture_output=True, text=True, encoding="utf-8",
|
|
errors="replace", timeout=300)
|
|
if proc.returncode != 0:
|
|
raise SystemExit(f"{os.path.basename(splitter)} failed: "
|
|
f"{proc.stderr}")
|
|
|
|
files = sorted(glob.glob(out_prefix + "*.png"))
|
|
return {"rendered": True, "files": files}
|
|
|
|
|
|
def main(argv=None):
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
parser = argparse.ArgumentParser(
|
|
description="Render each slide of a .pptx to a PNG via "
|
|
"soffice + pdftoppm/pdftocairo.",
|
|
epilog=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("pptx", help="path to the .pptx file")
|
|
parser.add_argument("--outdir", default="render",
|
|
help="directory for PNGs (default: ./render)")
|
|
parser.add_argument("--prefix", default="slide",
|
|
help="PNG filename prefix (default: slide)")
|
|
parser.add_argument("--dpi", type=int, default=100,
|
|
help="render resolution (default: 100)")
|
|
args = parser.parse_args(argv)
|
|
|
|
result = render(args.pptx, args.outdir, args.prefix, args.dpi)
|
|
print(json.dumps(result, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|