id2pdf 0.1.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.
- id2pdf/__init__.py +33 -0
- id2pdf/__main__.py +3 -0
- id2pdf/cli.py +71 -0
- id2pdf/detect.py +173 -0
- id2pdf/i18n.py +23 -0
- id2pdf/layout.py +49 -0
- id2pdf/locale/hr/LC_MESSAGES/id2pdf.mo +0 -0
- id2pdf/locale/hr/LC_MESSAGES/id2pdf.po +210 -0
- id2pdf/locale/id2pdf.pot +192 -0
- id2pdf/scanner.py +160 -0
- id2pdf-0.1.0.dist-info/METADATA +134 -0
- id2pdf-0.1.0.dist-info/RECORD +15 -0
- id2pdf-0.1.0.dist-info/WHEEL +4 -0
- id2pdf-0.1.0.dist-info/entry_points.txt +2 -0
- id2pdf-0.1.0.dist-info/licenses/LICENSE +232 -0
id2pdf/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Scan both sides of an ID card onto one A4 page, as a PDF.
|
|
2
|
+
|
|
3
|
+
The card may lie anywhere on the scanner glass: a quick scan of the whole glass finds it,
|
|
4
|
+
then only that spot is scanned at full resolution, straightened and cut out.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .detect import CARD_MM, card_from_image, cut_out, find_cards
|
|
8
|
+
from .i18n import set_language
|
|
9
|
+
from .layout import CARD_DPI, IMAGE_SUFFIXES, PAGE_DPI, id_card_page, images_to_pdf, open_image, save_pdf
|
|
10
|
+
from .scanner import ScanError, find_scanner, scan, scan_card_side, scan_id_card, scan_pages
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CARD_DPI",
|
|
16
|
+
"CARD_MM",
|
|
17
|
+
"IMAGE_SUFFIXES",
|
|
18
|
+
"PAGE_DPI",
|
|
19
|
+
"ScanError",
|
|
20
|
+
"card_from_image",
|
|
21
|
+
"cut_out",
|
|
22
|
+
"find_cards",
|
|
23
|
+
"find_scanner",
|
|
24
|
+
"id_card_page",
|
|
25
|
+
"images_to_pdf",
|
|
26
|
+
"open_image",
|
|
27
|
+
"save_pdf",
|
|
28
|
+
"scan",
|
|
29
|
+
"scan_card_side",
|
|
30
|
+
"scan_id_card",
|
|
31
|
+
"scan_pages",
|
|
32
|
+
"set_language",
|
|
33
|
+
]
|
id2pdf/__main__.py
ADDED
id2pdf/cli.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""The id2pdf command."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .detect import card_from_image
|
|
9
|
+
from .i18n import _
|
|
10
|
+
from .layout import CARD_DPI, id_card_page, open_image, save_pdf
|
|
11
|
+
from .scanner import ScanError, diagnose, find_scanner, scan_id_card, scan_pages
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parser() -> argparse.ArgumentParser:
|
|
15
|
+
p = argparse.ArgumentParser(
|
|
16
|
+
prog="id2pdf",
|
|
17
|
+
description=_(
|
|
18
|
+
"Scan both sides of an ID card onto one A4 page, as a PDF. The card may lie "
|
|
19
|
+
"anywhere on the scanner glass; it is found, straightened and cut out."
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
p.add_argument("output", type=Path, nargs="?", help=_("the PDF to write (with --diagnose: a folder for the scan)"))
|
|
23
|
+
mode = p.add_mutually_exclusive_group()
|
|
24
|
+
mode.add_argument(
|
|
25
|
+
"--images",
|
|
26
|
+
nargs=2,
|
|
27
|
+
type=Path,
|
|
28
|
+
metavar=("FRONT", "BACK"),
|
|
29
|
+
help=_("use existing scans or photos of the two sides instead of scanning"),
|
|
30
|
+
)
|
|
31
|
+
mode.add_argument("--pages", type=int, metavar="N", help=_("scan N plain A4 pages instead of a card"))
|
|
32
|
+
mode.add_argument(
|
|
33
|
+
"--diagnose", action="store_true", help=_("scan the glass and print what is found (numbers only)")
|
|
34
|
+
)
|
|
35
|
+
p.add_argument("--device", help=_("SANE device (default: the first scanner in 'scanimage -L')"))
|
|
36
|
+
p.add_argument("-f", "--force", action="store_true", help=_("overwrite the output without asking"))
|
|
37
|
+
p.add_argument("--version", action="version", version=f"id2pdf {__version__}")
|
|
38
|
+
return p
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main(argv: list[str] | None = None) -> None:
|
|
42
|
+
args = parser().parse_args(argv)
|
|
43
|
+
if not args.output and not args.diagnose:
|
|
44
|
+
parser().error(_("the output PDF is required"))
|
|
45
|
+
if args.output and args.output.exists() and not args.diagnose and not args.force:
|
|
46
|
+
answer = input(_("{path} already exists. Overwrite? [y/N] ").format(path=args.output)).strip().lower()
|
|
47
|
+
if answer not in (_("y"), "y"):
|
|
48
|
+
sys.exit(_("Nothing was changed."))
|
|
49
|
+
try:
|
|
50
|
+
if args.images:
|
|
51
|
+
sides = [card_from_image(open_image(path)) for path in args.images]
|
|
52
|
+
save_pdf([id_card_page(*sides)], args.output, CARD_DPI)
|
|
53
|
+
else:
|
|
54
|
+
device = args.device or find_scanner()
|
|
55
|
+
print(_("Scanner: {device}").format(device=device))
|
|
56
|
+
if args.diagnose:
|
|
57
|
+
diagnose(device, args.output)
|
|
58
|
+
return
|
|
59
|
+
if args.pages:
|
|
60
|
+
save_pdf(scan_pages(device, args.pages), args.output)
|
|
61
|
+
else:
|
|
62
|
+
save_pdf([scan_id_card(device)], args.output, CARD_DPI)
|
|
63
|
+
except ScanError as error:
|
|
64
|
+
sys.exit(str(error))
|
|
65
|
+
except (EOFError, KeyboardInterrupt):
|
|
66
|
+
sys.exit("\n" + _("Cancelled, nothing was saved."))
|
|
67
|
+
print(_("Saved: {path}").format(path=args.output))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
main()
|
id2pdf/detect.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Finding ID-1 cards (85.6 x 54 mm: ID cards, driving licences, ...) on scans."""
|
|
2
|
+
|
|
3
|
+
import cv2
|
|
4
|
+
import numpy as np
|
|
5
|
+
from PIL import Image, ImageDraw
|
|
6
|
+
|
|
7
|
+
CARD_MM = (85.6, 54.0)
|
|
8
|
+
CARD_CORNER_MM = 3.18
|
|
9
|
+
PREVIEW_DPI = 100 # quick scan of the whole glass to find the card
|
|
10
|
+
DARKER = 15 # grey levels below the lid that count as part of a card (the lid's shading is less)
|
|
11
|
+
GLASS_EDGE_MM = 2 # strip along the edges of the glass ignored when looking for the card
|
|
12
|
+
|
|
13
|
+
Rect = tuple[tuple[float, float], tuple[float, float], float] # cv2.minAreaRect: centre, size, angle
|
|
14
|
+
|
|
15
|
+
# Why card_candidates() did not take an object for a card.
|
|
16
|
+
SHAPE, SIZE, TOO_SMALL = "shape", "size", "too small"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def mm_to_px(mm: float, dpi: float) -> int:
|
|
20
|
+
return round(mm / 25.4 * dpi)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def px_to_mm(px: float, dpi: float) -> float:
|
|
24
|
+
return px / dpi * 25.4
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def card_candidates(image: Image.Image, dpi: float | None, glass: bool = False) -> list[tuple[Rect, str]]:
|
|
28
|
+
"""Every object on a scan with why it is not a card ("" = it is one), largest first.
|
|
29
|
+
|
|
30
|
+
A card shows as sharp edges (its outline and shadow, its print) and areas clearly darker
|
|
31
|
+
than the lid; the lid's shading is gradual and only slightly darker, and dust is too
|
|
32
|
+
small to make a card shape. glass: the image is the whole glass, whose edges are ignored.
|
|
33
|
+
dpi None skips the size check (photos).
|
|
34
|
+
"""
|
|
35
|
+
gray = cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2GRAY)
|
|
36
|
+
smooth = cv2.GaussianBlur(gray, (5, 5), 0)
|
|
37
|
+
lid = float(np.median(gray))
|
|
38
|
+
mask = ((cv2.Canny(smooth, 20, 60) > 0) | (smooth < lid - DARKER)).astype(np.uint8) * 255
|
|
39
|
+
if glass:
|
|
40
|
+
edge = mm_to_px(GLASS_EDGE_MM, dpi or PREVIEW_DPI)
|
|
41
|
+
mask[:edge, :] = mask[-edge:, :] = 0
|
|
42
|
+
mask[:, :edge] = mask[:, -edge:] = 0
|
|
43
|
+
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((2, 2), np.uint8))
|
|
44
|
+
k = max(3, mm_to_px(1.5, dpi or PREVIEW_DPI))
|
|
45
|
+
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k)))
|
|
46
|
+
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
47
|
+
found = []
|
|
48
|
+
for contour in contours:
|
|
49
|
+
rect = cv2.minAreaRect(cv2.convexHull(contour))
|
|
50
|
+
long, short = sorted(rect[1], reverse=True)
|
|
51
|
+
if short < 10:
|
|
52
|
+
continue
|
|
53
|
+
reason = ""
|
|
54
|
+
# A thick card lifts the lid: its shadow makes the object up to ~10 mm larger all round.
|
|
55
|
+
if abs(long / short - CARD_MM[0] / CARD_MM[1]) > 0.25:
|
|
56
|
+
reason = SHAPE
|
|
57
|
+
elif dpi is not None and not 0.85 < long / mm_to_px(CARD_MM[0], dpi) < 1.45:
|
|
58
|
+
reason = SIZE
|
|
59
|
+
elif dpi is None and long * short < 0.05 * gray.size:
|
|
60
|
+
reason = TOO_SMALL
|
|
61
|
+
found.append((rect, reason))
|
|
62
|
+
return sorted(found, key=lambda c: -c[0][1][0] * c[0][1][1])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def perimeter(width: float, height: float, corner: float, spacing: float) -> list[tuple[np.ndarray, np.ndarray]]:
|
|
66
|
+
"""For each straight side of a width x height rectangle centred on 0: its points and normal."""
|
|
67
|
+
xs = np.arange(-width / 2 + corner, width / 2 - corner, spacing)
|
|
68
|
+
ys = np.arange(-height / 2 + corner, height / 2 - corner, spacing)
|
|
69
|
+
return [
|
|
70
|
+
(np.stack([xs, np.full_like(xs, -height / 2)], 1), np.array([0.0, 1.0])),
|
|
71
|
+
(np.stack([xs, np.full_like(xs, height / 2)], 1), np.array([0.0, 1.0])),
|
|
72
|
+
(np.stack([np.full_like(ys, -width / 2), ys], 1), np.array([1.0, 0.0])),
|
|
73
|
+
(np.stack([np.full_like(ys, width / 2), ys], 1), np.array([1.0, 0.0])),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def fit_card(gray: np.ndarray, blob: Rect, dpi: float) -> Rect:
|
|
78
|
+
"""The card inside an object found on the scan, which may include the card's shadow.
|
|
79
|
+
|
|
80
|
+
Places a card-sized rectangle (within 3 %) on the sharpest edges near the object: the
|
|
81
|
+
card's edge is a sharp step, its shadow a soft one. Every side has to lie on an edge, so
|
|
82
|
+
the score is the weakest side's mean sharpness across it, blurred a little so that a
|
|
83
|
+
slightly misplaced edge still counts. The angle comes from the object, whose shadow is
|
|
84
|
+
about even all round; on edges alone it is poorly determined.
|
|
85
|
+
"""
|
|
86
|
+
smooth = cv2.GaussianBlur(gray, (3, 3), 0).astype(np.float32)
|
|
87
|
+
sigma = max(1.0, mm_to_px(0.3, dpi))
|
|
88
|
+
across_x = cv2.GaussianBlur(np.abs(cv2.Sobel(smooth, cv2.CV_32F, 1, 0)), (0, 0), sigma)
|
|
89
|
+
across_y = cv2.GaussianBlur(np.abs(cv2.Sobel(smooth, cv2.CV_32F, 0, 1)), (0, 0), sigma)
|
|
90
|
+
card_w, card_h = (mm_to_px(mm, dpi) for mm in CARD_MM)
|
|
91
|
+
corner = mm_to_px(CARD_CORNER_MM + 0.5, dpi)
|
|
92
|
+
spacing = max(1.0, dpi / 100)
|
|
93
|
+
(cx, cy), (w, h), angle = blob
|
|
94
|
+
if w < h:
|
|
95
|
+
w, h, angle = h, w, angle + 90
|
|
96
|
+
angle = (angle + 90) % 180 - 90
|
|
97
|
+
rows, cols = gray.shape
|
|
98
|
+
|
|
99
|
+
def best(angles, xs, ys, scales):
|
|
100
|
+
top = (-1.0, cx, cy, angle, 1.0, 1.0)
|
|
101
|
+
for a in angles:
|
|
102
|
+
t = np.deg2rad(a)
|
|
103
|
+
turn = np.array([[np.cos(t), -np.sin(t)], [np.sin(t), np.cos(t)]])
|
|
104
|
+
for sx in scales:
|
|
105
|
+
for sy in scales:
|
|
106
|
+
weakest = None
|
|
107
|
+
for points, normal in perimeter(card_w * sx, card_h * sy, corner, spacing):
|
|
108
|
+
r = points @ turn.T
|
|
109
|
+
nx, ny = np.abs(turn @ normal)
|
|
110
|
+
gx = np.clip(np.rint(xs[:, None, None] + r[None, None, :, 0]).astype(int), 0, cols - 1)
|
|
111
|
+
gy = np.clip(np.rint(ys[None, :, None] + r[None, None, :, 1]).astype(int), 0, rows - 1)
|
|
112
|
+
side = (across_x[gy, gx] * nx + across_y[gy, gx] * ny).mean(axis=2)
|
|
113
|
+
weakest = side if weakest is None else np.minimum(weakest, side)
|
|
114
|
+
i, j = np.unravel_index(np.argmax(weakest), weakest.shape)
|
|
115
|
+
if weakest[i, j] > top[0]:
|
|
116
|
+
top = (float(weakest[i, j]), float(xs[i]), float(ys[j]), float(a), sx, sy)
|
|
117
|
+
return top
|
|
118
|
+
|
|
119
|
+
step = max(1, mm_to_px(0.5, dpi))
|
|
120
|
+
span_x = max(0.0, (w - card_w) / 2) + mm_to_px(2, dpi)
|
|
121
|
+
span_y = max(0.0, (h - card_h) / 2) + mm_to_px(2, dpi)
|
|
122
|
+
_, x, y, a, _, _ = best(
|
|
123
|
+
np.array([angle]),
|
|
124
|
+
np.arange(cx - span_x, cx + span_x + 1, step),
|
|
125
|
+
np.arange(cy - span_y, cy + span_y + 1, step),
|
|
126
|
+
(1.0,),
|
|
127
|
+
)
|
|
128
|
+
fine = max(1.0, step / 3)
|
|
129
|
+
_, x, y, a, sx, sy = best(
|
|
130
|
+
np.arange(a - 0.5, a + 0.51, 0.25),
|
|
131
|
+
np.arange(x - step, x + step + 1, fine),
|
|
132
|
+
np.arange(y - step, y + step + 1, fine),
|
|
133
|
+
np.arange(0.97, 1.031, 0.01),
|
|
134
|
+
)
|
|
135
|
+
return (x, y), (card_w * sx, card_h * sy), a
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def find_cards(image: Image.Image, dpi: float | None, glass: bool = False) -> list[Rect]:
|
|
139
|
+
"""Cards on a scan, largest first; with a known dpi each is fitted to the card's real edges."""
|
|
140
|
+
blobs = [rect for rect, reason in card_candidates(image, dpi, glass) if not reason]
|
|
141
|
+
if dpi is None:
|
|
142
|
+
return blobs
|
|
143
|
+
gray = np.array(image.convert("L"))
|
|
144
|
+
return [fit_card(gray, blob, dpi) for blob in blobs]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cut_out(image: Image.Image, rect: Rect) -> Image.Image:
|
|
148
|
+
"""The card straightened (long side horizontal), with white at its rounded corners."""
|
|
149
|
+
(cx, cy), (w, h), angle = rect
|
|
150
|
+
if w < h: # make the angle the one of the long side
|
|
151
|
+
w, h, angle = h, w, angle + 90
|
|
152
|
+
angle = (angle + 90) % 180 - 90 # the smallest turn that makes the long side horizontal
|
|
153
|
+
turn = cv2.getRotationMatrix2D((cx, cy), angle, 1.0)
|
|
154
|
+
turn[0, 2] += w / 2 - cx
|
|
155
|
+
turn[1, 2] += h / 2 - cy
|
|
156
|
+
card = cv2.warpAffine(
|
|
157
|
+
np.array(image.convert("RGB")),
|
|
158
|
+
turn,
|
|
159
|
+
(round(w), round(h)),
|
|
160
|
+
flags=cv2.INTER_CUBIC,
|
|
161
|
+
borderValue=(255, 255, 255),
|
|
162
|
+
)
|
|
163
|
+
card = Image.fromarray(card)
|
|
164
|
+
outline = Image.new("L", card.size, 0)
|
|
165
|
+
radius = round(CARD_CORNER_MM / CARD_MM[1] * card.height)
|
|
166
|
+
ImageDraw.Draw(outline).rounded_rectangle((0, 0, card.width - 1, card.height - 1), radius=radius, fill=255)
|
|
167
|
+
return Image.composite(card, Image.new("RGB", card.size, "white"), outline)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def card_from_image(image: Image.Image, dpi: float | None = None) -> Image.Image:
|
|
171
|
+
"""The card cut out of a scan or photo, or the image as it is if no card is found."""
|
|
172
|
+
cards = find_cards(image, dpi)
|
|
173
|
+
return cut_out(image, cards[0]) if cards else image
|
id2pdf/i18n.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Translations of the messages (gettext, domain "id2pdf").
|
|
2
|
+
|
|
3
|
+
The language follows the usual environment variables (LANGUAGE, LC_ALL, LC_MESSAGES,
|
|
4
|
+
LANG); a program that uses id2pdf as a library can pick one with set_language().
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import gettext
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
LOCALE_DIR = Path(__file__).parent / "locale"
|
|
11
|
+
|
|
12
|
+
_translation: gettext.NullTranslations = gettext.translation("id2pdf", LOCALE_DIR, fallback=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def set_language(language: str | None) -> None:
|
|
16
|
+
"""Use this language ("hr", "en", ...); None goes back to the environment's."""
|
|
17
|
+
global _translation
|
|
18
|
+
languages = [language] if language else None
|
|
19
|
+
_translation = gettext.translation("id2pdf", LOCALE_DIR, languages=languages, fallback=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _(message: str) -> str:
|
|
23
|
+
return _translation.gettext(message)
|
id2pdf/layout.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Putting cards and pages into a PDF."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from PIL import Image, ImageOps
|
|
6
|
+
|
|
7
|
+
from .detect import CARD_MM, mm_to_px
|
|
8
|
+
|
|
9
|
+
CARD_DPI = 300
|
|
10
|
+
PAGE_DPI = 200 # enough for documents, keeps a page around 1 MB
|
|
11
|
+
A4_MM = (210, 297)
|
|
12
|
+
GAP_MM = 15
|
|
13
|
+
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def card_size(image: Image.Image) -> Image.Image:
|
|
17
|
+
"""Scale to the real card size (landscape) so the printout is 1:1."""
|
|
18
|
+
if image.height > image.width:
|
|
19
|
+
image = image.rotate(90, expand=True)
|
|
20
|
+
return ImageOps.contain(image, (mm_to_px(CARD_MM[0], CARD_DPI), mm_to_px(CARD_MM[1], CARD_DPI)))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def id_card_page(front: Image.Image, back: Image.Image) -> Image.Image:
|
|
24
|
+
"""Both sides on one A4 page, at real size like a photocopy."""
|
|
25
|
+
front, back = card_size(front), card_size(back)
|
|
26
|
+
page = Image.new("RGB", (mm_to_px(A4_MM[0], CARD_DPI), mm_to_px(A4_MM[1], CARD_DPI)), "white")
|
|
27
|
+
gap = mm_to_px(GAP_MM, CARD_DPI)
|
|
28
|
+
top = (page.height - front.height - gap - back.height) // 3 # a bit above the middle
|
|
29
|
+
for image, y in ((front, top), (back, top + front.height + gap)):
|
|
30
|
+
page.paste(image, ((page.width - image.width) // 2, y))
|
|
31
|
+
return page
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def save_pdf(images: list[Image.Image], output: Path, dpi: int = PAGE_DPI) -> None:
|
|
35
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
first, *rest = images
|
|
37
|
+
first.save(output, "PDF", resolution=dpi, save_all=True, append_images=rest, quality=85)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def open_image(path: Path) -> Image.Image:
|
|
41
|
+
"""An image file, turned the way the camera's EXIF data says."""
|
|
42
|
+
return ImageOps.exif_transpose(Image.open(path)).convert("RGB")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def images_to_pdf(paths: list[Path], output: Path) -> None:
|
|
46
|
+
"""Photos or scans from elsewhere into one PDF, one image per page."""
|
|
47
|
+
images = [open_image(p) for p in paths]
|
|
48
|
+
longest = max(max(i.size) for i in images)
|
|
49
|
+
save_pdf(images, output, dpi=max(72, round(longest / (A4_MM[1] / 25.4))))
|
|
Binary file
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# SOME DESCRIPTIVE TITLE.
|
|
2
|
+
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
|
3
|
+
# This file is distributed under the same license as the id2pdf package.
|
|
4
|
+
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
|
5
|
+
#
|
|
6
|
+
#, fuzzy
|
|
7
|
+
msgid ""
|
|
8
|
+
msgstr ""
|
|
9
|
+
"Project-Id-Version: id2pdf\n"
|
|
10
|
+
"Report-Msgid-Bugs-To: \n"
|
|
11
|
+
"POT-Creation-Date: 2026-10-18 19:52+0200\n"
|
|
12
|
+
"PO-Revision-Date: 2026-10-18 20:00+0200\n"
|
|
13
|
+
"Last-Translator: anaisoob <anaisoob@tutamail.com>\n"
|
|
14
|
+
"Language-Team: Croatian\n"
|
|
15
|
+
"Language: hr\n"
|
|
16
|
+
"MIME-Version: 1.0\n"
|
|
17
|
+
"Content-Type: text/plain; charset=UTF-8\n"
|
|
18
|
+
"Content-Transfer-Encoding: 8bit\n"
|
|
19
|
+
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
|
|
20
|
+
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
|
21
|
+
|
|
22
|
+
#: src/id2pdf/cli.py:18
|
|
23
|
+
msgid ""
|
|
24
|
+
"Scan both sides of an ID card onto one A4 page, as a PDF. The card may lie "
|
|
25
|
+
"anywhere on the scanner glass; it is found, straightened and cut out."
|
|
26
|
+
msgstr ""
|
|
27
|
+
"Skenira obje strane osobne iskaznice na jednu A4 stranicu, kao PDF. Osobna "
|
|
28
|
+
"može ležati bilo gdje na staklu skenera; skripta je pronađe, izravna i "
|
|
29
|
+
"izreže."
|
|
30
|
+
|
|
31
|
+
#: src/id2pdf/cli.py:22
|
|
32
|
+
msgid "the PDF to write (with --diagnose: a folder for the scan)"
|
|
33
|
+
msgstr "PDF koji se sprema (uz --diagnose: mapa za sken)"
|
|
34
|
+
|
|
35
|
+
#: src/id2pdf/cli.py:29
|
|
36
|
+
msgid "use existing scans or photos of the two sides instead of scanning"
|
|
37
|
+
msgstr ""
|
|
38
|
+
"koristi postojeće skenove ili fotografije obiju strana umjesto skeniranja"
|
|
39
|
+
|
|
40
|
+
#: src/id2pdf/cli.py:31
|
|
41
|
+
msgid "scan N plain A4 pages instead of a card"
|
|
42
|
+
msgstr "skenira N običnih A4 stranica umjesto kartice"
|
|
43
|
+
|
|
44
|
+
#: src/id2pdf/cli.py:33
|
|
45
|
+
msgid "scan the glass and print what is found (numbers only)"
|
|
46
|
+
msgstr "skenira staklo i ispiše što je pronađeno (samo brojevi)"
|
|
47
|
+
|
|
48
|
+
#: src/id2pdf/cli.py:35
|
|
49
|
+
msgid "SANE device (default: the first scanner in 'scanimage -L')"
|
|
50
|
+
msgstr "SANE uređaj (zadano: prvi skener iz 'scanimage -L')"
|
|
51
|
+
|
|
52
|
+
#: src/id2pdf/cli.py:36
|
|
53
|
+
msgid "overwrite the output without asking"
|
|
54
|
+
msgstr "zamijeni postojeću datoteku bez pitanja"
|
|
55
|
+
|
|
56
|
+
#: src/id2pdf/cli.py:44
|
|
57
|
+
msgid "the output PDF is required"
|
|
58
|
+
msgstr "potreban je izlazni PDF"
|
|
59
|
+
|
|
60
|
+
#: src/id2pdf/cli.py:46
|
|
61
|
+
#, python-brace-format
|
|
62
|
+
msgid "{path} already exists. Overwrite? [y/N] "
|
|
63
|
+
msgstr "{path} već postoji. Zamijeniti? [d/N] "
|
|
64
|
+
|
|
65
|
+
#: src/id2pdf/cli.py:47
|
|
66
|
+
msgid "y"
|
|
67
|
+
msgstr "d"
|
|
68
|
+
|
|
69
|
+
#: src/id2pdf/cli.py:48
|
|
70
|
+
msgid "Nothing was changed."
|
|
71
|
+
msgstr "Ništa nije promijenjeno."
|
|
72
|
+
|
|
73
|
+
#: src/id2pdf/cli.py:55
|
|
74
|
+
#, python-brace-format
|
|
75
|
+
msgid "Scanner: {device}"
|
|
76
|
+
msgstr "Skener: {device}"
|
|
77
|
+
|
|
78
|
+
#: src/id2pdf/cli.py:66
|
|
79
|
+
msgid "Cancelled, nothing was saved."
|
|
80
|
+
msgstr "Prekinuto, ništa nije spremljeno."
|
|
81
|
+
|
|
82
|
+
#: src/id2pdf/cli.py:67
|
|
83
|
+
#, python-brace-format
|
|
84
|
+
msgid "Saved: {path}"
|
|
85
|
+
msgstr "Spremljeno: {path}"
|
|
86
|
+
|
|
87
|
+
#: src/id2pdf/scanner.py:34
|
|
88
|
+
msgid "The scanimage command is missing (package sane)."
|
|
89
|
+
msgstr "Nema naredbe scanimage (paket sane)."
|
|
90
|
+
|
|
91
|
+
#: src/id2pdf/scanner.py:36
|
|
92
|
+
msgid "'scanimage -L' failed:"
|
|
93
|
+
msgstr "'scanimage -L' nije uspio:"
|
|
94
|
+
|
|
95
|
+
#: src/id2pdf/scanner.py:40
|
|
96
|
+
msgid "No scanner found. Check 'scanimage -L'."
|
|
97
|
+
msgstr "Skener nije pronađen. Provjerite 'scanimage -L'."
|
|
98
|
+
|
|
99
|
+
#: src/id2pdf/scanner.py:57
|
|
100
|
+
msgid "The scanner is still busy, trying again..."
|
|
101
|
+
msgstr "Skener je još zauzet, pokušavam ponovno..."
|
|
102
|
+
|
|
103
|
+
#: src/id2pdf/scanner.py:60
|
|
104
|
+
msgid "Scanning failed:"
|
|
105
|
+
msgstr "Skeniranje nije uspjelo:"
|
|
106
|
+
|
|
107
|
+
#: src/id2pdf/scanner.py:65
|
|
108
|
+
msgid "Then press Enter (Ctrl+C to cancel) "
|
|
109
|
+
msgstr "Zatim pritisnite Enter (Ctrl+C za prekid) "
|
|
110
|
+
|
|
111
|
+
#: src/id2pdf/scanner.py:71
|
|
112
|
+
msgid ""
|
|
113
|
+
"Put the card on the glass front side down, anywhere (text upright), and "
|
|
114
|
+
"close the lid."
|
|
115
|
+
msgstr ""
|
|
116
|
+
"Stavite osobnu prednjom stranom prema staklu, bilo gdje (tekst uspravno), i "
|
|
117
|
+
"zatvorite poklopac."
|
|
118
|
+
|
|
119
|
+
#: src/id2pdf/scanner.py:73
|
|
120
|
+
msgid ""
|
|
121
|
+
"Put the card on the glass back side down, anywhere (text upright), and close "
|
|
122
|
+
"the lid."
|
|
123
|
+
msgstr ""
|
|
124
|
+
"Stavite osobnu stražnjom stranom prema staklu, bilo gdje (tekst uspravno), i "
|
|
125
|
+
"zatvorite poklopac."
|
|
126
|
+
|
|
127
|
+
#: src/id2pdf/scanner.py:75
|
|
128
|
+
msgid "Looking for the card on the glass..."
|
|
129
|
+
msgstr "Tražim osobnu na staklu..."
|
|
130
|
+
|
|
131
|
+
#: src/id2pdf/scanner.py:80
|
|
132
|
+
msgid ""
|
|
133
|
+
"No card found. Put it away from the edge of the glass, flat, and close the "
|
|
134
|
+
"lid."
|
|
135
|
+
msgstr ""
|
|
136
|
+
"Osobna nije pronađena. Stavite je dalje od ruba stakla, ravno, i zatvorite "
|
|
137
|
+
"poklopac."
|
|
138
|
+
|
|
139
|
+
#: src/id2pdf/scanner.py:82
|
|
140
|
+
msgid "There is more than one card on the glass, taking the largest."
|
|
141
|
+
msgstr "Na staklu je više kartica, uzimam najveću."
|
|
142
|
+
|
|
143
|
+
#: src/id2pdf/scanner.py:91
|
|
144
|
+
msgid "Scanning the card..."
|
|
145
|
+
msgstr "Skeniram osobnu..."
|
|
146
|
+
|
|
147
|
+
#: src/id2pdf/scanner.py:118
|
|
148
|
+
#, python-brace-format
|
|
149
|
+
msgid "Put page {n} of {pages} face down on the glass and close the lid."
|
|
150
|
+
msgstr ""
|
|
151
|
+
"Stavite {n}. od {pages} stranica licem prema staklu i zatvorite poklopac."
|
|
152
|
+
|
|
153
|
+
#: src/id2pdf/scanner.py:119
|
|
154
|
+
msgid "Scanning..."
|
|
155
|
+
msgstr "Skeniram..."
|
|
156
|
+
|
|
157
|
+
#: src/id2pdf/scanner.py:126
|
|
158
|
+
msgid "Put the card on the glass as usual and close the lid."
|
|
159
|
+
msgstr "Stavite osobnu na staklo kao inače i zatvorite poklopac."
|
|
160
|
+
|
|
161
|
+
#: src/id2pdf/scanner.py:131
|
|
162
|
+
#, python-brace-format
|
|
163
|
+
msgid "Glass: {width:.1f} x {height:.1f} mm, lid (median grey): {lid:.0f}"
|
|
164
|
+
msgstr ""
|
|
165
|
+
"Staklo: {width:.1f} x {height:.1f} mm, poklopac (medijan sive): {lid:.0f}"
|
|
166
|
+
|
|
167
|
+
#: src/id2pdf/scanner.py:136
|
|
168
|
+
#, python-brace-format
|
|
169
|
+
msgid "Objects found: {count} (largest first; a card is 85.6 x 54 mm)"
|
|
170
|
+
msgstr "Pronađeno objekata: {count} (najveći prvi; osobna je 85.6 x 54 mm)"
|
|
171
|
+
|
|
172
|
+
#: src/id2pdf/scanner.py:137
|
|
173
|
+
msgid "shape"
|
|
174
|
+
msgstr "oblik"
|
|
175
|
+
|
|
176
|
+
#: src/id2pdf/scanner.py:137
|
|
177
|
+
msgid "size"
|
|
178
|
+
msgstr "veličina"
|
|
179
|
+
|
|
180
|
+
#: src/id2pdf/scanner.py:137
|
|
181
|
+
msgid "too small"
|
|
182
|
+
msgstr "premalo"
|
|
183
|
+
|
|
184
|
+
#: src/id2pdf/scanner.py:142
|
|
185
|
+
#, python-brace-format
|
|
186
|
+
msgid "rejected ({reason})"
|
|
187
|
+
msgstr "odbijeno ({reason})"
|
|
188
|
+
|
|
189
|
+
#: src/id2pdf/scanner.py:146
|
|
190
|
+
#, python-brace-format
|
|
191
|
+
msgid "CARD, without its shadow: {size} mm"
|
|
192
|
+
msgstr "OSOBNA, bez sjene: {size} mm"
|
|
193
|
+
|
|
194
|
+
#: src/id2pdf/scanner.py:150
|
|
195
|
+
#, python-brace-format
|
|
196
|
+
msgid ""
|
|
197
|
+
"{long:6.1f} x {short:5.1f} mm ratio {ratio:4.2f} centre ({where}) mm "
|
|
198
|
+
"angle {angle:5.1f} {verdict}"
|
|
199
|
+
msgstr ""
|
|
200
|
+
"{long:6.1f} x {short:5.1f} mm omjer {ratio:4.2f} sredina ({where}) mm kut "
|
|
201
|
+
"{angle:5.1f} {verdict}"
|
|
202
|
+
|
|
203
|
+
#: src/id2pdf/scanner.py:157
|
|
204
|
+
#, python-brace-format
|
|
205
|
+
msgid ""
|
|
206
|
+
"The scan is saved only on this computer: {path} (it shows the card, do not "
|
|
207
|
+
"share it)."
|
|
208
|
+
msgstr ""
|
|
209
|
+
"Sken je spremljen samo na ovom računalu: {path} (na njemu je osobna, ne "
|
|
210
|
+
"šaljite ga)."
|