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 +10 -0
- charmfix/cli.py +233 -0
- charmfix/dates.py +54 -0
- charmfix/discovery.py +85 -0
- charmfix/exif_read.py +217 -0
- charmfix/exif_write.py +148 -0
- charmfix/jpeg.py +221 -0
- charmfix/output.py +129 -0
- charmfix/pipeline.py +152 -0
- charmfix/repair.py +521 -0
- charmfix/report.py +138 -0
- charmfix/verify.py +60 -0
- charmfix-0.1.0.dist-info/METADATA +103 -0
- charmfix-0.1.0.dist-info/RECORD +17 -0
- charmfix-0.1.0.dist-info/WHEEL +4 -0
- charmfix-0.1.0.dist-info/entry_points.txt +3 -0
- charmfix-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|