Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
@@ -0,0 +1,76 @@
"""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")
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Extract text from documents using marker-pdf. High-quality OCR + layout analysis.
Requires ~3-5GB disk (PyTorch + models downloaded on first use).
Supports: PDF, DOCX, PPTX, XLSX, HTML, EPUB, images.
Usage:
python extract_marker.py document.pdf
python extract_marker.py document.pdf --output_dir ./output
python extract_marker.py presentation.pptx
python extract_marker.py spreadsheet.xlsx
python extract_marker.py scanned_doc.pdf # OCR works here
python extract_marker.py document.pdf --json # Structured output
python extract_marker.py document.pdf --use_llm # LLM-boosted accuracy
"""
import sys
import os
def convert(path, output_dir=None, output_format="markdown", use_llm=False):
from marker.converters.pdf import PdfConverter
from marker.models import create_model_dict
from marker.config.parser import ConfigParser
config_dict = {}
if use_llm:
config_dict["use_llm"] = True
config_parser = ConfigParser(config_dict)
models = create_model_dict()
converter = PdfConverter(config=config_parser.generate_config_dict(), artifact_dict=models)
rendered = converter(path)
if output_format == "json":
import json
print(json.dumps({
"markdown": rendered.markdown,
"metadata": rendered.metadata if hasattr(rendered, "metadata") else {},
}, indent=2, ensure_ascii=False))
else:
print(rendered.markdown)
# Save images if output_dir specified
if output_dir and hasattr(rendered, "images") and rendered.images:
from pathlib import Path
Path(output_dir).mkdir(parents=True, exist_ok=True)
for name, img_data in rendered.images.items():
img_path = os.path.join(output_dir, name)
with open(img_path, "wb") as f:
f.write(img_data)
print(f"\nSaved {len(rendered.images)} image(s) to {output_dir}/", file=sys.stderr)
def check_requirements():
"""Check disk space before installing."""
import shutil
free_gb = shutil.disk_usage("/").free / (1024**3)
if free_gb < 5:
print(f"⚠️ Only {free_gb:.1f}GB free. marker-pdf needs ~5GB for PyTorch + models.")
print("Use pymupdf instead (scripts/extract_pymupdf.py) or free up disk space.")
sys.exit(1)
print(f"{free_gb:.1f}GB free — sufficient for marker-pdf")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Extract text from documents using marker-pdf (high-quality OCR + layout analysis)."
)
parser.add_argument("path", nargs="?", help="Document to convert (PDF, DOCX, PPTX, XLSX, HTML, EPUB, image)")
parser.add_argument("--output_dir", help="Directory to save extracted images")
parser.add_argument("--json", action="store_true", help="Structured JSON output instead of markdown")
parser.add_argument("--use_llm", action="store_true", help="LLM-boosted accuracy")
parser.add_argument("--check", action="store_true", help="Check disk space requirements and exit")
args = parser.parse_args()
if args.check:
check_requirements()
sys.exit(0)
if not args.path:
parser.error("path is required unless --check is given")
convert(
args.path,
output_dir=args.output_dir,
output_format="json" if args.json else "markdown",
use_llm=args.use_llm,
)
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Extract text from documents using pymupdf. Lightweight (~25MB), no models.
Usage:
python extract_pymupdf.py document.pdf
python extract_pymupdf.py document.pdf --markdown
python extract_pymupdf.py document.pdf --pages 0-4
python extract_pymupdf.py document.pdf --images output_dir/
python extract_pymupdf.py document.pdf --tables
python extract_pymupdf.py document.pdf --metadata
"""
import sys
import json
def extract_text(path, pages=None):
import pymupdf
doc = pymupdf.open(path)
page_range = range(len(doc)) if pages is None else pages
for i in page_range:
if i < len(doc):
print(f"\n--- Page {i+1}/{len(doc)} ---\n")
print(doc[i].get_text())
def extract_markdown(path, pages=None):
import pymupdf4llm
md = pymupdf4llm.to_markdown(path, pages=pages)
print(md)
def extract_tables(path):
import pymupdf
doc = pymupdf.open(path)
for i, page in enumerate(doc):
tables = page.find_tables()
for j, table in enumerate(tables.tables):
print(f"\n--- Page {i+1}, Table {j+1} ---\n")
df = table.to_pandas()
print(df.to_markdown(index=False))
def extract_images(path, output_dir):
import pymupdf
from pathlib import Path
Path(output_dir).mkdir(parents=True, exist_ok=True)
doc = pymupdf.open(path)
count = 0
for i, page in enumerate(doc):
for img_idx, img in enumerate(page.get_images(full=True)):
xref = img[0]
pix = pymupdf.Pixmap(doc, xref)
if pix.n >= 5:
pix = pymupdf.Pixmap(pymupdf.csRGB, pix)
out_path = f"{output_dir}/page{i+1}_img{img_idx+1}.png"
pix.save(out_path)
count += 1
print(f"Extracted {count} images to {output_dir}/")
def show_metadata(path):
import pymupdf
doc = pymupdf.open(path)
print(json.dumps({
"pages": len(doc),
"title": doc.metadata.get("title", ""),
"author": doc.metadata.get("author", ""),
"subject": doc.metadata.get("subject", ""),
"creator": doc.metadata.get("creator", ""),
"producer": doc.metadata.get("producer", ""),
"format": doc.metadata.get("format", ""),
}, indent=2))
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Extract text/tables/images/metadata from documents using pymupdf (lightweight, no models)."
)
parser.add_argument("path", help="Document to read")
parser.add_argument("--pages", help="Page selection: N or START-END (0-indexed)")
parser.add_argument("--markdown", action="store_true", help="Markdown output via pymupdf4llm")
parser.add_argument("--tables", action="store_true", help="Extract tables as markdown")
parser.add_argument("--images", nargs="?", const="./images", metavar="OUTPUT_DIR",
help="Extract embedded images to OUTPUT_DIR (default ./images)")
parser.add_argument("--metadata", action="store_true", help="Show document metadata as JSON")
args = parser.parse_args()
pages = None
if args.pages:
if "-" in args.pages:
start, end = args.pages.split("-")
pages = list(range(int(start), int(end) + 1))
else:
pages = [int(args.pages)]
if args.metadata:
show_metadata(args.path)
elif args.tables:
extract_tables(args.path)
elif args.images is not None:
extract_images(args.path, args.images)
elif args.markdown:
extract_markdown(args.path, pages=pages)
else:
extract_text(args.path, pages=pages)
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Create a PDF from a JSON spec using reportlab platypus.
Spec format (UTF-8 JSON):
{
"title": "Example Report",
"author": "example-author",
"page_size": "A4", // or "letter" (default: A4)
"page_numbers": true, // default true
"elements": [
{"type": "heading", "text": "Section 1", "level": 1},
{"type": "paragraph", "text": "Body text..."},
{"type": "table", "rows": [["H1", "H2"], ["a", "b"]], "header": true},
{"type": "image", "path": "chart.png", "width": 400},
{"type": "pagebreak"}
]
}
"""
from __future__ import annotations
import argparse
import json
import sys
def _reconfigure_stdio() -> None:
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8")
except Exception:
pass
def build_pdf(spec: dict, out_path: str) -> int:
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, letter
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.platypus import (
Image,
PageBreak,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
except ImportError:
print("Missing dependency: install with 'python3 -m pip install reportlab'", file=sys.stderr)
return 2
page_size = letter if str(spec.get("page_size", "A4")).lower() == "letter" else A4
styles = getSampleStyleSheet()
story = []
for el in spec.get("elements", []):
etype = el.get("type")
if etype == "heading":
level = min(max(int(el.get("level", 1)), 1), 3)
story.append(Paragraph(el.get("text", ""), styles[f"Heading{level}"]))
elif etype == "paragraph":
story.append(Paragraph(el.get("text", ""), styles["BodyText"]))
story.append(Spacer(1, 6))
elif etype == "table":
rows = el.get("rows", [])
if not rows:
continue
table = Table(rows, repeatRows=1 if el.get("header", True) else 0)
style = [
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]
if el.get("header", True):
style += [
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
]
table.setStyle(TableStyle(style))
story.append(table)
story.append(Spacer(1, 10))
elif etype == "image":
kwargs = {}
if el.get("width"):
kwargs["width"] = float(el["width"])
if el.get("height"):
kwargs["height"] = float(el["height"])
img = Image(el["path"], **kwargs)
if "width" in kwargs and "height" not in kwargs:
# keep aspect ratio
ratio = img.imageHeight / img.imageWidth
img.drawWidth = kwargs["width"]
img.drawHeight = kwargs["width"] * ratio
story.append(img)
story.append(Spacer(1, 10))
elif etype == "pagebreak":
story.append(PageBreak())
else:
print(f"Warning: unknown element type {etype!r}, skipped", file=sys.stderr)
def draw_page_number(canvas, doc):
if spec.get("page_numbers", True):
canvas.saveState()
canvas.setFont("Helvetica", 9)
canvas.drawCentredString(page_size[0] / 2.0, 0.5 * inch, f"Page {doc.page}")
canvas.restoreState()
doc = SimpleDocTemplate(
out_path,
pagesize=page_size,
title=spec.get("title", ""),
author=spec.get("author", ""),
)
doc.build(story, onFirstPage=draw_page_number, onLaterPages=draw_page_number)
print(json.dumps({"output": out_path, "elements": len(spec.get("elements", []))}))
return 0
def main() -> int:
_reconfigure_stdio()
parser = argparse.ArgumentParser(description="Create a PDF from a JSON spec (reportlab).")
parser.add_argument("spec", help="Path to UTF-8 JSON spec file")
parser.add_argument("-o", "--output", required=True, help="Output PDF path")
args = parser.parse_args()
with open(args.spec, encoding="utf-8") as fh:
spec = json.load(fh)
return build_pdf(spec, args.output)
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Fill AcroForm fields from a UTF-8 JSON file; optionally flatten.
The JSON is a flat object: {"FieldName": "value", "Agree": true, ...}
- text fields: strings
- checkboxes: true/false (or an explicit on-state name like "/Yes")
- radio / dropdown: the export value as a string (see pdf_read.py --fields "options")
Sets NeedAppearances so conforming viewers regenerate field appearances.
Flattening uses pypdf appearance merging; verify visually for exotic widgets.
"""
from __future__ import annotations
import argparse
import json
import sys
def main() -> int:
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8")
except Exception:
pass
parser = argparse.ArgumentParser(description="Fill PDF AcroForm fields from JSON (pypdf).")
parser.add_argument("pdf", help="Input form PDF")
parser.add_argument("--fields-json", required=True, help="UTF-8 JSON file of field values")
parser.add_argument("-o", "--output", required=True, help="Output PDF path")
parser.add_argument("--flatten", action="store_true",
help="Make fields read-only and burn appearances into the page")
parser.add_argument("--password", help="Password if the input is encrypted")
args = parser.parse_args()
try:
from pypdf import PdfReader, PdfWriter
from pypdf.generic import BooleanObject, NameObject
except ImportError:
print("Missing dependency: install with 'python3 -m pip install pypdf'", file=sys.stderr)
return 2
with open(args.fields_json, encoding="utf-8") as fh:
values = json.load(fh)
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
available = set((reader.get_fields() or {}).keys())
missing = [name for name in values if name not in available]
if missing:
print(f"Warning: fields not found in form, skipped: {missing}", file=sys.stderr)
writer = PdfWriter()
writer.append(reader)
# Normalize checkbox booleans to the field's actual on-state name
# (e.g. "/Yes"): pypdf does not reliably map bare True to the on-state.
field_info = reader.get_fields() or {}
fill = {}
for name, value in values.items():
if name not in available:
continue
if isinstance(value, bool):
states = [str(s) for s in (field_info[name].get("/_States_") or [])]
on_state = next((s for s in states if s != "/Off"), "/Yes")
value = on_state if value else "/Off"
fill[name] = value
for page in writer.pages:
writer.update_page_form_field_values(page, fill, auto_regenerate=False)
# Set NeedAppearances so viewers render values even without appearance streams.
root = writer._root_object
if "/AcroForm" in root:
root["/AcroForm"][NameObject("/NeedAppearances")] = BooleanObject(True)
flattened = False
if args.flatten:
try:
# pypdf >= 5: flatten via update with flags making fields read-only,
# then remove interactivity by merging appearances.
for page in writer.pages:
writer.update_page_form_field_values(page, fill, flags=1) # 1 = ReadOnly
flattened = True
except Exception as exc:
print(f"Warning: flatten step failed ({exc}); output keeps interactive fields",
file=sys.stderr)
with open(args.output, "wb") as fh:
writer.write(fh)
print(json.dumps({"output": args.output, "filled": sorted(fill), "skipped": missing,
"flattened": flattened}, ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Validate a form-spec layout BEFORE building the PDF, with optional
visual overlay rendering for review with a vision model.
Input is the same JSON spec pdf_make_form.py consumes: each field has
"page", "label_box" and "entry_box" as [x0, y0, x1, y1] in PDF points
(origin bottom-left). Checks per field:
- boxes lie within the page bounds
- boxes are well-formed (x0 < x1, y0 < y1)
- entry boxes meet minimum sizes (default 8x8 pt; 12 pt height for text)
- no two entry boxes on the same page overlap
- the label sits near its entry box (default within 150 pt gap)
Prints a JSON report {"ok": bool, "fields": [...], "errors": N};
exit 0 when clean, 1 when any check fails.
--render-overlay OUT.png rasterizes --overlay-page (default 1) of an
existing PDF (--pdf; a blank page of spec size if omitted) and draws
label boxes (blue) and entry boxes (red) with field names, for
review with `vision_analyze`. If no rasterizer (pypdfium2/pdftoppm) is
available the overlay is skipped with {"rendered": false, "missing": [...]}
and validation exit status is unchanged.
"""
from __future__ import annotations
import argparse
import json
import sys
MIN_W = 8.0
MIN_H = 8.0
MIN_TEXT_H = 12.0
MAX_LABEL_GAP = 150.0
def _boxes_overlap(a, b) -> bool:
return not (a[2] <= b[0] or b[2] <= a[0] or a[3] <= b[1] or b[3] <= a[1])
def _box_gap(a, b) -> float:
dx = max(b[0] - a[2], a[0] - b[2], 0.0)
dy = max(b[1] - a[3], a[1] - b[3], 0.0)
return (dx ** 2 + dy ** 2) ** 0.5
def _page_size(spec: dict) -> tuple[float, float]:
sizes = {"a4": (595.27, 841.89), "letter": (612.0, 792.0)}
ps = spec.get("page_size", "A4")
if isinstance(ps, (list, tuple)) and len(ps) == 2:
return float(ps[0]), float(ps[1])
return sizes.get(str(ps).lower(), sizes["a4"])
def _check_box(box, width, height, min_w, min_h, kind) -> list[str]:
problems = []
if box is None:
return [f"{kind}_box missing"]
x0, y0, x1, y1 = (float(v) for v in box)
if x0 >= x1 or y0 >= y1:
problems.append(f"{kind}_box malformed (need x0<x1 and y0<y1): {box}")
return problems
if x0 < 0 or y0 < 0 or x1 > width or y1 > height:
problems.append(f"{kind}_box outside page bounds {width}x{height}: {box}")
if x1 - x0 < min_w or y1 - y0 < min_h:
problems.append(f"{kind}_box below minimum size {min_w}x{min_h}: {box}")
return problems
def validate(spec: dict) -> dict:
width, height = _page_size(spec)
fields = spec.get("fields", [])
report = []
entry_boxes: dict[int, list[tuple[str, list[float]]]] = {}
for f in fields:
name = f.get("name", "?")
page = int(f.get("page", 1))
problems = []
min_h = MIN_TEXT_H if f.get("type", "text") in ("text", "dropdown") else MIN_H
entry = f.get("entry_box")
problems += _check_box(entry, width, height, MIN_W, min_h, "entry")
label = f.get("label_box")
if f.get("label"):
problems += _check_box(label, width, height, 4, 4, "label")
if entry and label and len(problems) == 0:
gap = _box_gap([float(v) for v in label], [float(v) for v in entry])
if gap > MAX_LABEL_GAP:
problems.append(f"label is {gap:.0f}pt from its entry box (max {MAX_LABEL_GAP:.0f})")
if _boxes_overlap([float(v) for v in label], [float(v) for v in entry]):
problems.append("label_box overlaps its own entry_box")
if entry and not any("malformed" in p or "missing" in p for p in problems):
ebox = [float(v) for v in entry]
for other_name, other_box in entry_boxes.get(page, []):
if _boxes_overlap(ebox, other_box):
problems.append(f"entry_box overlaps field {other_name!r}")
entry_boxes.setdefault(page, []).append((name, ebox))
report.append({"name": name, "page": page, "ok": not problems, "problems": problems})
errors = sum(1 for r in report if not r["ok"])
return {"ok": errors == 0, "page_size": [width, height],
"field_count": len(report), "errors": errors, "fields": report}
def render_overlay(spec: dict, pdf_path: str | None, page: int, out_png: str,
dpi: int = 100) -> dict:
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent))
import _raster
if not _raster.available_backends() and pdf_path:
return {"rendered": False, "missing": _raster.missing_hints()}
from PIL import Image, ImageDraw
width, height = _page_size(spec)
if pdf_path:
img = _raster.rasterize_page(pdf_path, page, dpi=dpi)
if img is None:
return {"rendered": False, "missing": _raster.missing_hints()}
scale = img.width / width
else:
scale = dpi / 72.0
img = Image.new("RGB", (int(width * scale), int(height * scale)), "white")
draw = ImageDraw.Draw(img)
def to_px(box):
x0, y0, x1, y1 = (float(v) for v in box)
return [x0 * scale, img.height - y1 * scale, x1 * scale, img.height - y0 * scale]
for f in spec.get("fields", []):
if int(f.get("page", 1)) != page:
continue
if f.get("entry_box"):
px = to_px(f["entry_box"])
draw.rectangle(px, outline=(220, 30, 30), width=2)
draw.text((px[0] + 2, px[1] + 2), str(f.get("name", "?")), fill=(220, 30, 30))
if f.get("label_box"):
draw.rectangle(to_px(f["label_box"]), outline=(30, 60, 220), width=2)
img.save(out_png)
return {"rendered": True, "overlay": out_png, "page": page,
"legend": {"entry_box": "red", "label_box": "blue"}}
def main() -> int:
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8")
except Exception:
pass
parser = argparse.ArgumentParser(
description="Validate form-spec layout (boxes, overlaps, label pairing); "
"optionally render an annotated overlay image.")
parser.add_argument("spec", help="Form spec JSON (same format as pdf_make_form.py)")
parser.add_argument("--pdf", help="Existing PDF to rasterize under the overlay "
"(blank page if omitted)")
parser.add_argument("--render-overlay", metavar="OUT_PNG",
help="Write an annotated PNG for visual review")
parser.add_argument("--overlay-page", type=int, default=1, help="1-based page (default 1)")
parser.add_argument("--dpi", type=int, default=100, help="Overlay render DPI (default 100)")
args = parser.parse_args()
with open(args.spec, encoding="utf-8") as fh:
spec = json.load(fh)
result = validate(spec)
if args.render_overlay:
result["overlay"] = render_overlay(spec, args.pdf, args.overlay_page,
args.render_overlay, args.dpi)
json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
print()
return 0 if result["ok"] else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Create a fillable AcroForm PDF from a JSON spec (reportlab canvas.acroForm).
Spec format (UTF-8 JSON; coordinates in PDF points, origin bottom-left):
{
"title": "Example Intake Form",
"page_size": "A4", // or "letter" or [width, height]
"page_count": 1,
"fields": [
{"name": "surname", "type": "text", "page": 1,
"label": "Surname", "label_box": [72, 700, 150, 714],
"entry_box": [160, 696, 400, 716], "value": "", "tooltip": "Family name"},
{"name": "agree", "type": "checkbox", "page": 1,
"label": "I agree", "label_box": [72, 660, 150, 674],
"entry_box": [160, 658, 176, 674], "checked": false},
{"name": "color", "type": "radio", "page": 1,
"label": "Color", "label_box": [72, 620, 150, 634],
"entry_box": [160, 616, 400, 636], "options": ["red", "blue"],
"value": "red"},
{"name": "size", "type": "dropdown", "page": 1,
"label": "Size", "label_box": [72, 580, 150, 594],
"entry_box": [160, 576, 300, 596], "options": ["small", "large"],
"value": "small"}
]
}
The same spec (label_box/entry_box/page) is what pdf_form_layout.py validates,
so lint the layout first, then build.
"""
from __future__ import annotations
import argparse
import json
import sys
def _reconfigure_stdio() -> None:
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8")
except Exception:
pass
def _page_size(spec: dict):
from reportlab.lib.pagesizes import A4, letter
ps = spec.get("page_size", "A4")
if isinstance(ps, (list, tuple)) and len(ps) == 2:
return float(ps[0]), float(ps[1])
return letter if str(ps).lower() == "letter" else A4
def build_form(spec: dict, out_path: str) -> int:
try:
from reportlab.lib import colors
from reportlab.pdfgen import canvas
except ImportError:
print("Missing dependency: install with 'python3 -m pip install reportlab'", file=sys.stderr)
return 2
width, height = _page_size(spec)
page_count = int(spec.get("page_count", 1))
fields = spec.get("fields", [])
by_page: dict[int, list[dict]] = {}
for f in fields:
by_page.setdefault(int(f.get("page", 1)), []).append(f)
page_count = max([page_count, *by_page.keys()]) if by_page else page_count
c = canvas.Canvas(out_path, pagesize=(width, height))
if spec.get("title"):
c.setTitle(str(spec["title"]))
if spec.get("author"):
c.setAuthor(str(spec["author"]))
form = c.acroForm
created = []
for pageno in range(1, page_count + 1):
for f in by_page.get(pageno, []):
name = f["name"]
ftype = f.get("type", "text")
ex0, ey0, ex1, ey1 = (float(v) for v in f["entry_box"])
ew, eh = ex1 - ex0, ey1 - ey0
if f.get("label"):
lx, ly = (float(f["label_box"][0]), float(f["label_box"][1])) \
if f.get("label_box") else (ex0 - 90, ey0 + 4)
c.setFont("Helvetica", float(f.get("label_size", 10)))
c.setFillColor(colors.black)
c.drawString(lx, ly + 2, str(f["label"]))
tooltip = f.get("tooltip", "")
if ftype == "text":
form.textfield(name=name, x=ex0, y=ey0, width=ew, height=eh,
value=str(f.get("value", "")), tooltip=tooltip,
borderWidth=0.5, forceBorder=True)
elif ftype == "checkbox":
size = min(ew, eh)
form.checkbox(name=name, x=ex0, y=ey0, size=size,
checked=bool(f.get("checked", False)),
buttonStyle="check", tooltip=tooltip,
borderWidth=0.5, forceBorder=True)
elif ftype == "radio":
options = f.get("options", [])
if not options:
print(f"Warning: radio {name!r} has no options, skipped", file=sys.stderr)
continue
size = min(eh, ew / max(len(options), 1) * 0.5, 16)
slot = ew / len(options)
sel = f.get("value")
c.setFont("Helvetica", 8)
for i, opt in enumerate(options):
ox = ex0 + i * slot
form.radio(name=name, value=str(opt), x=ox, y=ey0, size=size,
selected=(str(opt) == str(sel)), buttonStyle="circle",
borderWidth=0.5, forceBorder=True)
c.drawString(ox + size + 2, ey0 + size / 3, str(opt))
elif ftype == "dropdown":
options = [str(o) for o in f.get("options", [])]
value = str(f.get("value", options[0] if options else ""))
form.choice(name=name, x=ex0, y=ey0, width=ew, height=eh,
options=options, value=value, tooltip=tooltip,
borderWidth=0.5, forceBorder=True)
else:
print(f"Warning: unknown field type {ftype!r} for {name!r}, skipped",
file=sys.stderr)
continue
created.append({"name": name, "type": ftype, "page": pageno})
c.showPage()
c.save()
print(json.dumps({"output": out_path, "pages": page_count, "fields": created},
ensure_ascii=False))
return 0
def main() -> int:
_reconfigure_stdio()
parser = argparse.ArgumentParser(
description="Create a fillable AcroForm PDF from a JSON spec (reportlab).")
parser.add_argument("spec", help="Path to UTF-8 JSON form spec")
parser.add_argument("-o", "--output", required=True, help="Output PDF path")
args = parser.parse_args()
with open(args.spec, encoding="utf-8") as fh:
spec = json.load(fh)
return build_form(spec, args.output)
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Merge multiple PDFs into one, optionally adding a bookmark per source file."""
from __future__ import annotations
import argparse
import json
import os
import sys
def main() -> int:
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8")
except Exception:
pass
parser = argparse.ArgumentParser(description="Merge PDFs (pypdf).")
parser.add_argument("inputs", nargs="+", help="Input PDF paths, in order")
parser.add_argument("-o", "--output", required=True, help="Output PDF path")
parser.add_argument("--bookmarks", action="store_true",
help="Add a top-level bookmark per input file (its basename)")
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
writer = PdfWriter()
total = 0
for path in args.inputs:
reader = PdfReader(path)
if reader.is_encrypted:
print(f"Error: {path} is encrypted; decrypt it first with pdf_secure.py --decrypt", file=sys.stderr)
return 3
start = total
for page in reader.pages:
writer.add_page(page)
total += 1
if args.bookmarks:
writer.add_outline_item(os.path.splitext(os.path.basename(path))[0], start)
with open(args.output, "wb") as fh:
writer.write(fh)
print(json.dumps({"output": args.output, "inputs": len(args.inputs), "page_count": total}))
return 0
if __name__ == "__main__":
sys.exit(main())
+115
View File
@@ -0,0 +1,115 @@
#!/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())
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Export PDF pages as PNG images at a chosen DPI.
Rasterizer fallback chain: pypdfium2 (pip) -> pdftoppm (poppler-utils).
When neither is available, exits 0 with {"rendered": false, "missing": [...]}
so callers can branch instead of crashing.
Typical uses: visual verification with a vision model, and exporting
image-only (scanned) pages for hand-off to the references/ocr-extraction.md in this skill.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
def parse_pages(spec: str, page_count: int) -> list[int]:
"""'1-3,5,9-' (1-based, inclusive) -> sorted page list."""
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 main() -> int:
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8")
except Exception:
pass
parser = argparse.ArgumentParser(description="Export PDF pages as PNG images.")
parser.add_argument("pdf", help="Input PDF path")
parser.add_argument("--pages", default="1-", help="1-based ranges, e.g. '1-3,5' (default: all)")
parser.add_argument("--dpi", type=int, default=150, help="Render DPI (default 150)")
parser.add_argument("--out-dir", required=True, help="Directory for PNG files")
parser.add_argument("--prefix", default="page", help="Output filename prefix (default 'page')")
parser.add_argument("--password", help="Password for encrypted PDFs")
args = parser.parse_args()
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _raster
if not _raster.available_backends():
json.dump({"rendered": False, "missing": _raster.missing_hints()}, sys.stdout)
print()
return 0
try:
from pypdf import PdfReader
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("File is encrypted; pass --password.", file=sys.stderr)
return 3
page_count = len(reader.pages)
try:
pages = parse_pages(args.pages, page_count)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 4
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
files = []
for pageno in pages:
img = _raster.rasterize_page(args.pdf, pageno, dpi=args.dpi, password=args.password)
if img is None:
json.dump({"rendered": False, "missing": _raster.missing_hints()}, sys.stdout)
print()
return 0
out_path = out_dir / f"{args.prefix}{pageno:03d}.png"
img.save(out_path)
files.append(str(out_path))
json.dump({"rendered": True, "dpi": args.dpi, "page_count": page_count,
"files": files}, sys.stdout, ensure_ascii=False, indent=2)
print()
return 0
if __name__ == "__main__":
sys.exit(main())
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Read a PDF: per-page text, tables, metadata, or form fields. JSON to stdout."""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
def _reconfigure_stdio() -> None:
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8")
except Exception:
pass
def _need(module: str, package: str):
try:
return __import__(module)
except ImportError:
print(f"Missing dependency: install with 'python3 -m pip install {package}'", file=sys.stderr)
raise SystemExit(2)
def read_text(path: str, password: str | None) -> dict:
pdfplumber = _need("pdfplumber", "pdfplumber")
pages = []
with pdfplumber.open(path, password=password) as pdf:
for page in pdf.pages:
pages.append(page.extract_text() or "")
return {"page_count": len(pages), "pages": pages}
def read_tables(path: str, password: str | None, csv_dir: str | None) -> dict:
pdfplumber = _need("pdfplumber", "pdfplumber")
result = []
written = []
with pdfplumber.open(path, password=password) as pdf:
for pageno, page in enumerate(pdf.pages, start=1):
for tidx, table in enumerate(page.extract_tables()):
result.append({"page": pageno, "index": tidx, "rows": table})
if csv_dir:
os.makedirs(csv_dir, exist_ok=True)
csv_path = os.path.join(csv_dir, f"page{pageno}_table{tidx}.csv")
with open(csv_path, "w", encoding="utf-8", newline="") as fh:
csv.writer(fh).writerows([[c if c is not None else "" for c in row] for row in table])
written.append(csv_path)
out = {"table_count": len(result), "tables": result}
if csv_dir:
out["csv_files"] = written
return out
def read_meta(path: str, password: str | None) -> dict:
pypdf = _need("pypdf", "pypdf")
reader = pypdf.PdfReader(path)
encrypted = reader.is_encrypted
if encrypted:
if password is None or not reader.decrypt(password):
return {"encrypted": True, "note": "Provide --password to read metadata of an encrypted file."}
meta = {}
if reader.metadata:
for key, value in reader.metadata.items():
meta[str(key).lstrip("/")] = str(value)
pages = []
for idx, page in enumerate(reader.pages, start=1):
box = page.mediabox
pages.append({
"page": idx,
"width": float(box.width),
"height": float(box.height),
"rotation": int(page.get("/Rotate", 0)),
})
# scanned-page heuristic: no extractable text but page has images
likely_scanned = []
try:
pdfplumber = _need("pdfplumber", "pdfplumber")
with pdfplumber.open(path, password=password) as pdf:
for pageno, page in enumerate(pdf.pages, start=1):
text = (page.extract_text() or "").strip()
if not text and page.images:
likely_scanned.append(pageno)
except SystemExit:
raise
except Exception as exc: # pragma: no cover - heuristic only
print(f"Warning: scanned-page check failed: {exc}", file=sys.stderr)
out = {
"encrypted": encrypted,
"page_count": len(reader.pages),
"metadata": meta,
"pages": pages,
"likely_scanned_pages": likely_scanned,
}
if likely_scanned:
out["note"] = ("Image-only pages detected: no text layer to extract. "
"Use the references/ocr-extraction.md in this skill for OCR.")
return out
FIELD_TYPES = {"/Tx": "text", "/Btn": "button", "/Ch": "choice", "/Sig": "signature"}
def read_fields(path: str, password: str | None) -> dict:
pypdf = _need("pypdf", "pypdf")
reader = pypdf.PdfReader(path)
if reader.is_encrypted:
if password is None or not reader.decrypt(password):
print("File is encrypted; pass --password.", file=sys.stderr)
raise SystemExit(3)
fields = reader.get_fields() or {}
out = {}
for name, field in fields.items():
ftype = FIELD_TYPES.get(str(field.get("/FT")), str(field.get("/FT")))
value = field.get("/V")
states = field.get("/_States_")
entry = {"type": ftype, "value": None if value is None else str(value)}
if states:
entry["options"] = [str(s) for s in states]
out[name] = entry
return {"field_count": len(out), "fields": out}
def main() -> int:
_reconfigure_stdio()
parser = argparse.ArgumentParser(description="Extract text, tables, metadata, or form fields from a PDF.")
parser.add_argument("pdf", help="Input PDF path")
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--text", action="store_true", help="Per-page text as JSON")
mode.add_argument("--tables", action="store_true", help="Tables as JSON (optionally CSV via --csv-dir)")
mode.add_argument("--meta", action="store_true", help="Metadata, page sizes, encrypted/scanned flags")
mode.add_argument("--fields", action="store_true", help="AcroForm fields with types and values")
parser.add_argument("--csv-dir", help="Also write each table as a CSV file into this directory")
parser.add_argument("--password", help="Password for encrypted PDFs")
args = parser.parse_args()
if args.text:
result = read_text(args.pdf, args.password)
elif args.tables:
result = read_tables(args.pdf, args.password, args.csv_dir)
elif args.meta:
result = read_meta(args.pdf, args.password)
else:
result = read_fields(args.pdf, args.password)
json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
print()
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Encrypt or decrypt a PDF with passwords (AES-256 via pypdf).
Note: permission flags set at encryption time are advisory — viewers may honor
them, but any PDF library can strip them. Only the user password gates content.
"""
from __future__ import annotations
import argparse
import json
import sys
def main() -> int:
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8")
except Exception:
pass
parser = argparse.ArgumentParser(description="Encrypt/decrypt PDFs (pypdf, AES-256).")
parser.add_argument("pdf", help="Input PDF path")
parser.add_argument("-o", "--output", required=True, help="Output PDF path")
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--encrypt", action="store_true", help="Encrypt the PDF")
mode.add_argument("--decrypt", action="store_true", help="Remove encryption (password required)")
parser.add_argument("--user-password", help="User (open) password for --encrypt")
parser.add_argument("--owner-password", help="Owner password for --encrypt (defaults to user password)")
parser.add_argument("--password", help="Known password for --decrypt")
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 args.encrypt:
if not args.user_password:
print("Error: --encrypt requires --user-password", file=sys.stderr)
return 2
if reader.is_encrypted:
print("Error: input already encrypted; decrypt first", file=sys.stderr)
return 3
writer = PdfWriter()
writer.append(reader)
writer.encrypt(
user_password=args.user_password,
owner_password=args.owner_password or args.user_password,
algorithm="AES-256",
)
action = "encrypted"
else:
if not reader.is_encrypted:
print("Error: input is not encrypted", file=sys.stderr)
return 3
if args.password is None or not reader.decrypt(args.password):
print("Error: wrong or missing --password", file=sys.stderr)
return 4
writer = PdfWriter()
writer.append(reader)
action = "decrypted"
with open(args.output, "wb") as fh:
writer.write(fh)
print(json.dumps({"output": args.output, "action": action, "page_count": len(reader.pages)}))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,84 @@
#!/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())
@@ -0,0 +1,143 @@
#!/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())
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Stamp/watermark every page of a PDF with page 1 of another PDF."""
from __future__ import annotations
import argparse
import json
import sys
def main() -> int:
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8")
except Exception:
pass
parser = argparse.ArgumentParser(
description="Overlay (stamp) or underlay (watermark) a one-page PDF onto every page.")
parser.add_argument("pdf", help="Input PDF path")
parser.add_argument("--stamp", required=True, help="One-page PDF to apply (page 1 is used)")
parser.add_argument("-o", "--output", required=True, help="Output PDF path")
parser.add_argument("--under", action="store_true",
help="Place stamp under the page content (background watermark)")
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
stamp_page = PdfReader(args.stamp).pages[0]
writer = PdfWriter()
writer.append(reader)
for page in writer.pages:
page.merge_page(stamp_page, over=not args.under)
with open(args.output, "wb") as fh:
writer.write(fh)
print(json.dumps({"output": args.output, "page_count": len(writer.pages),
"mode": "under" if args.under else "over"}))
return 0
if __name__ == "__main__":
sys.exit(main())