Files
hermes-agent/skills/productivity/pdf/scripts/_raster.py
T
Teknium fad88cf130 feat: extend clean-room office skills toward full parity
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.
2026-08-08 10:46:20 -07:00

77 lines
2.6 KiB
Python

"""Shared page rasterizer with a fallback chain: pypdfium2 -> pdftoppm.
Returns PIL Images so callers can annotate/save. Not a CLI.
"""
from __future__ import annotations
import shutil
import subprocess
import tempfile
from pathlib import Path
def available_backends() -> list[str]:
"""Names of usable rasterizer backends, in preference order."""
backends = []
try:
import pypdfium2 # noqa: F401
backends.append("pypdfium2")
except ImportError:
pass
if shutil.which("pdftoppm"):
backends.append("pdftoppm")
return backends
def missing_hints() -> list[str]:
"""Install hints for when no backend is available."""
return [
"python3 -m pip install pypdfium2",
"poppler-utils (provides pdftoppm), e.g. apt-get install poppler-utils",
]
def rasterize_page(pdf_path: str, page: int, dpi: int = 150, password: str | None = None):
"""Render one 1-based page to a PIL Image, or None if no backend works.
Raises ValueError for an out-of-range page when a backend is present.
"""
for backend in available_backends():
if backend == "pypdfium2":
return _via_pdfium(pdf_path, page, dpi, password)
if backend == "pdftoppm":
img = _via_pdftoppm(pdf_path, page, dpi, password)
if img is not None:
return img
return None
def _via_pdfium(pdf_path: str, page: int, dpi: int, password: str | None):
import pypdfium2 as pdfium
doc = pdfium.PdfDocument(pdf_path, password=password)
try:
if not 1 <= page <= len(doc):
raise ValueError(f"page {page} out of range 1-{len(doc)}")
bitmap = doc[page - 1].render(scale=dpi / 72.0)
return bitmap.to_pil().convert("RGB")
finally:
doc.close()
def _via_pdftoppm(pdf_path: str, page: int, dpi: int, password: str | None):
from PIL import Image
with tempfile.TemporaryDirectory() as tmp:
prefix = str(Path(tmp) / "page")
cmd = ["pdftoppm", "-png", "-r", str(dpi), "-f", str(page), "-l", str(page)]
if password:
cmd += ["-upw", password]
cmd += [pdf_path, prefix]
proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8")
if proc.returncode != 0:
raise ValueError(f"pdftoppm failed: {proc.stderr.strip()}")
produced = sorted(Path(tmp).glob("page*.png"))
if not produced:
raise ValueError(f"page {page} out of range (pdftoppm produced no image)")
with Image.open(produced[0]) as img:
return img.convert("RGB")