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.
144 lines
5.6 KiB
Python
144 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Stamp text or an image at coordinates onto selected PDF pages.
|
|
|
|
Builds an in-memory single-page overlay with reportlab, then merges it
|
|
onto each selected page with pypdf. Coordinates are PDF points, origin
|
|
bottom-left. Covers 'sign here' arrows, diagonal DRAFT banners, and
|
|
page-corner labels.
|
|
|
|
Examples:
|
|
pdf_stamp.py in.pdf -o out.pdf --text "DRAFT" --x 200 --y 400 \
|
|
--font-size 60 --rotation 45 --opacity 0.3 --color "#cc0000"
|
|
pdf_stamp.py in.pdf -o out.pdf --image sig.png --x 400 --y 60 \
|
|
--width 120 --pages 3
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import io
|
|
import json
|
|
import sys
|
|
|
|
|
|
def parse_pages(spec: str, page_count: int) -> list[int]:
|
|
pages: set[int] = set()
|
|
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
|
|
pages.update(range(start, end + 1))
|
|
else:
|
|
pages.add(int(part))
|
|
bad = [p for p in pages if not 1 <= p <= page_count]
|
|
if bad:
|
|
raise ValueError(f"pages out of range 1-{page_count}: {sorted(bad)}")
|
|
return sorted(pages)
|
|
|
|
|
|
def build_overlay(args, page_width: float, page_height: float) -> bytes:
|
|
from reportlab.lib.colors import HexColor
|
|
from reportlab.pdfgen import canvas
|
|
buf = io.BytesIO()
|
|
c = canvas.Canvas(buf, pagesize=(page_width, page_height))
|
|
c.saveState()
|
|
try:
|
|
c.setFillAlpha(float(args.opacity))
|
|
c.setStrokeAlpha(float(args.opacity))
|
|
except Exception:
|
|
pass # very old reportlab: no alpha support
|
|
c.translate(float(args.x), float(args.y))
|
|
if args.rotation:
|
|
c.rotate(float(args.rotation))
|
|
if args.text:
|
|
c.setFont(args.font, float(args.font_size))
|
|
c.setFillColor(HexColor(args.color))
|
|
c.drawString(0, 0, args.text)
|
|
else:
|
|
kwargs = {}
|
|
if args.width:
|
|
kwargs["width"] = float(args.width)
|
|
if args.height:
|
|
kwargs["height"] = float(args.height)
|
|
if "width" in kwargs and "height" not in kwargs:
|
|
from PIL import Image as PILImage
|
|
with PILImage.open(args.image) as im:
|
|
kwargs["height"] = kwargs["width"] * im.height / im.width
|
|
c.drawImage(args.image, 0, 0, mask="auto", **kwargs)
|
|
c.restoreState()
|
|
c.save()
|
|
return buf.getvalue()
|
|
|
|
|
|
def main() -> int:
|
|
for stream in (sys.stdout, sys.stderr):
|
|
try:
|
|
stream.reconfigure(encoding="utf-8")
|
|
except Exception:
|
|
pass
|
|
parser = argparse.ArgumentParser(description="Stamp text or an image onto PDF pages.")
|
|
parser.add_argument("pdf", help="Input PDF path")
|
|
parser.add_argument("-o", "--output", required=True, help="Output PDF path")
|
|
what = parser.add_mutually_exclusive_group(required=True)
|
|
what.add_argument("--text", help="Text to stamp")
|
|
what.add_argument("--image", help="Image file to stamp (PNG/JPEG)")
|
|
parser.add_argument("--x", type=float, required=True, help="X in points (origin bottom-left)")
|
|
parser.add_argument("--y", type=float, required=True, help="Y in points")
|
|
parser.add_argument("--pages", default="1-", help="1-based ranges, e.g. '1-3,5' (default: all)")
|
|
parser.add_argument("--font", default="Helvetica", help="Font name (default Helvetica)")
|
|
parser.add_argument("--font-size", type=float, default=24, help="Font size in points")
|
|
parser.add_argument("--color", default="#000000", help="Text color as #RRGGBB")
|
|
parser.add_argument("--rotation", type=float, default=0, help="Degrees counterclockwise")
|
|
parser.add_argument("--opacity", type=float, default=1.0, help="0.0-1.0 (default 1.0)")
|
|
parser.add_argument("--width", type=float, help="Image width in points")
|
|
parser.add_argument("--height", type=float, help="Image height in points")
|
|
parser.add_argument("--under", action="store_true",
|
|
help="Place the stamp under existing content instead of over it")
|
|
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 reportlab'",
|
|
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:
|
|
pages = set(parse_pages(args.pages, len(reader.pages)))
|
|
except ValueError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
return 4
|
|
|
|
writer = PdfWriter()
|
|
overlay_cache: dict[tuple[float, float], object] = {}
|
|
for idx, page in enumerate(reader.pages, start=1):
|
|
if idx in pages:
|
|
size = (float(page.mediabox.width), float(page.mediabox.height))
|
|
if size not in overlay_cache:
|
|
overlay_pdf = PdfReader(io.BytesIO(build_overlay(args, *size)))
|
|
overlay_cache[size] = overlay_pdf.pages[0]
|
|
stamp = overlay_cache[size]
|
|
if args.under:
|
|
page.merge_page(stamp, over=False)
|
|
else:
|
|
page.merge_page(stamp)
|
|
writer.add_page(page)
|
|
with open(args.output, "wb") as fh:
|
|
writer.write(fh)
|
|
print(json.dumps({"output": args.output, "stamped_pages": sorted(pages),
|
|
"kind": "text" if args.text else "image"}, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|