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.
- magicdispel/__init__.py +3 -0
- magicdispel/__main__.py +3 -0
- magicdispel/banner.py +81 -0
- magicdispel/cli.py +87 -0
- magicdispel/core.py +89 -0
- magicdispel/errors.py +25 -0
- magicdispel/exif.py +188 -0
- magicdispel/exiftool.py +147 -0
- magicdispel/formats/__init__.py +36 -0
- magicdispel/formats/bmff.py +314 -0
- magicdispel/formats/bmp.py +42 -0
- magicdispel/formats/gif.py +125 -0
- magicdispel/formats/heif.py +624 -0
- magicdispel/formats/jpeg.py +350 -0
- magicdispel/formats/png.py +218 -0
- magicdispel/formats/tiff.py +281 -0
- magicdispel/formats/webp.py +126 -0
- magicdispel/icc.py +168 -0
- magicdispel/messages.py +69 -0
- magicdispel/names.py +59 -0
- magicdispel/pixels.py +75 -0
- magicdispel/xmp.py +101 -0
- magicdispel-0.1.0.dist-info/METADATA +176 -0
- magicdispel-0.1.0.dist-info/RECORD +28 -0
- magicdispel-0.1.0.dist-info/WHEEL +5 -0
- magicdispel-0.1.0.dist-info/entry_points.txt +2 -0
- magicdispel-0.1.0.dist-info/licenses/LICENSE +21 -0
- magicdispel-0.1.0.dist-info/top_level.txt +1 -0
magicdispel/__init__.py
ADDED
magicdispel/__main__.py
ADDED
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
|
magicdispel/exiftool.py
ADDED
|
@@ -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
|