Files
hermes-agent/skills/productivity/pdf/scripts/pdf_meta.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

116 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""Document metadata and file attachments for PDFs (pypdf).
Modes (one required):
--set-meta set metadata keys given via --title/--author/...
--clear-meta drop all document info metadata
--attach FILE embed a file attachment
--list-attachments list embedded attachment names
--extract-attachments DIR write all attachments into DIR
Metadata note: values are stored in the classic DocInfo dictionary
(Title/Author/Subject/Keywords). XMP metadata, if present, is not
rewritten and may disagree in sophisticated viewers.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
def main() -> int:
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8")
except Exception:
pass
parser = argparse.ArgumentParser(description="Set/clear PDF metadata; manage attachments.")
parser.add_argument("pdf", help="Input PDF path")
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--set-meta", action="store_true", help="Set metadata fields")
mode.add_argument("--clear-meta", action="store_true", help="Remove all DocInfo metadata")
mode.add_argument("--attach", metavar="FILE", help="Embed FILE as an attachment")
mode.add_argument("--list-attachments", action="store_true", help="List attachment names")
mode.add_argument("--extract-attachments", metavar="DIR", help="Extract attachments into DIR")
parser.add_argument("-o", "--output", help="Output PDF (required for write modes)")
parser.add_argument("--title")
parser.add_argument("--author")
parser.add_argument("--subject")
parser.add_argument("--keywords")
parser.add_argument("--password", help="Password if the input is encrypted")
args = parser.parse_args()
try:
from pypdf import PdfReader, PdfWriter
except ImportError:
print("Missing dependency: install with 'python3 -m pip install pypdf'", file=sys.stderr)
return 2
reader = PdfReader(args.pdf)
if reader.is_encrypted:
if args.password is None or not reader.decrypt(args.password):
print("Error: input is encrypted; pass --password", file=sys.stderr)
return 3
if args.list_attachments:
names = list(reader.attachments.keys())
json.dump({"attachment_count": len(names), "attachments": names}, sys.stdout,
ensure_ascii=False, indent=2)
print()
return 0
if args.extract_attachments:
out_dir = Path(args.extract_attachments)
out_dir.mkdir(parents=True, exist_ok=True)
written = []
for name, contents in reader.attachments.items():
data = contents[0] if isinstance(contents, list) else contents
safe = os.path.basename(name) or "attachment.bin"
target = out_dir / safe
with open(target, "wb") as fh:
fh.write(bytes(data))
written.append(str(target))
json.dump({"extracted": written}, sys.stdout, ensure_ascii=False, indent=2)
print()
return 0
if not args.output:
print("Error: -o/--output is required for write modes", file=sys.stderr)
return 4
writer = PdfWriter()
writer.append(reader)
if args.set_meta:
meta = {}
for key, value in ((f"/{k.capitalize()}", getattr(args, k))
for k in ("title", "author", "subject", "keywords")):
if value is not None:
meta[key] = value
if not meta:
print("Error: --set-meta needs at least one of --title/--author/--subject/--keywords",
file=sys.stderr)
return 4
writer.add_metadata(meta)
result = {"output": args.output, "set": {k.lstrip("/"): v for k, v in meta.items()}}
elif args.clear_meta:
writer.metadata = None
result = {"output": args.output, "cleared": True}
else: # --attach
attach_path = Path(args.attach)
with open(attach_path, "rb") as fh:
writer.add_attachment(attach_path.name, fh.read())
result = {"output": args.output, "attached": attach_path.name}
with open(args.output, "wb") as fh:
writer.write(fh)
print(json.dumps(result, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())