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.
@@ -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"]
@@ -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."""