Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user