From c61747cf7a69f32c9f063f3b4ae00ecd1b4228c7 Mon Sep 17 00:00:00 2001 From: Axel Svensson Date: Tue, 9 Jul 2024 06:00:47 +0000 Subject: [PATCH] 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