pdf-sign/pdf-sign
2024-07-16 13:27:10 +00:00

552 Zeilen
26 KiB
Python
Ausführbare Datei

#!/usr/bin/env python3
# Dependencies:
# - python3.7 or later with module tkinter
# - gs (Ghostscript)
# - qpdf or pdftk (pdf-sign will use pdftk if qpdf cannot be found)
# - pdfinfo.
import argparse, os, queue, re, subprocess, sys, tempfile, traceback, time
# Inspired by https://unix.stackexchange.com/a/141496
def main(args):
if not hasQpdf and not has("pdftk"):
die("Needs either qpdf or pdftk installed")
if not has("gs"):
die("Needs Ghostscript installed")
if not has("pdfinfo"):
die("Needs pdfinfo installed")
filePath=args.input
if not isPdfFilename(filePath):
die("Input file must end with .pdf (case insensitive)")
with tempfile.TemporaryDirectory() as tempdir:
intmp=lambda fileName: Volatile(os.path.join(tempdir, fileName))
# Maybe flatten (make forms non-editable) before signing
if args.flatten:
inputPDF=str(intmp('input.pdf'))
qpdfOrPdftk([
'qpdf',
'--flatten-annotations=all',
'--generate-appearances',
filePath, inputPDF,
],[
'pdftk', filePath,
'output', inputPDF,
'flatten'
])
else:
inputPDF=filePath
# The chosen page
pageCount=pdfCountPages(inputPDF)
if args.page < -pageCount or args.page==0 or pageCount < args.page:
die('Page number out of range')
pageNumber=Cell(args.page if 0 < args.page else pageCount+args.page+1)
@Cell
def pagePDF():
outFile=intmp('page.pdf')
qpdfOrPdftk([
'qpdf', '--pages', '.', f'{pageNumber()}', '--',
inputPDF, str(outFile)
],[
'pdftk', inputPDF,
'cat', str(pageNumber()),
'output', str(outFile)])
return outFile
pageSize=Cell(lambda: pdfGetSize(str(pagePDF())))
# The chosen signature
if not args.signature and args.batch:
die('In batch mode, signature must be specified.')
signatureDir=getSignatureDir()
signatures=[*filter(isPdfFilename, os.listdir(signatureDir))] if not args.signature else [None]
if not signatures:
die(f'No .pdf files found in {signatureDir}')
signatures.sort()
signatureIndex=Cell(0)
signaturePath=Cell(lambda: args.signature if args.signature else os.path.join(signatureDir, signatures[signatureIndex()]))
signatureSize=Cell(lambda: pdfGetSize(signaturePath()))
signaturePositionX=Cell(args.x_coordinate)
signaturePositionY=Cell(args.y_coordinate)
signatureScale=Cell(0)
def translatablePDF(path):
cache = translatablePDF._cache
if path not in cache:
(w, h) = pdfGetSize(path)
(double_w, double_h) = (2*w, 2*h)
testFile = os.path.join(tempdir, 'translateTest.pdf')
subprocess.run([
'gs', '-dBATCH', '-dNOPAUSE', '-dSAFER', '-dQUIET',
f'-sOutputFile={testFile}',
'-sDEVICE=pdfwrite',
f'-dDEVICEWIDTHPOINTS={double_w}', f'-dDEVICEHEIGHTPOINTS={double_h}', '-dFIXEDMEDIA',
'-c', f'<</BeginPage{{{w} {h} translate}}>> setpagedevice',
'-f', path,
], check=True)
(test_w, test_h) = pdfGetSize(testFile)
if (test_w, test_h) == (double_w, double_h):
# The pdf file at path can be translated correctly
cache[path] = path
elif (test_w, test_h) == (w, h):
# The pdf file at path cannot be translated correctly.
# Rather, the size is unchanged. This can happen if the PDF
# has an explicit CropBox set, e.g. is created with
# gs -c '[/CropBox [0 0 100 50] /PAGES pdfmark'. We have to
# remove it to make the PDF translatable and usable as a
# signature.
translatableFileName = os.path.join(tempdir, f'translatable{len(cache)}.pdf')
emptyFileName = os.path.join(tempdir, f'empty{len(cache)}.pdf')
subprocess.run([
'gs', '-dBATCH', '-dNOPAUSE', '-dSAFER', '-dQUIET',
f'-sOutputFile={emptyFileName}',
'-sDEVICE=pdfwrite',
f'-dDEVICEWIDTHPOINTS={w}', f'-dDEVICEHEIGHTPOINTS={h}',
], check=True)
qpdfOrPdftk([
'qpdf', '--overlay', path, '--',
emptyFileName, translatableFileName,
],[
'pdftk', emptyFileName,
'stamp', path,
'output', translatableFileName
])
cache[path] = translatableFileName
else:
die(f"The PDF at {path} is unusable as a signature. Reason unknown.")
return cache[path]
translatablePDF._cache={}
@Cell
def signaturePositionedPDF():
(w, h)=pageSize()
(sw, sh)=signatureSize()
resize=1.1**signatureScale()*min(args.width*w/sw, args.height*h/sh)
dx=w*signaturePositionX()/resize - sw/2
dy=h*(1-signaturePositionY())/resize - sh/2
outFile=intmp('signature-positioned.pdf')
subprocess.run([
'gs', '-dBATCH', '-dNOPAUSE', '-dSAFER', '-dQUIET',
f'-sOutputFile={outFile}',
'-sDEVICE=pdfwrite',
f'-dDEVICEWIDTHPOINTS={w}', f'-dDEVICEHEIGHTPOINTS={h}', '-dFIXEDMEDIA',
'-c', f'<</BeginPage{{{resize} {resize} scale {dx} {dy} translate}}>> setpagedevice',
'-f', translatablePDF(signaturePath()),
], check=True)
return outFile
# The signed page
@Cell
def signedPagePDF():
outFile=intmp('signed-page.pdf')
qpdfOrPdftk([
'qpdf', '--overlay', str(signaturePositionedPDF()), '--',
str(pagePDF()), str(outFile),
],[
'pdftk', str(pagePDF()),
'stamp', str(signaturePositionedPDF()),
'output', str(outFile)
])
return outFile
# The signed page as PNG, for GUI use
displayMaxSize=Cell((400, 800))
@Cell
def displaySize():
(maxWidth, maxHeight)=displayMaxSize()
(pageWidth, pageHeight)=pageSize()
scale=min(maxWidth/pageWidth, maxHeight/pageHeight)
return (round(pageWidth*scale), round(pageHeight*scale))
@Cell
def displayPNG():
(w, h)=displaySize()
outFile=intmp('display.png')
subprocess.run([
'gs', '-dBATCH', '-dNOPAUSE', '-dSAFER', '-dQUIET',
f'-sOutputFile={outFile}',
'-sDEVICE=pngalpha',
'-dMaxBitmap=2147483647',
f'-dDEVICEWIDTHPOINTS={w}', f'-dDEVICEHEIGHTPOINTS={h}', '-dFIXEDMEDIA', '-dPDFFitPage',
'-f', str(signedPagePDF()),
], check=True)
return outFile
# GUI
doSign=True
gui=not args.batch
if gui:
try:
import tkinter as tk
except ModuleNotFoundError:
die('Cannot find Python module `tkinter`, which is needed for interactive use.')
doSign=False
# Commands
def uf(fun):
def ret():
fun()
update()
return ret
@uf
def cmd_prevPage():
if 1<pageNumber():
pageNumber(pageNumber()-1)
@uf
def cmd_nextPage():
if pageNumber()<pageCount:
pageNumber(pageNumber()+1)
cmd_firstPage = uf(lambda: pageNumber(1))
cmd_lastPage = uf(lambda: pageNumber(pageCount))
cmd_nextSignature = uf(lambda: signatureIndex((signatureIndex()+1)%len(signatures)))
cmd_prevSignature = uf(lambda: signatureIndex((signatureIndex()-1)%len(signatures)))
cmd_moveSignatureUp = uf(lambda: signaturePositionY(signaturePositionY()-0.02))
cmd_moveSignatureDown = uf(lambda: signaturePositionY(signaturePositionY()+0.02))
cmd_moveSignatureLeft = uf(lambda: signaturePositionX(signaturePositionX()-0.02))
cmd_moveSignatureRight = uf(lambda: signaturePositionX(signaturePositionX()+0.02))
cmd_enlargeSignature = uf(lambda: signatureScale(signatureScale()+1))
cmd_shrinkSignature = uf(lambda: signatureScale(signatureScale()-1))
def cmd_positionSignature(x, y):
signaturePositionX(x)
signaturePositionY(y)
update()
def cmd_selectSignatureByIndex(i):
if i<len(signatures):
signatureIndex(i)
update()
def cmd_abort():
root.destroy()
def cmd_sign():
nonlocal doSign
doSign=True
root.destroy()
# Error handling
def tkerror(self, exc, val, tb):
traceback.print_exception(exc, val, tb)
sys.exit(1)
tk.Tk.report_callback_exception = tkerror
# Window and menu
root = tk.Tk(className="pdf-sign")
rootmenu = tk.Menu(root)
root.config(menu=rootmenu)
filemenu = tk.Menu(rootmenu, tearoff=0)
rootmenu.add_cascade(label="File", underline=0, menu=filemenu)
filemenu.add_command(label='Sign & Exit', underline=0, accelerator='space / S', command=cmd_sign)
filemenu.add_command(label='Abort & Exit', underline=0, accelerator='Esc / Q', command=cmd_abort)
pagemenu = tk.Menu(rootmenu, tearoff=0)
rootmenu.add_cascade(label="Page", underline=0, menu=pagemenu)
pagemenu.add_command(label='First page', underline=0, accelerator='Home', command=cmd_firstPage)
pagemenu.add_command(label='Previous page', underline=0, accelerator='PgUp', command=cmd_prevPage)
pagemenu.add_command(label='Next page', underline=0, accelerator='PgDown', command=cmd_nextPage)
pagemenu.add_command(label='Last page', underline=0, accelerator='End', command=cmd_lastPage)
placemenu = tk.Menu(rootmenu, tearoff=0)
rootmenu.add_cascade(label="Signature", underline=1, menu=placemenu)
if not args.signature:
placemenu.add_command(label='Previous signature', underline=0, accelerator='Ctrl-Left', command=cmd_prevSignature)
placemenu.add_command(label='Next signature', underline=0, accelerator='Ctrl-Right', command=cmd_nextSignature)
placemenu.add_separator()
root.signatureControlVar=tk.IntVar()
root.signatureControlVar.set(signatureIndex())
def updateFromSignatureRadio():
if root.signatureControlVar.get() != signatureIndex():
signatureIndex(root.signatureControlVar.get())
update()
for index, filename in enumerate(signatures):
placemenu.add_radiobutton(value=index, label=f'{index+1}: {filename}', underline=0 if index<9 else None, variable=root.signatureControlVar, accelerator=(str(index+1) if index<9 else None), command=updateFromSignatureRadio)
placemenu.add_separator()
placemenu.add_command(label='Enlarge signature', underline=0, accelerator='+', command=cmd_enlargeSignature)
placemenu.add_command(label='Shrink signature', underline=0, accelerator='-', command=cmd_shrinkSignature)
placemenu.add_separator()
placemenu.add_command(label='Move signature left', underline=15, accelerator='Click / Left', command=cmd_moveSignatureLeft)
placemenu.add_command(label='Move signature down', underline=15, accelerator='Click / Down', command=cmd_moveSignatureDown)
placemenu.add_command(label='Move signature up', underline=15, accelerator='Click / Up', command=cmd_moveSignatureUp)
placemenu.add_command(label='Move signature right', underline=15, accelerator='Click / Right', command=cmd_moveSignatureRight)
# Key bindings
keyToFunction={
'Prior': cmd_prevPage,
'Next': cmd_nextPage,
'Home': cmd_firstPage,
'End': cmd_lastPage,
'C-Left': cmd_prevSignature,
'C-Right': cmd_nextSignature,
'Left': cmd_moveSignatureLeft,
'Down': cmd_moveSignatureDown,
'Up': cmd_moveSignatureUp,
'Right': cmd_moveSignatureRight,
'plus': cmd_enlargeSignature,
'minus': cmd_shrinkSignature,
'Escape': cmd_abort,
'Q': cmd_abort,
'q': cmd_abort,
'S': cmd_sign,
's': cmd_sign,
'space': cmd_sign,
}
def onkey(event):
key=('C-' if event.state in [4, 5] else '')+event.keysym
if key in keyToFunction:
keyToFunction[key]()
for key in keyToFunction.keys():
root.bind(f'<{key.split("-")[-1]}>', onkey)
def bindDigit(i, char):
keyToFunction[char]=lambda: cmd_selectSignatureByIndex(i)
root.bind(f'{char}', onkey)
for i, char in enumerate("123456789"): bindDigit(i, char)
# Canvas and click binding
root._docView=tk.Canvas(root, borderwidth=0, background='#ffffff')
docViewMargin=5
docViewMinDimension=50
def onRootResize(event):
rootWidth=root.winfo_width()
rootHeight=root.winfo_height()
newMaxDisplayDimensions=(max(rootWidth - 2 * docViewMargin, docViewMinDimension),
max(rootHeight - 2 * docViewMargin, docViewMinDimension))
if displayMaxSize() != newMaxDisplayDimensions:
displayMaxSize(newMaxDisplayDimensions)
update()
root._docView.place(x=docViewMargin, y=docViewMargin)
root._docViewIndex=root._docView.create_image(0, 0, anchor=tk.NW)
root.bind('<Configure>', onRootResize)
initWinSize=(root.winfo_screenwidth() * 0.8, root.winfo_screenheight() * 0.8)
initWinSize=(min(initWinSize[0], initWinSize[1] * pageSize()[0] / pageSize()[1]),
min(initWinSize[1], initWinSize[0] * pageSize()[1] / pageSize()[0]))
root.geometry(f"{int(initWinSize[0])}x{int(initWinSize[1])}")
@Cell
def updateTitle():
root.title(f'Signing page {pageNumber()}/{pageCount} of {filePath}')
# The update function triggers heavy PDF file operations, so we try
# to avoid calling it too much. In particular,
# 1) Depending on desktop environment and window manager, one or
# more resizes can happen soon after startup, triggering an
# update. We use the updateActive flag to avoid these, then
# instead update once 100 ms after startup.
# 2) An interactive resizing process using the pointer can produce a
# lot of resizing events. We use the @tkthrottle decorator to
# reduce them.
updateActive = False
def soonAfterStart():
nonlocal updateActive
updateActive = True
update()
root.after(100, soonAfterStart)
@tkthrottle(100, root)
def update():
if not updateActive:
return
(w, h) = displaySize()
root._docImg = tk.PhotoImage(file=str(displayPNG()))
root._docView.itemconfig(root._docViewIndex, image=root._docImg)
root._docView.configure(width=w, height=h)
updateTitle()
if not args.signature:
if root.signatureControlVar.get() != signatureIndex():
root.signatureControlVar.set(signatureIndex())
def onclick(event):
x=event.x
y=event.y
canvasConfig=root._docView.config()
canvasWidth=int(canvasConfig['width'][4])
canvasHeight=int(canvasConfig['height'][4])
cmd_positionSignature(x/canvasWidth, y/canvasHeight)
root._docView.bind('<Button-1>', onclick)
# Run GUI
root.mainloop()
# End of GUI
if doSign:
signedFilePath=args.output if args.output else f'{filePath[:-4]}.signed{filePath[-4:]}'
if os.path.exists(signedFilePath):
if args.existing=='backup':
backupFilePath=f'{signedFilePath}.backup{time.strftime("%Y%m%d_%H%M%S")}'
subprocess.run([
'mv',
signedFilePath,
backupFilePath,
], check=True)
print(f'Renamed {signedFilePath} to {backupFilePath}')
elif args.existing=='fail':
die(f'Output file {signedFilePath} already exists')
else:
assert args.existing=='overwrite'
pnr=pageNumber()
qpdfOrPdftk([
'qpdf', '--pages',
*(['.', f'1-{pnr-1}'] if 1 < pnr else []),
str(signedPagePDF()), '1',
*(['.', f'{pnr+1}-z'] if pnr < pageCount else []),
'--',
inputPDF,
signedFilePath,
],[
'pdftk',
f'A={inputPDF}',
f'B={signedPagePDF()}',
'cat',
*([f'A1-{pnr-1}'] if 1 < pnr else []),
'B',
*([f'A{pnr+1}-end'] if pnr < pageCount else []),
'output', signedFilePath,
])
print(f'Signed document saved as {signedFilePath}')
else:
print(f'Aborted')
# Used for file names that don't change but represents changed content
class Volatile():
def __init__(self, underlying): self._underlying = underlying
def __eq__(self, other): return self is other
def __str__(self): return str(self._underlying)
def has(cmd):
return subprocess.run(["which", cmd], check=False, capture_output=True).returncode == 0
hasQpdf = has("qpdf")
def qpdfOrPdftk(qpdfCmd, pdftkCmd):
assert qpdfCmd[0] == "qpdf" and pdftkCmd[0] == "pdftk"
cmd = qpdfCmd if hasQpdf else pdftkCmd
subprocess.run(cmd, check=True)
return True # Some lambdas above rely on this
def getSignatureDir():
if 'PDF_SIGNATURE_DIR' in os.environ:
sd=os.environ['PDF_SIGNATURE_DIR']
elif 'XDG_CONFIG_HOME' in os.environ:
sd=os.path.join(os.environ['XDG_CONFIG_HOME'], 'pdf_signatures')
elif os.path.exists(os.path.expanduser("~/.config/pdf_signatures")):
sd="~/.config/pdf_signatures"
else:
sd="~/.pdf_signatures"
sd=os.path.expanduser(sd)
if not os.path.exists(sd):
raise Exception(f'Signature directory {sd} does not exist')
if not os.path.isdir(sd):
raise Exception(f'Signature directory {sd} is not a directory')
return sd
# Simple dependency tracking.
# Init with a value or function to calculate the value.
# Update by calling with one argument (as in init).
# To retrieve an up-to-date value, call with no arguments.
# Calculations with unchanged inputs are skipped.
class Cell():
currentCell=None
def __init__(self, arg):
self._arg=arg
self._isuptodate=False
self._needEval=True
self._dependents=[]
self._precedents=[]
self._precedentvalues=[]
def __call__(self, *args):
if(len(args)==1):
if self._arg != args[0]:
self._arg=args[0]
self._needEval=True
self._dirty()
return
assert len(args)==0
if not self._isuptodate:
oldcell=Cell.currentCell
Cell.currentCell=None
for i in range(len(self._precedents)):
p=self._precedents[i]
oldval=self._precedentvalues[i]
newval=p()
if oldval!=newval:
self._needEval=True
break
if self._needEval:
Cell.currentCell=self
for p in self._precedents:
p._dependents.remove(self)
self._precedents=[]
self._precedentvalues=[]
self._value=self._arg() if callable(self._arg) else self._arg
self._needEval=False
self._isuptodate=True
Cell.currentCell=oldcell
if(Cell.currentCell):
self._dependents.append(Cell.currentCell)
Cell.currentCell._precedents.append(self)
Cell.currentCell._precedentvalues.append(self._value)
return self._value
def _dirty(self):
if self._isuptodate:
self._isuptodate=False
for d in self._dependents:
d._dirty()
def tkthrottle(frequency, root):
wait=1/frequency
now=lambda: time.time()
def decorator(fn):
def throttled():
if(throttled._suppressUntil<now()):
fn()
else:
throttled._callAfterWait=True
throttled._suppressUntil=now()+wait
root.after(int(wait*1000+10), atend)
def atend():
if throttled._callAfterWait:
if throttled._suppressUntil<now():
throttled._callAfterWait=False
fn()
throttled._suppressUntil=0
throttled._callAfterWait=False
return throttled
return decorator
def pdfCountPages(filePath):
return int(fromCmdOutput(["pdfinfo", str(filePath)], "^.*\nPages: +([0-9]+)\n.*$")[1])
def pdfGetSize(filePath):
[ignored, w, h, *ignored2]=fromCmdOutput(['pdfinfo', filePath], '^.*\nPage size: +([0-9.]+) x ([0-9.]+) pts( \([A-Za-z0-9]+\))?\n.*$')
return (float(w), float(h))
def m(pattern, string):
match = re.match(pattern, string, re.DOTALL)
if not match:
return None
if(match.group(0) != string):
die(f'Pattern /${pattern}/ matched only part of string')
ret = []
for index in range((match.lastindex or 0)+1):
ret.append(match.group(index))
return ret
def isPdfFilename(filename):
return m(r"^.*\.[Pp][Dd][Ff]$", filename)
def fromCmdOutput(cmd, pattern):
sp=subprocess.run(cmd, check=True, capture_output=True)
result=sp.stdout.decode('utf-8')
return m(pattern, result)
def die(reason):
print(reason, file=sys.stderr)
sys.exit(1)
# Monkey-patch argparse if necessary
if not 'BooleanOptionalAction' in dir(argparse):
class BooleanOptionalAction(argparse.Action):
def __init__(self, option_strings, dest, default=None, type=None, choices=None, required=False, help=None, metavar=None):
_option_strings = []
for option_string in option_strings:
_option_strings.append(option_string)
if option_string.startswith('--'):
option_string = '--no-' + option_string[2:]
_option_strings.append(option_string)
if help is not None and default is not None:
help += f" (default: {default})"
super().__init__(option_strings=_option_strings, dest=dest, nargs=0, default=default, type=type, choices=choices, required=required, help=help, metavar=metavar)
def __call__(self, _parser, namespace, values, option_string=None):
if option_string in self.option_strings:
setattr(namespace, self.dest, not option_string.startswith('--no-'))
def format_usage(self):
return ' | '.join(self.option_strings)
argparse.BooleanOptionalAction = BooleanOptionalAction
parser = argparse.ArgumentParser(description='Sign a PDF file.')
parser.add_argument('input', metavar='input.pdf', type=str, help='Input PDF file.')
parser.add_argument('-p', '--page', type=int, default=-1, help='The page to sign, negative for counting from the end. (default: -1)')
parser.add_argument('-s', '--signature', type=str, help='Path to file used as signature. Required in batch mode. In GUI mode, the user can choose among files in $PDF_SIGNATURE_DIR, $XDG_CONFIG_HOME/pdf_signatures (defaulting to ~/.config/pdf_signatures), or ~/.pdf_signatures.')
parser.add_argument('-x', '--x-coordinate', type=float, default=0.5, help='Horizontal coordinate of signature center, in page width units. (default: 0.5)')
parser.add_argument('-y', '--y-coordinate', type=float, default=0.75, help='Vertical coordinate of signature center, in page height units. (default: 0.75)')
parser.add_argument('-W', '--width', type=float, default=0.28, help='Width of box to fit signature to, in page width units. (default: 0.28)')
parser.add_argument('-H', '--height', type=float, default=0.28, help='Height of box to fit signature to, in page height units. (default: 0.28)')
parser.add_argument('-o', '--output', type=str, help='Output file. (default: Add ".signed" before the extension)')
parser.add_argument('-b', '--batch', action=argparse.BooleanOptionalAction, default=False, help='Batch mode: do not show GUI. (default: False)')
parser.add_argument('-f', '--flatten', action=argparse.BooleanOptionalAction, default=True, help='Flatten before signing, preventing subsequent changes in PDF forms. (default: True)')
parser.add_argument('-e', '--existing', type=str, choices=['backup', 'overwrite', 'fail'], default='backup', help='What to do if output file exists. (default: backup)')
main(parser.parse_args(sys.argv[1:] or ['-h']))