magicdispel 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.
@@ -0,0 +1,3 @@
1
+ """MagicDispel: clean common photo metadata locally, without recompression."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
magicdispel/banner.py ADDED
@@ -0,0 +1,81 @@
1
+ """The MagicDispel logo, shown when the command runs without photos."""
2
+ import os
3
+ import shutil
4
+ import sys
5
+
6
+ CREDITS = "Designed by VincentC, Powered by VincentC"
7
+ TAGLINE = "MagicDispel, wipe the metadata, keep every pixel!"
8
+ # Letters in the ANSI Shadow style: six rows each, every row the same width.
9
+ LETTERS = {
10
+ "M": ["███╗ ███╗", "████╗ ████║", "██╔████╔██║", "██║╚██╔╝██║", "██║ ╚═╝ ██║", "╚═╝ ╚═╝"],
11
+ "A": [" █████╗ ", "██╔══██╗", "███████║", "██╔══██║", "██║ ██║", "╚═╝ ╚═╝"],
12
+ "G": [" ██████╗ ", "██╔════╝ ", "██║ ███╗", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
13
+ "I": ["██╗", "██║", "██║", "██║", "██║", "╚═╝"],
14
+ "C": [" ██████╗", "██╔════╝", "██║ ", "██║ ", "╚██████╗", " ╚═════╝"],
15
+ "D": ["██████╗ ", "██╔══██╗", "██║ ██║", "██║ ██║", "██████╔╝", "╚═════╝ "],
16
+ "S": ["███████╗", "██╔════╝", "███████╗", "╚════██║", "███████║", "╚══════╝"],
17
+ "P": ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔═══╝ ", "██║ ", "╚═╝ "],
18
+ "E": ["███████╗", "██╔════╝", "█████╗ ", "██╔══╝ ", "███████╗", "╚══════╝"],
19
+ "L": ["██╗ ", "██║ ", "██║ ", "██║ ", "███████╗", "╚══════╝"],
20
+ }
21
+ # Magenta through violet and blue to cyan, from the 256-color palette nearly
22
+ # every terminal has; the letters' edges are gray.
23
+ GRADIENT = [201, 165, 129, 93, 57, 63, 69, 75, 81, 87, 51]
24
+ EDGE, DIM, BOLD, RESET = "\x1b[38;5;240m", "\x1b[2m", "\x1b[1m", "\x1b[0m"
25
+ INDENT = " "
26
+
27
+
28
+ def welcome(version, usage, stream=sys.stdout):
29
+ """The logo, credits and tagline, then `usage`. A terminal gets the logo
30
+ in color; anything else, such as a file, gets plain text."""
31
+ if not stream.isatty():
32
+ return "\n".join(["MagicDispel " + version, CREDITS, TAGLINE, "", usage])
33
+ color = colors(stream)
34
+ width = shutil.get_terminal_size((80, 24)).columns
35
+ lines = logo(["MAGICDISPEL"] if width >= len(INDENT) + 82 else ["MAGIC", "DISPEL"])
36
+ widest = max(map(len, lines)) # both words share one gradient
37
+ art = [INDENT + (painted(line, widest) if color else line) for line in lines]
38
+ style = (lambda code, text: code + text + RESET) if color else (lambda code, text: text)
39
+ return "\n".join(["", *art, "",
40
+ INDENT + style(DIM, CREDITS),
41
+ INDENT + style(BOLD, TAGLINE) + " " + style(DIM, "v" + version),
42
+ "", *(INDENT + line for line in usage.splitlines()), ""])
43
+
44
+
45
+ def logo(words):
46
+ """The rows of each word, the words one under the other."""
47
+ return ["".join(LETTERS[letter][row] for letter in word) for word in words for row in range(6)]
48
+
49
+
50
+ def painted(line, width):
51
+ """A logo row: blocks colored by column along the gradient, edges gray."""
52
+ out, current = [], None
53
+ for column, char in enumerate(line):
54
+ code = ("\x1b[38;5;%dm" % GRADIENT[min(column * len(GRADIENT) // width, len(GRADIENT) - 1)]
55
+ if char == "█" else EDGE if char != " " else current)
56
+ if code != current:
57
+ out.append(code)
58
+ current = code
59
+ out.append(char)
60
+ return "".join(out) + RESET
61
+
62
+
63
+ def colors(stream):
64
+ """Whether to use color: not when NO_COLOR is set or the terminal is dumb.
65
+ Windows consoles need their escape-sequence support switched on."""
66
+ if os.environ.get("NO_COLOR") or os.environ.get("TERM") == "dumb":
67
+ return False
68
+ return os.name != "nt" or windows_escapes()
69
+
70
+
71
+ def windows_escapes():
72
+ """Switch on escape sequences in the Windows console; whether that worked."""
73
+ try:
74
+ import ctypes
75
+ kernel32 = ctypes.windll.kernel32
76
+ handle, mode = kernel32.GetStdHandle(-11), ctypes.c_uint32() # standard output
77
+ # ENABLE_VIRTUAL_TERMINAL_PROCESSING
78
+ return bool(kernel32.GetConsoleMode(handle, ctypes.byref(mode))
79
+ and kernel32.SetConsoleMode(handle, mode.value | 0x0004))
80
+ except (AttributeError, OSError):
81
+ return False
magicdispel/cli.py ADDED
@@ -0,0 +1,87 @@
1
+ """Command line: type magicdispel, drag photos into the terminal, press Enter."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from . import __version__, banner, exiftool
7
+ from .core import clean
8
+ from .errors import UserError
9
+ from .messages import message
10
+
11
+
12
+ class Parser(argparse.ArgumentParser):
13
+ """Report bad arguments in our own words; the help text is our own too."""
14
+
15
+ def error(self, detail):
16
+ print(message("bad_arguments", detail=detail), file=sys.stderr)
17
+ sys.exit(2)
18
+
19
+
20
+ def main(argv=None):
21
+ parser = Parser(prog="magicdispel", add_help=False)
22
+ parser.add_argument("-h", "--help", action="store_true")
23
+ parser.add_argument("--version", action="store_true")
24
+ parser.add_argument("--check", action="store_true")
25
+ naming = parser.add_mutually_exclusive_group()
26
+ naming.add_argument("--anonymous", dest="naming", action="store_const", const="anonymous", default="plain")
27
+ naming.add_argument("--keep-name", dest="naming", action="store_const", const="original")
28
+ parser.add_argument("photos", nargs="*")
29
+ # A path the console cannot show is printed with replacements, not refused.
30
+ for stream in (sys.stdout, sys.stderr):
31
+ if hasattr(stream, "reconfigure"):
32
+ stream.reconfigure(errors="replace")
33
+ args = parser.parse_args(argv)
34
+ if args.version:
35
+ print("magicdispel " + __version__)
36
+ return 0
37
+ if args.help:
38
+ print(message("help", version=__version__))
39
+ return 0
40
+ if not (args.photos or args.check):
41
+ print(banner.welcome(__version__, message("usage")))
42
+ return 0
43
+ try:
44
+ # ExifTool is optional: when present and recent, it double-checks results.
45
+ path = exiftool.find()
46
+ found = exiftool.version(path) if path else None
47
+ second_check = path if exiftool.usable(found) else None
48
+ if args.check:
49
+ print(message("ready", version=__version__))
50
+ print(message(second_check_state(path, found, second_check), exiftool_version=found, exiftool=path))
51
+ failures = 0
52
+ for photo in args.photos:
53
+ try:
54
+ print(message("cleaned", path=clean(photo, second_check, args.naming)))
55
+ except Exception as error: # one photo failing never stops the others
56
+ failures += 1
57
+ print(message("failed", photo=photo, reason=explained(error)), file=sys.stderr)
58
+ if len(args.photos) > 1:
59
+ print(message("summary", succeeded=len(args.photos) - failures, failed=failures))
60
+ return 1 if failures else 0
61
+ except Exception as error:
62
+ print(message("error", reason=explained(error)), file=sys.stderr)
63
+ return 1
64
+ except KeyboardInterrupt:
65
+ print(message("cancelled"), file=sys.stderr)
66
+ return 130
67
+
68
+
69
+ def explained(error):
70
+ """Our own errors and system errors say what went wrong. Anything else is a
71
+ bug, or an input no check anticipated; it is reported as unexpected."""
72
+ if isinstance(error, (UserError, OSError)):
73
+ return str(error)
74
+ return message("unexpected_error", detail="%s: %s" % (type(error).__name__, error))
75
+
76
+
77
+ def second_check_state(path, found_version, second_check):
78
+ """Which --check line describes the optional ExifTool second check."""
79
+ if second_check:
80
+ return "second_check_on"
81
+ if found_version:
82
+ return "second_check_old"
83
+ return "second_check_unusable" if path else "second_check_off"
84
+
85
+
86
+ if __name__ == "__main__":
87
+ sys.exit(main())
magicdispel/core.py ADDED
@@ -0,0 +1,89 @@
1
+ """Clean one photo: rebuild it, check the result, and save a copy next to it."""
2
+ import errno
3
+ import hashlib
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import exiftool, formats, names, pixels
10
+ from .errors import FormatError, InputError, VerificationError
11
+
12
+
13
+ def clean(argument, exiftool_path=None, naming="plain"):
14
+ """Save a cleaned copy of a photo next to it and return the copy's path.
15
+
16
+ The format's module rebuilds the file from what is needed to show it and
17
+ checks the result on its own; Pillow must decode identical frames where it
18
+ can; ExifTool, when `exiftool_path` is given, reads it independently.
19
+ Nothing is saved unless every check passes. `naming` is described in
20
+ names.candidates.
21
+ """
22
+ source = Path(os.path.abspath(os.path.expanduser(argument)))
23
+ if not source.is_file():
24
+ raise InputError("not_a_file", path=source)
25
+ data = source.read_bytes()
26
+ kind = formats.identify(data)
27
+ if kind is None:
28
+ raise InputError("unsupported_format")
29
+ module = formats.MODULES[kind]
30
+ rebuilt = module.rebuild(data)
31
+ try:
32
+ module.verify(data, rebuilt)
33
+ except FormatError:
34
+ # The format's reader could not parse the result back.
35
+ raise VerificationError("verification_failed", detail="the result cannot be read back")
36
+ if pixels.decodes(kind):
37
+ pixels.compare(data, rebuilt, kind)
38
+ if exiftool_path:
39
+ exiftool.second_opinion(exiftool_path, rebuilt, kind)
40
+ if hashlib.sha256(data).digest() != file_digest(source):
41
+ raise VerificationError("source_changed")
42
+ suffixes = formats.SUFFIXES[kind]
43
+ suffix = source.suffix if source.suffix.lower() in suffixes else suffixes[0]
44
+ return publish(rebuilt, source, suffix, naming)
45
+
46
+
47
+ def file_digest(path):
48
+ digest = hashlib.sha256()
49
+ with path.open("rb") as stream:
50
+ for block in iter(lambda: stream.read(1024 * 1024), b""):
51
+ digest.update(block)
52
+ return digest.digest()
53
+
54
+
55
+ def publish(data, source, suffix, naming="plain"):
56
+ """Write data next to source under a new name; never overwrite anything."""
57
+ for name in names.candidates(source.stem, suffix, naming):
58
+ destination = source.with_name(name)
59
+ try:
60
+ output = destination.open("xb")
61
+ except FileExistsError:
62
+ continue
63
+ try:
64
+ with output:
65
+ output.write(data)
66
+ clear_attributes(destination)
67
+ return destination
68
+ except BaseException:
69
+ destination.unlink(missing_ok=True)
70
+ raise
71
+
72
+
73
+ def clear_attributes(path):
74
+ """The copy is written from verified bytes, so it has none of the original's
75
+ extended attributes, resource forks or alternate streams; clear any the
76
+ system added to the new file."""
77
+ if sys.platform == "darwin":
78
+ result = subprocess.run(["/usr/bin/xattr", "-c", str(path)], capture_output=True)
79
+ if result.returncode:
80
+ raise OSError("could not clear extended attributes: "
81
+ + result.stderr.decode("utf-8", "replace").strip())
82
+ elif hasattr(os, "listxattr"):
83
+ try:
84
+ for attribute in os.listxattr(path):
85
+ if attribute.startswith("user."):
86
+ os.removexattr(path, attribute)
87
+ except OSError as error:
88
+ if error.errno not in (errno.ENOTSUP, errno.EOPNOTSUPP, errno.ENOSYS):
89
+ raise
magicdispel/errors.py ADDED
@@ -0,0 +1,25 @@
1
+ """Errors shown to the user, worded in messages.py."""
2
+ from .messages import message
3
+
4
+
5
+ class UserError(ValueError):
6
+ """A message key plus values; str() gives the sentence for the user."""
7
+
8
+ def __init__(self, key, **values):
9
+ super().__init__(key, values)
10
+ self.key, self.values = key, values
11
+
12
+ def __str__(self):
13
+ return message(self.key, **self.values)
14
+
15
+
16
+ class InputError(UserError):
17
+ """The argument is not a file, or not a format MagicDispel handles."""
18
+
19
+
20
+ class FormatError(UserError):
21
+ """The file cannot be rebuilt safely, so nothing is saved."""
22
+
23
+
24
+ class VerificationError(UserError):
25
+ """The rebuilt file failed a check against the original, so nothing is saved."""
magicdispel/exif.py ADDED
@@ -0,0 +1,188 @@
1
+ """The few EXIF fields that change how an image is displayed.
2
+
3
+ `display_fields` reads only those fields from an EXIF (TIFF-structured) block.
4
+ `build` writes a fresh, minimal block from them, so nothing else in the
5
+ original EXIF can carry over. Reading is lenient: a field that cannot be read
6
+ is treated as absent, as a viewer would.
7
+ """
8
+ import struct
9
+ from dataclasses import dataclass
10
+
11
+ # TIFF field types, and the size of one value of each.
12
+ BYTE, ASCII, SHORT, LONG, RATIONAL, UNDEFINED, SRATIONAL, IFD = 1, 2, 3, 4, 5, 7, 10, 13
13
+ 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}
14
+
15
+ ORIENTATION, X_RESOLUTION, Y_RESOLUTION, RESOLUTION_UNIT = 0x0112, 0x011A, 0x011B, 0x0128
16
+ EXIF_POINTER, INTEROP_POINTER, COLOR_SPACE, INTEROP_INDEX = 0x8769, 0xA005, 0xA001, 0x0001
17
+ MAKER_NOTE = 0x927C
18
+ # Apple's maker note: a header, then an IFD whose offsets count from the note's start.
19
+ APPLE_NOTE = b"Apple iOS\0\0\1"
20
+ APPLE_HDR_TAGS = (0x21, 0x30) # HDR headroom and HDR gain, used to render HDR photos
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class DisplayFields:
25
+ orientation: int | None = None # 1-8; how the stored pixels are turned for display
26
+ resolution: tuple | None = None # ((x_num, x_den), (y_num, y_den), unit) - print/display size
27
+ color_space: int | None = None # 1 = sRGB, 0xFFFF = uncalibrated (see interop_index)
28
+ interop_index: bytes | None = None # b"R98" (sRGB) or b"R03" (Adobe RGB) for DCF files
29
+ apple_hdr: bytes | None = None # a fresh Apple maker note holding only the HDR values
30
+
31
+
32
+ def display_fields(data):
33
+ """Read the display fields from EXIF data, with or without an Exif\\0\\0 prefix."""
34
+ if data.startswith(b"Exif\0\0"):
35
+ data = data[6:]
36
+ reader = _Reader(data)
37
+ ifd0 = reader.directory(reader.first_directory())
38
+ exif_ifd = reader.directory(reader.pointer(ifd0.get(EXIF_POINTER)))
39
+ interop = reader.directory(reader.pointer(exif_ifd.get(INTEROP_POINTER)))
40
+ orientation = reader.number(ifd0.get(ORIENTATION), SHORT)
41
+ x, y = reader.rational(ifd0.get(X_RESOLUTION)), reader.rational(ifd0.get(Y_RESOLUTION))
42
+ unit = reader.number(ifd0.get(RESOLUTION_UNIT), SHORT) or 2
43
+ index = reader.text(interop.get(INTEROP_INDEX))
44
+ note = exif_ifd.get(MAKER_NOTE)
45
+ return DisplayFields(
46
+ orientation=orientation if orientation in range(1, 9) else None,
47
+ resolution=(x, y, unit) if x and y and unit in (1, 2, 3) else None,
48
+ color_space=reader.number(exif_ifd.get(COLOR_SPACE), SHORT),
49
+ interop_index=index if index in (b"R98", b"R03") else None,
50
+ # The standard type is UNDEFINED; some writers (Pillow among them) use BYTE.
51
+ apple_hdr=apple_hdr_note(note[2]) if note and note[0] in (BYTE, UNDEFINED) else None,
52
+ )
53
+
54
+
55
+ def apple_hdr_note(note):
56
+ """A new Apple maker note with only the HDR headroom and gain, or None."""
57
+ if not note.startswith(APPLE_NOTE):
58
+ return None
59
+ table = len(APPLE_NOTE) + 2 # after the header and a byte order mark
60
+ reader = _Reader(note, byte_order=note[len(APPLE_NOTE):table])
61
+ count = reader.unpack("H", table)
62
+ values = []
63
+ for index in range(count[0] if count else 0):
64
+ entry = reader.unpack("HHII", table + 2 + 12 * index)
65
+ if entry is None:
66
+ return None
67
+ tag, kind, number, offset = entry
68
+ if tag in APPLE_HDR_TAGS and kind == SRATIONAL and number == 1:
69
+ value = reader.unpack("ii", offset)
70
+ if value is None or value[1] == 0:
71
+ return None
72
+ values.append((tag, value))
73
+ if not values:
74
+ return None
75
+ # Big-endian, entries in tag order, values right after the empty next-IFD link.
76
+ header = APPLE_NOTE + b"MM" + struct.pack(">H", len(values))
77
+ entries = b"".join(struct.pack(">HHII", tag, SRATIONAL, 1, len(header) + 12 * len(values) + 4 + 8 * n)
78
+ for n, (tag, _) in enumerate(values))
79
+ return header + entries + b"\0" * 4 + b"".join(struct.pack(">ii", *value) for _, value in values)
80
+
81
+
82
+ def build(fields):
83
+ """A minimal big-endian EXIF block holding only `fields`, or b"" if empty."""
84
+ ifd0, exif_ifd, interop = [], [], []
85
+ if fields.orientation:
86
+ ifd0.append((ORIENTATION, SHORT, struct.pack(">H", fields.orientation)))
87
+ if fields.resolution:
88
+ (x, y, unit) = fields.resolution
89
+ ifd0 += [(X_RESOLUTION, RATIONAL, struct.pack(">II", *x)),
90
+ (Y_RESOLUTION, RATIONAL, struct.pack(">II", *y)),
91
+ (RESOLUTION_UNIT, SHORT, struct.pack(">H", unit))]
92
+ if fields.color_space is not None:
93
+ exif_ifd.append((COLOR_SPACE, SHORT, struct.pack(">H", fields.color_space)))
94
+ if fields.apple_hdr:
95
+ exif_ifd.append((MAKER_NOTE, UNDEFINED, fields.apple_hdr))
96
+ if fields.interop_index:
97
+ interop.append((INTEROP_INDEX, ASCII, fields.interop_index + b"\0"))
98
+ if not (ifd0 or exif_ifd or interop):
99
+ return b""
100
+ # Directories are laid out in order: IFD0, Exif IFD, Interop IFD. Pointer
101
+ # values do not change a directory's size, so sizes are known up front.
102
+ layout = [ifd0 + ([(EXIF_POINTER, LONG, None)] if exif_ifd or interop else []),
103
+ exif_ifd + ([(INTEROP_POINTER, LONG, None)] if interop else []),
104
+ interop]
105
+ layout = [entries for entries in layout if entries]
106
+ offsets = [8]
107
+ for entries in layout[:-1]:
108
+ offsets.append(offsets[-1] + _directory_size(entries))
109
+ blocks = []
110
+ for index, entries in enumerate(layout):
111
+ pointer = struct.pack(">I", offsets[index + 1]) if index + 1 < len(layout) else None
112
+ resolved = [(tag, kind, pointer if value is None else value) for tag, kind, value in entries]
113
+ blocks.append(_directory(resolved, offsets[index]))
114
+ return b"MM\0*" + struct.pack(">I", 8) + b"".join(blocks)
115
+
116
+
117
+ def _directory_size(entries):
118
+ extra = sum(len(value) + len(value) % 2 for _, _, value in entries
119
+ if value is not None and len(value) > 4)
120
+ return 2 + 12 * len(entries) + 4 + extra
121
+
122
+
123
+ def _directory(entries, start):
124
+ entries = sorted(entries)
125
+ table, extra = struct.pack(">H", len(entries)), b""
126
+ data_start = start + 2 + 12 * len(entries) + 4
127
+ for tag, kind, value in entries:
128
+ count = len(value) // TYPE_SIZES[kind]
129
+ if len(value) <= 4:
130
+ table += struct.pack(">HHI", tag, kind, count) + value.ljust(4, b"\0")
131
+ else:
132
+ table += struct.pack(">HHII", tag, kind, count, data_start + len(extra))
133
+ extra += value + b"\0" * (len(value) % 2)
134
+ return table + struct.pack(">I", 0) + extra
135
+
136
+
137
+ class _Reader:
138
+ """Bounds-checked access to one TIFF-structured block; problems read as absent."""
139
+
140
+ def __init__(self, data, byte_order=None):
141
+ self.data = data
142
+ self.order = {b"II": "<", b"MM": ">"}.get(data[:2] if byte_order is None else byte_order)
143
+
144
+ def unpack(self, fmt, offset):
145
+ if self.order is None or offset < 0 or offset + struct.calcsize(self.order + fmt) > len(self.data):
146
+ return None
147
+ return struct.unpack_from(self.order + fmt, self.data, offset)
148
+
149
+ def first_directory(self):
150
+ header = self.unpack("HI", 2)
151
+ return header[1] if header and header[0] == 42 else None
152
+
153
+ def directory(self, offset):
154
+ """{tag: (type, count, value bytes)} for one IFD; {} if unreadable."""
155
+ count = self.unpack("H", offset) if offset else None
156
+ if not count:
157
+ return {}
158
+ entries = {}
159
+ for index in range(count[0]):
160
+ entry = self.unpack("HHII", offset + 2 + 12 * index)
161
+ if entry is None:
162
+ return {}
163
+ tag, kind, number, value = entry
164
+ size = TYPE_SIZES.get(kind, 0) * number
165
+ start = offset + 2 + 12 * index + 8 if size <= 4 else value
166
+ if not size or start + size > len(self.data):
167
+ continue
168
+ entries[tag] = (kind, number, self.data[start:start + size])
169
+ return entries
170
+
171
+ def number(self, entry, kind):
172
+ if entry is None or entry[0] != kind or entry[1] != 1:
173
+ return None
174
+ return struct.unpack(self.order + {SHORT: "H", LONG: "I"}[kind], entry[2])[0]
175
+
176
+ def pointer(self, entry):
177
+ if entry is not None and entry[0] == IFD:
178
+ entry = (LONG,) + entry[1:]
179
+ return self.number(entry, LONG)
180
+
181
+ def rational(self, entry):
182
+ if entry is None or entry[0] != RATIONAL or entry[1] != 1:
183
+ return None
184
+ numerator, denominator = struct.unpack(self.order + "II", entry[2])
185
+ return (numerator, denominator) if numerator and denominator else None
186
+
187
+ def text(self, entry):
188
+ return entry[2].rstrip(b"\0") if entry and entry[0] == ASCII else None
@@ -0,0 +1,147 @@
1
+ """ExifTool as an optional second opinion.
2
+
3
+ When ExifTool 12.73 or newer is installed, every rebuilt file is read by it
4
+ before being saved. Any warning, any field outside the display allowlist and
5
+ any data ExifTool cannot identify stops the save. ExifTool reads the result
6
+ from a pipe: no file is written, and no path, which Windows would pass in its
7
+ legacy code page and ExifTool might misread, is involved.
8
+ """
9
+ import json
10
+ import os
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ from pathlib import Path
15
+
16
+ from . import xmp
17
+ from .errors import UserError, VerificationError
18
+
19
+ # Display fields a clean file may hold, as ExifTool names them. TIFF keeps its
20
+ # decoding tags in the same EXIF group.
21
+ DISPLAY_EXIF = {
22
+ "Orientation", "ColorSpace", "Gamma", "InteropIndex", "ExifVersion", "FlashpixVersion",
23
+ "ComponentsConfiguration", "YCbCrPositioning", "XResolution", "YResolution", "ResolutionUnit",
24
+ }
25
+ TIFF_STRUCTURE = {
26
+ "ImageWidth", "ImageHeight", "BitsPerSample", "Compression", "PhotometricInterpretation",
27
+ "Thresholding", "FillOrder", "StripOffsets", "SamplesPerPixel", "RowsPerStrip",
28
+ "StripByteCounts", "PlanarConfiguration", "T4Options", "T6Options", "Group3Options",
29
+ "Group4Options", "PageNumber", "TransferFunction", "WhitePoint", "PrimaryChromaticities",
30
+ "ColorMap", "HalftoneHints", "TileWidth", "TileLength", "TileOffsets", "TileByteCounts",
31
+ "ExtraSamples", "SampleFormat", "SMinSampleValue", "SMaxSampleValue", "MinSampleValue",
32
+ "MaxSampleValue", "JPEGTables", "JPEGProc", "YCbCrCoefficients", "YCbCrSubSampling",
33
+ "ReferenceBlackWhite", "Predictor", "InkSet", "NumberOfInks", "NewSubfileType", "SubfileType",
34
+ # ExifTool's names for the strip offsets of JPEG-compressed TIFF
35
+ "PreviewImageStart", "PreviewImageLength", "PreviewImage",
36
+ }
37
+ PRIVATE_GROUPS = {"XMP", "IPTC", "MakerNotes", "Photoshop", "GPS", "PLIST"}
38
+ PRIVATE_TAG = re.compile(
39
+ r"gps|latitude|longitude|location|serialnumber|ownername|"
40
+ r"^(?:make|model|artist|author|creator|comment|description|imagedescription|"
41
+ r"copyright|software|datetimeoriginal|createdate|modifydate|creationdate|"
42
+ r"creationtime|trackcreatedate|trackmodifydate|mediacreatedate|mediamodifydate)$",
43
+ re.IGNORECASE)
44
+ UNKNOWN_TAG = re.compile(r"^Unknown|_0x[0-9a-f]{4}$", re.IGNORECASE)
45
+ # HEIF/AVIF structure that ExifTool lists as unknown boxes: sequence edit lists,
46
+ # codec settings, color, coding constraints, alternative-image groups, item
47
+ # data and emptied space. formats/heif.py verifies these boxes itself.
48
+ BMFF_STRUCTURE = {"Unknown_" + name for name in ("edts", "av1C", "hvcC", "colr", "ccst", "pasp",
49
+ "btrt", "free", "altr", "idat")}
50
+ # ExifTool's group names for the XMP namespaces whose HDR fields xmp.py keeps.
51
+ XMP_GROUPS = {"XMP-hdrgm": xmp.ADOBE_GAIN_MAP, "XMP-apdi": xmp.APPLE_PIXEL_DATA,
52
+ "XMP-HDRGainMap": xmp.APPLE_GAIN_MAP}
53
+
54
+
55
+ class ExifToolError(Exception):
56
+ """ExifTool could not run, or reported a problem with a file."""
57
+
58
+
59
+ def find():
60
+ """The ExifTool executable, or None when it is not installed. An explicit
61
+ MAGICDISPEL_EXIFTOOL that does not name a program is an error."""
62
+ configured = os.environ.get("MAGICDISPEL_EXIFTOOL")
63
+ if configured:
64
+ candidate = shutil.which(configured) or os.path.expanduser(configured)
65
+ if Path(candidate).is_file() and os.access(candidate, os.X_OK):
66
+ return candidate
67
+ raise UserError("exiftool_misconfigured", path=configured)
68
+ for candidate in (shutil.which("exiftool"), "/opt/homebrew/bin/exiftool", "/usr/local/bin/exiftool"):
69
+ if candidate and Path(candidate).is_file() and os.access(candidate, os.X_OK):
70
+ return candidate
71
+ return None
72
+
73
+
74
+ def version(exiftool):
75
+ """ExifTool's version, or None if it does not run."""
76
+ try:
77
+ return run(exiftool, ["-ver"]).stdout.decode("utf-8", "replace").strip()
78
+ except (ExifToolError, OSError):
79
+ return None
80
+
81
+
82
+ def usable(found_version):
83
+ """12.73 is the oldest release that reads recent iPhone files reliably."""
84
+ match = re.fullmatch(r"(\d+)\.(\d+)(?:_\d+)?", found_version or "")
85
+ return bool(match) and tuple(map(int, match.groups())) >= (12, 73)
86
+
87
+
88
+ def second_opinion(exiftool, data, kind):
89
+ """Have ExifTool read a rebuilt file; raise VerificationError on anything off."""
90
+ try:
91
+ tags = read(exiftool, data)
92
+ except ExifToolError as error:
93
+ raise VerificationError("exiftool_problem", detail=str(error))
94
+ check_tags(tags, kind)
95
+
96
+
97
+ def read(exiftool, data):
98
+ """Every tag ExifTool finds in `data`, keyed "Group0:Group1[:CopyN]:Tag"; a warning raises."""
99
+ tags = json.loads(run(exiftool, ["-j", "-G0:1:4", "-a", "-s", "-n", "-e", "-u", "-all", "-"],
100
+ data).stdout)[0]
101
+ for key, value in tags.items():
102
+ if key.split(":")[-1] in ("Error", "Warning"):
103
+ raise ExifToolError(str(value))
104
+ return tags
105
+
106
+
107
+ def check_tags(tags, kind):
108
+ """Only structure and display fields may remain, and nothing unidentified."""
109
+ allowed_exif = DISPLAY_EXIF | (TIFF_STRUCTURE if kind == "TIFF" else set())
110
+ remaining = []
111
+ for key, value in tags.items():
112
+ parts = key.split(":")
113
+ group, tag = parts[0], parts[-1]
114
+ # ICC identity data is sanitized and verified as complete profile blocks.
115
+ if group in ("SourceFile", "ExifTool", "ICC_Profile", "Composite") or parts[:2] == ["File", "System"]:
116
+ continue
117
+ if rendering_field(parts, value):
118
+ continue
119
+ if (group in PRIVATE_GROUPS or (group == "EXIF" and tag not in allowed_exif)
120
+ or (group != "EXIF" and PRIVATE_TAG.search(tag)
121
+ and value not in (None, "", 0, "0000:00:00 00:00:00"))
122
+ or (UNKNOWN_TAG.search(tag) and not (group == "QuickTime" and tag in BMFF_STRUCTURE))):
123
+ remaining.append(key)
124
+ if remaining:
125
+ raise VerificationError("metadata_remains", tags=", ".join(remaining))
126
+
127
+
128
+ def rendering_field(parts, value):
129
+ """HDR rendering values: the gain-map XMP fields xmp.py keeps, and Apple's
130
+ HDR headroom and gain in its maker note."""
131
+ group, family1, tag = parts[0], parts[1] if len(parts) > 2 else "", parts[-1]
132
+ if group == "MakerNotes" and family1 == "Apple":
133
+ return tag in ("HDRHeadroom", "HDRGain") and xmp.numeric(value)
134
+ if group == "XMP" and family1 in XMP_GROUPS:
135
+ text = [str(item) for item in value] if isinstance(value, list) else str(value)
136
+ return xmp.permitted("{%s}%s" % (XMP_GROUPS[family1], tag), text)
137
+ return False
138
+
139
+
140
+ def run(exiftool, arguments, data=None):
141
+ """ExifTool's output for `arguments`, with `data` as its standard input."""
142
+ result = subprocess.run([exiftool, "-config", "", "-charset", "filename=UTF8", *arguments],
143
+ input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
144
+ if result.returncode:
145
+ raise ExifToolError((result.stderr or result.stdout).decode("utf-8", "replace").strip()
146
+ or "ExifTool failed")
147
+ return result
@@ -0,0 +1,36 @@
1
+ """Image formats rebuilt from an allowlist of the parts needed to show them.
2
+
3
+ Each format module provides:
4
+ rebuild(data) -> bytes a new file holding only the allowlisted parts;
5
+ raises FormatError if that cannot be done safely
6
+ verify(original, rebuilt) an independent check of the result; raises
7
+ VerificationError if anything is off
8
+ """
9
+ from . import bmp, gif, heif, jpeg, png, tiff, webp
10
+
11
+ MODULES = {"PNG": png, "APNG": png, "BMP": bmp, "JPEG": jpeg, "WEBP": webp, "GIF": gif,
12
+ "HEIC": heif, "AVIF": heif, "TIFF": tiff}
13
+ # File name extensions of each format, the first being the default. A source
14
+ # whose extension does not match its content gets the default; BMP becomes PNG.
15
+ SUFFIXES = {"JPEG": (".jpg", ".jpeg", ".jpe"), "PNG": (".png",), "APNG": (".png", ".apng"),
16
+ "HEIC": (".heic", ".heif", ".hif"), "AVIF": (".avif",), "WEBP": (".webp",),
17
+ "GIF": (".gif",), "TIFF": (".tiff", ".tif"), "BMP": (".png",)}
18
+
19
+
20
+ def identify(data):
21
+ """The format name from a file's leading bytes, or None for other files."""
22
+ if data[4:8] == b"ftyp":
23
+ return heif.brand_format(data)
24
+ if data.startswith(png.SIGNATURE):
25
+ return "APNG" if png.is_animated(data) else "PNG"
26
+ if data[:3] == b"\xff\xd8\xff":
27
+ return "JPEG"
28
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
29
+ return "WEBP"
30
+ if data[:6] in (b"GIF87a", b"GIF89a"):
31
+ return "GIF"
32
+ if data[:4] in (b"II*\0", b"MM\0*", b"II+\0", b"MM\0+"): # the last two are BigTIFF
33
+ return "TIFF"
34
+ if data[:2] == b"BM":
35
+ return "BMP"
36
+ return None