Source code for madam.exif

import datetime
import io
import shutil
import tempfile
from collections.abc import Callable, Iterable, Mapping
from fractions import Fraction
from typing import IO, Any

import piexif
from bidict import bidict

from madam.core import MetadataProcessor, UnsupportedFormatError
from madam.mime import MimeType


def _convert_sequence(dec_enc: tuple[Callable, Callable]) -> tuple[Callable, Callable]:
    return lambda exif_values: tuple(map(dec_enc[0], exif_values)), lambda values: list(map(dec_enc[1], values))


def _convert_first(dec_enc: tuple[Callable, Callable]) -> tuple[Callable, Callable]:
    return lambda exif_values: dec_enc[0](exif_values[0]), lambda value: [dec_enc[1](value)]


def _convert_mapping(mapping: Mapping) -> tuple[Callable, Callable]:
    bidi = bidict(mapping)
    return lambda exif_value: bidi[exif_value], lambda value: bidi.inv[value]


[docs] class ExifMetadataProcessor(MetadataProcessor): """ Represents a metadata processor for Exif metadata. """ supported_mime_types = { MimeType('image/jpeg'), MimeType('image/webp'), } metadata_to_exif = bidict( { 'aperture': ('Exif', piexif.ExifIFD.ApertureValue), 'artist': ('0th', piexif.ImageIFD.Artist), 'brightness': ('Exif', piexif.ExifIFD.BrightnessValue), 'camera.manufacturer': ('0th', piexif.ImageIFD.Make), 'camera.model': ('0th', piexif.ImageIFD.Model), 'datetime_digitized': ('Exif', piexif.ExifIFD.DateTimeDigitized), 'datetime_original': ('Exif', piexif.ExifIFD.DateTimeOriginal), 'description': ('0th', piexif.ImageIFD.ImageDescription), 'exposure_time': ('Exif', piexif.ExifIFD.ExposureTime), 'firmware': ('0th', piexif.ImageIFD.Software), 'fnumber': ('Exif', piexif.ExifIFD.FNumber), 'focal_length': ('Exif', piexif.ExifIFD.FocalLength), 'focal_length_35mm': ('Exif', piexif.ExifIFD.FocalLengthIn35mmFilm), 'gps.altitude': ('GPS', piexif.GPSIFD.GPSAltitude), 'gps.altitude_ref': ('GPS', piexif.GPSIFD.GPSAltitudeRef), 'gps.latitude': ('GPS', piexif.GPSIFD.GPSLatitude), 'gps.latitude_ref': ('GPS', piexif.GPSIFD.GPSLatitudeRef), 'gps.longitude': ('GPS', piexif.GPSIFD.GPSLongitude), 'gps.longitude_ref': ('GPS', piexif.GPSIFD.GPSLongitudeRef), 'gps.map_datum': ('GPS', piexif.GPSIFD.GPSMapDatum), 'gps.speed': ('GPS', piexif.GPSIFD.GPSSpeed), 'gps.speed_ref': ('GPS', piexif.GPSIFD.GPSSpeedRef), 'gps.date_stamp': ('GPS', piexif.GPSIFD.GPSDateStamp), 'gps.time_stamp': ('GPS', piexif.GPSIFD.GPSTimeStamp), 'lens.manufacturer': ('Exif', piexif.ExifIFD.LensMake), 'lens.model': ('Exif', piexif.ExifIFD.LensModel), 'orientation': ('0th', piexif.ImageIFD.Orientation), 'shutter_speed': ('Exif', piexif.ExifIFD.ShutterSpeedValue), 'software': ('0th', piexif.ImageIFD.ProcessingSoftware), } ) __STRING = lambda exif_val: exif_val.decode('utf-8'), lambda value: value.encode('utf-8') __INT = int, int __RATIONAL = ( lambda exif_val: float(Fraction(*exif_val)), lambda value: (Fraction(value).limit_denominator().numerator, Fraction(value).limit_denominator().denominator), ) __DATE = ( lambda exif_val: datetime.datetime.strptime(exif_val.decode('utf-8'), '%Y:%m:%d').date(), lambda value: value.strftime('%Y:%m:%d'), ) __DATETIME = ( lambda exif_val: datetime.datetime.strptime(exif_val.decode('utf-8'), '%Y:%m:%d %H:%M:%S'), lambda value: value.strftime('%Y:%m:%d %H:%M:%S').encode('utf-8'), ) __TIME = ( lambda exif_val: datetime.time(*map(lambda v: round(float(Fraction(*v))), exif_val)), lambda value: ((value.hour, 1), (value.minute, 1), (value.second, 1)), ) converters: dict[str, tuple[Callable, Callable]] = { 'aperture': __RATIONAL, 'artist': __STRING, 'brightness': __RATIONAL, 'camera.manufacturer': __STRING, 'camera.model': __STRING, 'datetime_digitized': __DATETIME, 'datetime_original': __DATETIME, 'description': __STRING, 'exposure_time': __RATIONAL, 'firmware': __STRING, 'fnumber': __RATIONAL, 'focal_length': __RATIONAL, 'focal_length_35mm': __INT, 'gps.altitude': __RATIONAL, 'gps.altitude_ref': _convert_mapping({0: 'm_above_sea_level', 1: 'm_below_sea_level'}), 'gps.latitude': _convert_sequence(__RATIONAL), 'gps.latitude_ref': _convert_mapping({b'N': 'north', b'S': 'south'}), 'gps.longitude': _convert_sequence(__RATIONAL), 'gps.longitude_ref': _convert_mapping({b'E': 'east', b'W': 'west'}), 'gps.map_datum': __STRING, 'gps.speed': __RATIONAL, 'gps.speed_ref': _convert_mapping({b'K': 'km/h', b'M': 'mph', b'N': 'kn'}), 'gps.date_stamp': __DATE, 'gps.time_stamp': __TIME, 'lens.manufacturer': __STRING, 'lens.model': __STRING, 'orientation': __INT, 'shutter_speed': __RATIONAL, 'software': __STRING, }
[docs] def __init__(self, config: Mapping[str, Any] | None = None) -> None: """ Initializes a new `ExifMetadataProcessor`. :param config: Mapping with settings """ super().__init__(config)
@property def formats(self) -> Iterable[str]: return {'exif'}
[docs] def read(self, file: IO) -> Mapping[str, Mapping]: with tempfile.NamedTemporaryFile(mode='wb') as tmp: tmp.write(file.read()) tmp.flush() try: metadata = piexif.load(tmp.name) except (piexif.InvalidImageDataError, ValueError): raise UnsupportedFormatError('Unsupported file format.') metadata_by_format = {} for metadata_format in self.formats: format_metadata: dict[str, Any] = {} raw_fields: dict[str, Any] = {} for ifd_key, ifd_values in metadata.items(): if not isinstance(ifd_values, dict): continue for exif_key, exif_value in ifd_values.items(): madam_key = ExifMetadataProcessor.metadata_to_exif.inv.get((ifd_key, exif_key)) if madam_key is None: # Preserve unmapped fields for round-trip fidelity. raw_fields[f'{ifd_key}.{exif_key}'] = exif_value else: convert_to_madam, _ = ExifMetadataProcessor.converters[madam_key] format_metadata[madam_key] = convert_to_madam(exif_value) if raw_fields: format_metadata['_raw'] = raw_fields if format_metadata: metadata_by_format[metadata_format] = format_metadata return metadata_by_format
[docs] def strip(self, file: IO) -> IO: result = io.BytesIO() with tempfile.NamedTemporaryFile(mode='w+b') as tmp: tmp.write(file.read()) tmp.flush() try: metadata = piexif.load(tmp.name) if any(metadata.values()): piexif.remove(tmp.name) except (piexif.InvalidImageDataError, ValueError, UnboundLocalError): raise UnsupportedFormatError('Unsupported file format.') tmp.seek(0) shutil.copyfileobj(tmp, result) result.seek(0) return result
[docs] def combine(self, file: IO, metadata: Mapping[str, Mapping]) -> IO: result = io.BytesIO() with tempfile.NamedTemporaryFile(mode='w+b') as tmp: tmp.write(file.read()) tmp.flush() try: exif_metadata = piexif.load(tmp.name) except (piexif.InvalidImageDataError, ValueError): raise UnsupportedFormatError('Unsupported essence format.') for metadata_format, metadata_values in metadata.items(): if metadata_format not in self.formats: raise UnsupportedFormatError(f'Metadata format {metadata_format!r} is not supported.') for madam_key, madam_value in metadata_values.items(): if madam_key == '_raw': # Write opaque raw fields back verbatim. for raw_key, raw_value in madam_value.items(): ifd_key, exif_key_str = raw_key.split('.', 1) exif_key_int = int(exif_key_str) if ifd_key not in exif_metadata: exif_metadata[ifd_key] = {} exif_metadata[ifd_key][exif_key_int] = raw_value continue if madam_key not in ExifMetadataProcessor.metadata_to_exif: continue ifd_key, exif_key = ExifMetadataProcessor.metadata_to_exif[madam_key] if ifd_key not in exif_metadata: exif_metadata[ifd_key] = {} _, convert_to_exif = ExifMetadataProcessor.converters[madam_key] exif_metadata[ifd_key][exif_key] = convert_to_exif(madam_value) try: piexif.insert(piexif.dump(exif_metadata), tmp.name) except (piexif.InvalidImageDataError, ValueError): raise UnsupportedFormatError(f'Could not write metadata: {metadata!r}') tmp.seek(0) shutil.copyfileobj(tmp, result) result.seek(0) return result