Created
May 2, 2026 08:09
-
-
Save nickfarrow/cf07f422349e806177bc52eab9e9133a to your computer and use it in GitHub Desktop.
Insert signature into a PDF
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Stamp a signature image onto a PDF — with interactive placement via a GUI preview.""" | |
| import argparse | |
| import io | |
| import sys | |
| import tempfile | |
| from pathlib import Path | |
| from pypdf import PdfReader, PdfWriter | |
| from reportlab.lib.utils import ImageReader | |
| from reportlab.pdfgen import canvas | |
| def stamp_pdf(pdf_path: str, image_path: str, output_path: str, | |
| page_num: int, x: float, y: float, | |
| width: float | None, height: float | None): | |
| reader = PdfReader(pdf_path) | |
| writer = PdfWriter() | |
| if page_num < 1 or page_num > len(reader.pages): | |
| sys.exit(f"Page {page_num} out of range (1-{len(reader.pages)})") | |
| target_page = reader.pages[page_num - 1] | |
| page_box = target_page.mediabox | |
| page_width = float(page_box.width) | |
| page_height = float(page_box.height) | |
| img = ImageReader(image_path) | |
| img_w, img_h = img.getSize() | |
| aspect = img_w / img_h | |
| if width and height: | |
| stamp_w, stamp_h = width, height | |
| elif width: | |
| stamp_w = width | |
| stamp_h = width / aspect | |
| elif height: | |
| stamp_h = height | |
| stamp_w = height * aspect | |
| else: | |
| stamp_w = 150 | |
| stamp_h = 150 / aspect | |
| buf = io.BytesIO() | |
| c = canvas.Canvas(buf, pagesize=(page_width, page_height)) | |
| c.drawImage(image_path, x, y, stamp_w, stamp_h, mask='auto') | |
| c.save() | |
| buf.seek(0) | |
| overlay_reader = PdfReader(buf) | |
| overlay_page = overlay_reader.pages[0] | |
| for i, page in enumerate(reader.pages): | |
| if i == page_num - 1: | |
| page.merge_page(overlay_page) | |
| writer.add_page(page) | |
| if reader.metadata: | |
| writer.add_metadata(reader.metadata) | |
| with open(output_path, 'wb') as f: | |
| writer.write(f) | |
| print(f"Stamped '{image_path}' onto page {page_num} at ({x}, {y})") | |
| print(f" Size: {stamp_w:.1f} x {stamp_h:.1f} pt") | |
| print(f" Saved to: {output_path}") | |
| def interactive_place(pdf_path: str, image_path: str, page_num: int, | |
| stamp_w: float, stamp_h: float): | |
| """Open a tkinter window showing the PDF page, let user click to place the signature.""" | |
| import tkinter as tk | |
| from PIL import Image | |
| # Render PDF page to image via poppler (pdftoppm) | |
| import subprocess | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| subprocess.run( | |
| ["pdftoppm", "-png", "-f", str(page_num), "-l", str(page_num), | |
| "-r", "150", pdf_path, f"{tmpdir}/page"], | |
| check=True, capture_output=True, | |
| ) | |
| # pdftoppm outputs page-01.png or page-1.png depending on version | |
| rendered = list(Path(tmpdir).glob("page-*.png")) | |
| if not rendered: | |
| sys.exit("Failed to render PDF page. Is poppler-utils (pdftoppm) installed?") | |
| page_img = Image.open(rendered[0]) | |
| # Get PDF page dimensions for coordinate mapping | |
| reader = PdfReader(pdf_path) | |
| page_box = reader.pages[page_num - 1].mediabox | |
| pdf_w = float(page_box.width) | |
| pdf_h = float(page_box.height) | |
| img_w, img_h = page_img.size | |
| # Scale to fit on screen if needed | |
| max_screen_h = 900 | |
| max_screen_w = 1200 | |
| display_scale = min(1.0, max_screen_w / img_w, max_screen_h / img_h) | |
| display_w = int(img_w * display_scale) | |
| display_h = int(img_h * display_scale) | |
| if display_scale < 1.0: | |
| page_img = page_img.resize((display_w, display_h), Image.LANCZOS) | |
| # Pixel-to-PDF coordinate scale factors | |
| px_to_pdf_x = pdf_w / display_w | |
| px_to_pdf_y = pdf_h / display_h | |
| # Stamp size in display pixels | |
| stamp_display_w = stamp_w / px_to_pdf_x | |
| stamp_display_h = stamp_h / px_to_pdf_y | |
| # Load signature image for preview overlay | |
| sig_img = Image.open(image_path).convert("RGBA") | |
| sig_img = sig_img.resize((int(stamp_display_w), int(stamp_display_h)), Image.LANCZOS) | |
| result = {} | |
| root = tk.Tk() | |
| root.title("Click to place signature — scroll to resize — Enter to confirm — Escape to cancel") | |
| from PIL import ImageTk | |
| canvas_widget = tk.Canvas(root, width=display_w, height=display_h) | |
| canvas_widget.pack() | |
| bg_photo = ImageTk.PhotoImage(page_img) | |
| canvas_widget.create_image(0, 0, anchor=tk.NW, image=bg_photo) | |
| sig_photo = ImageTk.PhotoImage(sig_img) | |
| sig_id = canvas_widget.create_image(-100, -100, anchor=tk.SW, image=sig_photo) | |
| state = { | |
| "stamp_w": stamp_w, | |
| "stamp_h": stamp_h, | |
| "sig_img_orig": Image.open(image_path).convert("RGBA"), | |
| "placed": False, | |
| "last_px": 0, | |
| "last_py": 0, | |
| } | |
| def update_sig_preview(): | |
| """Rebuild the signature preview at the current stamp size.""" | |
| sdw = state["stamp_w"] / px_to_pdf_x | |
| sdh = state["stamp_h"] / px_to_pdf_y | |
| resized = state["sig_img_orig"].resize((max(1, int(sdw)), max(1, int(sdh))), Image.LANCZOS) | |
| state["sig_photo"] = ImageTk.PhotoImage(resized) | |
| canvas_widget.itemconfig(sig_id, image=state["sig_photo"]) | |
| def on_move(event): | |
| canvas_widget.coords(sig_id, event.x, event.y) | |
| state["last_px"] = event.x | |
| state["last_py"] = event.y | |
| def on_click(event): | |
| state["placed"] = True | |
| state["last_px"] = event.x | |
| state["last_py"] = event.y | |
| def on_scroll(event): | |
| # Scroll up = bigger, scroll down = smaller | |
| factor = 1.1 if event.delta > 0 or event.num == 4 else 0.9 | |
| state["stamp_w"] *= factor | |
| state["stamp_h"] *= factor | |
| update_sig_preview() | |
| canvas_widget.coords(sig_id, state["last_px"], state["last_py"]) | |
| def on_confirm(event): | |
| if not state["placed"]: | |
| return | |
| px, py = state["last_px"], state["last_py"] | |
| # Convert from display pixels (origin top-left, anchor SW) to PDF points (origin bottom-left) | |
| pdf_x = px * px_to_pdf_x | |
| pdf_y = (display_h - py) * px_to_pdf_y # anchor is SW, so py is the bottom of the stamp | |
| result["x"] = pdf_x | |
| result["y"] = pdf_y | |
| result["w"] = state["stamp_w"] | |
| result["h"] = state["stamp_h"] | |
| root.destroy() | |
| def on_cancel(event): | |
| root.destroy() | |
| canvas_widget.bind("<Motion>", on_move) | |
| canvas_widget.bind("<Button-1>", on_click) | |
| # Linux scroll events | |
| canvas_widget.bind("<Button-4>", on_scroll) | |
| canvas_widget.bind("<Button-5>", on_scroll) | |
| # Windows/Mac scroll | |
| canvas_widget.bind("<MouseWheel>", on_scroll) | |
| root.bind("<Return>", on_confirm) | |
| root.bind("<Escape>", on_cancel) | |
| root.mainloop() | |
| if not result: | |
| sys.exit("Cancelled.") | |
| return result["x"], result["y"], result["w"], result["h"] | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="Stamp a signature image onto a PDF.", | |
| epilog="Coordinates use PDF points (72pt = 1 inch), origin at bottom-left.") | |
| parser.add_argument("pdf", help="Input PDF file") | |
| parser.add_argument("image", help="Signature image (PNG with transparency works best)") | |
| parser.add_argument("-o", "--output", help="Output PDF (default: <input>_signed.pdf)") | |
| parser.add_argument("-p", "--page", type=int, default=1, help="Page number to stamp (default: 1)") | |
| parser.add_argument("-x", type=float, help="X position in points from left") | |
| parser.add_argument("-y", type=float, help="Y position in points from bottom") | |
| parser.add_argument("-W", "--width", type=float, help="Stamp width in points (default: 150)") | |
| parser.add_argument("-H", "--height", type=float, help="Stamp height in points") | |
| parser.add_argument("-i", "--interactive", action="store_true", | |
| help="Interactively place the signature with a GUI preview (default if -x/-y not given)") | |
| args = parser.parse_args() | |
| pdf_path = Path(args.pdf) | |
| if not pdf_path.exists(): | |
| sys.exit(f"PDF not found: {pdf_path}") | |
| image_path = Path(args.image) | |
| if not image_path.exists(): | |
| sys.exit(f"Image not found: {image_path}") | |
| output = args.output or str(pdf_path.with_stem(pdf_path.stem + "_signed")) | |
| # Determine initial stamp size | |
| img = ImageReader(str(image_path)) | |
| img_w, img_h = img.getSize() | |
| aspect = img_w / img_h | |
| if args.width and args.height: | |
| stamp_w, stamp_h = args.width, args.height | |
| elif args.width: | |
| stamp_w = args.width | |
| stamp_h = args.width / aspect | |
| elif args.height: | |
| stamp_h = args.height | |
| stamp_w = args.height * aspect | |
| else: | |
| stamp_w = 150 | |
| stamp_h = 150 / aspect | |
| # Interactive mode if explicitly requested or if no coordinates given | |
| use_interactive = args.interactive or (args.x is None and args.y is None) | |
| if use_interactive: | |
| x, y, stamp_w, stamp_h = interactive_place( | |
| str(pdf_path), str(image_path), args.page, stamp_w, stamp_h) | |
| else: | |
| x = args.x or 100 | |
| y = args.y or 100 | |
| stamp_pdf(str(pdf_path), str(image_path), output, | |
| args.page, x, y, stamp_w, stamp_h) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment