From 6b80860a9b48304e43491c803d605a6488ba3136 Mon Sep 17 00:00:00 2001 From: Axel Svensson Date: Thu, 17 Aug 2023 00:30:07 +0200 Subject: [PATCH 01/14] Use either qpdf or pdftk Fixes #5. --- README.md | 2 +- pdf-create-empty | 4 +++- pdf-sign | 57 +++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 78128d7..9f7ee1c 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/pdf-create-empty b/pdf-create-empty index e4768af..a81401e 100755 --- a/pdf-create-empty +++ b/pdf-create-empty @@ -1,6 +1,8 @@ #!/usr/bin/env python3 -#Dependencies: python3, gs +# Dependencies: +# - python3.7 or later +# - gs (Ghostscript) import argparse, os, re, subprocess, sys diff --git a/pdf-sign b/pdf-sign index 23a0104..744b3a4 100755 --- a/pdf-sign +++ b/pdf-sign @@ -1,11 +1,21 @@ #!/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 # 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)") @@ -14,11 +24,16 @@ def main(args): # 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 # The chosen page @@ -29,11 +44,13 @@ def main(args): @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()))) # The chosen signature @@ -71,11 +88,14 @@ def main(args): @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)) @@ -283,7 +303,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 +320,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 +331,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'] From 8f299dda2e88d955edc85097ae2f1e93a358010a Mon Sep 17 00:00:00 2001 From: Axel Svensson Date: Sun, 30 Jun 2024 17:01:33 +0000 Subject: [PATCH 02/14] Improve help text Fixes #11. --- pdf-sign | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pdf-sign b/pdf-sign index 744b3a4..48edb4c 100755 --- a/pdf-sign +++ b/pdf-sign @@ -489,8 +489,8 @@ 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)') main(parser.parse_args(sys.argv[1:] or ['-h'])) From 34fdeb104372de011e8c79a04cee61adde8586a9 Mon Sep 17 00:00:00 2001 From: Axel Svensson Date: Mon, 1 Jul 2024 22:25:07 +0000 Subject: [PATCH 03/14] Improve display size calculations --- pdf-sign | 34 ++++++++++++---------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/pdf-sign b/pdf-sign index 48edb4c..aa633d0 100755 --- a/pdf-sign +++ b/pdf-sign @@ -238,30 +238,20 @@ 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', onDocViewResize) + root.bind('', onRootResize) root.geometry("800x600") @Cell def updateTitle(): From a3d25a566f518fb9c37d550bf00df81859db766f Mon Sep 17 00:00:00 2001 From: Axel Svensson Date: Sat, 13 Jul 2024 11:26:13 +0000 Subject: [PATCH 04/14] Make initial window size adaptive --- pdf-sign | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pdf-sign b/pdf-sign index aa633d0..7021220 100755 --- a/pdf-sign +++ b/pdf-sign @@ -252,7 +252,10 @@ def main(args): root._docView.place(x=docViewMargin, y=docViewMargin) root._docViewIndex=root._docView.create_image(0, 0, anchor=tk.NW) root.bind('', onRootResize) - root.geometry("800x600") + 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}') From 17ec3c5e562bb26b27332823edd5f83bb7fc6004 Mon Sep 17 00:00:00 2001 From: Axel Svensson Date: Sat, 13 Jul 2024 14:43:51 +0000 Subject: [PATCH 05/14] Improve startup time --- pdf-sign | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/pdf-sign b/pdf-sign index 7021220..d532819 100755 --- a/pdf-sign +++ b/pdf-sign @@ -259,8 +259,25 @@ def main(args): @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) From 0fbba14076bfe4b056f362949816f48a87b743fe Mon Sep 17 00:00:00 2001 From: Axel Svensson Date: Tue, 16 Jul 2024 07:04:08 +0000 Subject: [PATCH 06/14] Support signature PDFs with an explicit CropBox --- pdf-sign | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/pdf-sign b/pdf-sign index d532819..a56cb93 100755 --- a/pdf-sign +++ b/pdf-sign @@ -67,6 +67,52 @@ def main(args): 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'<> 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() @@ -81,7 +127,7 @@ def main(args): '-sDEVICE=pdfwrite', f'-dDEVICEWIDTHPOINTS={w}', f'-dDEVICEHEIGHTPOINTS={h}', '-dFIXEDMEDIA', '-c', f'<> setpagedevice', - '-f', signaturePath(), + '-f', translatablePDF(signaturePath()), ], check=True) return outFile # The signed page From 0a0c7c9e13e5aac3d825db78fe39b02d1fc6200a Mon Sep 17 00:00:00 2001 From: Axel Svensson Date: Mon, 3 Jun 2024 10:36:22 +0200 Subject: [PATCH 07/14] Improve information about signature directory Rejects #8. --- README.md | 3 ++- pdf-sign | 70 ++++++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 9f7ee1c..727930a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ The recommended way is: * Use an application of your choice to sign it. You can for example use Okular's Freehand Line, or transfer it to your smartphone and use Adobe Acrobat Reader. Keep in mind that it's the center of this mini-page that will be used for positioning the signature. -* Put the signed file in `~/.pdf_signatures/`. +* Put the signed file in your signature directory. + The signature directory is `$PDF_SIGNATURE_DIR`, `$XDG_CONFIG_HOME/pdf_signatures`, `$HOME/.config/pdf_signatures` or `$HOME/.pdf_signatures/`; the first one that exists. Use `pdf-sign -h` to confirm which one will be used on your system. You can now use the `pdf-sign` tool interactively (or non-interactively) to sign PDF files. The GUI is self documented and allows both keyboard-only and pointer-only operation. diff --git a/pdf-sign b/pdf-sign index a56cb93..5eb3199 100755 --- a/pdf-sign +++ b/pdf-sign @@ -397,20 +397,44 @@ def qpdfOrPdftk(qpdfCmd, pdftkCmd): 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 + (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. @@ -536,10 +560,24 @@ if not 'BooleanOptionalAction' in dir(argparse): return ' | '.join(self.option_strings) argparse.BooleanOptionalAction = BooleanOptionalAction -parser = argparse.ArgumentParser(description='Sign a PDF file.') + +def getSignatureHelp(): + (path, helptxt) = getSignatureDirAndHelp() + ret=""" + Path to file used as signature. + Required in batch mode. + 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='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('-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)') From 3eed74e1ef5bf09f4d54bc7f0fc79d68bd713da0 Mon Sep 17 00:00:00 2001 From: Axel Svensson Date: Mon, 3 Jun 2024 10:51:13 +0200 Subject: [PATCH 08/14] Refuse to operate on files with dash prefix Fixes #6. --- pdf-sign | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pdf-sign b/pdf-sign index 5eb3199..56d2a36 100755 --- a/pdf-sign +++ b/pdf-sign @@ -19,6 +19,9 @@ def main(args): 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: Volatile(os.path.join(tempdir, fileName)) # Maybe flatten (make forms non-editable) before signing From c61747cf7a69f32c9f063f3b4ae00ecd1b4228c7 Mon Sep 17 00:00:00 2001 From: Axel Svensson Date: Tue, 9 Jul 2024 06:00:47 +0000 Subject: [PATCH 09/14] Add pdf-sign --text and pdf-from-text Fixes #9. --- README.md | 14 ++++- pdf-from-text | 98 +++++++++++++++++++++++++++++ pdf-sign | 171 +++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 271 insertions(+), 12 deletions(-) create mode 100755 pdf-from-text diff --git a/README.md b/README.md index 727930a..0520279 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The recommended way is: You can now use the `pdf-sign` tool interactively (or non-interactively) to sign PDF files. The GUI is self documented and allows both keyboard-only and pointer-only operation. -Run `pdf-sign -h` or `pdf-create-empty -h` for details. +Run `pdf-sign -h`, `pdf-create-empty -h` or `pdf-from-text -h` for details. **Installation** @@ -41,9 +41,19 @@ apt-get update apt-get install -y coreutils git python3 python3-tk ghostscript pdftk poppler-utils git clone https://github.com/svenssonaxel/pdf-sign.git cd pdf-sign -cp pdf-sign pdf-create-empty /usr/local/bin/ +cp pdf-sign pdf-create-empty pdf-from-text /usr/local/bin/ ``` +### Related use cases + +* You can add the date or other pieces of text using the `--text` CLI option or `Signature -> Custom text` menu option. +* You can convert SVG stamps/marks and add them to your signature directory. Example: + ``` + curl -LO https://www.svgrepo.com/download/438371/checkmark-round.svg + sudo apt-get install librsvg2-bin + rsvg-convert -f pdf -o ~/.pdf_signatures/check.pdf checkmark-round.svg + ``` + ## Why There appears to be a lack of applications that run on Linux and allow for attaching free-hand signatures to PDF files in a good way. diff --git a/pdf-from-text b/pdf-from-text new file mode 100755 index 0000000..dfb89cb --- /dev/null +++ b/pdf-from-text @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 + +#Dependencies: python3.7 or later and gs (Ghostscript) + +import argparse, os, re, subprocess, sys, tempfile + +def main(): + parser = argparse.ArgumentParser(description="Create a correctly cropped PDF from one line of text. Only Latin-1 character set is supported.") + parser.add_argument("-t", "--text", required=True, help="Text to be converted to PDF.") + parser.add_argument("-o", "--output", required=True, help="Output PDF file.") + parser.add_argument("-s", "--size", type=int, default=12, help="Font size in points (default 12).") + parser.add_argument("-m", "--margin", type=int, default=1, help="Margin in points (default 1).") + args = parser.parse_args() + text_to_pdf(args.text, args.output, args.size, args.margin) + +# Keep this function in sync with pdf-sign +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 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 die(reason): + print(reason, file=sys.stderr) + sys.exit(2) + +if __name__ == "__main__": + main() diff --git a/pdf-sign b/pdf-sign index 56d2a36..837355c 100755 --- a/pdf-sign +++ b/pdf-sign @@ -57,15 +57,48 @@ def main(args): 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.') + 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.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=[*filter(isPdfFilename, os.listdir(signatureDir))] if not args.signature else [None] - if not signatures: - die(f'No .pdf files found in {signatureDir}') + 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) - signaturePath=Cell(lambda: args.signature if args.signature else os.path.join(signatureDir, signatures[signatureIndex()])) + 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=os.path.join(tempdir, 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) @@ -173,9 +206,11 @@ def main(args): 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(): @@ -208,6 +243,39 @@ def main(args): if i Date: Fri, 12 Jul 2024 12:56:22 +0000 Subject: [PATCH 10/14] Check that --signature exists --- pdf-sign | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pdf-sign b/pdf-sign index 837355c..cf1f4f7 100755 --- a/pdf-sign +++ b/pdf-sign @@ -64,6 +64,8 @@ def main(args): 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)]) From ed54c79aa7109947e54200261703ec08a8676082 Mon Sep 17 00:00:00 2001 From: Axel Svensson Date: Tue, 16 Jul 2024 08:01:05 +0000 Subject: [PATCH 11/14] Refactor dependency tracking for files --- pdf-sign | 65 ++++++++++++++++++++++++++++++-------------------------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/pdf-sign b/pdf-sign index cf1f4f7..259cb64 100755 --- a/pdf-sign +++ b/pdf-sign @@ -23,10 +23,10 @@ def main(args): # A cheap solution to a rare problem die("Input file may not start with a dash (-)") with tempfile.TemporaryDirectory() as tempdir: - intmp=lambda fileName: Volatile(os.path.join(tempdir, fileName)) + intmp=lambda fileName: os.path.join(tempdir, fileName) # Maybe flatten (make forms non-editable) before signing if args.flatten: - inputPDF=str(intmp('input.pdf')) + inputPDF=intmp('input.pdf') qpdfOrPdftk([ 'qpdf', '--flatten-annotations=all', @@ -44,18 +44,18 @@ def main(args): 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 + @VolatileCell def pagePDF(): outFile=intmp('page.pdf') qpdfOrPdftk([ 'qpdf', '--pages', '.', f'{pageNumber()}', '--', - inputPDF, str(outFile) + inputPDF, outFile ],[ 'pdftk', inputPDF, 'cat', str(pageNumber()), - 'output', str(outFile)]) + 'output', outFile]) return outFile - pageSize=Cell(lambda: pdfGetSize(str(pagePDF()))) + pageSize=Cell(lambda: pdfGetSize(pagePDF())) # The chosen signature if args.batch: if not args.signature and not args.text: @@ -96,7 +96,7 @@ def main(args): cache = signaturePath._cache if content in cache: return cache[content] - fileName=os.path.join(tempdir, f"text{len(cache)}.pdf") + fileName=intmp(f"text{len(cache)}.pdf") text_to_pdf(content, fileName, 12, 1) cache[content]=fileName return fileName @@ -110,7 +110,7 @@ def main(args): if path not in cache: (w, h) = pdfGetSize(path) (double_w, double_h) = (2*w, 2*h) - testFile = os.path.join(tempdir, 'translateTest.pdf') + testFile = intmp('translateTest.pdf') subprocess.run([ 'gs', '-dBATCH', '-dNOPAUSE', '-dSAFER', '-dQUIET', f'-sOutputFile={testFile}', @@ -130,8 +130,8 @@ def main(args): # 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') + translatableFileName = intmp(f'translatable{len(cache)}.pdf') + emptyFileName = intmp(f'empty{len(cache)}.pdf') subprocess.run([ 'gs', '-dBATCH', '-dNOPAUSE', '-dSAFER', '-dQUIET', f'-sOutputFile={emptyFileName}', @@ -151,7 +151,7 @@ def main(args): die(f"The PDF at {path} is unusable as a signature. Reason unknown.") return cache[path] translatablePDF._cache={} - @Cell + @VolatileCell def signaturePositionedPDF(): (w, h)=pageSize() (sw, sh)=signatureSize() @@ -169,16 +169,16 @@ def main(args): ], check=True) return outFile # The signed page - @Cell + @VolatileCell def signedPagePDF(): outFile=intmp('signed-page.pdf') qpdfOrPdftk([ - 'qpdf', '--overlay', str(signaturePositionedPDF()), '--', - str(pagePDF()), str(outFile), + 'qpdf', '--overlay', signaturePositionedPDF(), '--', + pagePDF(), outFile, ],[ - 'pdftk', str(pagePDF()), - 'stamp', str(signaturePositionedPDF()), - 'output', str(outFile) + 'pdftk', pagePDF(), + 'stamp', signaturePositionedPDF(), + 'output', outFile ]) return outFile # The signed page as PNG, for GUI use @@ -189,7 +189,7 @@ def main(args): (pageWidth, pageHeight)=pageSize() scale=min(maxWidth/pageWidth, maxHeight/pageHeight) return (round(pageWidth*scale), round(pageHeight*scale)) - @Cell + @VolatileCell def displayPNG(): (w, h)=displaySize() outFile=intmp('display.png') @@ -199,7 +199,7 @@ def main(args): '-sDEVICE=pngalpha', '-dMaxBitmap=2147483647', f'-dDEVICEWIDTHPOINTS={w}', f'-dDEVICEHEIGHTPOINTS={h}', '-dFIXEDMEDIA', '-dPDFFitPage', - '-f', str(signedPagePDF()), + '-f', signedPagePDF(), ], check=True) return outFile # GUI @@ -414,7 +414,7 @@ def main(args): if not updateActive: return (w, h) = displaySize() - root._docImg = tk.PhotoImage(file=str(displayPNG())) + root._docImg = tk.PhotoImage(file=displayPNG()) root._docView.itemconfig(root._docViewIndex, image=root._docImg) root._docView.configure(width=w, height=h) updateTitle() @@ -451,7 +451,7 @@ def main(args): qpdfOrPdftk([ 'qpdf', '--pages', *(['.', f'1-{pnr-1}'] if 1 < pnr else []), - str(signedPagePDF()), '1', + signedPagePDF(), '1', *(['.', f'{pnr+1}-z'] if pnr < pageCount else []), '--', inputPDF, @@ -470,12 +470,6 @@ def main(args): 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") @@ -553,7 +547,8 @@ class Cell(): for i in range(len(self._precedents)): p=self._precedents[i] oldval=self._precedentvalues[i] - newval=p() + p() + newval=p._cacheid() if oldval!=newval: self._needEval=True break @@ -570,13 +565,23 @@ class Cell(): if(Cell.currentCell): self._dependents.append(Cell.currentCell) Cell.currentCell._precedents.append(self) - Cell.currentCell._precedentvalues.append(self._value) + 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): @@ -666,7 +671,7 @@ def tkthrottle(frequency, root): return decorator def pdfCountPages(filePath): - return int(fromCmdOutput(["pdfinfo", str(filePath)], "^.*\nPages: +([0-9]+)\n.*$")[1]) + 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.*$') From 21aad7d1b9290528fc73b5dc1d864993f7079f44 Mon Sep 17 00:00:00 2001 From: Axel Svensson Date: Sat, 15 Jun 2024 15:58:38 +0000 Subject: [PATCH 12/14] Fix regex in pdfGetSize --- pdf-sign | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pdf-sign b/pdf-sign index 259cb64..8fb07a9 100755 --- a/pdf-sign +++ b/pdf-sign @@ -671,10 +671,10 @@ def tkthrottle(frequency, root): return decorator def pdfCountPages(filePath): - return int(fromCmdOutput(["pdfinfo", filePath], "^.*\nPages: +([0-9]+)\n.*$")[1]) + return int(fromCmdOutput(["pdfinfo", filePath], r"^.*\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], r'^.*\nPage size: +([0-9.]+) x ([0-9.]+) pts( \([A-Za-z0-9]+\))?\n.*$') return (float(w), float(h)) def m(pattern, string): From 9e0ef1a65cd0eb64ac79f789d403fb4ea7cc4919 Mon Sep 17 00:00:00 2001 From: Axel Svensson Date: Tue, 16 Jul 2024 14:40:03 +0000 Subject: [PATCH 13/14] Update copyright --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 6c17ebc..0521158 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright © 2021 Axel Svensson +Copyright © 2021-2024 Axel Svensson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: From 1a69e2094611703c6dac392bcfd4b8eb75226bc1 Mon Sep 17 00:00:00 2001 From: Axel Svensson Date: Tue, 16 Jul 2024 20:30:46 +0000 Subject: [PATCH 14/14] Check python version --- pdf-create-empty | 4 ++++ pdf-from-text | 4 ++++ pdf-sign | 12 ++++++++---- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/pdf-create-empty b/pdf-create-empty index a81401e..4347853 100755 --- a/pdf-create-empty +++ b/pdf-create-empty @@ -44,6 +44,10 @@ def die(reason): print(reason, file=sys.stderr) sys.exit(2) +pyver = sys.version_info +if not (pyver.major == 3 and pyver.minor >= 7): + die("Requires python 3.7 or later") + parser = argparse.ArgumentParser(description='Create an empty, transparent PDF file.') parser.add_argument('-o', '--output', metavar='FILE', type=str, help='Output PDF file. (default: empty-DIMENSIONS.pdf)') parser.add_argument('-d', '--dimensions', type=str, default='3inx2in', help='The page dimensions of the file to create. Supports units pts, in, cm, and mm. (default: 3inx2in)') diff --git a/pdf-from-text b/pdf-from-text index dfb89cb..0d46b1f 100755 --- a/pdf-from-text +++ b/pdf-from-text @@ -94,5 +94,9 @@ def die(reason): print(reason, file=sys.stderr) sys.exit(2) +pyver = sys.version_info +if not (pyver.major == 3 and pyver.minor >= 7): + die("Requires python 3.7 or later") + if __name__ == "__main__": main() diff --git a/pdf-sign b/pdf-sign index 8fb07a9..868836a 100755 --- a/pdf-sign +++ b/pdf-sign @@ -8,6 +8,14 @@ import argparse, os, queue, re, subprocess, sys, tempfile, traceback, time +def die(reason): + print(reason, file=sys.stderr) + sys.exit(1) + +pyver = sys.version_info +if not (pyver.major == 3 and pyver.minor >= 7): + die("Requires python 3.7 or later") + # Inspired by https://unix.stackexchange.com/a/141496 def main(args): if not hasQpdf and not has("pdftk"): @@ -696,10 +704,6 @@ def fromCmdOutput(cmd, pattern): 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):