charmfix 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.
charmfix/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """charmfix: repair EXIF metadata of Kodak Charmera JPEGs without touching image data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ try:
8
+ __version__ = version("charmfix")
9
+ except PackageNotFoundError: # run from a source tree that was never installed
10
+ __version__ = "0+unknown"
charmfix/cli.py ADDED
@@ -0,0 +1,233 @@
1
+ """Command-line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+ from typing import TextIO
11
+
12
+ from . import __version__
13
+ from .discovery import unreadable_reason
14
+ from .pipeline import DiskFullError, Job, RunOptions, prepare, process_file
15
+ from .report import FileResult, Summary, build_report, write_report
16
+
17
+ DEFAULT_OUTPUT = Path.home() / "Pictures" / "Kodak Charmera Repaired"
18
+ MACOS_PERMISSION_HINT = (
19
+ "macOS is blocking access. Allow the app you run charmfix from (Terminal, iTerm, "
20
+ "VS Code, ...) under System Settings > Privacy & Security > Files and Folders > "
21
+ "Removable Volumes (or Full Disk Access), then restart that app and try again."
22
+ )
23
+ EXIT_OK, EXIT_FAILURES, EXIT_FATAL, EXIT_INTERRUPTED = 0, 1, 2, 130
24
+
25
+ log = logging.getLogger("charmfix")
26
+
27
+
28
+ class _Progress:
29
+ """A single status line rewritten on stderr, only when stderr is a terminal."""
30
+
31
+ def __init__(self, stream: TextIO, enabled: bool) -> None:
32
+ self.stream, self.enabled, self.active = stream, enabled, False
33
+
34
+ def update(self, text: str) -> None:
35
+ if self.enabled:
36
+ self.stream.write("\r\x1b[K" + text[:120])
37
+ self.stream.flush()
38
+ self.active = True
39
+
40
+ def clear(self) -> None:
41
+ if self.active:
42
+ self.stream.write("\r\x1b[K")
43
+ self.stream.flush()
44
+ self.active = False
45
+
46
+
47
+ class _ProgressAwareHandler(logging.StreamHandler): # type: ignore[type-arg]
48
+ def __init__(self, progress: _Progress) -> None:
49
+ super().__init__(sys.stderr)
50
+ self.progress = progress
51
+
52
+ def emit(self, record: logging.LogRecord) -> None:
53
+ self.progress.clear()
54
+ super().emit(record)
55
+
56
+
57
+ def _parser() -> argparse.ArgumentParser:
58
+ p = argparse.ArgumentParser(
59
+ prog="charmfix",
60
+ description="Write copies of Kodak Charmera JPEGs with repaired EXIF metadata. "
61
+ "Image data is never changed and source files are never modified.",
62
+ )
63
+ p.add_argument("input", type=Path, help="folder (e.g. the mounted SD card) or a single JPEG")
64
+ p.add_argument(
65
+ "-o",
66
+ "--output",
67
+ type=Path,
68
+ default=DEFAULT_OUTPUT,
69
+ help=f"output folder (default: {DEFAULT_OUTPUT})",
70
+ )
71
+ p.add_argument(
72
+ "--recursive",
73
+ action=argparse.BooleanOptionalAction,
74
+ default=True,
75
+ help="descend into subfolders (default: yes)",
76
+ )
77
+ p.add_argument("-n", "--dry-run", action="store_true", help="analyse only; write no images")
78
+ p.add_argument("--force", action="store_true", help="overwrite existing output files")
79
+ p.add_argument("-v", "--verbose", action="store_true", help="log details for every file")
80
+ p.add_argument("--report", type=Path, help="write a JSON report to this path")
81
+ p.add_argument(
82
+ "--no-preserve-structure",
83
+ dest="preserve_structure",
84
+ action="store_false",
85
+ help="put all outputs directly in the output folder",
86
+ )
87
+ p.add_argument(
88
+ "--no-assume-charmera",
89
+ dest="assume_charmera",
90
+ action="store_false",
91
+ help="do not set Make/Model to Kodak/Charmera on files with only a GPEncoder comment",
92
+ )
93
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
94
+ return p
95
+
96
+
97
+ def _same(a: Path, b: Path) -> bool:
98
+ """Same file or folder on disk, whatever the spelling (letter case, Unicode form).
99
+
100
+ A path that cannot be statted counts as different: nothing can be written there either.
101
+ """
102
+ if a == b:
103
+ return True
104
+ try:
105
+ return a.samefile(b)
106
+ except OSError:
107
+ return False
108
+
109
+
110
+ def _within(path: Path, root: Path) -> bool:
111
+ """``path`` is ``root`` or lies under it, judged by identity on disk."""
112
+ return any(_same(p, root) for p in (path, *path.parents))
113
+
114
+
115
+ def _refusal(
116
+ input_path: Path, is_folder: bool, output_root: Path, report_path: Path | None
117
+ ) -> str | None:
118
+ """Why these paths would put a source file at risk (CLI.md source safety), or None."""
119
+ if is_folder:
120
+ if _within(output_root, input_path) or _within(input_path, output_root):
121
+ return "input and output folders must not overlap"
122
+ if report_path and _within(report_path, input_path):
123
+ return "the report must not be written inside the input folder"
124
+ else:
125
+ if _same(output_root, input_path.parent):
126
+ return "the output folder must not be the input file's folder"
127
+ if report_path and _same(report_path, input_path):
128
+ return "the report must not replace the input file"
129
+ return None
130
+
131
+
132
+ def _log_result(r: FileResult) -> None:
133
+ if r.status == "failed":
134
+ log.error("FAILED %s: %s", r.rel_path, r.error)
135
+ elif r.status == "partial":
136
+ log.warning("partial %s: %s", r.rel_path, "; ".join(r.partial_reasons))
137
+ elif r.warnings:
138
+ log.warning("%-7s %s: %s", r.status, r.rel_path, "; ".join(r.warnings))
139
+ else:
140
+ log.debug("%-7s %s", r.status, r.rel_path)
141
+ if r.status not in ("skipped", "failed"):
142
+ log.debug(
143
+ " SOF %s, old sizes %s; date %r -> %s (source: %s); identity %s; MakerNote %s",
144
+ r.sof_size,
145
+ r.old_sizes,
146
+ r.raw_date,
147
+ r.date_written,
148
+ r.timestamp_source,
149
+ r.identity,
150
+ r.makernote,
151
+ )
152
+ for change in r.changes:
153
+ log.debug(" change: %s", change)
154
+ elif r.reason:
155
+ log.debug(" reason: %s", r.reason)
156
+
157
+
158
+ def main(argv: Sequence[str] | None = None) -> int:
159
+ args = _parser().parse_args(argv)
160
+ progress = _Progress(sys.stderr, enabled=sys.stderr.isatty() and not args.verbose)
161
+ handler = _ProgressAwareHandler(progress)
162
+ handler.setFormatter(logging.Formatter("%(message)s"))
163
+ log.handlers[:] = [handler]
164
+ log.setLevel(logging.DEBUG if args.verbose else logging.INFO)
165
+ log.propagate = False
166
+
167
+ input_path: Path = args.input.expanduser().resolve()
168
+ output_root: Path = args.output.expanduser().resolve()
169
+ if not input_path.exists():
170
+ log.error("input not found: %s", args.input)
171
+ return EXIT_FATAL
172
+ report_path = args.report.expanduser().resolve() if args.report else None
173
+ is_folder = input_path.is_dir() # anything else is one file, as in discovery
174
+ refusal = _refusal(input_path, is_folder, output_root, report_path)
175
+ if refusal:
176
+ log.error(refusal)
177
+ return EXIT_FATAL
178
+
179
+ opts = RunOptions(
180
+ output_root=output_root,
181
+ recursive=args.recursive,
182
+ preserve_structure=args.preserve_structure,
183
+ force=args.force,
184
+ dry_run=args.dry_run,
185
+ assume_charmera=args.assume_charmera,
186
+ )
187
+ input_root = input_path if is_folder else input_path.parent
188
+ reason = unreadable_reason(input_root)
189
+ if reason is not None:
190
+ log.error("cannot read %s: %s", input_root, reason)
191
+ if sys.platform == "darwin" and reason == "Operation not permitted":
192
+ log.error(MACOS_PERMISSION_HINT)
193
+ return EXIT_FATAL
194
+
195
+ summary = Summary()
196
+ results: list[FileResult] = []
197
+ exit_code = EXIT_OK
198
+ try:
199
+ jobs: list[Job] = prepare(input_path, opts) # walks the card: Ctrl-C may land here too
200
+ if not jobs:
201
+ log.warning("no .jpg/.jpeg files found in %s", input_path)
202
+ summary.discovered = len(jobs)
203
+ for i, job in enumerate(jobs, 1):
204
+ progress.update(f"[{i}/{len(jobs)}] {job.candidate.rel.as_posix()}")
205
+ r = process_file(job, opts)
206
+ results.append(r)
207
+ summary.add(r)
208
+ _log_result(r)
209
+ except DiskFullError as exc:
210
+ summary.stopped = str(exc)
211
+ log.error("stopping: %s", exc)
212
+ exit_code = EXIT_FATAL
213
+ except KeyboardInterrupt:
214
+ summary.stopped = "interrupted"
215
+ exit_code = EXIT_INTERRUPTED
216
+ finally:
217
+ progress.clear()
218
+
219
+ print(summary.render(args.dry_run))
220
+ if report_path:
221
+ try:
222
+ write_report(report_path, build_report(results, summary, opts.report_options()))
223
+ except OSError as exc:
224
+ log.error("cannot write the report: %s", exc.strerror or exc.__class__.__name__)
225
+ if exit_code != EXIT_INTERRUPTED:
226
+ exit_code = EXIT_FATAL
227
+ if exit_code == EXIT_OK and summary.failed:
228
+ exit_code = EXIT_FAILURES
229
+ return exit_code
230
+
231
+
232
+ if __name__ == "__main__":
233
+ sys.exit(main())
charmfix/dates.py ADDED
@@ -0,0 +1,54 @@
1
+ """EXIF date parsing and formatting. Dates are naive local wall-clock times."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import datetime, timedelta
7
+
8
+ EXIF_FORMAT = "%Y:%m:%d %H:%M:%S"
9
+
10
+ # Accepts the standard "YYYY:MM:DD HH:MM:SS" and the broken variants seen in the
11
+ # wild, e.g. the Charmera's six-part "YYYY:MM:DD:HH:MM:SS", or "T"/"-" separators.
12
+ _DATE_RE = re.compile(r"^\s*(\d{4})[:\-](\d{2})[:\-](\d{2})[ :T\-](\d{2}):(\d{2}):(\d{2})\s*$")
13
+ _NORMAL_RE = re.compile(r"^\d{4}:\d{2}:\d{2} \d{2}:\d{2}:\d{2}$")
14
+
15
+
16
+ def _text(raw: bytes | str) -> str:
17
+ if isinstance(raw, bytes):
18
+ raw = raw.decode("ascii", errors="replace")
19
+ return raw.rstrip("\x00")
20
+
21
+
22
+ def normalize(raw: bytes | str | None) -> datetime | None:
23
+ """Parse an EXIF-ish date string; None for blank, zero or impossible dates."""
24
+ if raw is None:
25
+ return None
26
+ m = _DATE_RE.match(_text(raw))
27
+ if not m:
28
+ return None
29
+ year, month, day, hour, minute, second = (int(g) for g in m.groups())
30
+ try:
31
+ return datetime(year, month, day, hour, minute, second)
32
+ except ValueError:
33
+ return None
34
+
35
+
36
+ def is_normal_form(raw: bytes | str | None) -> bool:
37
+ """True when the value is already a valid, standard EXIF date string."""
38
+ if raw is None:
39
+ return False
40
+ text = _text(raw)
41
+ return bool(_NORMAL_RE.match(text)) and normalize(text) is not None
42
+
43
+
44
+ def format_exif(dt: datetime) -> str:
45
+ return dt.strftime(EXIF_FORMAT)
46
+
47
+
48
+ def sanity_warning(dt: datetime, now: datetime) -> str | None:
49
+ """A warning for dates that parse but look implausible; the date is still used."""
50
+ if dt.year < 2000:
51
+ return f"capture date {format_exif(dt)} is before 2000 (camera clock not set?)"
52
+ if dt > now + timedelta(days=1):
53
+ return f"capture date {format_exif(dt)} is in the future"
54
+ return None
charmfix/discovery.py ADDED
@@ -0,0 +1,85 @@
1
+ """Find JPEG files under an input folder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ from collections.abc import Iterator
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ JPEG_EXTENSIONS = frozenset({".jpg", ".jpeg"})
12
+ SKIP_DIRS = frozenset({".Trashes", ".Spotlight-V100", ".fseventsd"})
13
+ MAX_FILE_SIZE = 64 * 1024 * 1024
14
+ JPEG_MAGIC = b"\xff\xd8\xff"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Candidate:
19
+ path: Path
20
+ rel: Path # relative to the input root
21
+ skip_reason: str | None = None
22
+ error: str | None = None
23
+
24
+
25
+ def _check(path: Path, rel: Path) -> Candidate:
26
+ try:
27
+ st = path.lstat()
28
+ if stat.S_ISLNK(st.st_mode):
29
+ return Candidate(path, rel, skip_reason="symlink")
30
+ if not stat.S_ISREG(st.st_mode):
31
+ return Candidate(path, rel, skip_reason="not a regular file")
32
+ if st.st_size > MAX_FILE_SIZE:
33
+ return Candidate(path, rel, skip_reason="too large")
34
+ with open(path, "rb") as f:
35
+ magic = f.read(3)
36
+ except OSError as exc:
37
+ return Candidate(path, rel, error=f"cannot read: {exc.strerror or exc.__class__.__name__}")
38
+ if magic != JPEG_MAGIC:
39
+ return Candidate(path, rel, skip_reason="not JPEG content")
40
+ return Candidate(path, rel)
41
+
42
+
43
+ def _is_jpeg_name(name: str) -> bool:
44
+ return not name.startswith("._") and os.path.splitext(name)[1].lower() in JPEG_EXTENSIONS
45
+
46
+
47
+ def discover(root: Path, recursive: bool = True) -> Iterator[Candidate]:
48
+ """Yield every *.jpg/*.jpeg under root in a stable order.
49
+
50
+ AppleDouble ``._*`` files and macOS volume system folders are ignored.
51
+ Symlinked folders are not followed.
52
+ """
53
+ if not root.is_dir(): # a single file, or something that is neither (checked below)
54
+ yield _check(root, Path(root.name))
55
+ return
56
+ # os.walk silently skips folders it cannot list unless told otherwise; report them
57
+ # as failures instead, so an unreadable card never looks like an empty one.
58
+ errors: list[OSError] = []
59
+
60
+ def unreadable() -> Iterator[Candidate]:
61
+ while errors:
62
+ exc = errors.pop(0)
63
+ folder = Path(exc.filename) if exc.filename else root
64
+ rel = folder.relative_to(root) if folder.is_relative_to(root) else Path(folder.name)
65
+ reason = exc.strerror or exc.__class__.__name__
66
+ yield Candidate(folder, rel, error=f"cannot list folder: {reason}")
67
+
68
+ for dirpath, dirnames, filenames in os.walk(root, onerror=errors.append, followlinks=False):
69
+ yield from unreadable()
70
+ dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS) if recursive else []
71
+ base = Path(dirpath)
72
+ for name in sorted(filenames):
73
+ if _is_jpeg_name(name):
74
+ path = base / name
75
+ yield _check(path, path.relative_to(root))
76
+ yield from unreadable()
77
+
78
+
79
+ def unreadable_reason(folder: Path) -> str | None:
80
+ """Why ``folder`` cannot be listed, or None if it can."""
81
+ try:
82
+ with os.scandir(folder):
83
+ return None
84
+ except OSError as exc:
85
+ return exc.strerror or exc.__class__.__name__
charmfix/exif_read.py ADDED
@@ -0,0 +1,217 @@
1
+ """Tolerant TIFF/EXIF reader.
2
+
3
+ Never raises on malformed input: every problem is skipped and recorded as a
4
+ warning. Values are decoded into plain Python types; the original bytes are
5
+ kept as well.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import struct
11
+ from dataclasses import dataclass, field
12
+ from typing import Literal
13
+
14
+ TYPE_SIZES = {1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 6: 1, 7: 1, 8: 2, 9: 4, 10: 8, 11: 4, 12: 8, 13: 4}
15
+ MAX_ENTRIES = 1024
16
+
17
+ TAG_EXIF_IFD = 0x8769
18
+ TAG_GPS_IFD = 0x8825
19
+ TAG_INTEROP_IFD = 0xA005
20
+ TAG_MAKERNOTE = 0x927C
21
+ TAG_THUMB_OFFSET = 0x0201
22
+ TAG_THUMB_LENGTH = 0x0202
23
+
24
+ IfdName = Literal["0th", "Exif", "GPS", "Interop", "1st"]
25
+ MakerNoteStatus = Literal["absent", "present", "invalid"]
26
+
27
+ Value = bytes | tuple[int, ...] | tuple[tuple[int, int], ...] | tuple[float, ...]
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class RawValue:
32
+ type: int
33
+ count: int
34
+ raw: bytes # value bytes exactly as stored
35
+ value: Value # bytes for ASCII/UNDEFINED, otherwise a tuple of numbers/rationals
36
+
37
+
38
+ @dataclass
39
+ class ExifData:
40
+ byte_order: str = ""
41
+ ifds: dict[str, dict[int, RawValue]] = field(default_factory=dict)
42
+ thumbnail: bytes | None = None
43
+ warnings: list[str] = field(default_factory=list)
44
+ makernote_status: MakerNoteStatus = "absent"
45
+ duplicate_tags: list[tuple[str, int]] = field(default_factory=list)
46
+ readable: bool = False
47
+
48
+ def get(self, ifd: str, tag: int) -> RawValue | None:
49
+ return self.ifds.get(ifd, {}).get(tag)
50
+
51
+
52
+ def _decode(type_: int, count: int, raw: bytes, bo: str) -> Value:
53
+ if type_ in (2, 7):
54
+ return raw
55
+ if type_ == 1:
56
+ return tuple(raw)
57
+ if type_ == 6:
58
+ return struct.unpack(f"{count}b", raw)
59
+ fmt = {3: "H", 8: "h", 4: "L", 9: "l", 13: "L", 11: "f", 12: "d"}.get(type_)
60
+ if fmt is not None:
61
+ return struct.unpack(f"{bo}{count}{fmt}", raw)
62
+ # rationals
63
+ nums = struct.unpack(f"{bo}{count * 2}{'L' if type_ == 5 else 'l'}", raw)
64
+ return tuple((nums[i], nums[i + 1]) for i in range(0, len(nums), 2))
65
+
66
+
67
+ class _Reader:
68
+ def __init__(self, tiff: bytes) -> None:
69
+ self.data = tiff
70
+ self.n = len(tiff)
71
+ self.result = ExifData()
72
+ self.visited: set[int] = set()
73
+ self.bo = ">"
74
+
75
+ def warn(self, msg: str) -> None:
76
+ self.result.warnings.append(msg)
77
+
78
+ def u16(self, pos: int) -> int:
79
+ return int(struct.unpack_from(f"{self.bo}H", self.data, pos)[0])
80
+
81
+ def u32(self, pos: int) -> int:
82
+ return int(struct.unpack_from(f"{self.bo}L", self.data, pos)[0])
83
+
84
+ def run(self) -> ExifData:
85
+ d = self.data
86
+ if self.n < 8:
87
+ self.warn("EXIF block too short for a TIFF header")
88
+ return self.result
89
+ if d[:4] == b"II*\x00":
90
+ self.bo, self.result.byte_order = "<", "II"
91
+ elif d[:4] == b"MM\x00*":
92
+ self.bo, self.result.byte_order = ">", "MM"
93
+ else:
94
+ self.warn("invalid TIFF header in EXIF block")
95
+ return self.result
96
+
97
+ ifd0 = self.read_ifd(self.u32(4), "0th")
98
+ if ifd0 is None:
99
+ return self.result
100
+ self.result.readable = True
101
+ entries0, next_offset = ifd0
102
+
103
+ exif_ptr = self.pointer(entries0, TAG_EXIF_IFD, "0th")
104
+ if exif_ptr is not None:
105
+ exif = self.read_ifd(exif_ptr, "Exif")
106
+ if exif is not None:
107
+ interop_ptr = self.pointer(exif[0], TAG_INTEROP_IFD, "Exif")
108
+ if interop_ptr is not None:
109
+ self.read_ifd(interop_ptr, "Interop")
110
+ gps_ptr = self.pointer(entries0, TAG_GPS_IFD, "0th")
111
+ if gps_ptr is not None:
112
+ self.read_ifd(gps_ptr, "GPS")
113
+ if next_offset:
114
+ ifd1 = self.read_ifd(next_offset, "1st")
115
+ if ifd1 is not None:
116
+ self.read_thumbnail(ifd1[0])
117
+ return self.result
118
+
119
+ def pointer(self, entries: dict[int, RawValue], tag: int, ifd: str) -> int | None:
120
+ rv = entries.get(tag)
121
+ if rv is None:
122
+ return None
123
+ if rv.type not in (4, 13) or rv.count != 1 or not isinstance(rv.value[0], int):
124
+ self.warn(f"{ifd}: invalid IFD pointer tag 0x{tag:04X}")
125
+ return None
126
+ return rv.value[0]
127
+
128
+ def read_ifd(self, offset: int, name: str) -> tuple[dict[int, RawValue], int] | None:
129
+ if offset in self.visited:
130
+ self.warn(f"{name}: IFD offset {offset} already visited (loop)")
131
+ return None
132
+ if offset < 8 or offset + 2 > self.n:
133
+ self.warn(f"{name}: IFD offset {offset} outside EXIF block")
134
+ return None
135
+ self.visited.add(offset)
136
+ count = self.u16(offset)
137
+ if count > MAX_ENTRIES:
138
+ self.warn(f"{name}: implausible entry count {count}")
139
+ return None
140
+ avail = (self.n - offset - 2) // 12
141
+ if count > avail:
142
+ self.warn(f"{name}: IFD truncated ({count} entries declared, {avail} present)")
143
+ count = avail
144
+ entries: dict[int, RawValue] = {}
145
+ for i in range(count):
146
+ self.read_entry(offset + 2 + 12 * i, name, entries)
147
+ self.result.ifds[name] = entries
148
+ if name != "0th": # only IFD0 links to another IFD (IFD1); sub-IFD links are ignored
149
+ return entries, 0
150
+ next_pos = offset + 2 + 12 * count
151
+ next_offset = self.u32(next_pos) if next_pos + 4 <= self.n else 0
152
+ if next_offset and next_offset + 2 > self.n:
153
+ self.warn(f"{name}: next IFD offset {next_offset} outside EXIF block")
154
+ next_offset = 0
155
+ return entries, next_offset
156
+
157
+ def read_entry(self, pos: int, ifd: str, entries: dict[int, RawValue]) -> None:
158
+ tag = self.u16(pos)
159
+ type_ = self.u16(pos + 2)
160
+ count = self.u32(pos + 4)
161
+ is_makernote = ifd == "Exif" and tag == TAG_MAKERNOTE
162
+
163
+ def bad(msg: str) -> None:
164
+ self.warn(f"{ifd}: tag 0x{tag:04X} {msg}; skipped")
165
+ if is_makernote:
166
+ self.result.makernote_status = "invalid"
167
+
168
+ size_per = TYPE_SIZES.get(type_)
169
+ if size_per is None:
170
+ bad(f"has unknown type {type_}")
171
+ return
172
+ if count == 0:
173
+ bad("has count 0")
174
+ return
175
+ size = size_per * count
176
+ if size > self.n:
177
+ bad(f"declares {size} bytes, more than the EXIF block")
178
+ return
179
+ if size <= 4:
180
+ raw = self.data[pos + 8 : pos + 8 + size]
181
+ else:
182
+ value_offset = self.u32(pos + 8)
183
+ if value_offset + size > self.n:
184
+ bad(f"value at offset {value_offset} (+{size}) is outside the EXIF block")
185
+ return
186
+ raw = self.data[value_offset : value_offset + size]
187
+ if tag in entries:
188
+ self.warn(f"{ifd}: duplicate tag 0x{tag:04X}; first value kept")
189
+ self.result.duplicate_tags.append((ifd, tag))
190
+ return
191
+ entries[tag] = RawValue(type_, count, raw, _decode(type_, count, raw, self.bo))
192
+ if is_makernote:
193
+ self.result.makernote_status = "present"
194
+
195
+ def read_thumbnail(self, entries: dict[int, RawValue]) -> None:
196
+ off_rv = entries.get(TAG_THUMB_OFFSET)
197
+ len_rv = entries.get(TAG_THUMB_LENGTH)
198
+ if off_rv is None or len_rv is None:
199
+ return
200
+ off, length = off_rv.value[0], len_rv.value[0]
201
+ if not isinstance(off, int) or not isinstance(length, int):
202
+ self.warn("1st: invalid thumbnail pointer")
203
+ return
204
+ if length == 0 or off + length > self.n:
205
+ self.warn(f"1st: thumbnail at offset {off} (+{length}) is outside the EXIF block")
206
+ return
207
+ self.result.thumbnail = self.data[off : off + length]
208
+
209
+
210
+ def read_exif(tiff: bytes) -> ExifData:
211
+ """Parse TIFF bytes (the APP1 payload after the 6-byte Exif header)."""
212
+ try:
213
+ return _Reader(tiff).run()
214
+ except Exception as exc: # defensive: the reader must never crash the pipeline
215
+ result = ExifData()
216
+ result.warnings.append(f"EXIF reader error: {exc!r}")
217
+ return result