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.
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,7 +41,17 @@ 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

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
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<len(signatures):
signatureIndex(i)
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():
root.destroy()
def cmd_sign():
@ -245,8 +313,22 @@ def main(args):
if root.signatureControlVar.get() != signatureIndex():
signatureIndex(root.signatureControlVar.get())
update()
for index, filename 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)
for index, (_, signature) in enumerate(signatures):
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_command(label='Enlarge signature', underline=0, accelerator='+', command=cmd_enlargeSignature)
placemenu.add_command(label='Shrink signature', underline=0, accelerator='-', command=cmd_shrinkSignature)
@ -274,6 +356,8 @@ def main(args):
'q': cmd_abort,
'S': cmd_sign,
's': cmd_sign,
'T': cmd_customText,
't': cmd_customText,
'space': cmd_sign,
}
def onkey(event):
@ -492,6 +576,72 @@ class Cell():
for d in self._dependents:
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):
wait=1/frequency
now=lambda: time.time()
@ -568,7 +718,7 @@ def getSignatureHelp():
(path, helptxt) = getSignatureDirAndHelp()
ret="""
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.
"""
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('-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('-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('-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)')
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']))