369 lines
12 KiB
Python
369 lines
12 KiB
Python
"""
|
|
Find Exif data in an HEIC file.
|
|
|
|
As of 2019, the latest standard seems to be "ISO/IEC 14496-12:2015"
|
|
There are many different related standards. (quicktime, mov, mp4, etc...)
|
|
See https://en.wikipedia.org/wiki/ISO_base_media_file_format for more details.
|
|
|
|
We parse just enough of the ISO format to locate the Exif data in the file.
|
|
Inside the 'meta' box are two directories we need:
|
|
1) the 'iinf' box contains 'infe' records, we look for the item_id for 'Exif'.
|
|
2) once we have the item_id, we find a matching entry in the 'iloc' box, which
|
|
gives us position and size information.
|
|
"""
|
|
|
|
import struct
|
|
from typing import Any, BinaryIO, Callable, Dict, List, Optional, Tuple
|
|
|
|
from exifread.core.exceptions import ExifError, InvalidExif
|
|
from exifread.exif_log import get_logger
|
|
|
|
logger = get_logger()
|
|
|
|
|
|
def find_heic_tiff(fh: BinaryIO) -> Tuple[int, bytes]:
|
|
"""
|
|
Look for TIFF header in HEIC files.
|
|
|
|
In some HEIC files, the Exif offset is 0,
|
|
and yet there is a plain TIFF header near end of the file.
|
|
"""
|
|
|
|
data = fh.read(4)
|
|
if data[0:2] in [b"II", b"MM"] and data[2] == 42 and data[3] == 0:
|
|
offset = fh.tell() - 4
|
|
fh.seek(offset)
|
|
endian = data[0:2]
|
|
offset = fh.tell()
|
|
logger.debug("Found TIFF header in Exif, offset = %0xH", offset)
|
|
else:
|
|
raise InvalidExif(
|
|
"Exif pointer to zeros, but found "
|
|
+ str(data)
|
|
+ " instead of a TIFF header."
|
|
)
|
|
|
|
return offset, endian
|
|
|
|
|
|
class BoxVersion(ExifError):
|
|
"""Wrong box version."""
|
|
|
|
|
|
class BadSize(ExifError):
|
|
"""Wrong box size."""
|
|
|
|
|
|
class Box:
|
|
"""A HEIC Box."""
|
|
|
|
version = 0
|
|
minor_version = 0
|
|
item_count = 0
|
|
size = 0
|
|
after = 0
|
|
pos = 0
|
|
compat: List[bytes] = []
|
|
base_offset = 0
|
|
# this is full of boxes, but not in a predictable order.
|
|
subs: Dict[str, "Box"] = {}
|
|
locs: Dict = {}
|
|
exif_infe: Optional["Box"] = None
|
|
item_id = 0
|
|
item_type = b""
|
|
item_name = b""
|
|
item_protection_index = 0
|
|
major_brand = b""
|
|
offset_size = 0
|
|
length_size = 0
|
|
base_offset_size = 0
|
|
index_size = 0
|
|
flags = 0
|
|
name: str
|
|
|
|
def __init__(self, name: str) -> None:
|
|
self.name = name
|
|
|
|
def __repr__(self) -> str:
|
|
return "<box '%s'>" % self.name
|
|
|
|
def set_sizes(self, offset: int, length: int, base_offset: int, index: int) -> None:
|
|
self.offset_size = offset
|
|
self.length_size = length
|
|
self.base_offset_size = base_offset
|
|
self.index_size = index
|
|
|
|
def set_full(self, vflags: int) -> None:
|
|
"""
|
|
ISO boxes come in 'old' and 'full' variants.
|
|
The 'full' variant contains version and flags information.
|
|
"""
|
|
self.version = vflags >> 24
|
|
self.flags = vflags & 0x00FFFFFF
|
|
|
|
|
|
class HEICExifFinder:
|
|
"""Find HEIC EXIF tags."""
|
|
|
|
file_handle: BinaryIO
|
|
|
|
def __init__(self, file_handle: BinaryIO) -> None:
|
|
self.file_handle = file_handle
|
|
|
|
def get(self, nbytes: int) -> bytes:
|
|
read = self.file_handle.read(nbytes)
|
|
if not read:
|
|
raise EOFError
|
|
if len(read) != nbytes:
|
|
msg = "get(nbytes={nbytes}) found {read} bytes at position {pos}".format(
|
|
nbytes=nbytes, read=len(read), pos=self.file_handle.tell()
|
|
)
|
|
raise BadSize(msg)
|
|
return read
|
|
|
|
def get16(self) -> int:
|
|
return struct.unpack(">H", self.get(2))[0]
|
|
|
|
def get32(self) -> int:
|
|
return struct.unpack(">L", self.get(4))[0]
|
|
|
|
def get64(self) -> int:
|
|
return struct.unpack(">Q", self.get(8))[0]
|
|
|
|
def get_int4x2(self) -> tuple:
|
|
num = struct.unpack(">B", self.get(1))[0]
|
|
num0 = num >> 4
|
|
num1 = num & 0xF
|
|
return num0, num1
|
|
|
|
def get_int(self, size: int) -> int:
|
|
"""some fields have variant-sized data."""
|
|
if size == 2:
|
|
return self.get16()
|
|
if size == 4:
|
|
return self.get32()
|
|
if size == 8:
|
|
return self.get64()
|
|
if size == 0:
|
|
return 0
|
|
raise BadSize(size)
|
|
|
|
def get_string(self) -> bytes:
|
|
read = []
|
|
while 1:
|
|
char = self.get(1)
|
|
if char == b"\x00":
|
|
break
|
|
read.append(char)
|
|
return b"".join(read)
|
|
|
|
def next_box(self) -> Box:
|
|
pos = self.file_handle.tell()
|
|
size = self.get32()
|
|
kind = self.get(4).decode("ascii")
|
|
box = Box(kind)
|
|
if size == 0:
|
|
# signifies 'to the end of the file', we shouldn't see this.
|
|
raise NotImplementedError
|
|
if size == 1:
|
|
# 64-bit size follows type.
|
|
size = self.get64()
|
|
box.size = size - 16
|
|
box.after = pos + size
|
|
else:
|
|
box.size = size - 8
|
|
box.after = pos + size
|
|
box.pos = self.file_handle.tell()
|
|
return box
|
|
|
|
def get_full(self, box: Box) -> None:
|
|
box.set_full(self.get32())
|
|
|
|
def skip(self, box: Box) -> None:
|
|
self.file_handle.seek(box.after)
|
|
|
|
def expect_parse(self, name: str) -> Box:
|
|
while True:
|
|
box = self.next_box()
|
|
if box.name == name:
|
|
return self.parse_box(box)
|
|
self.skip(box)
|
|
|
|
def get_parser(self, box: Box) -> Optional[Callable[[Box], Any]]:
|
|
defs = {
|
|
"ftyp": self._parse_ftyp,
|
|
"meta": self._parse_meta,
|
|
"infe": self._parse_infe,
|
|
"iinf": self._parse_iinf,
|
|
"iloc": self._parse_iloc,
|
|
"hdlr": self._parse_hdlr, # HEIC/AVIF hdlr = Handler
|
|
"pitm": self._parse_pitm, # HEIC/AVIF pitm = Primary Item
|
|
"iref": self._parse_iref, # HEIC/AVIF idat = Item Reference
|
|
"idat": self._parse_idat, # HEIC/AVIF idat = Item Data Box
|
|
"dinf": self._parse_dinf, # HEIC/AVIF dinf = Data Information Box
|
|
"iprp": self._parse_iprp, # HEIC/AVIF iprp = Item Protection Box
|
|
}
|
|
return defs.get(box.name)
|
|
|
|
def parse_box(self, box: Box) -> Box:
|
|
probe = self.get_parser(box)
|
|
if probe is not None:
|
|
probe(box)
|
|
# in case anything is left unread
|
|
self.file_handle.seek(box.after)
|
|
return box
|
|
|
|
def _parse_ftyp(self, box: Box) -> None:
|
|
box.major_brand = self.get(4)
|
|
box.minor_version = self.get32()
|
|
box.compat = []
|
|
size = box.size - 8
|
|
while size > 0:
|
|
box.compat.append(self.get(4))
|
|
size -= 4
|
|
|
|
def _parse_meta(self, meta: Box) -> None:
|
|
self.get_full(meta)
|
|
while self.file_handle.tell() < meta.after:
|
|
box = self.next_box()
|
|
psub = self.get_parser(box)
|
|
if psub is not None:
|
|
psub(box)
|
|
meta.subs[box.name] = box
|
|
else:
|
|
logger.debug("HEIC: skipping %r", box)
|
|
# skip any unparsed data
|
|
self.skip(box)
|
|
|
|
def _parse_infe(self, box: Box) -> None:
|
|
self.get_full(box)
|
|
if box.version >= 2:
|
|
if box.version == 2:
|
|
box.item_id = self.get16()
|
|
elif box.version == 3:
|
|
box.item_id = self.get32()
|
|
box.item_protection_index = self.get16()
|
|
box.item_type = self.get(4)
|
|
box.item_name = self.get_string()
|
|
# ignore the rest
|
|
|
|
def _parse_iinf(self, box: Box) -> None:
|
|
self.get_full(box)
|
|
count = self.get16()
|
|
box.exif_infe = None
|
|
for _ in range(count):
|
|
infe = self.expect_parse("infe")
|
|
if infe.item_type == b"Exif":
|
|
logger.debug("HEIC: found Exif 'infe' box")
|
|
box.exif_infe = infe
|
|
break
|
|
|
|
def _parse_iloc(self, box: Box) -> None:
|
|
self.get_full(box)
|
|
size0, size1 = self.get_int4x2()
|
|
size2, size3 = self.get_int4x2()
|
|
box.set_sizes(size0, size1, size2, size3)
|
|
if box.version < 2:
|
|
box.item_count = self.get16()
|
|
elif box.version == 2:
|
|
box.item_count = self.get32()
|
|
else:
|
|
raise BoxVersion(2, box.version)
|
|
box.locs = {}
|
|
logger.debug("HEIC: %d iloc items", box.item_count)
|
|
for _ in range(box.item_count):
|
|
if box.version < 2:
|
|
item_id = self.get16()
|
|
elif box.version == 2:
|
|
item_id = self.get32()
|
|
else:
|
|
# notreached
|
|
raise BoxVersion(2, box.version)
|
|
if box.version in (1, 2):
|
|
# ignore construction_method
|
|
self.get16()
|
|
# ignore data_reference_index
|
|
self.get16()
|
|
box.base_offset = self.get_int(box.base_offset_size)
|
|
extent_count = self.get16()
|
|
extents = []
|
|
for _ in range(extent_count):
|
|
if box.version in (1, 2) and box.index_size > 0:
|
|
self.get_int(box.index_size)
|
|
extent_offset = self.get_int(box.offset_size)
|
|
extent_length = self.get_int(box.length_size)
|
|
extents.append((extent_offset, extent_length))
|
|
box.locs[item_id] = extents
|
|
|
|
# Added a few box names, which as unhandled aborted data extraction:
|
|
# hdlr, pitm, dinf, iprp, idat, iref
|
|
#
|
|
# Handling is initially `None`.
|
|
# They were found in .heif photo files produced by Nokia 8.3 5G.
|
|
#
|
|
# They are part of the standard, referring to:
|
|
# - ISO/IEC 14496-12 fifth edition 2015-02-20 (chapter 8.10 Metadata)
|
|
# found in:
|
|
# https://mpeg.chiariglione.org/standards/mpeg-4/iso-base-media-file-format/text-isoiec-14496-12-5th-edition
|
|
# (The newest is ISO/IEC 14496-12:2022, but would cost 208 Swiss Francs at iso.org)
|
|
# - A C++ example: https://exiv2.org/book/#BMFF
|
|
|
|
def _parse_hdlr(self, box: Box) -> None:
|
|
logger.debug("HEIC: found 'hdlr' Box %s, skipped", box.name)
|
|
|
|
def _parse_pitm(self, box: Box) -> None:
|
|
logger.debug("HEIC: found 'pitm' Box %s, skipped", box.name)
|
|
|
|
def _parse_dinf(self, box: Box) -> None:
|
|
logger.debug("HEIC: found 'dinf' Box %s, skipped", box.name)
|
|
|
|
def _parse_iprp(self, box: Box) -> None:
|
|
logger.debug("HEIC: found 'iprp' Box %s, skipped", box.name)
|
|
|
|
def _parse_idat(self, box: Box) -> None:
|
|
logger.debug("HEIC: found 'idat' Box %s, skipped", box.name)
|
|
|
|
def _parse_iref(self, box: Box) -> None:
|
|
logger.debug("HEIC: found 'iref' Box %s, skipped", box.name)
|
|
|
|
def find_exif(self) -> Tuple[int, bytes]:
|
|
ftyp = self.expect_parse("ftyp")
|
|
if (
|
|
ftyp.major_brand not in [b"heic", b"avif", b"mif1"]
|
|
or ftyp.minor_version != 0
|
|
):
|
|
return 0, b""
|
|
|
|
meta = self.expect_parse("meta")
|
|
if meta.subs["iinf"].exif_infe is None:
|
|
return 0, b""
|
|
|
|
item_id = meta.subs["iinf"].exif_infe.item_id
|
|
extents = meta.subs["iloc"].locs[item_id]
|
|
logger.debug("HEIC: found Exif location.")
|
|
# we expect the Exif data to be in one piece.
|
|
assert len(extents) == 1
|
|
pos, _ = extents[0]
|
|
# looks like there's a kind of pseudo-box here.
|
|
self.file_handle.seek(pos)
|
|
# the payload of "Exif" item may be start with either
|
|
# b'\xFF\xE1\xSS\xSSExif\x00\x00' (with APP1 marker, e.g. Android Q)
|
|
# or
|
|
# b'Exif\x00\x00' (without APP1 marker, e.g. iOS)
|
|
# according to "ISO/IEC 23008-12, 2017-12", both of them are legal
|
|
exif_tiff_header_offset = self.get32()
|
|
|
|
if exif_tiff_header_offset == 0:
|
|
# This case was found in HMD Nokia 8.3 5G heic photos.
|
|
# The TIFF header just sits there without any 'Exif'.
|
|
|
|
offset = 0
|
|
endian = b"?" # Haven't got Endian info yet
|
|
else:
|
|
assert exif_tiff_header_offset >= 6
|
|
assert self.get(exif_tiff_header_offset)[-6:] == b"Exif\x00\x00"
|
|
offset = self.file_handle.tell()
|
|
endian = self.file_handle.read(1)
|
|
|
|
return offset, endian
|