from .constants import MIME_TYPE, TIFF_FLD, TIFF_TAG from .helpers import BIG_ENDIAN, LITTLE_ENDIAN, StreamReader from .image import BaseImageHeader class Tiff(BaseImageHeader): """Image header parser for TIFF images. Handles both big and little endian byte ordering. """ @property def content_type(self): """Return the MIME type of this TIFF image, unconditionally the string ``image/tiff``.""" return MIME_TYPE.TIFF @property def default_ext(self): """Default filename extension, always 'tiff' for TIFF images.""" return "tiff" @classmethod def from_stream(cls, stream): """Return a |Tiff| instance containing the properties of the TIFF image in `stream`.""" parser = _TiffParser.parse(stream) px_width = parser.px_width px_height = parser.px_height horz_dpi = parser.horz_dpi vert_dpi = parser.vert_dpi return cls(px_width, px_height, horz_dpi, vert_dpi) class _TiffParser: """Parses a TIFF image stream to extract the image properties found in its main image file directory (IFD)""" def __init__(self, ifd_entries): super(_TiffParser, self).__init__() self._ifd_entries = ifd_entries @classmethod def parse(cls, stream): """Return an instance of |_TiffParser| containing the properties parsed from the TIFF image in `stream`.""" stream_rdr = cls._make_stream_reader(stream) ifd0_offset = stream_rdr.read_long(4) ifd_entries = _IfdEntries.from_stream(stream_rdr, ifd0_offset) return cls(ifd_entries) @property def horz_dpi(self): """The horizontal dots per inch value calculated from the XResolution and ResolutionUnit tags of the IFD; defaults to 72 if those tags are not present.""" return self._dpi(TIFF_TAG.X_RESOLUTION) @property def vert_dpi(self): """The vertical dots per inch value calculated from the XResolution and ResolutionUnit tags of the IFD; defaults to 72 if those tags are not present.""" return self._dpi(TIFF_TAG.Y_RESOLUTION) @property def px_height(self): """The number of stacked rows of pixels in the image, |None| if the IFD contains no ``ImageLength`` tag, the expected case when the TIFF is embeded in an Exif image.""" return self._ifd_entries.get(TIFF_TAG.IMAGE_LENGTH) @property def px_width(self): """The number of pixels in each row in the image, |None| if the IFD contains no ``ImageWidth`` tag, the expected case when the TIFF is embeded in an Exif image.""" return self._ifd_entries.get(TIFF_TAG.IMAGE_WIDTH) @classmethod def _detect_endian(cls, stream): """Return either BIG_ENDIAN or LITTLE_ENDIAN depending on the endian indicator found in the TIFF `stream` header, either 'MM' or 'II'.""" stream.seek(0) endian_str = stream.read(2) return BIG_ENDIAN if endian_str == b"MM" else LITTLE_ENDIAN def _dpi(self, resolution_tag): """Return the dpi value calculated for `resolution_tag`, which can be either TIFF_TAG.X_RESOLUTION or TIFF_TAG.Y_RESOLUTION. The calculation is based on the values of both that tag and the TIFF_TAG.RESOLUTION_UNIT tag in this parser's |_IfdEntries| instance. """ ifd_entries = self._ifd_entries if resolution_tag not in ifd_entries: return 72 # resolution unit defaults to inches (2) resolution_unit = ifd_entries.get(TIFF_TAG.RESOLUTION_UNIT, 2) if resolution_unit == 1: # aspect ratio only return 72 # resolution_unit == 2 for inches, 3 for centimeters units_per_inch = 1 if resolution_unit == 2 else 2.54 dots_per_unit = ifd_entries[resolution_tag] return int(round(dots_per_unit * units_per_inch)) @classmethod def _make_stream_reader(cls, stream): """Return a |StreamReader| instance with wrapping `stream` and having "endian- ness" determined by the 'MM' or 'II' indicator in the TIFF stream header.""" endian = cls._detect_endian(stream) return StreamReader(stream, endian) class _IfdEntries: """Image File Directory for a TIFF image, having mapping (dict) semantics allowing "tag" values to be retrieved by tag code.""" def __init__(self, entries): super(_IfdEntries, self).__init__() self._entries = entries def __contains__(self, key): """Provides ``in`` operator, e.g. ``tag in ifd_entries``""" return self._entries.__contains__(key) def __getitem__(self, key): """Provides indexed access, e.g. ``tag_value = ifd_entries[tag_code]``""" return self._entries.__getitem__(key) @classmethod def from_stream(cls, stream, offset): """Return a new |_IfdEntries| instance parsed from `stream` starting at `offset`.""" ifd_parser = _IfdParser(stream, offset) entries = {e.tag: e.value for e in ifd_parser.iter_entries()} return cls(entries) def get(self, tag_code, default=None): """Return value of IFD entry having tag matching `tag_code`, or `default` if no matching tag found.""" return self._entries.get(tag_code, default) class _IfdParser: """Service object that knows how to extract directory entries from an Image File Directory (IFD)""" def __init__(self, stream_rdr, offset): super(_IfdParser, self).__init__() self._stream_rdr = stream_rdr self._offset = offset def iter_entries(self): """Generate an |_IfdEntry| instance corresponding to each entry in the directory.""" for idx in range(self._entry_count): dir_entry_offset = self._offset + 2 + (idx * 12) ifd_entry = _IfdEntryFactory(self._stream_rdr, dir_entry_offset) yield ifd_entry @property def _entry_count(self): """The count of directory entries, read from the top of the IFD header.""" return self._stream_rdr.read_short(self._offset) def _IfdEntryFactory(stream_rdr, offset): """Return an |_IfdEntry| subclass instance containing the value of the directory entry at `offset` in `stream_rdr`.""" ifd_entry_classes = { TIFF_FLD.ASCII: _AsciiIfdEntry, TIFF_FLD.SHORT: _ShortIfdEntry, TIFF_FLD.LONG: _LongIfdEntry, TIFF_FLD.RATIONAL: _RationalIfdEntry, } field_type = stream_rdr.read_short(offset, 2) EntryCls = ifd_entry_classes.get(field_type, _IfdEntry) return EntryCls.from_stream(stream_rdr, offset) class _IfdEntry: """Base class for IFD entry classes. Subclasses are differentiated by value type, e.g. ASCII, long int, etc. """ def __init__(self, tag_code, value): super(_IfdEntry, self).__init__() self._tag_code = tag_code self._value = value @classmethod def from_stream(cls, stream_rdr, offset): """Return an |_IfdEntry| subclass instance containing the tag and value of the tag parsed from `stream_rdr` at `offset`. Note this method is common to all subclasses. Override the ``_parse_value()`` method to provide distinctive behavior based on field type. """ tag_code = stream_rdr.read_short(offset, 0) value_count = stream_rdr.read_long(offset, 4) value_offset = stream_rdr.read_long(offset, 8) value = cls._parse_value(stream_rdr, offset, value_count, value_offset) return cls(tag_code, value) @classmethod def _parse_value(cls, stream_rdr, offset, value_count, value_offset): """Return the value of this field parsed from `stream_rdr` at `offset`. Intended to be overridden by subclasses. """ return "UNIMPLEMENTED FIELD TYPE" # pragma: no cover @property def tag(self): """Short int code that identifies this IFD entry.""" return self._tag_code @property def value(self): """Value of this tag, its type being dependent on the tag.""" return self._value class _AsciiIfdEntry(_IfdEntry): """IFD entry having the form of a NULL-terminated ASCII string.""" @classmethod def _parse_value(cls, stream_rdr, offset, value_count, value_offset): """Return the ASCII string parsed from `stream_rdr` at `value_offset`. The length of the string, including a terminating '\x00' (NUL) character, is in `value_count`. """ return stream_rdr.read_str(value_count - 1, value_offset) class _ShortIfdEntry(_IfdEntry): """IFD entry expressed as a short (2-byte) integer.""" @classmethod def _parse_value(cls, stream_rdr, offset, value_count, value_offset): """Return the short int value contained in the `value_offset` field of this entry. Only supports single values at present. """ if value_count == 1: return stream_rdr.read_short(offset, 8) else: # pragma: no cover return "Multi-value short integer NOT IMPLEMENTED" class _LongIfdEntry(_IfdEntry): """IFD entry expressed as a long (4-byte) integer.""" @classmethod def _parse_value(cls, stream_rdr, offset, value_count, value_offset): """Return the long int value contained in the `value_offset` field of this entry. Only supports single values at present. """ if value_count == 1: return stream_rdr.read_long(offset, 8) else: # pragma: no cover return "Multi-value long integer NOT IMPLEMENTED" class _RationalIfdEntry(_IfdEntry): """IFD entry expressed as a numerator, denominator pair.""" @classmethod def _parse_value(cls, stream_rdr, offset, value_count, value_offset): """Return the rational (numerator / denominator) value at `value_offset` in `stream_rdr` as a floating-point number. Only supports single values at present. """ if value_count == 1: numerator = stream_rdr.read_long(value_offset) denominator = stream_rdr.read_long(value_offset, 4) return numerator / denominator else: # pragma: no cover return "Multi-value Rational NOT IMPLEMENTED"