commit c499826141d7cbe5a7e0705c477da40c0318a5a4 Author: Axel Svensson Date: Mon Oct 11 19:31:03 2021 +0200 First working version diff --git a/pdf-sign b/pdf-sign new file mode 100755 index 0000000..c438efa --- /dev/null +++ b/pdf-sign @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 + +#Dependencies: python3, pdftk, gs, mv, pdfinfo + +import contextlib, os, queue, re, subprocess, sys, tempfile, threading, time, tkinter as tk + +signatureDir=os.path.expanduser(os.environ['PDF_SIGNATURE_DIR'] if 'PDF_SIGNATURE_DIR' in os.environ else "~/.pdf_signatures") + +# Inspired by https://unix.stackexchange.com/a/141496 +def main(filePath, pagestr=None): + fileAbsPath=os.path.abspath(filePath) + with inTmpDir(): + # Flatten (make forms non-editable) before signing + flatPDF=Cell(lambda: subprocess.run(['pdftk', fileAbsPath, 'output', 'flat.pdf', 'flatten'], check=True) and 'flat.pdf') + # The chosen page + pageCount=pdfCountPages(flatPDF()) + pageNumber=Cell(int(pagestr) if pagestr else pageCount) + pagePDF=Cell(lambda: subprocess.run(['pdftk', flatPDF(), 'cat', str(pageNumber()), 'output', 'page.pdf'], check=True) and 'page.pdf') + pageSize=Cell(lambda: pdfGetSize(pagePDF())) + # The chosen signature + signatures=[*filter(lambda x: m("^.*\.pdf$", x), os.listdir(signatureDir))] + signatureIndex=Cell(0) + signatureAbsPath=Cell(lambda: os.path.join(signatureDir, signatures[signatureIndex()])) + signatureSize=Cell(lambda: pdfGetSize(signatureAbsPath())) + signaturePositionX=Cell(0.5) + signaturePositionY=Cell(0.75) + signatureScale=Cell(0) + @Cell + def signaturePositionedPDF(): + (w, h)=pageSize() + (sw, sh)=signatureSize() + resize=1.1**signatureScale()*min(w/sw, h/sh)/3 + dx=w*signaturePositionX()/resize - sw/2 + dy=h*(1-signaturePositionY())/resize - sh/2 + subprocess.run([ + 'gs', '-dBATCH', '-dNOPAUSE', '-dSAFER', '-dQUIET', + '-sOutputFile=page.signature.pdf', + '-sDEVICE=pdfwrite', + f'-dDEVICEWIDTHPOINTS={w}', f'-dDEVICEHEIGHTPOINTS={h}', '-dFIXEDMEDIA', + '-c', f'<> setpagedevice', + '-f', signatureAbsPath(), + ], check=True) + return 'page.signature.pdf' + # The signed page + signedPagePDF=Cell(lambda: subprocess.run([ + 'pdftk', + pagePDF(), + 'stamp', signaturePositionedPDF(), + 'output', 'page.signed.pdf', + ], check=True) and 'page.signed.pdf') + # 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/pageWidth) + return (int(pageWidth*scale), int(pageHeight*scale)) + @Cell + def displayPNG(): + (w, h)=displaySize() + subprocess.run([ + 'gs', '-dBATCH', '-dNOPAUSE', '-dSAFER', '-dQUIET', + '-sOutputFile=page.display.png', + '-sDEVICE=pngalpha', + '-dMaxBitmap=2147483647', + f'-dDEVICEWIDTHPOINTS={w}', f'-dDEVICEHEIGHTPOINTS={h}', '-dFIXEDMEDIA', '-dPDFFitPage', + '-f', signedPagePDF(), + ], check=True) + return './page.display.png' + # GUI + doSign=True + gui=True + if gui: + doSign=False + # Commands + def uf(fun): + def ret(): + fun() + update() + return ret + @uf + def cmd_prevPage(): + if 1', onkey) + # 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', onDocViewResize) + root._docView.pack(expand=1) + root._docViewIndex=root._docView.create_image(0, 0, anchor=tk.NW) + def update(): + (w, h) = displaySize() + root._docImg = tk.PhotoImage(file=displayPNG()) + root._docView.itemconfig(root._docViewIndex, image=root._docImg) + root._docView.configure(width=w, height=h) + 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('', onclick) + # Run GUI + root.mainloop() + # End of GUI + if doSign: + [ignored, pathPart1, pathPart2] = m("^(.*)(\.[Pp][Dd][Ff])$", fileAbsPath) + signedFileAbsPath=f'{pathPart1}.signed{pathPart2}' + if os.path.exists(signedFileAbsPath): + backupFileAbsPath=f'{pathPart1}.signed.backup{time.strftime("%Y%m%d_%H%M%S")}{pathPart2}' + subprocess.run([ + 'mv', + signedFileAbsPath, + backupFileAbsPath, + ], check=True) + print(f'Renamed {signedFileAbsPath} to {backupFileAbsPath}') + pnr=pageNumber() + subprocess.run([ + 'pdftk', + f'A={flatPDF()}', + f'B={signedPagePDF()}', + 'cat', + *([f'A1-{pnr-1}'] if 1 < pnr else []), + 'B', + *([f'A{pnr+1}-end'] if pnr < pageCount else []), + 'output', signedFileAbsPath, + ], check=True) + print(f'Signed document saved as {signedFileAbsPath}') + else: + print(f'Aborted') + +# 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. +class Cell(): + currentCell=None + def __init__(self, arg): + self._arg=arg + self._isuptodate=False + self._dependents=[] + def __call__(self, *args): + if(len(args)==1): + self._arg=args[0] + self.dirty() + return + assert len(args)==0 + if(Cell.currentCell): + self._dependents.append(Cell.currentCell) + if not self._isuptodate: + oldcell=Cell.currentCell + Cell.currentCell=self + self._value=self._arg() if callable(self._arg) else self._arg + self._isuptodate=True + Cell.currentCell=oldcell + return self._value + def dirty(self): + if self._isuptodate: + self._isuptodate=False + for d in self._dependents: + d.dirty() + self._dependents=[] + +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 + +@contextlib.contextmanager +def inTmpDir(): + olddir = os.getcwd() + with tempfile.TemporaryDirectory() as tempdir: + try: + os.chdir(tempdir) + yield + finally: + os.chdir(olddir) + +def fromCmdOutput(cmd, pattern): + sp=subprocess.run(cmd, check=True, capture_output=True) + result=sp.stdout.decode('utf-8') + return m(pattern, result) + +# Inspired by http://eyalarubas.com/python-subproc-nonblock.html +class NonBlockingIterable: + def __init__(self, iterable, timeout = 0.3): + self._i = iterable + self._q = queue.Queue() + self._timeout = timeout + self._done = False + def _populateQueue(iterable, queue): + for item in iterable: + queue.put(item) + self._done = True + self._t = threading.Thread( + target = _populateQueue, + args = (self._i, self._q)) + self._t.daemon = True + self._t.start() + def __iter__(self): + while not self._done: + try: + yield self._q.get(block = True, timeout = self._timeout) + except queue.Empty: + yield None + +main(*sys.argv[1:])