Skip to content

Instantly share code, notes, and snippets.

@nickfarrow
Created May 2, 2026 08:09
Show Gist options
  • Select an option

  • Save nickfarrow/cf07f422349e806177bc52eab9e9133a to your computer and use it in GitHub Desktop.

Select an option

Save nickfarrow/cf07f422349e806177bc52eab9e9133a to your computer and use it in GitHub Desktop.
Insert signature into a PDF
#!/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