Add pdf-sign --text and pdf-from-text

Fixes #9.
Dieser Commit ist enthalten in:
Axel Svensson 2024-07-09 06:00:47 +00:00
Ursprung 3eed74e1ef
Commit c61747cf7a
3 geänderte Dateien mit 271 neuen und 12 gelöschten Zeilen

Datei anzeigen

@ -22,7 +22,7 @@ The recommended way is:
You can now use the `pdf-sign` tool interactively (or non-interactively) to sign PDF files. 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. 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** **Installation**
@ -41,9 +41,19 @@ apt-get update
apt-get install -y coreutils git python3 python3-tk ghostscript pdftk poppler-utils apt-get install -y coreutils git python3 python3-tk ghostscript pdftk poppler-utils
git clone https://github.com/svenssonaxel/pdf-sign.git git clone https://github.com/svenssonaxel/pdf-sign.git
cd pdf-sign 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 ## 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. 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.

98
pdf-from-text Ausführbare Datei
Datei anzeigen

@ -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()

171
pdf-sign
Datei anzeigen

@ -57,15 +57,48 @@ def main(args):
return outFile return outFile
pageSize=Cell(lambda: pdfGetSize(str(pagePDF()))) pageSize=Cell(lambda: pdfGetSize(str(pagePDF())))
# The chosen signature # The chosen signature
if not args.signature and args.batch: if args.batch:
die('In batch mode, signature must be specified.') 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() signatureDir=getSignatureDir()
signatures=[*filter(isPdfFilename, os.listdir(signatureDir))] if not args.signature else [None] signatures=[('file', x) for x in filter(isPdfFilename, os.listdir(signatureDir))] if not args.signature else [None]
if not signatures: if not signatures and not args.text:
die(f'No .pdf files found in {signatureDir}') die(f'Could not find anything usable as signature, since no .pdf files found in {signatureDir} and no --text option given.')
signatures.sort() signatures.sort()
if args.text:
signatures=[('text', x) for x in args.text] + signatures
signatureIndex=Cell(0) 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())) signatureSize=Cell(lambda: pdfGetSize(signaturePath()))
signaturePositionX=Cell(args.x_coordinate) signaturePositionX=Cell(args.x_coordinate)
signaturePositionY=Cell(args.y_coordinate) signaturePositionY=Cell(args.y_coordinate)
@ -173,9 +206,11 @@ def main(args):
if gui: if gui:
try: try:
import tkinter as tk import tkinter as tk
from tkinter import simpledialog
except ModuleNotFoundError: except ModuleNotFoundError:
die('Cannot find Python module `tkinter`, which is needed for interactive use.') die('Cannot find Python module `tkinter`, which is needed for interactive use.')
doSign=False doSign=False
customTextIndex = len(signatures)
# Commands # Commands
def uf(fun): def uf(fun):
def ret(): def ret():
@ -208,6 +243,39 @@ def main(args):
if i<len(signatures): if i<len(signatures):
signatureIndex(i) signatureIndex(i)
update() 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(): def cmd_abort():
root.destroy() root.destroy()
def cmd_sign(): def cmd_sign():
@ -245,8 +313,22 @@ def main(args):
if root.signatureControlVar.get() != signatureIndex(): if root.signatureControlVar.get() != signatureIndex():
signatureIndex(root.signatureControlVar.get()) signatureIndex(root.signatureControlVar.get())
update() update()
for index, filename in enumerate(signatures): for index, (_, signature) in enumerate(signatures):
placemenu.add_radiobutton(value=index, label=f'{index+1}: {filename}', underline=0 if index<9 else None, variable=root.signatureControlVar, accelerator=(str(index+1) if index<9 else None), command=updateFromSignatureRadio) 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_separator()
placemenu.add_command(label='Enlarge signature', underline=0, accelerator='+', command=cmd_enlargeSignature) 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_command(label='Shrink signature', underline=0, accelerator='-', command=cmd_shrinkSignature)
@ -274,6 +356,8 @@ def main(args):
'q': cmd_abort, 'q': cmd_abort,
'S': cmd_sign, 'S': cmd_sign,
's': cmd_sign, 's': cmd_sign,
'T': cmd_customText,
't': cmd_customText,
'space': cmd_sign, 'space': cmd_sign,
} }
def onkey(event): def onkey(event):
@ -492,6 +576,72 @@ class Cell():
for d in self._dependents: for d in self._dependents:
d._dirty() d._dirty()
# 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): def tkthrottle(frequency, root):
wait=1/frequency wait=1/frequency
now=lambda: time.time() now=lambda: time.time()
@ -568,7 +718,7 @@ def getSignatureHelp():
(path, helptxt) = getSignatureDirAndHelp() (path, helptxt) = getSignatureDirAndHelp()
ret=""" ret="""
Path to file used as signature. Path to file used as signature.
Required in batch mode. Required in batch mode unless -t is given.
In GUI mode, the user can choose among PDF files in the signature directory. In GUI mode, the user can choose among PDF files in the signature directory.
""" """
if path: if path:
@ -584,10 +734,11 @@ 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('-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('-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('-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('-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('-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('-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('-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('-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'])) main(parser.parse_args(sys.argv[1:] or ['-h']))