pdf-sign-kit 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pdf_sign_kit/__init__.py +190 -0
- pdf_sign_kit/anchor.py +213 -0
- pdf_sign_kit/background.py +55 -0
- pdf_sign_kit/compose.py +142 -0
- pdf_sign_kit/exceptions.py +27 -0
- pdf_sign_kit/fonts.py +195 -0
- pdf_sign_kit/geometry.py +130 -0
- pdf_sign_kit/hashing.py +38 -0
- pdf_sign_kit/identity.py +45 -0
- pdf_sign_kit/keywords.py +70 -0
- pdf_sign_kit/logtable.py +199 -0
- pdf_sign_kit/placement.py +401 -0
- pdf_sign_kit/preview.py +89 -0
- pdf_sign_kit/scheme.py +398 -0
- pdf_sign_kit/seam.py +186 -0
- pdf_sign_kit/stamps.py +129 -0
- pdf_sign_kit/template.py +166 -0
- pdf_sign_kit/text.py +260 -0
- pdf_sign_kit/watermark.py +186 -0
- pdf_sign_kit/workflow.py +474 -0
- pdf_sign_kit-0.5.0.dist-info/METADATA +366 -0
- pdf_sign_kit-0.5.0.dist-info/RECORD +24 -0
- pdf_sign_kit-0.5.0.dist-info/WHEEL +4 -0
- pdf_sign_kit-0.5.0.dist-info/licenses/LICENSE +21 -0
pdf_sign_kit/__init__.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""pdf-sign-kit: a framework-agnostic toolkit for stamping signatures and
|
|
2
|
+
text onto PDF files, with automatic table-cell detection, cross-platform CJK
|
|
3
|
+
font support, page preview rendering and integrity hashing.
|
|
4
|
+
|
|
5
|
+
Coordinate convention
|
|
6
|
+
---------------------
|
|
7
|
+
All public stamping functions take ``x`` / ``y`` in PDF points with the page's
|
|
8
|
+
**bottom-left** origin (i.e. the orientation the page is displayed in); page
|
|
9
|
+
rotation is handled internally. Page indices are zero-based.
|
|
10
|
+
|
|
11
|
+
Note: image stamps are visual ("electronic") signatures, not PKI/certificate
|
|
12
|
+
("digital") signatures.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from . import (
|
|
18
|
+
anchor,
|
|
19
|
+
background,
|
|
20
|
+
compose,
|
|
21
|
+
fonts,
|
|
22
|
+
geometry,
|
|
23
|
+
hashing,
|
|
24
|
+
identity,
|
|
25
|
+
keywords,
|
|
26
|
+
logtable,
|
|
27
|
+
placement,
|
|
28
|
+
preview,
|
|
29
|
+
scheme,
|
|
30
|
+
seam,
|
|
31
|
+
stamps,
|
|
32
|
+
template,
|
|
33
|
+
text,
|
|
34
|
+
watermark,
|
|
35
|
+
workflow,
|
|
36
|
+
)
|
|
37
|
+
from .anchor import AFTER, BELOW, OVER, TextAnchor, find_all_text, find_text, stamp_at_anchor
|
|
38
|
+
from .background import remove_background
|
|
39
|
+
from .compose import TRANSPARENT, hconcat, scale_to_height, scale_to_width, vconcat
|
|
40
|
+
from .exceptions import (
|
|
41
|
+
FontNotFoundError,
|
|
42
|
+
InvalidImageError,
|
|
43
|
+
InvalidPdfError,
|
|
44
|
+
PageOutOfRangeError,
|
|
45
|
+
PdfSignKitError,
|
|
46
|
+
PlacementError,
|
|
47
|
+
)
|
|
48
|
+
from .geometry import TableCell, find_table_cell
|
|
49
|
+
from .hashing import hash_file, verify_file_hash
|
|
50
|
+
from .identity import bare_id, identity_matches
|
|
51
|
+
from .keywords import extract_keywords
|
|
52
|
+
from .logtable import render_log_data_url, render_log_image, render_log_png_bytes
|
|
53
|
+
from .placement import (
|
|
54
|
+
AGREE,
|
|
55
|
+
CHECKMARK,
|
|
56
|
+
GENERAL,
|
|
57
|
+
HANDWRITTEN,
|
|
58
|
+
TEXT,
|
|
59
|
+
StampResult,
|
|
60
|
+
stamp,
|
|
61
|
+
stamp_batch,
|
|
62
|
+
)
|
|
63
|
+
from .preview import page_count, page_size, render_page, render_page_png
|
|
64
|
+
from .scheme import (
|
|
65
|
+
DEFAULT_LEVEL_NAMES,
|
|
66
|
+
DEPARTMENT,
|
|
67
|
+
GROUP,
|
|
68
|
+
PERSONAL,
|
|
69
|
+
FlowScheme,
|
|
70
|
+
Initiator,
|
|
71
|
+
LevelSpec,
|
|
72
|
+
build_metadata,
|
|
73
|
+
)
|
|
74
|
+
from .seam import LEFT, RIGHT, stamp_seam
|
|
75
|
+
from .stamps import (
|
|
76
|
+
make_agree_image,
|
|
77
|
+
make_checkmark_image,
|
|
78
|
+
stamp_agree,
|
|
79
|
+
stamp_checkmark,
|
|
80
|
+
)
|
|
81
|
+
from .template import OpSpec, PlacementTemplate, load_template, save_template
|
|
82
|
+
from .text import (
|
|
83
|
+
TextImage,
|
|
84
|
+
render_text_image,
|
|
85
|
+
render_wrapped_text_image,
|
|
86
|
+
stamp_text,
|
|
87
|
+
)
|
|
88
|
+
from .watermark import DEFAULT_COLOR, render_watermark_overlay, stamp_watermark
|
|
89
|
+
|
|
90
|
+
__version__ = "0.5.0"
|
|
91
|
+
|
|
92
|
+
__all__ = [
|
|
93
|
+
"__version__",
|
|
94
|
+
# submodules
|
|
95
|
+
"anchor",
|
|
96
|
+
"background",
|
|
97
|
+
"compose",
|
|
98
|
+
"fonts",
|
|
99
|
+
"geometry",
|
|
100
|
+
"identity",
|
|
101
|
+
"keywords",
|
|
102
|
+
"logtable",
|
|
103
|
+
"preview",
|
|
104
|
+
"hashing",
|
|
105
|
+
"scheme",
|
|
106
|
+
"placement",
|
|
107
|
+
"seam",
|
|
108
|
+
"stamps",
|
|
109
|
+
"template",
|
|
110
|
+
"text",
|
|
111
|
+
"watermark",
|
|
112
|
+
"workflow",
|
|
113
|
+
# exceptions
|
|
114
|
+
"PdfSignKitError",
|
|
115
|
+
"InvalidPdfError",
|
|
116
|
+
"InvalidImageError",
|
|
117
|
+
"PageOutOfRangeError",
|
|
118
|
+
"FontNotFoundError",
|
|
119
|
+
"PlacementError",
|
|
120
|
+
# placement
|
|
121
|
+
"GENERAL",
|
|
122
|
+
"HANDWRITTEN",
|
|
123
|
+
"TEXT",
|
|
124
|
+
"AGREE",
|
|
125
|
+
"CHECKMARK",
|
|
126
|
+
"StampResult",
|
|
127
|
+
"stamp",
|
|
128
|
+
"stamp_batch",
|
|
129
|
+
# compose
|
|
130
|
+
"TRANSPARENT",
|
|
131
|
+
"hconcat",
|
|
132
|
+
"vconcat",
|
|
133
|
+
"scale_to_height",
|
|
134
|
+
"scale_to_width",
|
|
135
|
+
# anchor
|
|
136
|
+
"AFTER",
|
|
137
|
+
"BELOW",
|
|
138
|
+
"OVER",
|
|
139
|
+
"TextAnchor",
|
|
140
|
+
"find_text",
|
|
141
|
+
"find_all_text",
|
|
142
|
+
"stamp_at_anchor",
|
|
143
|
+
# watermark / seam
|
|
144
|
+
"DEFAULT_COLOR",
|
|
145
|
+
"render_watermark_overlay",
|
|
146
|
+
"stamp_watermark",
|
|
147
|
+
"RIGHT",
|
|
148
|
+
"LEFT",
|
|
149
|
+
"stamp_seam",
|
|
150
|
+
# template
|
|
151
|
+
"OpSpec",
|
|
152
|
+
"PlacementTemplate",
|
|
153
|
+
"save_template",
|
|
154
|
+
"load_template",
|
|
155
|
+
# stamps
|
|
156
|
+
"make_checkmark_image",
|
|
157
|
+
"make_agree_image",
|
|
158
|
+
"stamp_checkmark",
|
|
159
|
+
"stamp_agree",
|
|
160
|
+
# text
|
|
161
|
+
"TextImage",
|
|
162
|
+
"render_text_image",
|
|
163
|
+
"render_wrapped_text_image",
|
|
164
|
+
"stamp_text",
|
|
165
|
+
# scheme
|
|
166
|
+
"PERSONAL",
|
|
167
|
+
"GROUP",
|
|
168
|
+
"DEPARTMENT",
|
|
169
|
+
"DEFAULT_LEVEL_NAMES",
|
|
170
|
+
"LevelSpec",
|
|
171
|
+
"FlowScheme",
|
|
172
|
+
"Initiator",
|
|
173
|
+
"build_metadata",
|
|
174
|
+
# geometry / hashing / identity / background / preview / logtable / keywords
|
|
175
|
+
"TableCell",
|
|
176
|
+
"find_table_cell",
|
|
177
|
+
"hash_file",
|
|
178
|
+
"verify_file_hash",
|
|
179
|
+
"bare_id",
|
|
180
|
+
"identity_matches",
|
|
181
|
+
"remove_background",
|
|
182
|
+
"page_count",
|
|
183
|
+
"page_size",
|
|
184
|
+
"render_page",
|
|
185
|
+
"render_page_png",
|
|
186
|
+
"render_log_image",
|
|
187
|
+
"render_log_png_bytes",
|
|
188
|
+
"render_log_data_url",
|
|
189
|
+
"extract_keywords",
|
|
190
|
+
]
|
pdf_sign_kit/anchor.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Text-anchor placement.
|
|
2
|
+
|
|
3
|
+
Locate a position on a PDF page by searching for visible text (e.g.
|
|
4
|
+
``"签字:"``) rather than by detecting table borders. This complements
|
|
5
|
+
:mod:`pdf_sign_kit.geometry` for borderless contracts and blank form
|
|
6
|
+
templates.
|
|
7
|
+
|
|
8
|
+
The search runs on the page in its display orientation, so returned
|
|
9
|
+
coordinates follow the same bottom-left-origin convention as
|
|
10
|
+
:func:`pdf_sign_kit.stamp`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
|
|
18
|
+
import fitz
|
|
19
|
+
|
|
20
|
+
from .exceptions import InvalidPdfError, PageOutOfRangeError
|
|
21
|
+
from .geometry import TableCell
|
|
22
|
+
from .placement import GENERAL, ImageLike, StampResult, stamp
|
|
23
|
+
|
|
24
|
+
# Where to place content relative to the matched text.
|
|
25
|
+
AFTER = "after" # immediately right of the text, vertically centered
|
|
26
|
+
BELOW = "below" # immediately under the text, left aligned
|
|
27
|
+
OVER = "over" # centered over the text itself
|
|
28
|
+
|
|
29
|
+
_POSITIONS = frozenset({AFTER, BELOW, OVER})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TextAnchor:
|
|
33
|
+
"""A matched text rectangle (top-left origin, PDF points, display space)."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
page: int,
|
|
38
|
+
rect: fitz.Rect,
|
|
39
|
+
matched_text: str,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.page = int(page)
|
|
42
|
+
self.rect = rect
|
|
43
|
+
self.left = float(rect.x0)
|
|
44
|
+
self.top = float(rect.y0)
|
|
45
|
+
self.width = float(rect.width)
|
|
46
|
+
self.height = float(rect.height)
|
|
47
|
+
self.matched_text = matched_text
|
|
48
|
+
|
|
49
|
+
def __repr__(self) -> str:
|
|
50
|
+
return (
|
|
51
|
+
f"TextAnchor(page={self.page}, left={self.left:.1f}, top={self.top:.1f}, "
|
|
52
|
+
f"width={self.width:.1f}, height={self.height:.1f}, text={self.matched_text!r})"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def point(
|
|
56
|
+
self,
|
|
57
|
+
page_height: float,
|
|
58
|
+
position: str = AFTER,
|
|
59
|
+
gap: float = 3.0,
|
|
60
|
+
) -> tuple[float, float]:
|
|
61
|
+
"""Return bottom-left-origin ``(x, y)`` for the chosen placement spot."""
|
|
62
|
+
if position not in _POSITIONS:
|
|
63
|
+
raise ValueError(f"position must be one of {sorted(_POSITIONS)}, got {position!r}")
|
|
64
|
+
if gap < 0:
|
|
65
|
+
raise ValueError("gap must be >= 0")
|
|
66
|
+
|
|
67
|
+
center_x = self.left + self.width / 2
|
|
68
|
+
center_y_top = self.top + self.height / 2
|
|
69
|
+
|
|
70
|
+
if position == AFTER:
|
|
71
|
+
x = self.left + self.width + gap
|
|
72
|
+
y = page_height - center_y_top
|
|
73
|
+
elif position == BELOW:
|
|
74
|
+
x = self.left
|
|
75
|
+
y = page_height - (self.top + self.height + gap)
|
|
76
|
+
else: # OVER
|
|
77
|
+
x = center_x
|
|
78
|
+
y = page_height - center_y_top
|
|
79
|
+
return x, y
|
|
80
|
+
|
|
81
|
+
def to_cell(self) -> TableCell:
|
|
82
|
+
"""Expose the text rect as a placement cell."""
|
|
83
|
+
return TableCell(self.left, self.top, self.width, self.height, self.left)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _open_doc(pdf_path: str | os.PathLike) -> fitz.Document:
|
|
87
|
+
try:
|
|
88
|
+
return fitz.open(os.fspath(pdf_path))
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
raise InvalidPdfError(f"cannot open PDF {pdf_path}: {exc}") from exc
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def find_all_text(
|
|
94
|
+
pdf_path: str | os.PathLike,
|
|
95
|
+
text: str,
|
|
96
|
+
*,
|
|
97
|
+
pages: Sequence[int] | None = None,
|
|
98
|
+
) -> list[TextAnchor]:
|
|
99
|
+
"""Find every occurrence of ``text``.
|
|
100
|
+
|
|
101
|
+
:param pages: restrict search to these zero-based page indices; ``None``
|
|
102
|
+
searches the whole document. Multi-line matches are reported with the
|
|
103
|
+
bounding box of all their line rectangles.
|
|
104
|
+
"""
|
|
105
|
+
if not text:
|
|
106
|
+
raise ValueError("search text must not be empty")
|
|
107
|
+
|
|
108
|
+
doc = _open_doc(pdf_path)
|
|
109
|
+
try:
|
|
110
|
+
if pages is None:
|
|
111
|
+
target_pages = range(doc.page_count)
|
|
112
|
+
else:
|
|
113
|
+
target_pages = list(dict.fromkeys(int(p) for p in pages))
|
|
114
|
+
for p in target_pages:
|
|
115
|
+
if p < 0 or p >= doc.page_count:
|
|
116
|
+
raise PageOutOfRangeError(
|
|
117
|
+
f"page {p} out of range (document has {doc.page_count} pages)"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
anchors: list[TextAnchor] = []
|
|
121
|
+
for page_index in target_pages:
|
|
122
|
+
page = doc[page_index]
|
|
123
|
+
# Each returned rect is one distinct match position (a multi-line
|
|
124
|
+
# search text produces one rect per line, each independently
|
|
125
|
+
# placeable).
|
|
126
|
+
for rect in page.search_for(text):
|
|
127
|
+
anchors.append(TextAnchor(page_index, fitz.Rect(rect), text))
|
|
128
|
+
return anchors
|
|
129
|
+
finally:
|
|
130
|
+
doc.close()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def find_text(
|
|
134
|
+
pdf_path: str | os.PathLike,
|
|
135
|
+
text: str,
|
|
136
|
+
*,
|
|
137
|
+
page: int | None = None,
|
|
138
|
+
occurrence: int = 0,
|
|
139
|
+
) -> TextAnchor | None:
|
|
140
|
+
"""Find one occurrence of ``text``.
|
|
141
|
+
|
|
142
|
+
:param page: zero-based page index; ``None`` searches the whole document.
|
|
143
|
+
:param occurrence: zero-based index among ordered matches (page order, then
|
|
144
|
+
in-page order).
|
|
145
|
+
"""
|
|
146
|
+
if occurrence < 0:
|
|
147
|
+
raise ValueError("occurrence must be >= 0")
|
|
148
|
+
|
|
149
|
+
pages = None if page is None else [int(page)]
|
|
150
|
+
if page is not None and int(page) < 0:
|
|
151
|
+
raise ValueError("page must be >= 0")
|
|
152
|
+
|
|
153
|
+
anchors = find_all_text(pdf_path, text, pages=pages)
|
|
154
|
+
if occurrence >= len(anchors):
|
|
155
|
+
return None
|
|
156
|
+
return anchors[occurrence]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def stamp_at_anchor(
|
|
160
|
+
pdf_path: str | os.PathLike,
|
|
161
|
+
image: ImageLike,
|
|
162
|
+
anchor: TextAnchor,
|
|
163
|
+
*,
|
|
164
|
+
position: str = AFTER,
|
|
165
|
+
gap: float = 3.0,
|
|
166
|
+
page: int | None = None,
|
|
167
|
+
out_path: str | os.PathLike | None = None,
|
|
168
|
+
width: float | None = None,
|
|
169
|
+
height: float | None = None,
|
|
170
|
+
) -> StampResult:
|
|
171
|
+
"""Stamp ``image`` relative to a text anchor.
|
|
172
|
+
|
|
173
|
+
Table-cell detection is skipped (the anchor itself defines the location);
|
|
174
|
+
the image keeps its natural or explicit size.
|
|
175
|
+
"""
|
|
176
|
+
if not isinstance(anchor, TextAnchor):
|
|
177
|
+
raise TypeError(f"anchor must be a TextAnchor, got {type(anchor)!r}")
|
|
178
|
+
|
|
179
|
+
doc = _open_doc(pdf_path)
|
|
180
|
+
try:
|
|
181
|
+
if anchor.page < 0 or anchor.page >= doc.page_count:
|
|
182
|
+
raise PageOutOfRangeError(
|
|
183
|
+
f"anchor page {anchor.page} out of range (document has {doc.page_count} pages)"
|
|
184
|
+
)
|
|
185
|
+
page_height = doc[anchor.page].rect.height
|
|
186
|
+
finally:
|
|
187
|
+
doc.close()
|
|
188
|
+
|
|
189
|
+
x, y = anchor.point(page_height, position, gap)
|
|
190
|
+
target_page = anchor.page if page is None else int(page)
|
|
191
|
+
return stamp(
|
|
192
|
+
pdf_path,
|
|
193
|
+
image,
|
|
194
|
+
x,
|
|
195
|
+
y,
|
|
196
|
+
page=target_page,
|
|
197
|
+
out_path=out_path,
|
|
198
|
+
width=width,
|
|
199
|
+
height=height,
|
|
200
|
+
mode=GENERAL,
|
|
201
|
+
detect_cell=False,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
__all__ = [
|
|
206
|
+
"AFTER",
|
|
207
|
+
"BELOW",
|
|
208
|
+
"OVER",
|
|
209
|
+
"TextAnchor",
|
|
210
|
+
"find_text",
|
|
211
|
+
"find_all_text",
|
|
212
|
+
"stamp_at_anchor",
|
|
213
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Turn handwriting/scan images into transparent-PNG stamps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
from typing import Union
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
from .exceptions import InvalidImageError
|
|
13
|
+
|
|
14
|
+
ImageLike = Union[str, os.PathLike, Image.Image, bytes]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _to_pil(image: ImageLike) -> Image.Image:
|
|
18
|
+
if isinstance(image, Image.Image):
|
|
19
|
+
return image
|
|
20
|
+
if isinstance(image, (str, os.PathLike)):
|
|
21
|
+
path = os.fspath(image)
|
|
22
|
+
try:
|
|
23
|
+
return Image.open(path)
|
|
24
|
+
except Exception as exc:
|
|
25
|
+
raise InvalidImageError(f"cannot open image {path}: {exc}") from exc
|
|
26
|
+
if isinstance(image, bytes):
|
|
27
|
+
try:
|
|
28
|
+
return Image.open(io.BytesIO(image))
|
|
29
|
+
except Exception as exc:
|
|
30
|
+
raise InvalidImageError(f"cannot decode image bytes: {exc}") from exc
|
|
31
|
+
raise InvalidImageError(f"unsupported image type: {type(image)!r}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def remove_background(image: ImageLike, threshold: int = 235) -> Image.Image:
|
|
35
|
+
"""Make near-white pixels transparent.
|
|
36
|
+
|
|
37
|
+
:param image: PIL image, file path, or raw image bytes.
|
|
38
|
+
:param threshold: per-channel whiteness threshold (0-255); pixels whose
|
|
39
|
+
RGB values are all >= threshold become fully transparent.
|
|
40
|
+
:returns: RGBA :class:`~PIL.Image.Image`.
|
|
41
|
+
"""
|
|
42
|
+
if not 0 <= threshold <= 255:
|
|
43
|
+
raise ValueError(f"threshold must be within 0-255, got {threshold!r}")
|
|
44
|
+
|
|
45
|
+
img = _to_pil(image)
|
|
46
|
+
if img.mode != "RGBA":
|
|
47
|
+
img = img.convert("RGBA")
|
|
48
|
+
|
|
49
|
+
data = np.array(img)
|
|
50
|
+
white_mask = np.all(data[:, :, :3] >= threshold, axis=2)
|
|
51
|
+
data[white_mask, 3] = 0
|
|
52
|
+
return Image.fromarray(data)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = ["remove_background"]
|
pdf_sign_kit/compose.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Bitmap composition helpers.
|
|
2
|
+
|
|
3
|
+
Concatenate multiple PIL images horizontally or vertically with control over
|
|
4
|
+
cross-axis alignment, gaps and the canvas background. All functions accept PIL
|
|
5
|
+
images and return new images; inputs are never modified. RGBA inputs keep their
|
|
6
|
+
transparency when the canvas is also RGBA.
|
|
7
|
+
|
|
8
|
+
Typical use: assemble a "name + date" signature strip, or stack a name row over
|
|
9
|
+
a date row before stamping the combined image onto a PDF.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
|
|
16
|
+
from PIL import Image
|
|
17
|
+
|
|
18
|
+
# Canvas background: transparent by default so composed stamps can be overlaid
|
|
19
|
+
# without white boxes. Pass an RGB/RGBA tuple to force a different background.
|
|
20
|
+
TRANSPARENT = (255, 255, 255, 0)
|
|
21
|
+
|
|
22
|
+
_H_ALIGN = frozenset({"top", "middle", "bottom"})
|
|
23
|
+
_V_ALIGN = frozenset({"left", "center", "right"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _normalize(images: Sequence[Image.Image]) -> list[Image.Image]:
|
|
27
|
+
normalized: list[Image.Image] = []
|
|
28
|
+
for img in images:
|
|
29
|
+
if not isinstance(img, Image.Image):
|
|
30
|
+
raise TypeError(f"compose expects PIL images, got {type(img)!r}")
|
|
31
|
+
normalized.append(img if img.mode == "RGBA" else img.convert("RGBA"))
|
|
32
|
+
if not normalized:
|
|
33
|
+
raise ValueError("compose requires at least one image")
|
|
34
|
+
return normalized
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _canvas(size: tuple[int, int], background) -> Image.Image:
|
|
38
|
+
mode = "RGBA"
|
|
39
|
+
return Image.new(mode, size, background if background is not None else TRANSPARENT)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def hconcat(
|
|
43
|
+
images: Sequence[Image.Image],
|
|
44
|
+
*,
|
|
45
|
+
gap: int = 0,
|
|
46
|
+
align: str = "middle",
|
|
47
|
+
background=TRANSPARENT,
|
|
48
|
+
) -> Image.Image:
|
|
49
|
+
"""Concatenate images left-to-right.
|
|
50
|
+
|
|
51
|
+
:param gap: pixel gap inserted between adjacent images.
|
|
52
|
+
:param align: cross-axis alignment: ``"top"`` / ``"middle"`` / ``"bottom"``.
|
|
53
|
+
"""
|
|
54
|
+
images = _normalize(images)
|
|
55
|
+
if align not in _H_ALIGN:
|
|
56
|
+
raise ValueError(f"align must be one of {sorted(_H_ALIGN)}, got {align!r}")
|
|
57
|
+
if gap < 0:
|
|
58
|
+
raise ValueError("gap must be >= 0")
|
|
59
|
+
|
|
60
|
+
total_w = sum(img.width for img in images) + gap * (len(images) - 1)
|
|
61
|
+
total_h = max(img.height for img in images)
|
|
62
|
+
canvas = _canvas((total_w, total_h), background)
|
|
63
|
+
|
|
64
|
+
x = 0
|
|
65
|
+
for img in images:
|
|
66
|
+
if align == "top":
|
|
67
|
+
y = 0
|
|
68
|
+
elif align == "bottom":
|
|
69
|
+
y = total_h - img.height
|
|
70
|
+
else:
|
|
71
|
+
y = (total_h - img.height) // 2
|
|
72
|
+
canvas.alpha_composite(img, (x, y))
|
|
73
|
+
x += img.width + gap
|
|
74
|
+
return canvas
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def vconcat(
|
|
78
|
+
images: Sequence[Image.Image],
|
|
79
|
+
*,
|
|
80
|
+
gap: int = 0,
|
|
81
|
+
align: str = "left",
|
|
82
|
+
background=TRANSPARENT,
|
|
83
|
+
) -> Image.Image:
|
|
84
|
+
"""Concatenate images top-to-bottom.
|
|
85
|
+
|
|
86
|
+
:param gap: pixel gap inserted between adjacent images.
|
|
87
|
+
:param align: cross-axis alignment: ``"left"`` / ``"center"`` / ``"right"``.
|
|
88
|
+
"""
|
|
89
|
+
images = _normalize(images)
|
|
90
|
+
if align not in _V_ALIGN:
|
|
91
|
+
raise ValueError(f"align must be one of {sorted(_V_ALIGN)}, got {align!r}")
|
|
92
|
+
if gap < 0:
|
|
93
|
+
raise ValueError("gap must be >= 0")
|
|
94
|
+
|
|
95
|
+
total_w = max(img.width for img in images)
|
|
96
|
+
total_h = sum(img.height for img in images) + gap * (len(images) - 1)
|
|
97
|
+
canvas = _canvas((total_w, total_h), background)
|
|
98
|
+
|
|
99
|
+
y = 0
|
|
100
|
+
for img in images:
|
|
101
|
+
if align == "left":
|
|
102
|
+
x = 0
|
|
103
|
+
elif align == "right":
|
|
104
|
+
x = total_w - img.width
|
|
105
|
+
else:
|
|
106
|
+
x = (total_w - img.width) // 2
|
|
107
|
+
canvas.alpha_composite(img, (x, y))
|
|
108
|
+
y += img.height + gap
|
|
109
|
+
return canvas
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def scale_to_height(img: Image.Image, target_height: int) -> Image.Image:
|
|
113
|
+
"""Return a copy of ``img`` resized to ``target_height`` px, width proportional."""
|
|
114
|
+
if not isinstance(img, Image.Image):
|
|
115
|
+
raise TypeError(f"expected a PIL image, got {type(img)!r}")
|
|
116
|
+
if target_height <= 0:
|
|
117
|
+
raise ValueError("target_height must be > 0")
|
|
118
|
+
if img.height == target_height:
|
|
119
|
+
return img.copy()
|
|
120
|
+
target_width = max(1, round(img.width * target_height / img.height))
|
|
121
|
+
return img.resize((target_width, target_height), Image.LANCZOS)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def scale_to_width(img: Image.Image, target_width: int) -> Image.Image:
|
|
125
|
+
"""Return a copy of ``img`` resized to ``target_width`` px, height proportional."""
|
|
126
|
+
if not isinstance(img, Image.Image):
|
|
127
|
+
raise TypeError(f"expected a PIL image, got {type(img)!r}")
|
|
128
|
+
if target_width <= 0:
|
|
129
|
+
raise ValueError("target_width must be > 0")
|
|
130
|
+
if img.width == target_width:
|
|
131
|
+
return img.copy()
|
|
132
|
+
target_height = max(1, round(img.height * target_width / img.width))
|
|
133
|
+
return img.resize((target_width, target_height), Image.LANCZOS)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
__all__ = [
|
|
137
|
+
"TRANSPARENT",
|
|
138
|
+
"hconcat",
|
|
139
|
+
"vconcat",
|
|
140
|
+
"scale_to_height",
|
|
141
|
+
"scale_to_width",
|
|
142
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Exception hierarchy for pdf-sign-kit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PdfSignKitError(Exception):
|
|
7
|
+
"""Base class for all pdf-sign-kit errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvalidPdfError(PdfSignKitError):
|
|
11
|
+
"""The path does not point to a readable/valid PDF file."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InvalidImageError(PdfSignKitError):
|
|
15
|
+
"""The supplied image bytes/path cannot be decoded."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PageOutOfRangeError(PdfSignKitError):
|
|
19
|
+
"""Requested page index is outside the document's page range."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FontNotFoundError(PdfSignKitError):
|
|
23
|
+
"""No usable font was found and no explicit font path was provided."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PlacementError(PdfSignKitError):
|
|
27
|
+
"""The image/text could not be placed or the output commit failed."""
|