Commits vergleichen

...
Anmelden, um einen neuen Pull-Request zu erstellen.

17 Commits

Autor SHA1 Nachricht Datum
Axel Svensson
2d0ef9c085 Fix use of signatures with CropBox 2024-07-16 07:04:08 +00:00
Axel Svensson
8e83ba1a2d timing debug prints 2024-07-16 06:38:11 +00:00
Axel Svensson
6f3e268672 improve debug prints 2024-07-13 17:12:59 +00:00
Axel Svensson
bfab36ece4 debug print photo load time 2024-07-13 17:03:19 +00:00
Axel Svensson
b2531a89e4 debug subprocess runs 2024-07-13 15:24:03 +00:00
Axel Svensson
fffac1c147 Improve Adaptive initial window size 2024-07-13 15:01:40 +00:00
Axel Svensson
823c67bbee Improve startup time 2024-07-13 14:45:33 +00:00
Axel Svensson
74ad2b009c Improve debug prints 2024-07-13 14:45:33 +00:00
Axel Svensson
d6f52c917e Adaptive initial window size 2024-07-13 11:26:13 +00:00
Axel Svensson
91cc247154 More Debug prints for issue-5 2024-07-13 11:22:27 +00:00
Axel Svensson
80ca4898ca WIP fix help text
Maybe fixes #11.
2024-07-12 13:22:11 +00:00
Axel Svensson
939a6c96f8 Debug issue-5 key/button events 2024-07-02 23:07:21 +00:00
Axel Svensson
89c808fb32 WIP issue-5 2024-07-01 22:52:27 +00:00
Axel Svensson
a8df5d398c WIP: Improve size calculation 2024-07-01 22:26:30 +00:00
Axel Svensson
2025f66620 WIP Improve help text
Maybe fixes #11
2024-07-01 22:26:30 +00:00
Axel Svensson
4727a65f08 WIP: fix regex escapes 2024-07-01 22:26:16 +00:00
Axel Svensson
71b1a84970 WIP issue #5 2024-06-03 09:44:19 +02:00
3 geänderte Dateien mit 217 neuen und 50 gelöschten Zeilen

Datei anzeigen

@ -29,7 +29,7 @@ Run `pdf-sign -h` or `pdf-create-empty -h` for details.
* `python3.7` or later
* Python module `tkinter` (only needed for interactive use)
* `gs` (Ghostscript)
* `pdftk`
* `qpdf` or `pdftk` (at least one of them)
* `pdfinfo`
* Copy one or both tools to a directory in your `$PATH`.

Datei anzeigen

@ -1,6 +1,8 @@
#!/usr/bin/env python3
#Dependencies: python3, gs
# Dependencies:
# - python3.7 or later
# - gs (Ghostscript)
import argparse, os, re, subprocess, sys

261
pdf-sign
Datei anzeigen

@ -1,42 +1,69 @@
#!/usr/bin/env python3
#Dependencies: python3.7 or later with module tkinter, gs (Ghostscript), pdftk and pdfinfo.
# 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
from inspect import currentframe
starttime=time.time()
def tdbg():
print(f"========== DBG: line {currentframe().f_back.f_lineno}, time after start={time.time()-starttime:.3f} ==========", flush=True)
# Inspired by https://unix.stackexchange.com/a/141496
def main(args):
tdbg()
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)")
tdbg()
with tempfile.TemporaryDirectory() as tempdir:
tdbg()
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'))
subprocess.run([
qpdfOrPdftk([
'qpdf',
'--flatten-annotations=all',
'--generate-appearances',
filePath, inputPDF,
],[
'pdftk', filePath,
'output', inputPDF,
'flatten'
], check=True)
])
else:
inputPDF=filePath
tdbg()
# 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)
pageNumber=Cell(args.page if 0 < args.page else pageCount+args.page+1, 'pageNumber')
@Cell
def pagePDF():
outFile=intmp('page.pdf')
subprocess.run([
qpdfOrPdftk([
'qpdf', '--pages', '.', f'{pageNumber()}', '--',
inputPDF, str(outFile)
],[
'pdftk', inputPDF,
'cat', str(pageNumber()),
'output', str(outFile)
], check=True)
'output', str(outFile)])
return outFile
pageSize=Cell(lambda: pdfGetSize(str(pagePDF())))
pageSize=Cell(lambda: pdfGetSize(str(pagePDF())), 'pageSize')
# The chosen signature
tdbg()
if not args.signature and args.batch:
die('In batch mode, signature must be specified.')
signatureDir=getSignatureDir()
@ -44,12 +71,57 @@ def main(args):
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)
signatureIndex=Cell(0, 'signatureIndex')
signaturePath=Cell(lambda: args.signature if args.signature else os.path.join(signatureDir, signatures[signatureIndex()]), 'signaturePath')
signatureSize=Cell(lambda: pdfGetSize(signaturePath()), 'signatureSize')
signaturePositionX=Cell(args.x_coordinate, 'signaturePositionX')
signaturePositionY=Cell(args.y_coordinate, 'signaturePositionY')
signatureScale=Cell(0, 'signatureScale')
tdbg()
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. 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()
@ -64,21 +136,24 @@ def main(args):
'-sDEVICE=pdfwrite',
f'-dDEVICEWIDTHPOINTS={w}', f'-dDEVICEHEIGHTPOINTS={h}', '-dFIXEDMEDIA',
'-c', f'<</BeginPage{{{resize} {resize} scale {dx} {dy} translate}}>> setpagedevice',
'-f', signaturePath(),
'-f', translatablePDF(signaturePath()),
], check=True)
return outFile
# The signed page
@Cell
def signedPagePDF():
outFile=intmp('signed-page.pdf')
subprocess.run([
qpdfOrPdftk([
'qpdf', '--overlay', str(signaturePositionedPDF()), '--',
str(pagePDF()), str(outFile),
],[
'pdftk', str(pagePDF()),
'stamp', str(signaturePositionedPDF()),
'output', str(outFile)
], check=True)
])
return outFile
# The signed page as PNG, for GUI use
displayMaxSize=Cell((400, 800))
displayMaxSize=Cell((400, 800), 'displayMaxSize')
@Cell
def displaySize():
(maxWidth, maxHeight)=displayMaxSize()
@ -89,6 +164,7 @@ def main(args):
def displayPNG():
(w, h)=displaySize()
outFile=intmp('display.png')
tdbg()
subprocess.run([
'gs', '-dBATCH', '-dNOPAUSE', '-dSAFER', '-dQUIET',
f'-sOutputFile={outFile}',
@ -97,11 +173,13 @@ def main(args):
f'-dDEVICEWIDTHPOINTS={w}', f'-dDEVICEHEIGHTPOINTS={h}', '-dFIXEDMEDIA', '-dPDFFitPage',
'-f', str(signedPagePDF()),
], check=True)
tdbg()
return outFile
# GUI
doSign=True
gui=not args.batch
if gui:
tdbg()
try:
import tkinter as tk
except ModuleNotFoundError:
@ -151,7 +229,9 @@ def main(args):
sys.exit(1)
tk.Tk.report_callback_exception = tkerror
# Window and menu
tdbg()
root = tk.Tk(className="pdf-sign")
tdbg()
rootmenu = tk.Menu(root)
root.config(menu=rootmenu)
filemenu = tk.Menu(rootmenu, tearoff=0)
@ -208,9 +288,13 @@ def main(args):
'space': cmd_sign,
}
def onkey(event):
print(f"Debug: in onkey(event): char={event.char}, delta={event.delta}, height={event.height}, keycode={event.keycode}, keysym={event.keysym}, keysym_num={event.keysym_num}, num={event.num}, send_event={event.send_event}, serial={event.serial}, state={event.state}, time={event.time}, type={event.type}, widget={event.widget}, width={event.width}, x={event.x}, x_root={event.x_root}, y={event.y}, y_root={event.y_root}")
key=('C-' if event.state in [4, 5] else '')+event.keysym
if key in keyToFunction:
print(f"Debug: Calling function for {key}")
keyToFunction[key]()
else:
print(f"Debug: No function for {key}")
for key in keyToFunction.keys():
root.bind(f'<{key.split("-")[-1]}>', onkey)
def bindDigit(i, char):
@ -218,54 +302,91 @@ def main(args):
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', confine=True)
def onDocViewResize(event):
canvasMarginX=event.x
canvasMarginY=event.y
canvasWidth=event.width
canvasHeight=event.height
(oldMaxWidth, oldMaxHeight)=displayMaxSize()
if(0<canvasMarginX and 0<canvasMarginY):
apparentScale=max(canvasHeight/oldMaxHeight, canvasWidth/oldMaxWidth, 0.5)
newMaxWidth=(canvasWidth+2*canvasMarginX)/apparentScale-10
newMaxHeight=(canvasHeight+2*canvasMarginY)/apparentScale-10
else:
newMaxWidth=oldMaxWidth/2
newMaxHeight=oldMaxHeight/2
newMaxWidth=max(newMaxWidth, 10)
newMaxHeight=max(newMaxHeight, 10)
if abs(oldMaxWidth-newMaxWidth) < 5: newMaxWidth=oldMaxWidth
if abs(oldMaxHeight-newMaxHeight) < 5: newMaxHeight=oldMaxHeight
if oldMaxWidth != newMaxWidth or oldMaxHeight != newMaxHeight:
displayMaxSize((newMaxWidth, newMaxHeight))
tdbg()
root._docView=tk.Canvas(root, borderwidth=0, background='#ffffff')
tdbg()
docViewMargin=5
docViewMinDimension=50
tdbg()
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.pack(expand=1)
root._docView.place(x=docViewMargin, y=docViewMargin)
root._docViewIndex=root._docView.create_image(0, 0, anchor=tk.NW)
root._docView.bind('<Configure>', onDocViewResize)
root.geometry("800x600")
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])}")
tdbg()
@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)
tdbg()
@tkthrottle(100, root)
def update():
tdbg()
if not updateActive:
return
(w, h) = displaySize()
root._docImg = tk.PhotoImage(file=str(displayPNG()))
filename = str(displayPNG())
before = time.time()
tdbg()
root._docImg = tk.PhotoImage(file=filename)
tdbg()
print(f"Debug: Loading photo image took {time.time()-before:.2f} seconds", flush=True)
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())
tdbg()
def dbg(event, x):
print(f"Debug: in dbg(event) for {x}: char={event.char}, delta={event.delta}, height={event.height}, keycode={event.keycode}, keysym={event.keysym}, keysym_num={event.keysym_num}, num={event.num}, send_event={event.send_event}, serial={event.serial}, state={event.state}, time={event.time}, type={event.type}, widget={event.widget}, width={event.width}, x={event.x}, x_root={event.x_root}, y={event.y}, y_root={event.y_root}")
def dbgfun(x):
def fun(event):
dbg(event, x)
return fun
root._docView.bind('<KeyPress>', dbgfun("root._docView <KeyPress>"))
root._docView.bind('<Button>', dbgfun("root._docView <Button>"))
root.bind('<KeyPress>', dbgfun("root <KeyPress>"))
root.bind('<Button>', dbgfun("root <Button>"))
tdbg()
def onclick(event):
print(f"Debug: in onclick(event): char={event.char}, delta={event.delta}, height={event.height}, keycode={event.keycode}, keysym={event.keysym}, keysym_num={event.keysym_num}, num={event.num}, send_event={event.send_event}, serial={event.serial}, state={event.state}, time={event.time}, type={event.type}, widget={event.widget}, width={event.width}, x={event.x}, x_root={event.x_root}, y={event.y}, y_root={event.y_root}")
x=event.x
y=event.y
canvasConfig=root._docView.config()
canvasWidth=int(canvasConfig['width'][4])
canvasHeight=int(canvasConfig['height'][4])
print(f"Debug: in onclick(event): canvasConfig={repr(canvasConfig)}, canvasWidth={canvasWidth}, canvasHeight={canvasHeight}, calling cmd_positionSignature({x/canvasWidth}, {y/canvasHeight})")
cmd_positionSignature(x/canvasWidth, y/canvasHeight)
root._docView.bind('<Button-1>', onclick)
# Run GUI
tdbg()
root.mainloop()
tdbg()
# End of GUI
if doSign:
signedFilePath=args.output if args.output else f'{filePath[:-4]}.signed{filePath[-4:]}'
@ -283,7 +404,15 @@ def main(args):
else:
assert args.existing=='overwrite'
pnr=pageNumber()
subprocess.run([
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()}',
@ -292,7 +421,7 @@ def main(args):
'B',
*([f'A{pnr+1}-end'] if pnr < pageCount else []),
'output', signedFilePath,
], check=True)
])
print(f'Signed document saved as {signedFilePath}')
else:
print(f'Aborted')
@ -303,6 +432,15 @@ class Volatile():
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']
@ -326,22 +464,32 @@ def getSignatureDir():
# Calculations with unchanged inputs are skipped.
class Cell():
currentCell=None
def __init__(self, arg):
def __init__(self, arg, dbgname=None):
self._dbgname=dbgname
if dbgname==None and callable(arg): self._dbgname=arg.__name__
self._arg=arg
self._isuptodate=False
self._needEval=True
self._dependents=[]
self._precedents=[]
self._precedentvalues=[]
print(f"Debug: Init cell {dbgname}{repr(arg)}")
def __call__(self, *args):
if(len(args)==1):
if self._arg != args[0]:
print(f"{Cell._dbgprefix}Debug: Called cell {self._dbgname}{repr(args)}, changing value from {self._arg} to {args[0]}")
self._arg=args[0]
self._needEval=True
self._dirty()
else:
print(f"Debug: Called cell {self._dbgname}{repr(args)}, not changing value.")
return
assert len(args)==0
print(f"{Cell._dbgprefix}Debug: Called cell {self._dbgname}{repr(args)}...")
dbgtext=f"{Cell._dbgprefix}.."
Cell._dbgprefix+=' '
if not self._isuptodate:
dbgtext+=", updating it"
oldcell=Cell.currentCell
Cell.currentCell=None
for i in range(len(self._precedents)):
@ -349,9 +497,11 @@ class Cell():
oldval=self._precedentvalues[i]
newval=p()
if oldval!=newval:
dbgtext+=f", detected change in precedent {p._dbgname}"
self._needEval=True
break
if self._needEval:
dbgtext+=", evaluating"
Cell.currentCell=self
for p in self._precedents:
p._dependents.remove(self)
@ -365,12 +515,16 @@ class Cell():
self._dependents.append(Cell.currentCell)
Cell.currentCell._precedents.append(self)
Cell.currentCell._precedentvalues.append(self._value)
dbgtext+=f", returning {repr(self._value)}"
Cell._dbgprefix=Cell._dbgprefix[:-2]
print(dbgtext)
return self._value
def _dirty(self):
if self._isuptodate:
self._isuptodate=False
for d in self._dependents:
d._dirty()
Cell._dbgprefix=''
def tkthrottle(frequency, root):
wait=1/frequency
@ -397,7 +551,7 @@ 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.*$')
[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):
@ -452,8 +606,19 @@ parser.add_argument('-y', '--y-coordinate', type=float, default=0.75, help='Vert
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.')
parser.add_argument('-f', '--flatten', action=argparse.BooleanOptionalAction, default=True, help='Flatten before signing.')
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)')
# debug subprocess runs
orig_subprocess_run = subprocess.run
def debug_subprocess_run(*args, **kwargs):
before=time.time()
ret = orig_subprocess_run(*args, **kwargs)
after=time.time()
duration=after-before
print(f"{Cell._dbgprefix}Subprocess [{duration:.2f}s]: {repr(args)} {repr(kwargs)}", flush=True)
return ret
subprocess.run = debug_subprocess_run
main(parser.parse_args(sys.argv[1:] or ['-h']))