Commits vergleichen

..

17 Commits

Autor SHA1 Nachricht Datum
Axel Svensson
2d0ef9c085 Fix use of signatures with CropBox 2024-07-16 07:04:08 +00:00
Axel Svensson
8e83ba1a2d timing debug prints 2024-07-16 06:38:11 +00:00
Axel Svensson
6f3e268672 improve debug prints 2024-07-13 17:12:59 +00:00
Axel Svensson
bfab36ece4 debug print photo load time 2024-07-13 17:03:19 +00:00
Axel Svensson
b2531a89e4 debug subprocess runs 2024-07-13 15:24:03 +00:00
Axel Svensson
fffac1c147 Improve Adaptive initial window size 2024-07-13 15:01:40 +00:00
Axel Svensson
823c67bbee Improve startup time 2024-07-13 14:45:33 +00:00
Axel Svensson
74ad2b009c Improve debug prints 2024-07-13 14:45:33 +00:00
Axel Svensson
d6f52c917e Adaptive initial window size 2024-07-13 11:26:13 +00:00
Axel Svensson
91cc247154 More Debug prints for issue-5 2024-07-13 11:22:27 +00:00
Axel Svensson
80ca4898ca WIP fix help text
Maybe fixes #11.
2024-07-12 13:22:11 +00:00
Axel Svensson
939a6c96f8 Debug issue-5 key/button events 2024-07-02 23:07:21 +00:00
Axel Svensson
89c808fb32 WIP issue-5 2024-07-01 22:52:27 +00:00
Axel Svensson
a8df5d398c WIP: Improve size calculation 2024-07-01 22:26:30 +00:00
Axel Svensson
2025f66620 WIP Improve help text
Maybe fixes #11
2024-07-01 22:26:30 +00:00
Axel Svensson
4727a65f08 WIP: fix regex escapes 2024-07-01 22:26:16 +00:00
Axel Svensson
71b1a84970 WIP issue #5 2024-06-03 09:44:19 +02:00
10 geänderte Dateien mit 163 neuen und 530 gelöschten Zeilen

Datei anzeigen

@ -1,23 +0,0 @@
pkgbase = pdf-sign
pkgdesc = Simple tool to sign PDFs using an saved signature
pkgver = 1.0
pkgrel = 1
url = https://github.com/svenssonaxel/pdf-sign
arch = x86_64
license = MIT
depends = ghostscript
depends = python
depends = qpdf
depends = poppler
optdepends = tk: for interactive usage
optdepends = pdftk: an alterative tool to process PDF for qpdf
source = pdf-create-empty
source = pdf-from-text
source = pdf-sign
source = empty-3inx2in.pdf
sha256sums = d6b3bd23a3fcf28561e152690332738babf5e71afde40e5727c643a5805ae76b
sha256sums = 4a509aa3e787c3e617f151bcd25dd93adf9af3af477562c64988f7784395d40d
sha256sums = 7b52792ead60257c57f4a12d727a882936f24b9cb3ae57d73beb90cdd7960707
sha256sums = a2fd2476c1d8364853c2382a8090d8fbc2f11fb4a535714e27a3069ce009eef3
pkgname = pdf-sign

Datei anzeigen

@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright © 2021-2024 Axel Svensson
Copyright © 2021 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:

Datei anzeigen

@ -1,39 +0,0 @@
# Maintainer: Sebastian Tobie <archlinux@sebastian-tobie.de>
# based on the PKGBUILD of sulis
pkgname=pdf-sign
pkgver=1.0
pkgrel=1
pkgdesc='Simple tool to sign PDFs using an saved signature'
arch=('x86_64')
url='https://github.com/svenssonaxel/pdf-sign'
license=('MIT')
depends=(
'ghostscript'
'python'
'qpdf'
'poppler'
)
optdepends=(
'tk: for interactive usage'
'pdftk: an alterative tool to process PDF for qpdf'
)
source=(
'pdf-create-empty'
'pdf-from-text'
'pdf-sign'
'empty-3inx2in.pdf'
)
sha256sums=(
'd6b3bd23a3fcf28561e152690332738babf5e71afde40e5727c643a5805ae76b'
'4a509aa3e787c3e617f151bcd25dd93adf9af3af477562c64988f7784395d40d'
'7b52792ead60257c57f4a12d727a882936f24b9cb3ae57d73beb90cdd7960707'
'a2fd2476c1d8364853c2382a8090d8fbc2f11fb4a535714e27a3069ce009eef3'
)
package() {
install -Dm755 -T pdf-create-empty "${pkgdir}/usr/bin/pdf-create-empty"
install -Dm755 -T pdf-from-text "${pkgdir}/usr/bin/pdf-from-text"
install -Dm755 -T pdf-sign "${pkgdir}/usr/bin/pdf-sign"
install -Dm644 -T empty-3inx2in.pdf "${pkgdir}/usr/share/doc/pdf-sign/examples/empty-3inx2in.pdf"
}

Binäre Datei nicht angezeigt.

Vorher

Breite:  |  Höhe:  |  Größe: 22 KiB

Datei anzeigen

Vorher

Breite:  |  Höhe:  |  Größe: 342 KiB

Nachher

Breite:  |  Höhe:  |  Größe: 342 KiB

Datei anzeigen

@ -5,7 +5,7 @@
A tool to sign PDF files, with Linux support.
We are here referring to the visible, non-cryptographic squiggles.
![](README-example-use.gif)
![](README-example.gif)
## How
@ -16,18 +16,12 @@ 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.
<img src="README-example-signature.gif" width="250"/>
It's a good idea to write your signature on an imagined line through the center of the mini-page.
That way, it can be positioned correctly by clicking on the signature line.
* 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.
* Put the signed file in `~/.pdf_signatures/`.
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`, `pdf-create-empty -h` or `pdf-from-text -h` for details.
Run `pdf-sign -h` or `pdf-create-empty -h` for details.
**Installation**
@ -37,7 +31,7 @@ Run `pdf-sign -h`, `pdf-create-empty -h` or `pdf-from-text -h` for details.
* `gs` (Ghostscript)
* `qpdf` or `pdftk` (at least one of them)
* `pdfinfo`
* Copy the tools to a directory in your `$PATH`.
* Copy one or both tools to a directory in your `$PATH`.
**Installation on Debian**
@ -46,20 +40,9 @@ 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-* /usr/local/bin/
cp pdf-sign pdf-create-empty /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.
If you want both a date and a signature, you have to invoke `pdf-sign` twice.
* 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 ~/.config/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.

Binäre Datei nicht angezeigt.

Datei anzeigen

@ -44,10 +44,6 @@ 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)')

Datei anzeigen

@ -1,102 +0,0 @@
#!/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)
pyver = sys.version_info
if not (pyver.major == 3 and pyver.minor >= 7):
die("Requires python 3.7 or later")
if __name__ == "__main__":
main()

494
pdf-sign
Datei anzeigen

@ -8,16 +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")
from inspect import currentframe
starttime=time.time()
def tdbg():
print(f"========== DBG: line {currentframe().f_back.f_lineno}, time after start={time.time()-starttime:.3f} ==========", flush=True)
# Inspired by https://unix.stackexchange.com/a/141496
def main(args):
tdbg()
if not hasQpdf and not has("pdftk"):
die("Needs either qpdf or pdftk installed")
if not has("gs"):
@ -27,15 +25,14 @@ 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 (-)")
tdbg()
with tempfile.TemporaryDirectory() as tempdir:
intmp=lambda fileName: os.path.join(tempdir, fileName)
tdbg()
intmp=lambda fileName: Volatile(os.path.join(tempdir, fileName))
# Maybe flatten (make forms non-editable) before signing
if args.flatten:
inputPDF=intmp('input.pdf')
qpdfOrPdftk(args, [
inputPDF=str(intmp('input.pdf'))
qpdfOrPdftk([
'qpdf',
'--flatten-annotations=all',
'--generate-appearances',
@ -47,93 +44,46 @@ def main(args):
])
else:
inputPDF=filePath
tdbg()
# The chosen page
pageCount=pdfCountPages(inputPDF)
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)
@VolatileCell
pageNumber=Cell(args.page if 0 < args.page else pageCount+args.page+1, 'pageNumber')
@Cell
def pagePDF():
outFile=intmp('page.pdf')
qpdfOrPdftk(args, [
qpdfOrPdftk([
'qpdf', '--pages', '.', f'{pageNumber()}', '--',
inputPDF, outFile
inputPDF, str(outFile)
],[
'pdftk', inputPDF,
'cat', str(pageNumber()),
'output', outFile])
'output', str(outFile)])
return outFile
pageSize=Cell(lambda: pdfGetSize(pagePDF()))
pageSize=Cell(lambda: pdfGetSize(str(pagePDF())), 'pageSize')
# The chosen signature
if args.signature:
if args.text:
die('--signature and --text cannot be specified together.')
if not os.path.exists(args.signature):
die(f'File not found: {args.signature}')
signatures=[('file', args.signature, args.signature)]
elif args.batch:
assert not args.signature
if not args.text:
die('In batch mode, --signature or --text must be specified.')
if len(args.text) > 1:
die('In batch mode, --text must be given only once.')
validateText(args.text[0], True)
signatures=[('text', textLabel(args.text[0]), args.text[0])]
else:
(signatureDir, signatureDirHelp)=getSignatureDirAndHelp()
signatures=([('file', x, os.path.join(signatureDir, x))
for x in filter(isPdfFilename, os.listdir(signatureDir))]
if signatureDir else [])
tdbg()
if not args.signature and args.batch:
die('In batch mode, signature must be specified.')
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.sort()
if not signatures and not args.text:
die('Could not find any usable signatures. No --signature, no --text, and ' +
('no .pdf files found in {signatureDir}.'
if signatureDir else
f'no signature directory. The options considered for signature directory are: {signatureDirHelp}'))
if args.text:
assert(len(args.text) > 0)
for text in args.text:
validateText(text, True)
signatures=[('text', textLabel(x), x) for x in args.text] + signatures
signatureIndex=Cell(0)
@Cell
def signaturePath():
(signType, _, content)=signatures[signatureIndex()]
if signType=='cell':
(signType, _, content)=content()
if signType=='file':
return content
assert signType=='text'
cache = signaturePath._cache
if content in cache:
return cache[content]
fileName=intmp(f"text{len(cache)}.pdf")
text_to_pdf(content, fileName, 12, 1)
cache[content]=fileName
return fileName
signaturePath._cache={}
signatureSize=Cell(lambda: pdfGetSize(signaturePath()))
try:
xm=m("^(L\\+|L-|R\\+|R-|-|)([0-9.]+)%$", args.x_coordinate)
default_x=(lambda x:{'L+':x,'L-':-x,'R+':1+x,'R-':1-x,'-':-x,'':x}[xm[1]])(float(xm[2])/100)
except:
die('Invalid -x option')
try:
ym=m("^(T\\+|T-|B\\+|B-|-|)([0-9.]+)%$", args.y_coordinate)
default_y=(lambda y:{'T+':y,'T-':-y,'B+':1+y,'B-':1-y,'-':-y,'':y}[ym[1]])(float(ym[2])/100)
except:
die('Invalid -y option')
signaturePositionX=Cell(default_x)
signaturePositionY=Cell(default_y)
signatureScale=Cell(0)
signatureIndex=Cell(0, 'signatureIndex')
signaturePath=Cell(lambda: args.signature if args.signature else os.path.join(signatureDir, signatures[signatureIndex()]), 'signaturePath')
signatureSize=Cell(lambda: pdfGetSize(signaturePath()), 'signatureSize')
signaturePositionX=Cell(args.x_coordinate, 'signaturePositionX')
signaturePositionY=Cell(args.y_coordinate, 'signaturePositionY')
signatureScale=Cell(0, 'signatureScale')
tdbg()
def translatablePDF(path):
cache = translatablePDF._cache
if path not in cache:
(w, h) = pdfGetSize(path)
if not (1 < w and 1 < h):
die(f"The PDF at {path} is unusable as a signature due to too small dimensions.")
(double_w, double_h) = (2*w, 2*h)
testFile = intmp('translateTest.pdf')
testFile = os.path.join(tempdir, 'translateTest.pdf')
subprocess.run([
'gs', '-dBATCH', '-dNOPAUSE', '-dSAFER', '-dQUIET',
f'-sOutputFile={testFile}',
@ -143,25 +93,23 @@ def main(args):
'-f', path,
], check=True)
(test_w, test_h) = pdfGetSize(testFile)
if abs(test_w - double_w) < 0.01 and abs(test_h - double_h) < 0.01:
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 = intmp(f'translatable{len(cache)}.pdf')
emptyFileName = intmp(f'empty{len(cache)}.pdf')
# has an explicit CropBox set. 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(args, [
qpdfOrPdftk([
'qpdf', '--overlay', path, '--',
emptyFileName, translatableFileName,
],[
@ -175,27 +123,10 @@ def main(args):
return cache[path]
translatablePDF._cache={}
@Cell
def defaultResizeFactor():
(pageWidth, pageHeight)=pageSize()
try:
wm=m("^([0-9.]+)(pts|pt|in|cm|mm|%)$", args.width)
maxSignatureWidth=int(float(wm[1])*({'pts':1,'pt':1,'in':72,'cm':28.3,'mm':2.83,'%':pageWidth/100}[wm[2]]))
except:
die('Invalid -W option')
try:
hm=m("^([0-9.]+)(pts|pt|in|cm|mm|%)$", args.height)
maxSignatureHeight=int(float(hm[1])*({'pts':1,'pt':1,'in':72,'cm':28.3,'mm':2.83,'%':pageHeight/100}[hm[2]]))
except:
die('Invalid -H option')
(signatureWidth, signatureHeight)=signatureSize()
return min(args.resize_factor,
maxSignatureWidth / signatureWidth,
maxSignatureHeight / signatureHeight)
@VolatileCell
def signaturePositionedPDF():
(w, h)=pageSize()
(sw, sh)=signatureSize()
resize=1.1**signatureScale()*defaultResizeFactor()
resize=1.1**signatureScale()*min(args.width*w/sw, args.height*h/sh)
dx=w*signaturePositionX()/resize - sw/2
dy=h*(1-signaturePositionY())/resize - sh/2
outFile=intmp('signature-positioned.pdf')
@ -209,51 +140,51 @@ def main(args):
], check=True)
return outFile
# The signed page
@VolatileCell
@Cell
def signedPagePDF():
outFile=intmp('signed-page.pdf')
qpdfOrPdftk(args, [
'qpdf', '--overlay', signaturePositionedPDF(), '--',
pagePDF(), outFile,
qpdfOrPdftk([
'qpdf', '--overlay', str(signaturePositionedPDF()), '--',
str(pagePDF()), str(outFile),
],[
'pdftk', pagePDF(),
'stamp', signaturePositionedPDF(),
'output', outFile
'pdftk', str(pagePDF()),
'stamp', str(signaturePositionedPDF()),
'output', str(outFile)
])
return outFile
# The signed page as PNG, for GUI use
displayMaxSize=Cell((400, 800))
displayMaxSize=Cell((400, 800), 'displayMaxSize')
@Cell
def displaySize():
(maxWidth, maxHeight)=displayMaxSize()
(pageWidth, pageHeight)=pageSize()
scale=min(maxWidth/pageWidth, maxHeight/pageHeight)
return (round(pageWidth*scale), round(pageHeight*scale))
@VolatileCell
@Cell
def displayPNG():
(w, h)=displaySize()
outFile=intmp('display.png')
tdbg()
subprocess.run([
'gs', '-dBATCH', '-dNOPAUSE', '-dSAFER', '-dQUIET',
f'-sOutputFile={outFile}',
'-sDEVICE=pngalpha',
'-dMaxBitmap=2147483647',
f'-dDEVICEWIDTHPOINTS={w}', f'-dDEVICEHEIGHTPOINTS={h}', '-dFIXEDMEDIA', '-dPDFFitPage',
'-f', signedPagePDF(),
'-f', str(signedPagePDF()),
], check=True)
tdbg()
return outFile
# GUI
doSign=True
gui=not args.batch
if gui:
tdbg()
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
customText=Cell(('text', None, time.strftime('%Y-%m-%d')))
customTextIndex = len(signatures)
# Commands
def uf(fun):
def ret():
@ -286,32 +217,6 @@ 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()[2])
if text == None:
return
# Validate text
validateMsg = validateText(text, False)
if validateMsg:
simpledialog.messagebox.showerror(
parent=root,
title="Invalid text",
message=validateMsg
)
return
# Set text
customText(('text', None, text))
signatureIndex(customTextIndex)
label='Custom text: ' + textLabel(text)
placemenu.entryconfig(len(signatures) + 2, label=label)
update()
def textLabel(text): return f'"{text}"' if len(text) <= 20 else f'Custom text: "{text[:17]}"...'
def cmd_abort():
root.destroy()
def cmd_sign():
@ -324,7 +229,9 @@ def main(args):
sys.exit(1)
tk.Tk.report_callback_exception = tkerror
# Window and menu
tdbg()
root = tk.Tk(className="pdf-sign")
tdbg()
rootmenu = tk.Menu(root)
root.config(menu=rootmenu)
filemenu = tk.Menu(rootmenu, tearoff=0)
@ -349,27 +256,11 @@ def main(args):
if root.signatureControlVar.get() != signatureIndex():
signatureIndex(root.signatureControlVar.get())
update()
for index, (_, signatureText, _) in enumerate(signatures):
placemenu.add_radiobutton(
value=index,
label=f'{index+1}: {signatureText}',
underline=0 if index<9 else None,
variable=root.signatureControlVar,
accelerator=(str(index+1) if index<9 else None),
command=updateFromSignatureRadio)
signatures.append(('cell', None, customText))
placemenu.add_radiobutton(
value=customTextIndex,
label='Custom text: ' + textLabel(customText()[2]),
underline=7,
variable=root.signatureControlVar,
accelerator='T',
command=cmd_customText)
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)
placemenu.add_separator()
placemenu.add_command(label='Enlarge signature', underline=0, accelerator='+', command=cmd_enlargeSignature)
root.bind_all('<KP_Add>', lambda e: cmd_enlargeSignature())
placemenu.add_command(label='Shrink signature', underline=0, accelerator='-', command=cmd_shrinkSignature)
root.bind_all('<KP_Subtract>', lambda e: cmd_shrinkSignature())
placemenu.add_separator()
placemenu.add_command(label='Move signature left', underline=15, accelerator='Click / Left', command=cmd_moveSignatureLeft)
placemenu.add_command(label='Move signature down', underline=15, accelerator='Click / Down', command=cmd_moveSignatureDown)
@ -394,14 +285,16 @@ def main(args):
'q': cmd_abort,
'S': cmd_sign,
's': cmd_sign,
'T': cmd_customText,
't': cmd_customText,
'space': cmd_sign,
}
def onkey(event):
print(f"Debug: in onkey(event): char={event.char}, delta={event.delta}, height={event.height}, keycode={event.keycode}, keysym={event.keysym}, keysym_num={event.keysym_num}, num={event.num}, send_event={event.send_event}, serial={event.serial}, state={event.state}, time={event.time}, type={event.type}, widget={event.widget}, width={event.width}, x={event.x}, x_root={event.x_root}, y={event.y}, y_root={event.y_root}")
key=('C-' if event.state in [4, 5] else '')+event.keysym
if key in keyToFunction:
print(f"Debug: Calling function for {key}")
keyToFunction[key]()
else:
print(f"Debug: No function for {key}")
for key in keyToFunction.keys():
root.bind(f'<{key.split("-")[-1]}>', onkey)
def bindDigit(i, char):
@ -409,9 +302,12 @@ def main(args):
root.bind(f'{char}', onkey)
for i, char in enumerate("123456789"): bindDigit(i, char)
# Canvas and click binding
tdbg()
root._docView=tk.Canvas(root, borderwidth=0, background='#ffffff')
tdbg()
docViewMargin=5
docViewMinDimension=50
tdbg()
def onRootResize(event):
rootWidth=root.winfo_width()
rootHeight=root.winfo_height()
@ -427,6 +323,7 @@ def main(args):
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])}")
tdbg()
@Cell
def updateTitle():
root.title(f'Signing page {pageNumber()}/{pageCount} of {filePath}')
@ -445,28 +342,51 @@ def main(args):
updateActive = True
update()
root.after(100, soonAfterStart)
tdbg()
@tkthrottle(100, root)
def update():
tdbg()
if not updateActive:
return
(w, h) = displaySize()
root._docImg = tk.PhotoImage(file=displayPNG())
filename = str(displayPNG())
before = time.time()
tdbg()
root._docImg = tk.PhotoImage(file=filename)
tdbg()
print(f"Debug: Loading photo image took {time.time()-before:.2f} seconds", flush=True)
root._docView.itemconfig(root._docViewIndex, image=root._docImg)
root._docView.configure(width=w, height=h)
updateTitle()
if not args.signature:
if root.signatureControlVar.get() != signatureIndex():
root.signatureControlVar.set(signatureIndex())
tdbg()
def dbg(event, x):
print(f"Debug: in dbg(event) for {x}: char={event.char}, delta={event.delta}, height={event.height}, keycode={event.keycode}, keysym={event.keysym}, keysym_num={event.keysym_num}, num={event.num}, send_event={event.send_event}, serial={event.serial}, state={event.state}, time={event.time}, type={event.type}, widget={event.widget}, width={event.width}, x={event.x}, x_root={event.x_root}, y={event.y}, y_root={event.y_root}")
def dbgfun(x):
def fun(event):
dbg(event, x)
return fun
root._docView.bind('<KeyPress>', dbgfun("root._docView <KeyPress>"))
root._docView.bind('<Button>', dbgfun("root._docView <Button>"))
root.bind('<KeyPress>', dbgfun("root <KeyPress>"))
root.bind('<Button>', dbgfun("root <Button>"))
tdbg()
def onclick(event):
print(f"Debug: in onclick(event): char={event.char}, delta={event.delta}, height={event.height}, keycode={event.keycode}, keysym={event.keysym}, keysym_num={event.keysym_num}, num={event.num}, send_event={event.send_event}, serial={event.serial}, state={event.state}, time={event.time}, type={event.type}, widget={event.widget}, width={event.width}, x={event.x}, x_root={event.x_root}, y={event.y}, y_root={event.y_root}")
x=event.x
y=event.y
canvasConfig=root._docView.config()
canvasWidth=int(canvasConfig['width'][4])
canvasHeight=int(canvasConfig['height'][4])
print(f"Debug: in onclick(event): canvasConfig={repr(canvasConfig)}, canvasWidth={canvasWidth}, canvasHeight={canvasHeight}, calling cmd_positionSignature({x/canvasWidth}, {y/canvasHeight})")
cmd_positionSignature(x/canvasWidth, y/canvasHeight)
root._docView.bind('<Button-1>', onclick)
# Run GUI
tdbg()
root.mainloop()
tdbg()
# End of GUI
if doSign:
signedFilePath=args.output if args.output else f'{filePath[:-4]}.signed{filePath[-4:]}'
@ -484,10 +404,10 @@ def main(args):
else:
assert args.existing=='overwrite'
pnr=pageNumber()
qpdfOrPdftk(args, [
qpdfOrPdftk([
'qpdf', '--pages',
*(['.', f'1-{pnr-1}'] if 1 < pnr else []),
signedPagePDF(), '1',
str(signedPagePDF()), '1',
*(['.', f'{pnr+1}-z'] if pnr < pageCount else []),
'--',
inputPDF,
@ -506,75 +426,36 @@ 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")
def qpdfOrPdftk(args, qpdfCmd, pdftkCmd):
def qpdfOrPdftk(qpdfCmd, pdftkCmd):
assert qpdfCmd[0] == "qpdf" and pdftkCmd[0] == "pdftk"
if hasQpdf:
if args.continue_on_warnings:
subprocess.run(['qpdf', '--warning-exit-0', *qpdfCmd[1:]], check=True)
else:
try:
subprocess.run(qpdfCmd, check=True)
except subprocess.CalledProcessError as e:
if e.returncode == 3:
# https://qpdf.readthedocs.io/en/stable/cli.html#exit-status
die("qpdf exited with code 3, indicating warnings. Run pdf-sign with flag --continue-on-warnings to ignore.")
raise
else:
subprocess.run(pdftkCmd, check=True)
cmd = qpdfCmd if hasQpdf else pdftkCmd
subprocess.run(cmd, check=True)
return True # Some lambdas above rely on this
def validateText(text, do_die):
latin1_chars=set([*range(0x20, 0x7f), *range(0xa0, 0x100)])
text_chars=set(map(ord, text))
msg = ""
if not text_chars.issubset(latin1_chars):
msg = ("Error: Only non-control latin-1 characters are supported" +
(" in --text" if do_die else "") +
". Unsupported characters: " + ', '.join(map(hex, text_chars - latin1_chars)))
elif not (1 <= len(text) and len(text) <= 100):
msg = "Text must be between 1 and 100 characters long."
if not msg:
return None
if do_die:
die(msg)
return msg
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)
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
# Simple dependency tracking.
# Init with a value or function to calculate the value.
@ -583,33 +464,44 @@ def getSignatureDirAndHelp():
# Calculations with unchanged inputs are skipped.
class Cell():
currentCell=None
def __init__(self, arg):
def __init__(self, arg, dbgname=None):
self._dbgname=dbgname
if dbgname==None and callable(arg): self._dbgname=arg.__name__
self._arg=arg
self._isuptodate=False
self._needEval=True
self._dependents=[]
self._precedents=[]
self._precedentvalues=[]
print(f"Debug: Init cell {dbgname}{repr(arg)}")
def __call__(self, *args):
if(len(args)==1):
if self._arg != args[0]:
print(f"{Cell._dbgprefix}Debug: Called cell {self._dbgname}{repr(args)}, changing value from {self._arg} to {args[0]}")
self._arg=args[0]
self._needEval=True
self._dirty()
else:
print(f"Debug: Called cell {self._dbgname}{repr(args)}, not changing value.")
return
assert len(args)==0
print(f"{Cell._dbgprefix}Debug: Called cell {self._dbgname}{repr(args)}...")
dbgtext=f"{Cell._dbgprefix}.."
Cell._dbgprefix+=' '
if not self._isuptodate:
dbgtext+=", updating it"
oldcell=Cell.currentCell
Cell.currentCell=None
for i in range(len(self._precedents)):
p=self._precedents[i]
oldval=self._precedentvalues[i]
p()
newval=p._cacheid()
newval=p()
if oldval!=newval:
dbgtext+=f", detected change in precedent {p._dbgname}"
self._needEval=True
break
if self._needEval:
dbgtext+=", evaluating"
Cell.currentCell=self
for p in self._precedents:
p._dependents.remove(self)
@ -622,89 +514,17 @@ class Cell():
if(Cell.currentCell):
self._dependents.append(Cell.currentCell)
Cell.currentCell._precedents.append(self)
Cell.currentCell._precedentvalues.append(self._cacheid())
Cell.currentCell._precedentvalues.append(self._value)
dbgtext+=f", returning {repr(self._value)}"
Cell._dbgprefix=Cell._dbgprefix[:-2]
print(dbgtext)
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):
# 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)
Cell._dbgprefix=''
def tkthrottle(frequency, root):
wait=1/frequency
@ -728,10 +548,10 @@ def tkthrottle(frequency, root):
return decorator
def pdfCountPages(filePath):
return int(fromCmdOutput(["pdfinfo", filePath], r"^.*\nPages: +([0-9]+)\n.*$")[1])
return int(fromCmdOutput(["pdfinfo", str(filePath)], "^.*\nPages: +([0-9]+)\n.*$")[1])
def pdfGetSize(filePath):
[ignored, w, h, *ignored2]=fromCmdOutput(['pdfinfo', filePath], r'^.*\nPage size: +([0-9.]+) x ([0-9.]+) pts( \([A-Za-z0-9]+\))?\n.*$')
[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):
@ -753,6 +573,10 @@ 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):
@ -773,34 +597,28 @@ if not 'BooleanOptionalAction' in dir(argparse):
return ' | '.join(self.option_strings)
argparse.BooleanOptionalAction = BooleanOptionalAction
def getSignatureHelp():
(path, helptxt) = getSignatureDirAndHelp()
ret="""
Path to file used as signature.
Required in batch mode unless -t is given.
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 = argparse.ArgumentParser(description='Sign a PDF file.')
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=getSignatureHelp())
parser.add_argument('-x', '--x-coordinate', type=str, default="50%", help='Horizontal coordinate of signature center. Requires unit %% (meaning percent of page width). May be preceded by L+ (default), L-, R+ or R- to give coordinate relative left of right edge of the page. For example, 25%% means a quarter page width from left edge, R-3%% means 3%% of page width from right edge. (default: 50%%)')
parser.add_argument('-y', '--y-coordinate', type=str, default="B-25%", help='Vertical coordinate of signature center. Requires unit %% (meaning percent of page height). May be preceded by T+ (default), T-, B+ or B- to give coordinate relative top or bottom edge of the page. For example, 3%% means 3%% of page height from top edge, B-10%% means 10%% of page height from bottom edge. (default: B-25%%)')
parser.add_argument('-r', '--resize-factor', type=float, default=1.0, help='Resize signature by this factor. If this would make the signature larger than allowed by -W or -H, then instead use the maximum allowed size.')
parser.add_argument('-W', '--width', type=str, default="50%", help='Maximum width of signature. Supports units pts, in, cm, mm and %% (meaning percent of page width). (default: 50%%)')
parser.add_argument('-H', '--height', type=str, default="50%", help='Maximum height of signature. Supports units pts, in, cm, mm and %% (meaning percent of page height). (default: 50%%)')
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('-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('-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.')
parser.add_argument('--continue-on-warnings', action=argparse.BooleanOptionalAction, default=False, help='Do not fail on warnings from qpdf. (default: False)')
# debug subprocess runs
orig_subprocess_run = subprocess.run
def debug_subprocess_run(*args, **kwargs):
before=time.time()
ret = orig_subprocess_run(*args, **kwargs)
after=time.time()
duration=after-before
print(f"{Cell._dbgprefix}Subprocess [{duration:.2f}s]: {repr(args)} {repr(kwargs)}", flush=True)
return ret
subprocess.run = debug_subprocess_run
main(parser.parse_args(sys.argv[1:] or ['-h']))