Files
hermes-agent/skills/productivity/pdf/scripts/pdf_split.py
T
Teknium 51570f4da7 feat: replace Anthropic office document skills with clean-room MIT implementations
The bundled docx, xlsx, powerpoint, and pdf skills were adapted from
Anthropic's document skills and carried their proprietary LICENSE.txt
(no derivatives, no redistribution). Flagged as critical license
findings by the SkillEvaluator Tier 1 scan of our skill tree.

This replaces all four with clean-room rewrites:

- Authored from scratch against library knowledge only (python-docx,
  openpyxl, python-pptx, pypdf/reportlab/pdfplumber — all MIT/BSD) by
  isolated subagents given functional specs, with an explicit
  prohibition on reading the prior skill content or anthropics/skills;
  session transcripts retained as provenance evidence.
- MIT licensed (LICENSE file per skill), author: Nous Research.
- Each skill: SKILL.md to house standards + argparse helper scripts
  with UTF-8-explicit I/O + its own e2e pytest suite (fixtures built
  on the fly, non-ASCII round-trips run under LC_ALL=C).
- All four pass SkillEvaluator Tier 1 pii+unicode+lint 3/3.

tests/skills/test_office_document_skills.py rewritten against the new
contracts: MIT/no-Anthropic-text invariants, scripts documented in
SKILL.md, argparse CLI shape, and a no-locale-default-open() check
(which caught and fixed a real gap: pdfplumber text reads are fine,
but the invariant scan now guards every future script).

Docs pages regenerated for the four skills (scoped; unrelated
generator drift excluded).

Honest capability deltas vs the old versions are documented per
SKILL.md (e.g. tracked-changes accept/reject and OOXML XSD validation
are not reimplemented; form flattening limits stated).
2026-08-08 10:46:20 -07:00

85 lines
3.0 KiB
Python

#!/usr/bin/env python3
"""Extract page ranges from a PDF, optionally rotating and/or compressing pages."""
from __future__ import annotations
import argparse
import json
import sys
def parse_pages(spec: str, page_count: int) -> list[int]:
"""Parse a 1-based page spec like '1-3,5,9-' into 0-based indices."""
indices: list[int] = []
for part in spec.split(","):
part = part.strip()
if not part:
continue
if "-" in part:
start_s, _, end_s = part.partition("-")
start = int(start_s) if start_s else 1
end = int(end_s) if end_s else page_count
else:
start = end = int(part)
if start < 1 or end > page_count or start > end:
raise ValueError(f"Page range {part!r} out of bounds (1-{page_count})")
indices.extend(range(start - 1, end))
return indices
def main() -> int:
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8")
except Exception:
pass
parser = argparse.ArgumentParser(
description="Split/extract pages from a PDF (pypdf). Pages are 1-based: '1-3,5,9-'.")
parser.add_argument("pdf", help="Input PDF path")
parser.add_argument("--pages", required=True, help="1-based page spec, e.g. '1-3,5,9-'")
parser.add_argument("-o", "--output", required=True, help="Output PDF path")
parser.add_argument("--rotate", type=int, default=0,
help="Rotate extracted pages clockwise (multiple of 90)")
parser.add_argument("--compress", action="store_true",
help="Deflate content streams (modest savings; does not recompress images)")
parser.add_argument("--password", help="Password if the input is encrypted")
args = parser.parse_args()
if args.rotate % 90 != 0:
print("Error: --rotate must be a multiple of 90", file=sys.stderr)
return 2
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
try:
indices = parse_pages(args.pages, len(reader.pages))
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 2
writer = PdfWriter()
for idx in indices:
page = reader.pages[idx]
if args.rotate:
page.rotate(args.rotate)
writer.add_page(page)
if args.compress:
for page in writer.pages:
page.compress_content_streams()
with open(args.output, "wb") as fh:
writer.write(fh)
print(json.dumps({"output": args.output, "page_count": len(indices), "rotated": args.rotate}))
return 0
if __name__ == "__main__":
sys.exit(main())