751 Zeilen
34 KiB
Python
Ausführbare Datei
751 Zeilen
34 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)")
|
|
if filePath.startswith('-'):
|
|
# A cheap solution to a rare problem
|
|
die("Input file may not start with a dash (-)")
|
|
with tempfile.TemporaryDirectory() as tempdir:
|
|
intmp=lambda fileName: os.path.join(tempdir, fileName)
|
|
# Maybe flatten (make forms non-editable) before signing
|
|
if args.flatten:
|
|
inputPDF=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)
|
|
@VolatileCell
|
|
def pagePDF():
|
|
outFile=intmp('page.pdf')
|
|
qpdfOrPdftk([
|
|
'qpdf', '--pages', '.', f'{pageNumber()}', '--',
|
|
inputPDF, outFile
|
|
],[
|
|
'pdftk', inputPDF,
|
|
'cat', str(pageNumber()),
|
|
'output', outFile])
|
|
return outFile
|
|
pageSize=Cell(lambda: pdfGetSize(pagePDF()))
|
|
# The chosen signature
|
|
if args.batch:
|
|
if not args.signature and not args.text:
|
|
die('In batch mode, --signature or --text must be specified.')
|
|
if args.text and len(args.text) > 1:
|
|
die('In batch mode, --text must be given only once.')
|
|
if args.signature and args.text:
|
|
die('--signature and --text cannot be specified together.')
|
|
if args.signature and not os.path.exists(args.signature):
|
|
die(f'File not found: {args.signature}')
|
|
if args.text:
|
|
assert(len(args.text) > 0)
|
|
latin1_chars=set([*range(0x20, 0x7f), *range(0xa0, 0x100)])
|
|
for text in args.text:
|
|
text_chars=set(map(ord, text))
|
|
if not text_chars.issubset(latin1_chars):
|
|
die("Error: Only non-control latin-1 characters are supported in --text." +
|
|
" Unsupported characters: " + ', '.join(map(hex, text_chars - latin1_chars)))
|
|
signatureDir=getSignatureDir()
|
|
signatures=[('file', x) for x in filter(isPdfFilename, os.listdir(signatureDir))] if not args.signature else [None]
|
|
if not signatures and not args.text:
|
|
die(f'Could not find anything usable as signature, since no .pdf files found in {signatureDir} and no --text option given.')
|
|
signatures.sort()
|
|
if args.text:
|
|
signatures=[('text', x) for x in args.text] + signatures
|
|
signatureIndex=Cell(0)
|
|
customText=Cell(('text', time.strftime('%Y-%m-%d')))
|
|
@Cell
|
|
def signaturePath():
|
|
if args.signature:
|
|
return args.signature
|
|
(signType, content)=signatures[signatureIndex()]
|
|
if signType=='cell':
|
|
(signType, content)=content()
|
|
if signType=='file':
|
|
return os.path.join(signatureDir, content)
|
|
assert signType=='text'
|
|
cache = signaturePath._cache
|
|
if content in cache:
|
|
return cache[content]
|
|
fileName=intmp(f"text{len(cache)}.pdf")
|
|
text_to_pdf(content, fileName, 12, 1)
|
|
cache[content]=fileName
|
|
return fileName
|
|
signaturePath._cache={}
|
|
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 = intmp('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 = intmp(f'translatable{len(cache)}.pdf')
|
|
emptyFileName = intmp(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={}
|
|
@VolatileCell
|
|
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
|
|
@VolatileCell
|
|
def signedPagePDF():
|
|
outFile=intmp('signed-page.pdf')
|
|
qpdfOrPdftk([
|
|
'qpdf', '--overlay', signaturePositionedPDF(), '--',
|
|
pagePDF(), outFile,
|
|
],[
|
|
'pdftk', pagePDF(),
|
|
'stamp', signaturePositionedPDF(),
|
|
'output', 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))
|
|
@VolatileCell
|
|
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', signedPagePDF(),
|
|
], check=True)
|
|
return outFile
|
|
# GUI
|
|
doSign=True
|
|
gui=not args.batch
|
|
if gui:
|
|
try:
|
|
import tkinter as tk
|
|
from tkinter import simpledialog
|
|
except ModuleNotFoundError:
|
|
die('Cannot find Python module `tkinter`, which is needed for interactive use.')
|
|
doSign=False
|
|
customTextIndex = len(signatures)
|
|
# 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_customText():
|
|
if args.signature:
|
|
return
|
|
# Get text from user
|
|
text = simpledialog.askstring(
|
|
"Custom Text",
|
|
"Input the text you want to stamp this PDF file with",
|
|
initialvalue=customText()[1])
|
|
if text == None:
|
|
return
|
|
# Validate text
|
|
text_chars=set(map(ord, text))
|
|
latin1_chars=set([*range(0x20, 0x7f), *range(0xa0, 0x100)])
|
|
if not text_chars.issubset(latin1_chars):
|
|
simpledialog.messagebox.showerror(
|
|
parent=root,
|
|
title="Invalid text",
|
|
message=f"Only non-control latin-1 characters are supported. Unsupported characters: {', '.join(map(hex, text_chars - latin1_chars))}"
|
|
)
|
|
return
|
|
if not (1 <= len(text) and len(text) <= 100):
|
|
simpledialog.messagebox.showerror(
|
|
parent=root,
|
|
title="Invalid text",
|
|
message="Text must be between 1 and 100 characters long."
|
|
)
|
|
return
|
|
# Set text
|
|
customText(('text', text))
|
|
signatureIndex(customTextIndex)
|
|
label='Custom text: ' + (text if len(text) <= 20 else (text[:17] + '...'))
|
|
placemenu.entryconfig(len(signatures) + 2, label=label)
|
|
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, (_, signature) in enumerate(signatures):
|
|
placemenu.add_radiobutton(
|
|
value=index,
|
|
label=f'{index+1}: {signature}',
|
|
underline=0 if index<9 else None,
|
|
variable=root.signatureControlVar,
|
|
accelerator=(str(index+1) if index<9 else None),
|
|
command=updateFromSignatureRadio)
|
|
signatures.append(('cell', customText))
|
|
placemenu.add_radiobutton(
|
|
value=customTextIndex,
|
|
label='Custom text: ' + customText()[1],
|
|
underline=7,
|
|
variable=root.signatureControlVar,
|
|
accelerator='T',
|
|
command=cmd_customText)
|
|
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,
|
|
'T': cmd_customText,
|
|
't': cmd_customText,
|
|
'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=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 []),
|
|
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')
|
|
|
|
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():
|
|
(path, helptxt) = getSignatureDirAndHelp()
|
|
if not path:
|
|
die(f"Could not find a valid signature directory. The options considered are: {helptxt}")
|
|
return path
|
|
|
|
def getSignatureDirAndHelp():
|
|
def candidate(prevpath, prevhelptxt, nr, lbl, envvar, path):
|
|
helptxt = f"{nr}) {lbl}"
|
|
if prevhelptxt: helptxt = f"{prevhelptxt} {helptxt}"
|
|
if envvar and not envvar in os.environ:
|
|
helptxt += " which is not used since the environment variable is not set."
|
|
return (prevpath, helptxt)
|
|
path = os.path.expanduser(path)
|
|
if not os.path.exists(path):
|
|
helptxt += f" which is not used since {path} does not exist."
|
|
return (prevpath, helptxt)
|
|
if not os.path.isdir(path):
|
|
helptxt += f" which is not used since {path} is not a directory."
|
|
return (prevpath, helptxt)
|
|
if prevpath:
|
|
helptxt += f" which is valid, but not used since it's not the highest priority directory."
|
|
return (prevpath, helptxt)
|
|
helptxt += f" which resolves to {path} and IS USED as the signature directory."
|
|
return (path, helptxt)
|
|
(path, helptxt) = (None, None)
|
|
(path, helptxt) = candidate(path, helptxt,
|
|
1, '$PDF_SIGNATURE_DIR', 'PDF_SIGNATURE_DIR',
|
|
os.environ.get('PDF_SIGNATURE_DIR'))
|
|
(path, helptxt) = candidate(path, helptxt,
|
|
2, '$XDG_CONFIG_HOME/pdf_signatures', 'XDG_CONFIG_HOME',
|
|
os.path.join(os.environ.get('XDG_CONFIG_HOME') or '/', 'pdf_signatures'))
|
|
(path, helptxt) = candidate(path, helptxt,
|
|
3, '~/.config/pdf_signatures', None,
|
|
'~/.config/pdf_signatures')
|
|
(path, helptxt) = candidate(path, helptxt,
|
|
4, '~/.pdf_signatures', None,
|
|
'~/.pdf_signatures')
|
|
return (path, helptxt)
|
|
|
|
# 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]
|
|
p()
|
|
newval=p._cacheid()
|
|
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._cacheid())
|
|
return self._value
|
|
def _dirty(self):
|
|
if self._isuptodate:
|
|
self._isuptodate=False
|
|
for d in self._dependents:
|
|
d._dirty()
|
|
def _cacheid(self):
|
|
assert self._isuptodate
|
|
return self._value
|
|
# When the return value is just a file name, the contents of that file needs to
|
|
# be taken into account in order to decide whether the "value" has changed. In
|
|
# these cases, we track the precedent values along the cell's own value.
|
|
class VolatileCell(Cell):
|
|
def _cacheid(self):
|
|
assert self._isuptodate
|
|
return (self._value, self._precedentvalues)
|
|
|
|
# Keep this function in sync with pdf-from-text
|
|
def text_to_pdf(text, output, size, margin):
|
|
# Validate text
|
|
text_chars=set(map(ord, text))
|
|
latin1_chars=set([*range(0x20, 0x7f), *range(0xa0, 0x100)])
|
|
if not text_chars.issubset(latin1_chars):
|
|
die(f"Error: Only non-control latin-1 characters are supported. Unsupported characters: {', '.join(map(hex, text_chars - latin1_chars))}")
|
|
text.encode('latin-1').decode('latin-1') # Assertion. E.i., an exception here indicates a bug.
|
|
with tempfile.TemporaryDirectory() as tempdir:
|
|
# Write postscript file
|
|
ps_file=os.path.join(tempdir, "file.ps")
|
|
text_len=len(text)
|
|
w=text_len * size + margin * 2
|
|
h=size * 3 + margin * 2
|
|
x=size + margin
|
|
y=size + margin
|
|
ps_text = (text
|
|
.replace("\\", "\\\\")
|
|
.replace("(", "\\(")
|
|
.replace(")", "\\)")
|
|
)
|
|
def write_ps():
|
|
with open(ps_file, "w", encoding="latin-1") as f:
|
|
f.write('\n'.join([
|
|
"%!PS-Adobe-3.0",
|
|
f"%%BoundingBox: 0 0 {w} {h}",
|
|
"%%Pages: 1",
|
|
"%%EndComments",
|
|
"%%Page: 1 1",
|
|
f"/DejaVuSansMono findfont {size} scalefont setfont",
|
|
f"{x} {y} moveto",
|
|
f"({ps_text}) show",
|
|
"showpage"]))
|
|
# Write postscript file with too big bounding box
|
|
write_ps()
|
|
# Get correct bounding box
|
|
bbox_result = subprocess.run([
|
|
"gs", '-dBATCH', '-dNOPAUSE', '-dSAFER', '-dQUIET',
|
|
"-sDEVICE=bbox",
|
|
ps_file
|
|
], stderr=subprocess.PIPE, text=True, check=True)
|
|
bbox = m(r'.*%%HiResBoundingBox: (\d+(\.\d+)?) (\d+(\.\d+)?) (\d+(\.\d+)?) (\d+(\.\d+)?)\n.*', bbox_result.stderr)
|
|
if not bbox:
|
|
die("Error: Unable to extract bounding box.")
|
|
# Adjust variables for bounding box
|
|
llx, lly, urx, ury = float(bbox[1]), float(bbox[3]), float(bbox[5]), float(bbox[7])
|
|
llx, lly, urx, ury = llx - margin, lly - margin, urx + margin, ury + margin
|
|
w=urx - llx
|
|
h=ury - lly
|
|
x-=llx
|
|
y-=lly
|
|
# Write postscript file with correct bounding box
|
|
write_ps()
|
|
# Convert to PDF
|
|
gs_cmd = [
|
|
"gs", '-dBATCH', '-dNOPAUSE', '-dSAFER', '-dQUIET',
|
|
"-o", output,
|
|
"-sDEVICE=pdfwrite",
|
|
f"-dDEVICEWIDTHPOINTS={w}",
|
|
f"-dDEVICEHEIGHTPOINTS={h}",
|
|
"-dFIXEDMEDIA",
|
|
"-c", "[ /PAGES pdfmark",
|
|
"-f", ps_file
|
|
]
|
|
subprocess.run(gs_cmd, check=True, stdout=subprocess.DEVNULL)
|
|
|
|
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", 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
|
|
|
|
|
|
def getSignatureHelp():
|
|
(path, helptxt) = getSignatureDirAndHelp()
|
|
ret="""
|
|
Path to file used as signature.
|
|
Required in batch mode unless -t is given.
|
|
In GUI mode, the user can choose among PDF files in the signature directory.
|
|
"""
|
|
if path:
|
|
ret += f"The signature directory is {path}, chosen from these options: {helptxt}"
|
|
else:
|
|
ret += f"Right now, no signature directory could be found. The options are: {helptxt}"
|
|
return ret
|
|
|
|
parser = argparse.ArgumentParser(description='Sign a PDF file, with a non-cryptographic squiggle.')
|
|
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=getSignatureHelp())
|
|
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.11, help='Height of box to fit signature to, in page height units. (default: 0.11)')
|
|
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)')
|
|
parser.add_argument('-t', '--text', type=str, action='append', help='In GUI mode, a text option to be added to the list of signatures (can be repeated). In batch mode only one can be given, and will be used instead of --signature.')
|
|
|
|
main(parser.parse_args(sys.argv[1:] or ['-h']))
|