diff options
author | Olivier Tilloy <olivier@tilloy.net> | 2009-10-18 02:27:32 +0200 |
---|---|---|
committer | Olivier Tilloy <olivier@tilloy.net> | 2009-10-18 02:27:32 +0200 |
commit | 6300f33df2e838bec12f9038e8063fe895bafe0e (patch) | |
tree | 269702769f9ffdb66392c86f118e6767749b4ee0 /src | |
parent | 4a7092691ef7ab3f11c8edcf4f28c8b42bf5342f (diff) | |
download | pyexiv2-6300f33df2e838bec12f9038e8063fe895bafe0e.tar.gz |
Experimental code split into various python modules.
Diffstat (limited to 'src')
-rw-r--r-- | src/pyexiv2.py | 1574 | ||||
-rw-r--r-- | src/pyexiv2/__init__.py | 73 | ||||
-rw-r--r-- | src/pyexiv2/exif.py | 317 | ||||
-rw-r--r-- | src/pyexiv2/iptc.py | 254 | ||||
-rwxr-xr-x | src/pyexiv2/main.py | 53 | ||||
-rw-r--r-- | src/pyexiv2/metadata.py | 287 | ||||
-rw-r--r-- | src/pyexiv2/tag.py | 77 | ||||
-rw-r--r-- | src/pyexiv2/utils.py | 361 | ||||
-rw-r--r-- | src/pyexiv2/xmp.py | 371 |
9 files changed, 1793 insertions, 1574 deletions
diff --git a/src/pyexiv2.py b/src/pyexiv2.py deleted file mode 100644 index 0e914f7..0000000 --- a/src/pyexiv2.py +++ /dev/null @@ -1,1574 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# ****************************************************************************** -# -# Copyright (C) 2006-2009 Olivier Tilloy <olivier@tilloy.net> -# -# This file is part of the pyexiv2 distribution. -# -# pyexiv2 is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# pyexiv2 is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with pyexiv2; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA. -# -# Author: Olivier Tilloy <olivier@tilloy.net> -# -# ****************************************************************************** - -""" -Manipulation of EXIF, IPTC and XMP metadata and thumbnails embedded in images. - -The L{ImageMetadata} class provides read/write access to all the metadata and -the various thumbnails embedded in an image file such as JPEG and TIFF files. - -Metadata is accessed through subclasses of L{MetadataTag} and the tag values are -conveniently wrapped in python objects. -A tag containing a date/time information for the image -(e.g. C{Exif.Photo.DateTimeOriginal}) will be represented by a python -C{datetime.datetime} object. - -This module is a python layer on top of the low-level python binding of the -C++ library Exiv2, libpyexiv2. - -A typical use of this binding would be: - ->>> import pyexiv2 ->>> metadata = pyexiv2.ImageMetadata('test/smiley.jpg') ->>> metadata.read() ->>> print metadata.exif_keys -['Exif.Image.ImageDescription', 'Exif.Image.XResolution', - 'Exif.Image.YResolution', 'Exif.Image.ResolutionUnit', 'Exif.Image.Software', - 'Exif.Image.DateTime', 'Exif.Image.Artist', 'Exif.Image.Copyright', - 'Exif.Image.ExifTag', 'Exif.Photo.Flash', 'Exif.Photo.PixelXDimension', - 'Exif.Photo.PixelYDimension'] ->>> print metadata['Exif.Image.DateTime'].value -2004-07-13 21:23:44 ->>> import datetime ->>> metadata['Exif.Image.DateTime'].value = datetime.datetime.today() ->>> metadata.write() -""" - -import libexiv2python - -import os -import time -import datetime -import re - - -__version__ = (0, 2, 1) - -__exiv2_version__ = libexiv2python.__exiv2_version__ - - -class FixedOffset(datetime.tzinfo): - - """ - Fixed positive or negative offset from a local time east from UTC. - - @ivar sign: the sign of the offset ('+' or '-') - @type sign: C{str} - @ivar hours: the absolute number of hours of the offset - @type hours: C{int} - @ivar minutes: the absolute number of minutes of the offset - @type minutes: C{int} - - """ - - def __init__(self, sign='+', hours=0, minutes=0): - """ - Initialize an offset from a sign ('+' or '-') and an absolute value - expressed in hours and minutes. - No check on the validity of those values is performed, it is the - responsibility of the caller to pass valid values. - - @param sign: the sign of the offset ('+' or '-') - @type sign: C{str} - @param hours: an absolute number of hours - @type hours: C{int} - @param minutes: an absolute number of minutes - @type minutes: C{int} - """ - self.sign = sign - self.hours = hours - self.minutes = minutes - - def utcoffset(self, dt): - """ - Return offset of local time from UTC, in minutes east of UTC. - If local time is west of UTC, this value will be negative. - - @param dt: the local time - @type dt: C{datetime.time} - - @return: a whole number of minutes in the range -1439 to 1439 inclusive - @rtype: C{datetime.timedelta} - """ - total = self.hours * 60 + self.minutes - if self.sign == '-': - total = -total - return datetime.timedelta(minutes = total) - - def dst(self, dt): - """ - Return the daylight saving time (DST) adjustment. - In this implementation, it is always nil. - - @param dt: the local time - @type dt: C{datetime.time} - - @return: the DST adjustment (always nil) - @rtype: C{datetime.timedelta} - """ - return datetime.timedelta(0) - - def tzname(self, dt): - """ - Return a string representation of the offset in the format '±%H:%M'. - If the offset is nil, the representation is, by convention, 'Z'. - - @param dt: the local time - @type dt: C{datetime.time} - - @return: a human-readable representation of the offset - @rtype: C{str} - """ - if self.hours == 0 and self.minutes == 0: - return 'Z' - else: - return '%s%02d:%02d' % (self.sign, self.hours, self.minutes) - - def __equal__(self, other): - """ - Test equality between this offset and another offset. - - @param other: another offset - @type other: L{FixedOffset} - - @return: C{True} if the offset are equal, C{False} otherwise - @rtype: C{bool} - """ - return (self.sign == other.sign) and (self.hours == other.hours) and \ - (self.minutes == other.minutes) - - -def UndefinedToString(undefined): - """ - Convert an undefined string into its corresponding sequence of bytes. - The undefined string must contain the ascii codes of a sequence of bytes, - each followed by a blank space (e.g. "48 50 50 49 " will be converted into - "0221"). - The Undefined type is part of the EXIF specification. - - @param undefined: an undefined string - @type undefined: C{str} - - @return: the corresponding decoded string - @rtype: C{str} - """ - return ''.join(map(lambda x: chr(int(x)), undefined.rstrip().split(' '))) - - -def StringToUndefined(sequence): - """ - Convert a string into its undefined form. - The undefined form contains a sequence of ascii codes, each followed by a - blank space (e.g. "0221" will be converted into "48 50 50 49 "). - The Undefined type is part of the EXIF specification. - - @param sequence: a sequence of bytes - @type sequence: C{str} - - @return: the corresponding undefined string - @rtype: C{str} - """ - return ''.join(map(lambda x: '%d ' % ord(x), sequence)) - - -class Rational(object): - - """ - A class representing a rational number. - - Its numerator and denominator are read-only properties. - """ - - _format_re = re.compile(r'(?P<numerator>-?\d+)/(?P<denominator>\d+)') - - def __init__(self, numerator, denominator): - """ - Constructor. - - @param numerator: the numerator - @type numerator: C{long} - @param denominator: the denominator - @type denominator: C{long} - - @raise ZeroDivisionError: if the denominator equals zero - """ - if denominator == 0: - msg = 'Denominator of a rational number cannot be zero.' - raise ZeroDivisionError(msg) - self._numerator = long(numerator) - self._denominator = long(denominator) - - @property - def numerator(self): - return self._numerator - - @property - def denominator(self): - return self._denominator - - @staticmethod - def from_string(string): - """ - Instantiate a Rational from a string formatted as - C{[-]numerator/denominator}. - - @param string: a string representation of a rational number - @type string: C{str} - - @return: the rational number parsed - @rtype: L{Rational} - - @raise ValueError: if the format of the string is invalid - """ - match = Rational._format_re.match(string) - if match is None: - raise ValueError('Invalid format for a rational: %s' % string) - gd = match.groupdict() - return Rational(long(gd['numerator']), long(gd['denominator'])) - - def to_float(self): - """ - @return: a floating point number approximation of the value - @rtype: C{float} - """ - return float(self._numerator) / self._denominator - - def __eq__(self, other): - """ - Compare two rational numbers for equality. - - Two rational numbers are equal if their reduced forms are equal. - - @param other: the rational number to compare to self for equality - @type other: L{Rational} - - @return: C{True} if equal, C{False} otherwise - @rtype: C{bool} - """ - return (self._numerator * other._denominator) == \ - (other._numerator * self._denominator) - - def __str__(self): - """ - Return a string representation of the rational number. - """ - return '%d/%d' % (self._numerator, self._denominator) - - -class ListenerInterface(object): - - """ - Interface that an object that wants to listen to changes on another object - should implement. - """ - - def contents_changed(self): - """ - React on changes on the object observed. - Override to implement specific behaviours. - """ - raise NotImplementedError() - - -class NotifyingList(list): - - """ - A simplistic implementation of a notifying list. - Any changes to the list are notified in a synchronous way to all previously - registered listeners. A listener must implement the L{ListenerInterface}. - """ - - # Useful documentation: - # file:///usr/share/doc/python2.5/html/lib/typesseq-mutable.html - # http://docs.python.org/reference/datamodel.html#additional-methods-for-emulation-of-sequence-types - - def __init__(self, items=[]): - super(NotifyingList, self).__init__(items) - self._listeners = set() - - def register_listener(self, listener): - """ - Register a new listener to be notified of changes. - - @param listener: any object that listens for changes - @type listener: any class that implements the L{ListenerInterface} - """ - self._listeners.add(listener) - - def unregister_listener(self, listener): - """ - Unregister a previously registered listener. - - @param listener: a previously registered listener - @type listener: any class that implements the L{ListenerInterface} - - @raise KeyError: if the listener was not previously registered - """ - self._listeners.remove(listener) - - def _notify_listeners(self, *args): - for listener in self._listeners: - listener.contents_changed(*args) - - def __setitem__(self, index, item): - # FIXME: support slice arguments for extended slicing - super(NotifyingList, self).__setitem__(index, item) - self._notify_listeners() - - def __delitem__(self, index): - # FIXME: support slice arguments for extended slicing - super(NotifyingList, self).__delitem__(index) - self._notify_listeners() - - def append(self, item): - super(NotifyingList, self).append(item) - self._notify_listeners() - - def extend(self, items): - super(NotifyingList, self).extend(items) - self._notify_listeners() - - def insert(self, index, item): - super(NotifyingList, self).insert(index, item) - self._notify_listeners() - - def pop(self, index=None): - if index is None: - item = super(NotifyingList, self).pop() - else: - item = super(NotifyingList, self).pop(index) - self._notify_listeners() - return item - - def remove(self, item): - super(NotifyingList, self).remove(item) - self._notify_listeners() - - def reverse(self): - super(NotifyingList, self).reverse() - self._notify_listeners() - - def sort(self, cmp=None, key=None, reverse=False): - super(NotifyingList, self).sort(cmp, key, reverse) - self._notify_listeners() - - def __iadd__(self, other): - self = super(NotifyingList, self).__iadd__(other) - self._notify_listeners() - return self - - def __imul__(self, coefficient): - self = super(NotifyingList, self).__imul__(coefficient) - self._notify_listeners() - return self - - def __setslice__(self, i, j, items): - # __setslice__ is deprecated but needs to be overridden for completeness - super(NotifyingList, self).__setslice__(i, j, items) - self._notify_listeners() - - def __delslice__(self, i, j): - # __delslice__ is deprecated but needs to be overridden for completeness - deleted = self[i:j] - super(NotifyingList, self).__delslice__(i, j) - if deleted: - self._notify_listeners() - - -class MetadataTag(object): - - """ - A generic metadata tag. - It is meant to be subclassed to implement specific tag types behaviours. - - @ivar key: a unique key that identifies the tag - @type key: C{str} - @ivar name: the short internal name that identifies the tag within - its scope - @type name: C{str} - @ivar label: a human readable label for the tag - @type label: C{str} - @ivar description: a description of the function of the tag - @type description: C{str} - @ivar type: the data type name - @type type: C{str} - @ivar raw_value: the raw value of the tag as provided by exiv2 - @type raw_value: C{str} - @ivar metadata: reference to the containing metadata if any - @type metadata: L{pyexiv2.ImageMetadata} - """ - - def __init__(self, key, name, label, description, type, value): - self.key = key - self.name = name - # FIXME: all attributes that may contain a localized string should be - # unicode. - self.label = label - self.description = description - self.type = type - self.raw_value = value - self.metadata = None - - def __str__(self): - """ - Return a string representation of the value of the tag suitable to pass - to libexiv2 to set it. - - @rtype: C{str} - """ - return self.raw_value - - def __repr__(self): - """ - Return a string representation of the tag for debugging purposes. - - @rtype: C{str} - """ - return '<%s [%s] = %s>' % (self.key, self.type, self.raw_value) - - -class ExifValueError(ValueError): - - """ - Exception raised when failing to parse the value of an EXIF tag. - - @ivar value: the value that fails to be parsed - @type value: C{str} - @ivar type: the EXIF type of the tag - @type type: C{str} - """ - - def __init__(self, value, type): - self.value = value - self.type = type - - def __str__(self): - return 'Invalid value for EXIF type [%s]: [%s]' % \ - (self.type, self.value) - - -class ExifTag(MetadataTag, ListenerInterface): - - """ - An EXIF metadata tag. - This tag has an additional field that contains the value of the tag - formatted as a human readable string. - - @ivar fvalue: the value of the tag formatted as a human readable string - @type fvalue: C{str} - """ - - # According to the EXIF specification, the only accepted format for an Ascii - # value representing a datetime is '%Y:%m:%d %H:%M:%S', but it seems that - # others formats can be found in the wild. - _datetime_formats = ('%Y:%m:%d %H:%M:%S', - '%Y-%m-%d %H:%M:%S', - '%Y-%m-%dT%H:%M:%SZ') - - _date_formats = ('%Y:%m:%d',) - - def __init__(self, key, name, label, description, type, value, fvalue): - super(ExifTag, self).__init__(key, name, label, - description, type, value) - self.fvalue = fvalue - self._init_values() - - def _init_values(self): - # Initial conversion of the raw values to their corresponding python - # types. - if self.type in ('Short', 'Long', 'SLong', 'Rational', 'SRational'): - # May contain multiple values - values = self.raw_value.split() - if len(values) > 1: - # Make values a notifying list - values = map(self._convert_to_python, values) - self._value = NotifyingList(values) - self._value.register_listener(self) - return - self._value = self._convert_to_python(self.raw_value) - - def _get_value(self): - return self._value - - def _set_value(self, new_value): - if self.metadata is not None: - if isinstance(new_value, (list, tuple)): - raw_values = map(self._convert_to_string, new_value) - raw_value = ' '.join(raw_values) - else: - raw_value = self._convert_to_string(new_value) - self.metadata._set_exif_tag_value(self.key, raw_value) - - if isinstance(self._value, NotifyingList): - self._value.unregister_listener(self) - - if isinstance(new_value, NotifyingList): - # Already a notifying list - self._value = new_value - self._value.register_listener(self) - elif isinstance(new_value, (list, tuple)): - # Make the values a notifying list - self._value = NotifyingList(new_value) - self._value.register_listener(self) - else: - # Single value - self._value = new_value - - def _del_value(self): - if self.metadata is not None: - self.metadata._delete_exif_tag(self.key) - - if isinstance(self._value, NotifyingList): - self._value.unregister_listener(self) - - del self._value - - """the value of the tag converted to its corresponding python type""" - value = property(fget=_get_value, fset=_set_value, fdel=_del_value, - doc=None) - - def contents_changed(self): - """ - Implementation of the L{ListenerInterface}. - React on changes to the list of values of the tag. - """ - # self._value is a list of value and its contents changed. - self._set_value(self._value) - - def _convert_to_python(self, value): - """ - Convert one raw value to its corresponding python type. - - @param value: the raw value to be converted - @type value: C{str} - - @return: the value converted to its corresponding python type - @rtype: depends on C{self.type} (DOCME) - - @raise ExifValueError: if the conversion fails - """ - if self.type == 'Ascii': - # The value may contain a Datetime - for format in self._datetime_formats: - try: - t = time.strptime(value, format) - except ValueError: - continue - else: - return datetime.datetime(*t[:6]) - # Or a Date (e.g. Exif.GPSInfo.GPSDateStamp) - for format in self._date_formats: - try: - t = time.strptime(value, format) - except ValueError: - continue - else: - return datetime.date(*t[:3]) - # Default to string. - # There is currently no charset conversion. - # TODO: guess the encoding and decode accordingly into unicode - # where relevant. - return value - - elif self.type == 'Byte': - return value - - elif self.type == 'Short': - try: - return int(value) - except ValueError: - raise ExifValueError(value, self.type) - - elif self.type in ('Long', 'SLong'): - try: - return long(value) - except ValueError: - raise ExifValueError(value, self.type) - - elif self.type in ('Rational', 'SRational'): - try: - r = Rational.from_string(value) - except (ValueError, ZeroDivisionError): - raise ExifValueError(value, self.type) - else: - if self.type == 'Rational' and r.numerator < 0: - raise ExifValueError(value, self.type) - return r - - elif self.type == 'Undefined': - # There is currently no charset conversion. - # TODO: guess the encoding and decode accordingly into unicode - # where relevant. - return self.fvalue - - raise ExifValueError(value, self.type) - - def _convert_to_string(self, value): - """ - Convert one value to its corresponding string representation, suitable - to pass to libexiv2. - - @param value: the value to be converted - @type value: depends on C{self.type} (DOCME) - - @return: the value converted to its corresponding string representation - @rtype: C{str} - - @raise ExifValueError: if the conversion fails - """ - if self.type == 'Ascii': - if type(value) is datetime.datetime: - return value.strftime(self._datetime_formats[0]) - elif type(value) is datetime.date: - if self.key == 'Exif.GPSInfo.GPSDateStamp': - # Special case - return value.strftime(self._date_formats[0]) - else: - return value.strftime('%s 00:00:00' % self._date_formats[0]) - elif type(value) is unicode: - try: - return value.encode('utf-8') - except UnicodeEncodeError: - raise ExifValueError(value, self.type) - elif type(value) is str: - return value - else: - raise ExifValueError(value, self.type) - - elif self.type == 'Byte': - if type(value) is unicode: - try: - return value.encode('utf-8') - except UnicodeEncodeError: - raise ExifValueError(value, self.type) - elif type(value) is str: - return value - else: - raise ExifValueError(value, self.type) - - elif self.type == 'Short': - if type(value) is int and value >= 0: - return str(value) - else: - raise ExifValueError(value, self.type) - - elif self.type == 'Long': - if type(value) in (int, long) and value >= 0: - return str(value) - else: - raise ExifValueError(value, self.type) - - elif self.type == 'SLong': - if type(value) in (int, long): - return str(value) - else: - raise ExifValueError(value, self.type) - - elif self.type == 'Rational': - if type(value) is Rational and value.numerator >= 0: - return str(value) - else: - raise ExifValueError(value, self.type) - - elif self.type == 'SRational': - if type(value) is Rational: - return str(value) - else: - raise ExifValueError(value, self.type) - - elif self.type == 'Undefined': - if type(value) is unicode: - try: - return value.encode('utf-8') - except UnicodeEncodeError: - raise ExifValueError(value, self.type) - elif type(value) is str: - return value - else: - raise ExifValueError(value, self.type) - - raise ExifValueError(value, self.type) - - def __str__(self): - """ - Return a string representation of the value of the EXIF tag suitable to - pass to libexiv2 to set it. - - @rtype: C{str} - """ - return self._convert_to_string(self.value) - - def __repr__(self): - """ - Return a string representation of the EXIF tag for debugging purposes. - - @rtype: C{str} - """ - left = '%s [%s]' % (self.key, self.type) - if self.type == 'Undefined' and len(self._value) > 100: - right = '(Binary value suppressed)' - else: - right = self.fvalue - return '<%s = %s>' % (left, right) - - -class IptcValueError(ValueError): - - """ - Exception raised when failing to parse the value of an IPTC tag. - - @ivar value: the value that fails to be parsed - @type value: C{str} - @ivar type: the IPTC type of the tag - @type type: C{str} - """ - - def __init__(self, value, type): - self.value = value - self.type = type - - def __str__(self): - return 'Invalid value for IPTC type [%s]: [%s]' % \ - (self.type, self.value) - - -class IptcTag(MetadataTag): - - """ - An IPTC metadata tag. - This tag can have several values (tags that have the repeatable property). - """ - - # strptime is not flexible enough to handle all valid Time formats, we use a - # custom regular expression - _time_zone_re = r'(?P<sign>\+|-)(?P<ohours>\d{2}):(?P<ominutes>\d{2})' - _time_re = re.compile(r'(?P<hours>\d{2}):(?P<minutes>\d{2}):(?P<seconds>\d{2})(?P<tzd>%s)' % _time_zone_re) - - def __init__(self, key, name, label, description, type, values): - super(IptcTag, self).__init__(key, name, label, - description, type, values) - self._init_values() - - def _init_values(self): - # Initial conversion of the raw values to their corresponding python - # types. - values = map(self._convert_to_python, self.raw_value) - # Make values a notifying list - self._values = NotifyingList(values) - self._values.register_listener(self) - - def _get_values(self): - return self._values - - def _set_values(self, new_values): - if self.metadata is not None: - raw_values = map(self._convert_to_string, new_values) - self.metadata._set_iptc_tag_values(self.key, raw_values) - # Make values a notifying list if needed - if isinstance(new_values, NotifyingList): - self._values = new_values - else: - self._values = NotifyingList(new_values) - - def _del_values(self): - if self.metadata is not None: - self.metadata._delete_iptc_tag(self.key) - del self._values - - """the list of values of the tag converted to their corresponding python - type""" - values = property(fget=_get_values, fset=_set_values, fdel=_del_values, - doc=None) - - def contents_changed(self): - """ - Implementation of the L{ListenerInterface}. - React on changes to the list of values of the tag. - """ - # The contents of self._values was changed. - # The following is a quick, non optimal solution. - self._set_values(self._values) - - def _convert_to_python(self, value): - """ - Convert one raw value to its corresponding python type. - - @param value: the raw value to be converted - @type value: C{str} - - @return: the value converted to its corresponding python type - @rtype: depends on C{self.type} (DOCME) - - @raise IptcValueError: if the conversion fails - """ - if self.type == 'Short': - try: - return int(value) - except ValueError: - raise IptcValueError(value, self.type) - - elif self.type == 'String': - # There is currently no charset conversion. - # TODO: guess the encoding and decode accordingly into unicode - # where relevant. - return value - - elif self.type == 'Date': - # According to the IPTC specification, the format for a string field - # representing a date is '%Y%m%d'. However, the string returned by - # exiv2 using method DateValue::toString() is formatted using - # pattern '%Y-%m-%d'. - format = '%Y-%m-%d' - try: - t = time.strptime(value, format) - return datetime.date(*t[:3]) - except ValueError: - raise IptcValueError(value, self.type) - - elif self.type == 'Time': - # According to the IPTC specification, the format for a string field - # representing a time is '%H%M%S±%H%M'. However, the string returned - # by exiv2 using method TimeValue::toString() is formatted using - # pattern '%H:%M:%S±%H:%M'. - match = IptcTag._time_re.match(value) - if match is None: - raise IptcValueError(value, self.type) - gd = match.groupdict() - try: - tzinfo = FixedOffset(gd['sign'], int(gd['ohours']), - int(gd['ominutes'])) - except TypeError: - raise IptcValueError(value, self.type) - try: - return datetime.time(int(gd['hours']), int(gd['minutes']), - int(gd['seconds']), tzinfo=tzinfo) - except (TypeError, ValueError): - raise IptcValueError(value, self.type) - - elif self.type == 'Undefined': - # Binary data, return it unmodified - return value - - raise IptcValueError(value, self.type) - - def _convert_to_string(self, value): - """ - Convert one value to its corresponding string representation, suitable - to pass to libexiv2. - - @param value: the value to be converted - @type value: depends on C{self.type} (DOCME) - - @return: the value converted to its corresponding string representation - @rtype: C{str} - - @raise IptcValueError: if the conversion fails - """ - if self.type == 'Short': - if type(value) is int: - return str(value) - else: - raise IptcValueError(value, self.type) - - elif self.type == 'String': - if type(value) is unicode: - try: - return value.encode('utf-8') - except UnicodeEncodeError: - raise IptcValueError(value, self.type) - elif type(value) is str: - return value - else: - raise IptcValueError(value, self.type) - - elif self.type == 'Date': - if type(value) in (datetime.date, datetime.datetime): - # ISO 8601 date format - return value.strftime('%Y%m%d') - else: - raise IptcValueError(value, self.type) - - elif self.type == 'Time': - if type(value) in (datetime.time, datetime.datetime): - r = value.strftime('%H%M%S') - if value.tzinfo is not None: - r += value.strftime('%z') - else: - r += '+0000' - return r - else: - raise IptcValueError(value, self.type) - - elif self.type == 'Undefined': - if type(value) is str: - return value - else: - raise IptcValueError(value, self.type) - - raise IptcValueError(value, self.type) - - def to_string(self): - """ - Return a list of string representations of the values of the IPTC tag - suitable to pass to libexiv2 to set it. - - @rtype: C{list} of C{str} - """ - return map(self._convert_to_string, self.values) - - def __str__(self): - """ - Return a string representation of the list of values of the IPTC tag. - - @rtype: C{str} - """ - return ', '.join(self.to_string()) - - def __repr__(self): - """ - Return a string representation of the IPTC tag for debugging purposes. - - @rtype: C{str} - """ - return '<%s [%s] = %s>' % (self.key, self.type, str(self)) - - -class XmpValueError(ValueError): - - """ - Exception raised when failing to parse the value of an XMP tag. - - @ivar value: the value that fails to be parsed - @type value: C{str} - @ivar type: the XMP type of the tag - @type type: C{str} - """ - def __init__(self, value, type): - self.value = value - self.type = type - - def __str__(self): - return 'Invalid value for XMP type [%s]: [%s]' % \ - (self.type, self.value) - - -class XmpTag(MetadataTag): - - """ - An XMP metadata tag. - """ - - # strptime is not flexible enough to handle all valid Date formats, we use a - # custom regular expression - _time_zone_re = r'Z|((?P<sign>\+|-)(?P<ohours>\d{2}):(?P<ominutes>\d{2}))' - _time_re = r'(?P<hours>\d{2})(:(?P<minutes>\d{2})(:(?P<seconds>\d{2})(.(?P<decimal>\d+))?)?(?P<tzd>%s))?' % _time_zone_re - _date_re = re.compile(r'(?P<year>\d{4})(-(?P<month>\d{2})(-(?P<day>\d{2})(T(?P<time>%s))?)?)?' % _time_re) - - def __init__(self, key, name, label, description, type, value): - super(XmpTag, self).__init__(key, name, label, description, type, value) - self._value = XmpTag._convert_to_python(value, type) - - def _get_value(self): - return self._value - - def _set_value(self, new_value): - if self.metadata is not None: - raw_value = XmpTag._convert_to_string(new_value, self.type) - self.metadata._set_xmp_tag_value(self.key, raw_value) - self._value = new_value - - def _del_value(self): - if self.metadata is not None: - self.metadata._delete_xmp_tag(self.key) - del self._value - - """the value of the tag converted to its corresponding python type""" - value = property(fget=_get_value, fset=_set_value, fdel=_del_value, - doc=None) - - @staticmethod - def _convert_to_python(value, xtype): - """ - Convert a raw value to its corresponding python type. - - @param value: the raw value to be converted - @type value: C{str} - @param xtype: the XMP type of the value - @type xtype: C{str} - - @return: the value converted to its corresponding python type - @rtype: depends on xtype (DOCME) - - @raise XmpValueError: if the conversion fails - """ - if xtype.startswith('bag '): - # FIXME: make the value a notifying list. - if value == '': - return [] - values = value.split(', ') - return map(lambda x: XmpTag._convert_to_python(x, xtype[4:]), values) - - elif xtype == 'Boolean': - if value == 'True': - return True - elif value == 'False': - return False - else: - raise XmpValueError(value, xtype) - - elif xtype == 'Choice': - # TODO - raise NotImplementedError('XMP conversion for type [%s]' % xtype) - - elif xtype == 'Colorant': - # TODO - raise NotImplementedError('XMP conversion for type [%s]' % xtype) - - elif xtype == 'Date': - match = XmpTag._date_re.match(value) - if match is None: - raise XmpValueError(value, xtype) - gd = match.groupdict() - if gd['month'] is not None: - month = int(gd['month']) - else: - month = 1 - if gd['day'] is not None: - day = int(gd['day']) - else: - day = 1 - if gd['time'] is None: - try: - return datetime.date(int(gd['year']), month, day) - except ValueError: - raise XmpValueError(value, xtype) - else: - if gd['minutes'] is None: - # Malformed time - raise XmpValueError(value, xtype) - if gd['seconds'] is not None: - seconds = int(gd['seconds']) - else: - seconds = 0 - if gd['decimal'] is not None: - microseconds = int(float('0.%s' % gd['decimal']) * 1E6) - else: - microseconds = 0 - if gd['tzd'] == 'Z': - tzinfo = FixedOffset() - else: - tzinfo = FixedOffset(gd['sign'], int(gd['ohours']), - int(gd['ominutes'])) - try: - return datetime.datetime(int(gd['year']), month, day, - int(gd['hours']), int(gd['minutes']), - seconds, microseconds, tzinfo) - except ValueError: - raise XmpValueError(value, xtype) - - elif xtype == 'Dimensions': - # TODO - raise NotImplementedError('XMP conversion for type [%s]' % xtype) - - elif xtype == 'Font': - # TODO - raise NotImplementedError('XMP conversion for type [%s]' % xtype) - - elif xtype == 'Integer': - try: - return int(value) - except ValueError: - raise XmpValueError(value, xtype) - - elif xtype == 'Lang Alt': - matches = value.split('lang="') - nb = len(matches) - if nb < 2 or matches[0] != '': - raise XmpValueError(value, xtype) - result = {} - for i, match in enumerate(matches[1:]): - try: - qualifier, text = match.split('" ', 1) - except ValueError: - raise XmpValueError(value, xtype) - else: - if not text.rstrip().endswith(','): - if (i < nb - 2): - # If not the last match, it should end with a comma - raise XmpValueError(value, xtype) - else: - result[qualifier] = text - try: - result[qualifier] = unicode(text, 'utf-8') - except TypeError: - raise XmpValueError(value, xtype) - else: - try: - result[qualifier] = unicode(text.rstrip()[:-1], 'utf-8') - except TypeError: - raise XmpValueError(value, xtype) - return result - - elif xtype == 'Locale': - # TODO - # See RFC 3066 - raise NotImplementedError('XMP conversion for type [%s]' % xtype) - - elif xtype == 'MIMEType': - try: - mtype, msubtype = value.split('/', 1) - except ValueError: - raise XmpValueError(value, xtype) - else: - return {'type': mtype, 'subtype': msubtype} - - elif xtype == 'Real': - # TODO - raise NotImplementedError('XMP conversion for type [%s]' % xtype) - - elif xtype in ('ProperName', 'Text'): - try: - return unicode(value, 'utf-8') - except TypeError: - raise XmpValueError(value, xtype) - - elif xtype == 'Thumbnail': - # TODO - raise NotImplementedError('XMP conversion for type [%s]' % xtype) - - elif xtype in ('URI', 'URL'): - return value - - elif xtype == 'XPath': - # TODO - raise NotImplementedError('XMP conversion for type [%s]' % xtype) - - raise NotImplementedError('XMP conversion for type [%s]' % xtype) - - @staticmethod - def _convert_to_string(value, xtype): - """ - Convert a value to its corresponding string representation, suitable to - pass to libexiv2. - - @param value: the value to be converted - @type value: depends on xtype (DOCME) - @param xtype: the XMP type of the value - @type xtype: C{str} - - @return: the value converted to its corresponding string representation - @rtype: C{str} - - @raise XmpValueError: if the conversion fails - """ - if xtype.startswith('bag '): - if type(value) in (list, tuple): - return ', '.join(map(lambda x: XmpTag._convert_to_string(x, xtype[4:]), value)) - else: - raise XmpValueError(value, xtype) - - elif xtype == 'Boolean': - if type(value) is bool: - return str(value) - else: - raise XmpValueError(value, xtype) - - elif xtype == 'Date': - if type(value) is datetime.date: - return value.isoformat() - elif type(value) is datetime.datetime: - if value.hour == 0 and value.minute == 0 and \ - value.second == 0 and value.microsecond == 0 and \ - (value.tzinfo is None or value.tzinfo == FixedOffset()): - return value.strftime('%Y-%m-%d') - elif value.second == 0 and value.microsecond == 0: - return value.strftime('%Y-%m-%dT%H:%M%Z') - elif value.microsecond == 0: - return value.strftime('%Y-%m-%dT%H:%M:%S%Z') - else: - r = value.strftime('%Y-%m-%dT%H:%M:%S.') - r += str(int(value.microsecond) / 1E6)[2:] - r += value.strftime('%Z') - return r - else: - raise XmpValueError(value, xtype) - - elif xtype == 'Integer': - if type(value) in (int, long): - return str(value) - else: - raise XmpValueError(value, xtype) - - elif xtype == 'Lang Alt': - if type(value) is dict and len(value) > 0: - r = '' - for key, avalue in value.iteritems(): - if type(key) is unicode: - try: - rkey = key.encode('utf-8') - except UnicodeEncodeError: - raise XmpValueError(value, xtype) - elif type(key) is str: - rkey = key - else: - raise XmpValueError(value, xtype) - if type(avalue) is unicode: - try: - ravalue = avalue.encode('utf-8') - except UnicodeEncodeError: - raise XmpValueError(value, xtype) - elif type(avalue) is str: - ravalue = avalue - else: - raise XmpValueError(value, xtype) - r += 'lang="%s" %s, ' % (rkey, ravalue) - return r[:-2] - else: - raise XmpValueError(value, xtype) - - elif xtype == 'MIMEType': - if type(value) is dict: - try: - return '%s/%s' % (value['type'], value['subtype']) - except KeyError: - raise XmpValueError(value, xtype) - else: - raise XmpValueError(value, xtype) - - elif xtype in ('ProperName', 'Text', 'URI', 'URL'): - if type(value) is unicode: - try: - return value.encode('utf-8') - except UnicodeEncodeError: - raise XmpValueError(value, xtype) - elif type(value) is str: - return value - else: - raise XmpValueError(value, xtype) - - raise NotImplementedError('XMP conversion for type [%s]' % xtype) - - def to_string(self): - """ - Return a string representation of the XMP tag suitable to pass to - libexiv2 to set the value of the tag. - - @rtype: C{str} - """ - return XmpTag._convert_to_string(self.value, self.type) - - def __str__(self): - """ - Return a string representation of the XMP tag for debugging purposes. - - @rtype: C{str} - """ - r = 'Key = ' + self.key + os.linesep + \ - 'Name = ' + self.name + os.linesep + \ - 'Label = ' + self.label + os.linesep + \ - 'Description = ' + self.description + os.linesep + \ - 'Type = ' + self.type + os.linesep + \ - 'Values = ' + str(self.values) - return r - - -class ImageMetadata(object): - - """ - A container for all the metadata attached to an image. - - It provides convenient methods for the manipulation of EXIF, IPTC and XMP - metadata embedded in image files such as JPEG and TIFF files, using Python - types. - It also provides access to the thumbnails embedded in an image. - """ - - def __init__(self, filename): - """ - @param filename: absolute path to an image file - @type filename: C{str} or C{unicode} - """ - self.filename = filename - if type(filename) is unicode: - self.filename = filename.encode('utf-8') - self._image = None - self._keys = {'exif': None, 'iptc': None, 'xmp': None} - self._tags = {'exif': {}, 'iptc': {}, 'xmp': {}} - - def _instantiate_image(self, filename): - # This method is meant to be overridden in unit tests to easily replace - # the internal image reference by a mock. - return libexiv2python.Image(filename) - - def read(self): - """ - Read the metadata embedded in the associated image file. - It is necessary to call this method once before attempting to access - the metadata (an exception will be raised if trying to access metadata - before calling this method). - """ - if self._image is None: - self._image = self._instantiate_image(self.filename) - self._image.readMetadata() - - def write(self): - """ - Write the metadata back to the associated image file. - """ - self._image.writeMetadata() - - """List the keys of the available EXIF tags embedded in the image.""" - @property - def exif_keys(self): - if self._keys['exif'] is None: - self._keys['exif'] = self._image.exifKeys() - return self._keys['exif'] - - """List the keys of the available IPTC tags embedded in the image.""" - @property - def iptc_keys(self): - if self._keys['iptc'] is None: - self._keys['iptc'] = self._image.iptcKeys() - return self._keys['iptc'] - - """List the keys of the available XMP tags embedded in the image.""" - @property - def xmp_keys(self): - if self._keys['xmp'] is None: - self._keys['xmp'] = self._image.xmpKeys() - return self._keys['xmp'] - - def _get_exif_tag(self, key): - # Return the EXIF tag for the given key. - # Throw a KeyError if the tag doesn't exist. - try: - return self._tags['exif'][key] - except KeyError: - tag = ExifTag(*self._image.getExifTag(key)) - tag.metadata = self - self._tags['exif'][key] = tag - return tag - - def _get_iptc_tag(self, key): - # Return the IPTC tag for the given key. - # Throw a KeyError if the tag doesn't exist. - try: - return self._tags['iptc'][key] - except KeyError: - tag = IptcTag(*self._image.getIptcTag(key)) - tag.metadata = self - self._tags['iptc'][key] = tag - return tag - - def _get_xmp_tag(self, key): - # Return the XMP tag for the given key. - # Throw a KeyError if the tag doesn't exist. - try: - return self._tags['xmp'][key] - except KeyError: - tag = XmpTag(*self._image.getXmpTag(key)) - tag.metadata = self - self._tags['xmp'][key] = tag - return tag - - def __getitem__(self, key): - """ - Get a metadata tag for a given key. - - @param key: a metadata key in the dotted form C{family.group.tag} where - family may be C{exif}, C{iptc} or C{xmp}. - @type key: C{str} - - @return: the metadata tag corresponding to the key - @rtype: a subclass of L{pyexiv2.MetadataTag} - - @raise KeyError: if the tag doesn't exist - """ - family = key.split('.')[0].lower() - try: - return getattr(self, '_get_%s_tag' % family)(key) - except AttributeError: - raise KeyError(key) - - def _set_exif_tag(self, tag): - # Set an EXIF tag. If the tag already exists, its value is overwritten. - if type(tag) is not ExifTag: - raise TypeError('Expecting an ExifTag') - self._image.setExifTagValue(tag.key, str(tag)) - self._tags['exif'][tag.key] = tag - tag.metadata = self - - def _set_exif_tag_value(self, key, value): - # Overwrite the tag value for an already existing tag. - # The tag is already in cache. - # Warning: this is not meant to be called directly as it doesn't update - # the internal cache (which would leave the object in an inconsistent - # state). - if key not in self.exif_keys: - raise KeyError('Cannot set the value of an inexistent tag') - if type(value) is not str: - raise TypeError('Expecting a string') - self._image.setExifTagValue(key, value) - - def _set_iptc_tag(self, tag): - # Set an IPTC tag. If the tag already exists, its values are - # overwritten. - if type(tag) is not IptcTag: - raise TypeError('Expecting an IptcTag') - self._image.setIptcTagValues(tag.key, tag.to_string()) - self._tags['iptc'][tag.key] = tag - tag.metadata = self - - def _set_iptc_tag_values(self, key, values): - # Overwrite the tag values for an already existing tag. - # The tag is already in cache. - # Warning: this is not meant to be called directly as it doesn't update - # the internal cache (which would leave the object in an inconsistent - # state). - # FIXME: this is sub-optimal as it sets all the values regardless of how - # many of them really changed. Need to implement the same method with an - # index/range parameter (here and in the C++ wrapper). - if key not in self.iptc_keys: - raise KeyError('Cannot set the value of an inexistent tag') - if type(values) is not list or not \ - reduce(lambda x, y: x and type(y) is str, values, True): - raise TypeError('Expecting a list of strings') - self._image.setIptcTagValues(key, values) - - def _set_xmp_tag(self, tag): - # Set an XMP tag. If the tag already exists, its value is overwritten. - if type(tag) is not XmpTag: - raise TypeError('Expecting an XmpTag') - self._image.setXmpTagValue(tag.key, tag.to_string()) - self._tags['xmp'][tag.key] = tag - tag.metadata = self - - def _set_xmp_tag_value(self, key, value): - # Overwrite the tag value for an already existing tag. - # The tag is already in cache. - # Warning: this is not meant to be called directly as it doesn't update - # the internal cache (which would leave the object in an inconsistent - # state). - if key not in self.xmp_keys: - raise KeyError('Cannot set the value of an inexistent tag') - if type(value) is not str: - raise TypeError('Expecting a string') - self._image.setXmpTagValue(key, value) - - def __setitem__(self, key, tag): - """ - Set a metadata tag for a given key. - If the tag was previously set, it is overwritten. - - @param key: a metadata key in the dotted form C{family.group.tag} where - family may be C{exif}, C{iptc} or C{xmp}. - @type key: C{str} - @param tag: a metadata tag - @type tag: a subclass of L{pyexiv2.MetadataTag} - - @raise KeyError: if the key is invalid - """ - family = key.split('.')[0].lower() - try: - return getattr(self, '_set_%s_tag' % family)(tag) - except AttributeError: - raise KeyError(key) - - def _delete_exif_tag(self, key): - # Delete an EXIF tag. - # Throw a KeyError if the tag doesn't exist. - if key not in self.exif_keys: - raise KeyError('Cannot delete an inexistent tag') - self._image.deleteExifTag(key) - try: - del self._tags['exif'][key] - except KeyError: - # The tag was not cached. - pass - - def _delete_iptc_tag(self, key): - # Delete an IPTC tag. - # Throw a KeyError if the tag doesn't exist. - if key not in self.iptc_keys: - raise KeyError('Cannot delete an inexistent tag') - self._image.deleteIptcTag(key) - try: - del self._tags['iptc'][key] - except KeyError: - # The tag was not cached. - pass - - def _delete_xmp_tag(self, key): - # Delete an XMP tag. - # Throw a KeyError if the tag doesn't exist. - if key not in self.xmp_keys: - raise KeyError('Cannot delete an inexistent tag') - self._image.deleteXmpTag(key) - try: - del self._tags['xmp'][key] - except KeyError: - # The tag was not cached. - pass - - def __delitem__(self, key): - """ - Delete a metadata tag for a given key. - - @param key: a metadata key in the dotted form C{family.group.tag} where - family may be C{exif}, C{iptc} or C{xmp}. - @type key: C{str} - - @raise KeyError: if the tag with the given key doesn't exist - """ - family = key.split('.')[0].lower() - try: - return getattr(self, '_delete_%s_tag' % family)(key) - except AttributeError: - raise KeyError(key) - - -if __name__ == '__main__': - import sys - args = sys.argv - - if len(args) != 2: - print 'Usage: %s image_file' % args[0] - sys.exit(-1) - - metadata = ImageMetadata(args[1]) - metadata.read() - - for key in metadata.exif_keys: - tag = metadata[key] - print '%-45s%-11s%s' % (key, tag.type, str(tag)) - - for key in metadata.iptc_keys: - tag = metadata[key] - print '%-45s%-11s%s' % (key, tag.type, str(tag)) - - # TODO: print XMP tags. - diff --git a/src/pyexiv2/__init__.py b/src/pyexiv2/__init__.py new file mode 100644 index 0000000..d4e83cd --- /dev/null +++ b/src/pyexiv2/__init__.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- + +# ****************************************************************************** +# +# Copyright (C) 2006-2009 Olivier Tilloy <olivier@tilloy.net> +# +# This file is part of the pyexiv2 distribution. +# +# pyexiv2 is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# pyexiv2 is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pyexiv2; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA. +# +# Author: Olivier Tilloy <olivier@tilloy.net> +# +# ****************************************************************************** + +""" +Manipulation of EXIF, IPTC and XMP metadata and thumbnails embedded in images. + +The L{ImageMetadata} class provides read/write access to all the metadata and +the various thumbnails embedded in an image file such as JPEG and TIFF files. + +Metadata is accessed through subclasses of L{MetadataTag} and the tag values are +conveniently wrapped in python objects. +A tag containing a date/time information for the image +(e.g. C{Exif.Photo.DateTimeOriginal}) will be represented by a python +C{datetime.datetime} object. + +This module is a python layer on top of the low-level python binding of the +C++ library Exiv2, libexiv2python. + +A typical use of this binding would be: + +>>> import pyexiv2 +>>> metadata = pyexiv2.ImageMetadata('test/smiley.jpg') +>>> metadata.read() +>>> print metadata.exif_keys +['Exif.Image.ImageDescription', 'Exif.Image.XResolution', + 'Exif.Image.YResolution', 'Exif.Image.ResolutionUnit', 'Exif.Image.Software', + 'Exif.Image.DateTime', 'Exif.Image.Artist', 'Exif.Image.Copyright', + 'Exif.Image.ExifTag', 'Exif.Photo.Flash', 'Exif.Photo.PixelXDimension', + 'Exif.Photo.PixelYDimension'] +>>> print metadata['Exif.Image.DateTime'].value +2004-07-13 21:23:44 +>>> import datetime +>>> metadata['Exif.Image.DateTime'].value = datetime.datetime.today() +>>> metadata.write() +""" + +import libexiv2python + +from pyexiv2.metadata import ImageMetadata +from pyexiv2.exif import ExifValueError, ExifTag +from pyexiv2.iptc import IptcValueError, IptcTag +from pyexiv2.xmp import XmpValueError, XmpTag +from pyexiv2.utils import FixedOffset, UndefinedToString, StringToUndefined, \ + Rational, NotifyingList + + +__version__ = (0, 2, 1) + +__exiv2_version__ = libexiv2python.__exiv2_version__ + diff --git a/src/pyexiv2/exif.py b/src/pyexiv2/exif.py new file mode 100644 index 0000000..aaed440 --- /dev/null +++ b/src/pyexiv2/exif.py @@ -0,0 +1,317 @@ +# -*- coding: utf-8 -*- + +# ****************************************************************************** +# +# Copyright (C) 2006-2009 Olivier Tilloy <olivier@tilloy.net> +# +# This file is part of the pyexiv2 distribution. +# +# pyexiv2 is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# pyexiv2 is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pyexiv2; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA. +# +# Author: Olivier Tilloy <olivier@tilloy.net> +# +# ****************************************************************************** + +from pyexiv2.tag import MetadataTag +from pyexiv2.utils import ListenerInterface, NotifyingList, Rational + +import time +import datetime + + +class ExifValueError(ValueError): + + """ + Exception raised when failing to parse the value of an EXIF tag. + + @ivar value: the value that fails to be parsed + @type value: C{str} + @ivar type: the EXIF type of the tag + @type type: C{str} + """ + + def __init__(self, value, type): + self.value = value + self.type = type + + def __str__(self): + return 'Invalid value for EXIF type [%s]: [%s]' % \ + (self.type, self.value) + + +class ExifTag(MetadataTag, ListenerInterface): + + """ + An EXIF metadata tag. + This tag has an additional field that contains the value of the tag + formatted as a human readable string. + + @ivar fvalue: the value of the tag formatted as a human readable string + @type fvalue: C{str} + """ + + # According to the EXIF specification, the only accepted format for an Ascii + # value representing a datetime is '%Y:%m:%d %H:%M:%S', but it seems that + # others formats can be found in the wild. + _datetime_formats = ('%Y:%m:%d %H:%M:%S', + '%Y-%m-%d %H:%M:%S', + '%Y-%m-%dT%H:%M:%SZ') + + _date_formats = ('%Y:%m:%d',) + + def __init__(self, key, name, label, description, type, value, fvalue): + super(ExifTag, self).__init__(key, name, label, + description, type, value) + self.fvalue = fvalue + self._init_values() + + def _init_values(self): + # Initial conversion of the raw values to their corresponding python + # types. + if self.type in ('Short', 'Long', 'SLong', 'Rational', 'SRational'): + # May contain multiple values + values = self.raw_value.split() + if len(values) > 1: + # Make values a notifying list + values = map(self._convert_to_python, values) + self._value = NotifyingList(values) + self._value.register_listener(self) + return + self._value = self._convert_to_python(self.raw_value) + + def _get_value(self): + return self._value + + def _set_value(self, new_value): + if self.metadata is not None: + if isinstance(new_value, (list, tuple)): + raw_values = map(self._convert_to_string, new_value) + raw_value = ' '.join(raw_values) + else: + raw_value = self._convert_to_string(new_value) + self.metadata._set_exif_tag_value(self.key, raw_value) + + if isinstance(self._value, NotifyingList): + self._value.unregister_listener(self) + + if isinstance(new_value, NotifyingList): + # Already a notifying list + self._value = new_value + self._value.register_listener(self) + elif isinstance(new_value, (list, tuple)): + # Make the values a notifying list + self._value = NotifyingList(new_value) + self._value.register_listener(self) + else: + # Single value + self._value = new_value + + def _del_value(self): + if self.metadata is not None: + self.metadata._delete_exif_tag(self.key) + + if isinstance(self._value, NotifyingList): + self._value.unregister_listener(self) + + del self._value + + """the value of the tag converted to its corresponding python type""" + value = property(fget=_get_value, fset=_set_value, fdel=_del_value, + doc=None) + + def contents_changed(self): + """ + Implementation of the L{ListenerInterface}. + React on changes to the list of values of the tag. + """ + # self._value is a list of value and its contents changed. + self._set_value(self._value) + + def _convert_to_python(self, value): + """ + Convert one raw value to its corresponding python type. + + @param value: the raw value to be converted + @type value: C{str} + + @return: the value converted to its corresponding python type + @rtype: depends on C{self.type} (DOCME) + + @raise ExifValueError: if the conversion fails + """ + if self.type == 'Ascii': + # The value may contain a Datetime + for format in self._datetime_formats: + try: + t = time.strptime(value, format) + except ValueError: + continue + else: + return datetime.datetime(*t[:6]) + # Or a Date (e.g. Exif.GPSInfo.GPSDateStamp) + for format in self._date_formats: + try: + t = time.strptime(value, format) + except ValueError: + continue + else: + return datetime.date(*t[:3]) + # Default to string. + # There is currently no charset conversion. + # TODO: guess the encoding and decode accordingly into unicode + # where relevant. + return value + + elif self.type == 'Byte': + return value + + elif self.type == 'Short': + try: + return int(value) + except ValueError: + raise ExifValueError(value, self.type) + + elif self.type in ('Long', 'SLong'): + try: + return long(value) + except ValueError: + raise ExifValueError(value, self.type) + + elif self.type in ('Rational', 'SRational'): + try: + r = Rational.from_string(value) + except (ValueError, ZeroDivisionError): + raise ExifValueError(value, self.type) + else: + if self.type == 'Rational' and r.numerator < 0: + raise ExifValueError(value, self.type) + return r + + elif self.type == 'Undefined': + # There is currently no charset conversion. + # TODO: guess the encoding and decode accordingly into unicode + # where relevant. + return self.fvalue + + raise ExifValueError(value, self.type) + + def _convert_to_string(self, value): + """ + Convert one value to its corresponding string representation, suitable + to pass to libexiv2. + + @param value: the value to be converted + @type value: depends on C{self.type} (DOCME) + + @return: the value converted to its corresponding string representation + @rtype: C{str} + + @raise ExifValueError: if the conversion fails + """ + if self.type == 'Ascii': + if type(value) is datetime.datetime: + return value.strftime(self._datetime_formats[0]) + elif type(value) is datetime.date: + if self.key == 'Exif.GPSInfo.GPSDateStamp': + # Special case + return value.strftime(self._date_formats[0]) + else: + return value.strftime('%s 00:00:00' % self._date_formats[0]) + elif type(value) is unicode: + try: + return value.encode('utf-8') + except UnicodeEncodeError: + raise ExifValueError(value, self.type) + elif type(value) is str: + return value + else: + raise ExifValueError(value, self.type) + + elif self.type == 'Byte': + if type(value) is unicode: + try: + return value.encode('utf-8') + except UnicodeEncodeError: + raise ExifValueError(value, self.type) + elif type(value) is str: + return value + else: + raise ExifValueError(value, self.type) + + elif self.type == 'Short': + if type(value) is int and value >= 0: + return str(value) + else: + raise ExifValueError(value, self.type) + + elif self.type == 'Long': + if type(value) in (int, long) and value >= 0: + return str(value) + else: + raise ExifValueError(value, self.type) + + elif self.type == 'SLong': + if type(value) in (int, long): + return str(value) + else: + raise ExifValueError(value, self.type) + + elif self.type == 'Rational': + if type(value) is Rational and value.numerator >= 0: + return str(value) + else: + raise ExifValueError(value, self.type) + + elif self.type == 'SRational': + if type(value) is Rational: + return str(value) + else: + raise ExifValueError(value, self.type) + + elif self.type == 'Undefined': + if type(value) is unicode: + try: + return value.encode('utf-8') + except UnicodeEncodeError: + raise ExifValueError(value, self.type) + elif type(value) is str: + return value + else: + raise ExifValueError(value, self.type) + + raise ExifValueError(value, self.type) + + def __str__(self): + """ + Return a string representation of the value of the EXIF tag suitable to + pass to libexiv2 to set it. + + @rtype: C{str} + """ + return self._convert_to_string(self.value) + + def __repr__(self): + """ + Return a string representation of the EXIF tag for debugging purposes. + + @rtype: C{str} + """ + left = '%s [%s]' % (self.key, self.type) + if self.type == 'Undefined' and len(self._value) > 100: + right = '(Binary value suppressed)' + else: + right = self.fvalue + return '<%s = %s>' % (left, right) + diff --git a/src/pyexiv2/iptc.py b/src/pyexiv2/iptc.py new file mode 100644 index 0000000..4ba6b21 --- /dev/null +++ b/src/pyexiv2/iptc.py @@ -0,0 +1,254 @@ +# -*- coding: utf-8 -*- + +# ****************************************************************************** +# +# Copyright (C) 2006-2009 Olivier Tilloy <olivier@tilloy.net> +# +# This file is part of the pyexiv2 distribution. +# +# pyexiv2 is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# pyexiv2 is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pyexiv2; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA. +# +# Author: Olivier Tilloy <olivier@tilloy.net> +# +# ****************************************************************************** + +from pyexiv2.tag import MetadataTag +from pyexiv2.utils import ListenerInterface, NotifyingList, FixedOffset + +import time +import datetime +import re + + +class IptcValueError(ValueError): + + """ + Exception raised when failing to parse the value of an IPTC tag. + + @ivar value: the value that fails to be parsed + @type value: C{str} + @ivar type: the IPTC type of the tag + @type type: C{str} + """ + + def __init__(self, value, type): + self.value = value + self.type = type + + def __str__(self): + return 'Invalid value for IPTC type [%s]: [%s]' % \ + (self.type, self.value) + + +class IptcTag(MetadataTag, ListenerInterface): + + """ + An IPTC metadata tag. + This tag can have several values (tags that have the repeatable property). + """ + + # strptime is not flexible enough to handle all valid Time formats, we use a + # custom regular expression + _time_zone_re = r'(?P<sign>\+|-)(?P<ohours>\d{2}):(?P<ominutes>\d{2})' + _time_re = re.compile(r'(?P<hours>\d{2}):(?P<minutes>\d{2}):(?P<seconds>\d{2})(?P<tzd>%s)' % _time_zone_re) + + def __init__(self, key, name, label, description, type, values): + super(IptcTag, self).__init__(key, name, label, + description, type, values) + self._init_values() + + def _init_values(self): + # Initial conversion of the raw values to their corresponding python + # types. + values = map(self._convert_to_python, self.raw_value) + # Make values a notifying list + self._values = NotifyingList(values) + self._values.register_listener(self) + + def _get_values(self): + return self._values + + def _set_values(self, new_values): + if self.metadata is not None: + raw_values = map(self._convert_to_string, new_values) + self.metadata._set_iptc_tag_values(self.key, raw_values) + # Make values a notifying list if needed + if isinstance(new_values, NotifyingList): + self._values = new_values + else: + self._values = NotifyingList(new_values) + + def _del_values(self): + if self.metadata is not None: + self.metadata._delete_iptc_tag(self.key) + del self._values + + """the list of values of the tag converted to their corresponding python + type""" + values = property(fget=_get_values, fset=_set_values, fdel=_del_values, + doc=None) + + def contents_changed(self): + """ + Implementation of the L{ListenerInterface}. + React on changes to the list of values of the tag. + """ + # The contents of self._values was changed. + # The following is a quick, non optimal solution. + self._set_values(self._values) + + def _convert_to_python(self, value): + """ + Convert one raw value to its corresponding python type. + + @param value: the raw value to be converted + @type value: C{str} + + @return: the value converted to its corresponding python type + @rtype: depends on C{self.type} (DOCME) + + @raise IptcValueError: if the conversion fails + """ + if self.type == 'Short': + try: + return int(value) + except ValueError: + raise IptcValueError(value, self.type) + + elif self.type == 'String': + # There is currently no charset conversion. + # TODO: guess the encoding and decode accordingly into unicode + # where relevant. + return value + + elif self.type == 'Date': + # According to the IPTC specification, the format for a string field + # representing a date is '%Y%m%d'. However, the string returned by + # exiv2 using method DateValue::toString() is formatted using + # pattern '%Y-%m-%d'. + format = '%Y-%m-%d' + try: + t = time.strptime(value, format) + return datetime.date(*t[:3]) + except ValueError: + raise IptcValueError(value, self.type) + + elif self.type == 'Time': + # According to the IPTC specification, the format for a string field + # representing a time is '%H%M%S±%H%M'. However, the string returned + # by exiv2 using method TimeValue::toString() is formatted using + # pattern '%H:%M:%S±%H:%M'. + match = IptcTag._time_re.match(value) + if match is None: + raise IptcValueError(value, self.type) + gd = match.groupdict() + try: + tzinfo = FixedOffset(gd['sign'], int(gd['ohours']), + int(gd['ominutes'])) + except TypeError: + raise IptcValueError(value, self.type) + try: + return datetime.time(int(gd['hours']), int(gd['minutes']), + int(gd['seconds']), tzinfo=tzinfo) + except (TypeError, ValueError): + raise IptcValueError(value, self.type) + + elif self.type == 'Undefined': + # Binary data, return it unmodified + return value + + raise IptcValueError(value, self.type) + + def _convert_to_string(self, value): + """ + Convert one value to its corresponding string representation, suitable + to pass to libexiv2. + + @param value: the value to be converted + @type value: depends on C{self.type} (DOCME) + + @return: the value converted to its corresponding string representation + @rtype: C{str} + + @raise IptcValueError: if the conversion fails + """ + if self.type == 'Short': + if type(value) is int: + return str(value) + else: + raise IptcValueError(value, self.type) + + elif self.type == 'String': + if type(value) is unicode: + try: + return value.encode('utf-8') + except UnicodeEncodeError: + raise IptcValueError(value, self.type) + elif type(value) is str: + return value + else: + raise IptcValueError(value, self.type) + + elif self.type == 'Date': + if type(value) in (datetime.date, datetime.datetime): + # ISO 8601 date format + return value.strftime('%Y%m%d') + else: + raise IptcValueError(value, self.type) + + elif self.type == 'Time': + if type(value) in (datetime.time, datetime.datetime): + r = value.strftime('%H%M%S') + if value.tzinfo is not None: + r += value.strftime('%z') + else: + r += '+0000' + return r + else: + raise IptcValueError(value, self.type) + + elif self.type == 'Undefined': + if type(value) is str: + return value + else: + raise IptcValueError(value, self.type) + + raise IptcValueError(value, self.type) + + def to_string(self): + """ + Return a list of string representations of the values of the IPTC tag + suitable to pass to libexiv2 to set it. + + @rtype: C{list} of C{str} + """ + return map(self._convert_to_string, self.values) + + def __str__(self): + """ + Return a string representation of the list of values of the IPTC tag. + + @rtype: C{str} + """ + return ', '.join(self.to_string()) + + def __repr__(self): + """ + Return a string representation of the IPTC tag for debugging purposes. + + @rtype: C{str} + """ + return '<%s [%s] = %s>' % (self.key, self.type, str(self)) + diff --git a/src/pyexiv2/main.py b/src/pyexiv2/main.py new file mode 100755 index 0000000..e785e53 --- /dev/null +++ b/src/pyexiv2/main.py @@ -0,0 +1,53 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# ****************************************************************************** +# +# Copyright (C) 2006-2009 Olivier Tilloy <olivier@tilloy.net> +# +# This file is part of the pyexiv2 distribution. +# +# pyexiv2 is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# pyexiv2 is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pyexiv2; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA. +# +# Author: Olivier Tilloy <olivier@tilloy.net> +# +# ****************************************************************************** + +import sys + +from pyexiv2.metadata import ImageMetadata + + +if __name__ == '__main__': + import sys + args = sys.argv + + if len(args) != 2: + print 'Usage: %s image_file' % args[0] + sys.exit(-1) + + metadata = ImageMetadata(args[1]) + metadata.read() + + for key in metadata.exif_keys: + tag = metadata[key] + print '%-45s%-11s%s' % (key, tag.type, str(tag)) + + for key in metadata.iptc_keys: + tag = metadata[key] + print '%-45s%-11s%s' % (key, tag.type, str(tag)) + + # TODO: print XMP tags. + diff --git a/src/pyexiv2/metadata.py b/src/pyexiv2/metadata.py new file mode 100644 index 0000000..fce964b --- /dev/null +++ b/src/pyexiv2/metadata.py @@ -0,0 +1,287 @@ +# -*- coding: utf-8 -*- + +# ****************************************************************************** +# +# Copyright (C) 2006-2009 Olivier Tilloy <olivier@tilloy.net> +# +# This file is part of the pyexiv2 distribution. +# +# pyexiv2 is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# pyexiv2 is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pyexiv2; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA. +# +# Author: Olivier Tilloy <olivier@tilloy.net> +# +# ****************************************************************************** + +import libexiv2python + +from pyexiv2.exif import ExifTag +from pyexiv2.iptc import IptcTag +from pyexiv2.xmp import XmpTag + + +class ImageMetadata(object): + + """ + A container for all the metadata attached to an image. + + It provides convenient methods for the manipulation of EXIF, IPTC and XMP + metadata embedded in image files such as JPEG and TIFF files, using Python + types. + It also provides access to the thumbnails embedded in an image. + """ + + def __init__(self, filename): + """ + @param filename: absolute path to an image file + @type filename: C{str} or C{unicode} + """ + self.filename = filename + if type(filename) is unicode: + self.filename = filename.encode('utf-8') + self._image = None + self._keys = {'exif': None, 'iptc': None, 'xmp': None} + self._tags = {'exif': {}, 'iptc': {}, 'xmp': {}} + + def _instantiate_image(self, filename): + # This method is meant to be overridden in unit tests to easily replace + # the internal image reference by a mock. + return libexiv2python.Image(filename) + + def read(self): + """ + Read the metadata embedded in the associated image file. + It is necessary to call this method once before attempting to access + the metadata (an exception will be raised if trying to access metadata + before calling this method). + """ + if self._image is None: + self._image = self._instantiate_image(self.filename) + self._image.readMetadata() + + def write(self): + """ + Write the metadata back to the associated image file. + """ + self._image.writeMetadata() + + """List the keys of the available EXIF tags embedded in the image.""" + @property + def exif_keys(self): + if self._keys['exif'] is None: + self._keys['exif'] = self._image.exifKeys() + return self._keys['exif'] + + """List the keys of the available IPTC tags embedded in the image.""" + @property + def iptc_keys(self): + if self._keys['iptc'] is None: + self._keys['iptc'] = self._image.iptcKeys() + return self._keys['iptc'] + + """List the keys of the available XMP tags embedded in the image.""" + @property + def xmp_keys(self): + if self._keys['xmp'] is None: + self._keys['xmp'] = self._image.xmpKeys() + return self._keys['xmp'] + + def _get_exif_tag(self, key): + # Return the EXIF tag for the given key. + # Throw a KeyError if the tag doesn't exist. + try: + return self._tags['exif'][key] + except KeyError: + tag = ExifTag(*self._image.getExifTag(key)) + tag.metadata = self + self._tags['exif'][key] = tag + return tag + + def _get_iptc_tag(self, key): + # Return the IPTC tag for the given key. + # Throw a KeyError if the tag doesn't exist. + try: + return self._tags['iptc'][key] + except KeyError: + tag = IptcTag(*self._image.getIptcTag(key)) + tag.metadata = self + self._tags['iptc'][key] = tag + return tag + + def _get_xmp_tag(self, key): + # Return the XMP tag for the given key. + # Throw a KeyError if the tag doesn't exist. + try: + return self._tags['xmp'][key] + except KeyError: + tag = XmpTag(*self._image.getXmpTag(key)) + tag.metadata = self + self._tags['xmp'][key] = tag + return tag + + def __getitem__(self, key): + """ + Get a metadata tag for a given key. + + @param key: a metadata key in the dotted form C{family.group.tag} where + family may be C{exif}, C{iptc} or C{xmp}. + @type key: C{str} + + @return: the metadata tag corresponding to the key + @rtype: a subclass of L{pyexiv2.MetadataTag} + + @raise KeyError: if the tag doesn't exist + """ + family = key.split('.')[0].lower() + try: + return getattr(self, '_get_%s_tag' % family)(key) + except AttributeError: + raise KeyError(key) + + def _set_exif_tag(self, tag): + # Set an EXIF tag. If the tag already exists, its value is overwritten. + if type(tag) is not ExifTag: + raise TypeError('Expecting an ExifTag') + self._image.setExifTagValue(tag.key, str(tag)) + self._tags['exif'][tag.key] = tag + tag.metadata = self + + def _set_exif_tag_value(self, key, value): + # Overwrite the tag value for an already existing tag. + # The tag is already in cache. + # Warning: this is not meant to be called directly as it doesn't update + # the internal cache (which would leave the object in an inconsistent + # state). + if key not in self.exif_keys: + raise KeyError('Cannot set the value of an inexistent tag') + if type(value) is not str: + raise TypeError('Expecting a string') + self._image.setExifTagValue(key, value) + + def _set_iptc_tag(self, tag): + # Set an IPTC tag. If the tag already exists, its values are + # overwritten. + if type(tag) is not IptcTag: + raise TypeError('Expecting an IptcTag') + self._image.setIptcTagValues(tag.key, tag.to_string()) + self._tags['iptc'][tag.key] = tag + tag.metadata = self + + def _set_iptc_tag_values(self, key, values): + # Overwrite the tag values for an already existing tag. + # The tag is already in cache. + # Warning: this is not meant to be called directly as it doesn't update + # the internal cache (which would leave the object in an inconsistent + # state). + # FIXME: this is sub-optimal as it sets all the values regardless of how + # many of them really changed. Need to implement the same method with an + # index/range parameter (here and in the C++ wrapper). + if key not in self.iptc_keys: + raise KeyError('Cannot set the value of an inexistent tag') + if type(values) is not list or not \ + reduce(lambda x, y: x and type(y) is str, values, True): + raise TypeError('Expecting a list of strings') + self._image.setIptcTagValues(key, values) + + def _set_xmp_tag(self, tag): + # Set an XMP tag. If the tag already exists, its value is overwritten. + if type(tag) is not XmpTag: + raise TypeError('Expecting an XmpTag') + self._image.setXmpTagValue(tag.key, tag.to_string()) + self._tags['xmp'][tag.key] = tag + tag.metadata = self + + def _set_xmp_tag_value(self, key, value): + # Overwrite the tag value for an already existing tag. + # The tag is already in cache. + # Warning: this is not meant to be called directly as it doesn't update + # the internal cache (which would leave the object in an inconsistent + # state). + if key not in self.xmp_keys: + raise KeyError('Cannot set the value of an inexistent tag') + if type(value) is not str: + raise TypeError('Expecting a string') + self._image.setXmpTagValue(key, value) + + def __setitem__(self, key, tag): + """ + Set a metadata tag for a given key. + If the tag was previously set, it is overwritten. + + @param key: a metadata key in the dotted form C{family.group.tag} where + family may be C{exif}, C{iptc} or C{xmp}. + @type key: C{str} + @param tag: a metadata tag + @type tag: a subclass of L{pyexiv2.MetadataTag} + + @raise KeyError: if the key is invalid + """ + family = key.split('.')[0].lower() + try: + return getattr(self, '_set_%s_tag' % family)(tag) + except AttributeError: + raise KeyError(key) + + def _delete_exif_tag(self, key): + # Delete an EXIF tag. + # Throw a KeyError if the tag doesn't exist. + if key not in self.exif_keys: + raise KeyError('Cannot delete an inexistent tag') + self._image.deleteExifTag(key) + try: + del self._tags['exif'][key] + except KeyError: + # The tag was not cached. + pass + + def _delete_iptc_tag(self, key): + # Delete an IPTC tag. + # Throw a KeyError if the tag doesn't exist. + if key not in self.iptc_keys: + raise KeyError('Cannot delete an inexistent tag') + self._image.deleteIptcTag(key) + try: + del self._tags['iptc'][key] + except KeyError: + # The tag was not cached. + pass + + def _delete_xmp_tag(self, key): + # Delete an XMP tag. + # Throw a KeyError if the tag doesn't exist. + if key not in self.xmp_keys: + raise KeyError('Cannot delete an inexistent tag') + self._image.deleteXmpTag(key) + try: + del self._tags['xmp'][key] + except KeyError: + # The tag was not cached. + pass + + def __delitem__(self, key): + """ + Delete a metadata tag for a given key. + + @param key: a metadata key in the dotted form C{family.group.tag} where + family may be C{exif}, C{iptc} or C{xmp}. + @type key: C{str} + + @raise KeyError: if the tag with the given key doesn't exist + """ + family = key.split('.')[0].lower() + try: + return getattr(self, '_delete_%s_tag' % family)(key) + except AttributeError: + raise KeyError(key) + diff --git a/src/pyexiv2/tag.py b/src/pyexiv2/tag.py new file mode 100644 index 0000000..6776a71 --- /dev/null +++ b/src/pyexiv2/tag.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- + +# ****************************************************************************** +# +# Copyright (C) 2006-2009 Olivier Tilloy <olivier@tilloy.net> +# +# This file is part of the pyexiv2 distribution. +# +# pyexiv2 is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# pyexiv2 is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pyexiv2; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA. +# +# Author: Olivier Tilloy <olivier@tilloy.net> +# +# ****************************************************************************** + + +class MetadataTag(object): + + """ + A generic metadata tag. + It is meant to be subclassed to implement specific tag types behaviours. + + @ivar key: a unique key that identifies the tag + @type key: C{str} + @ivar name: the short internal name that identifies the tag within + its scope + @type name: C{str} + @ivar label: a human readable label for the tag + @type label: C{str} + @ivar description: a description of the function of the tag + @type description: C{str} + @ivar type: the data type name + @type type: C{str} + @ivar raw_value: the raw value of the tag as provided by exiv2 + @type raw_value: C{str} + @ivar metadata: reference to the containing metadata if any + @type metadata: L{pyexiv2.ImageMetadata} + """ + + def __init__(self, key, name, label, description, type, value): + self.key = key + self.name = name + # FIXME: all attributes that may contain a localized string should be + # unicode. + self.label = label + self.description = description + self.type = type + self.raw_value = value + self.metadata = None + + def __str__(self): + """ + Return a string representation of the value of the tag suitable to pass + to libexiv2 to set it. + + @rtype: C{str} + """ + return self.raw_value + + def __repr__(self): + """ + Return a string representation of the tag for debugging purposes. + + @rtype: C{str} + """ + return '<%s [%s] = %s>' % (self.key, self.type, self.raw_value) diff --git a/src/pyexiv2/utils.py b/src/pyexiv2/utils.py new file mode 100644 index 0000000..e87d1e4 --- /dev/null +++ b/src/pyexiv2/utils.py @@ -0,0 +1,361 @@ +# -*- coding: utf-8 -*- + +# ****************************************************************************** +# +# Copyright (C) 2006-2009 Olivier Tilloy <olivier@tilloy.net> +# +# This file is part of the pyexiv2 distribution. +# +# pyexiv2 is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# pyexiv2 is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pyexiv2; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA. +# +# Author: Olivier Tilloy <olivier@tilloy.net> +# +# ****************************************************************************** + +""" +Utilitary classes and functions. +""" + +import datetime +import re + + +class FixedOffset(datetime.tzinfo): + + """ + Fixed positive or negative offset from a local time east from UTC. + + @ivar sign: the sign of the offset ('+' or '-') + @type sign: C{str} + @ivar hours: the absolute number of hours of the offset + @type hours: C{int} + @ivar minutes: the absolute number of minutes of the offset + @type minutes: C{int} + + """ + + def __init__(self, sign='+', hours=0, minutes=0): + """ + Initialize an offset from a sign ('+' or '-') and an absolute value + expressed in hours and minutes. + No check on the validity of those values is performed, it is the + responsibility of the caller to pass valid values. + + @param sign: the sign of the offset ('+' or '-') + @type sign: C{str} + @param hours: an absolute number of hours + @type hours: C{int} + @param minutes: an absolute number of minutes + @type minutes: C{int} + """ + self.sign = sign + self.hours = hours + self.minutes = minutes + + def utcoffset(self, dt): + """ + Return offset of local time from UTC, in minutes east of UTC. + If local time is west of UTC, this value will be negative. + + @param dt: the local time + @type dt: C{datetime.time} + + @return: a whole number of minutes in the range -1439 to 1439 inclusive + @rtype: C{datetime.timedelta} + """ + total = self.hours * 60 + self.minutes + if self.sign == '-': + total = -total + return datetime.timedelta(minutes = total) + + def dst(self, dt): + """ + Return the daylight saving time (DST) adjustment. + In this implementation, it is always nil. + + @param dt: the local time + @type dt: C{datetime.time} + + @return: the DST adjustment (always nil) + @rtype: C{datetime.timedelta} + """ + return datetime.timedelta(0) + + def tzname(self, dt): + """ + Return a string representation of the offset in the format '±%H:%M'. + If the offset is nil, the representation is, by convention, 'Z'. + + @param dt: the local time + @type dt: C{datetime.time} + + @return: a human-readable representation of the offset + @rtype: C{str} + """ + if self.hours == 0 and self.minutes == 0: + return 'Z' + else: + return '%s%02d:%02d' % (self.sign, self.hours, self.minutes) + + def __equal__(self, other): + """ + Test equality between this offset and another offset. + + @param other: another offset + @type other: L{FixedOffset} + + @return: C{True} if the offset are equal, C{False} otherwise + @rtype: C{bool} + """ + return (self.sign == other.sign) and (self.hours == other.hours) and \ + (self.minutes == other.minutes) + + +def UndefinedToString(undefined): + """ + Convert an undefined string into its corresponding sequence of bytes. + The undefined string must contain the ascii codes of a sequence of bytes, + each followed by a blank space (e.g. "48 50 50 49 " will be converted into + "0221"). + The Undefined type is part of the EXIF specification. + + @param undefined: an undefined string + @type undefined: C{str} + + @return: the corresponding decoded string + @rtype: C{str} + """ + return ''.join(map(lambda x: chr(int(x)), undefined.rstrip().split(' '))) + + +def StringToUndefined(sequence): + """ + Convert a string into its undefined form. + The undefined form contains a sequence of ascii codes, each followed by a + blank space (e.g. "0221" will be converted into "48 50 50 49 "). + The Undefined type is part of the EXIF specification. + + @param sequence: a sequence of bytes + @type sequence: C{str} + + @return: the corresponding undefined string + @rtype: C{str} + """ + return ''.join(map(lambda x: '%d ' % ord(x), sequence)) + + +class Rational(object): + + """ + A class representing a rational number. + + Its numerator and denominator are read-only properties. + """ + + _format_re = re.compile(r'(?P<numerator>-?\d+)/(?P<denominator>\d+)') + + def __init__(self, numerator, denominator): + """ + Constructor. + + @param numerator: the numerator + @type numerator: C{long} + @param denominator: the denominator + @type denominator: C{long} + + @raise ZeroDivisionError: if the denominator equals zero + """ + if denominator == 0: + msg = 'Denominator of a rational number cannot be zero.' + raise ZeroDivisionError(msg) + self._numerator = long(numerator) + self._denominator = long(denominator) + + @property + def numerator(self): + return self._numerator + + @property + def denominator(self): + return self._denominator + + @staticmethod + def from_string(string): + """ + Instantiate a Rational from a string formatted as + C{[-]numerator/denominator}. + + @param string: a string representation of a rational number + @type string: C{str} + + @return: the rational number parsed + @rtype: L{Rational} + + @raise ValueError: if the format of the string is invalid + """ + match = Rational._format_re.match(string) + if match is None: + raise ValueError('Invalid format for a rational: %s' % string) + gd = match.groupdict() + return Rational(long(gd['numerator']), long(gd['denominator'])) + + def to_float(self): + """ + @return: a floating point number approximation of the value + @rtype: C{float} + """ + return float(self._numerator) / self._denominator + + def __eq__(self, other): + """ + Compare two rational numbers for equality. + + Two rational numbers are equal if their reduced forms are equal. + + @param other: the rational number to compare to self for equality + @type other: L{Rational} + + @return: C{True} if equal, C{False} otherwise + @rtype: C{bool} + """ + return (self._numerator * other._denominator) == \ + (other._numerator * self._denominator) + + def __str__(self): + """ + Return a string representation of the rational number. + """ + return '%d/%d' % (self._numerator, self._denominator) + + +class ListenerInterface(object): + + """ + Interface that an object that wants to listen to changes on another object + should implement. + """ + + def contents_changed(self): + """ + React on changes on the object observed. + Override to implement specific behaviours. + """ + raise NotImplementedError() + + +class NotifyingList(list): + + """ + A simplistic implementation of a notifying list. + Any changes to the list are notified in a synchronous way to all previously + registered listeners. A listener must implement the L{ListenerInterface}. + """ + + # Useful documentation: + # file:///usr/share/doc/python2.5/html/lib/typesseq-mutable.html + # http://docs.python.org/reference/datamodel.html#additional-methods-for-emulation-of-sequence-types + + def __init__(self, items=[]): + super(NotifyingList, self).__init__(items) + self._listeners = set() + + def register_listener(self, listener): + """ + Register a new listener to be notified of changes. + + @param listener: any object that listens for changes + @type listener: any class that implements the L{ListenerInterface} + """ + self._listeners.add(listener) + + def unregister_listener(self, listener): + """ + Unregister a previously registered listener. + + @param listener: a previously registered listener + @type listener: any class that implements the L{ListenerInterface} + + @raise KeyError: if the listener was not previously registered + """ + self._listeners.remove(listener) + + def _notify_listeners(self, *args): + for listener in self._listeners: + listener.contents_changed(*args) + + def __setitem__(self, index, item): + # FIXME: support slice arguments for extended slicing + super(NotifyingList, self).__setitem__(index, item) + self._notify_listeners() + + def __delitem__(self, index): + # FIXME: support slice arguments for extended slicing + super(NotifyingList, self).__delitem__(index) + self._notify_listeners() + + def append(self, item): + super(NotifyingList, self).append(item) + self._notify_listeners() + + def extend(self, items): + super(NotifyingList, self).extend(items) + self._notify_listeners() + + def insert(self, index, item): + super(NotifyingList, self).insert(index, item) + self._notify_listeners() + + def pop(self, index=None): + if index is None: + item = super(NotifyingList, self).pop() + else: + item = super(NotifyingList, self).pop(index) + self._notify_listeners() + return item + + def remove(self, item): + super(NotifyingList, self).remove(item) + self._notify_listeners() + + def reverse(self): + super(NotifyingList, self).reverse() + self._notify_listeners() + + def sort(self, cmp=None, key=None, reverse=False): + super(NotifyingList, self).sort(cmp, key, reverse) + self._notify_listeners() + + def __iadd__(self, other): + self = super(NotifyingList, self).__iadd__(other) + self._notify_listeners() + return self + + def __imul__(self, coefficient): + self = super(NotifyingList, self).__imul__(coefficient) + self._notify_listeners() + return self + + def __setslice__(self, i, j, items): + # __setslice__ is deprecated but needs to be overridden for completeness + super(NotifyingList, self).__setslice__(i, j, items) + self._notify_listeners() + + def __delslice__(self, i, j): + # __delslice__ is deprecated but needs to be overridden for completeness + deleted = self[i:j] + super(NotifyingList, self).__delslice__(i, j) + if deleted: + self._notify_listeners() + diff --git a/src/pyexiv2/xmp.py b/src/pyexiv2/xmp.py new file mode 100644 index 0000000..4e8cb1b --- /dev/null +++ b/src/pyexiv2/xmp.py @@ -0,0 +1,371 @@ +# -*- coding: utf-8 -*- + +# ****************************************************************************** +# +# Copyright (C) 2006-2009 Olivier Tilloy <olivier@tilloy.net> +# +# This file is part of the pyexiv2 distribution. +# +# pyexiv2 is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# pyexiv2 is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with pyexiv2; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, 5th Floor, Boston, MA 02110-1301 USA. +# +# Author: Olivier Tilloy <olivier@tilloy.net> +# +# ****************************************************************************** + +from pyexiv2.tag import MetadataTag +from pyexiv2.utils import ListenerInterface, NotifyingList, FixedOffset + +import datetime +import re + + +class XmpValueError(ValueError): + + """ + Exception raised when failing to parse the value of an XMP tag. + + @ivar value: the value that fails to be parsed + @type value: C{str} + @ivar type: the XMP type of the tag + @type type: C{str} + """ + def __init__(self, value, type): + self.value = value + self.type = type + + def __str__(self): + return 'Invalid value for XMP type [%s]: [%s]' % \ + (self.type, self.value) + + +class XmpTag(MetadataTag): + + """ + An XMP metadata tag. + """ + + # strptime is not flexible enough to handle all valid Date formats, we use a + # custom regular expression + _time_zone_re = r'Z|((?P<sign>\+|-)(?P<ohours>\d{2}):(?P<ominutes>\d{2}))' + _time_re = r'(?P<hours>\d{2})(:(?P<minutes>\d{2})(:(?P<seconds>\d{2})(.(?P<decimal>\d+))?)?(?P<tzd>%s))?' % _time_zone_re + _date_re = re.compile(r'(?P<year>\d{4})(-(?P<month>\d{2})(-(?P<day>\d{2})(T(?P<time>%s))?)?)?' % _time_re) + + def __init__(self, key, name, label, description, type, value): + super(XmpTag, self).__init__(key, name, label, description, type, value) + self._value = XmpTag._convert_to_python(value, type) + + def _get_value(self): + return self._value + + def _set_value(self, new_value): + if self.metadata is not None: + raw_value = XmpTag._convert_to_string(new_value, self.type) + self.metadata._set_xmp_tag_value(self.key, raw_value) + self._value = new_value + + def _del_value(self): + if self.metadata is not None: + self.metadata._delete_xmp_tag(self.key) + del self._value + + """the value of the tag converted to its corresponding python type""" + value = property(fget=_get_value, fset=_set_value, fdel=_del_value, + doc=None) + + @staticmethod + def _convert_to_python(value, xtype): + """ + Convert a raw value to its corresponding python type. + + @param value: the raw value to be converted + @type value: C{str} + @param xtype: the XMP type of the value + @type xtype: C{str} + + @return: the value converted to its corresponding python type + @rtype: depends on xtype (DOCME) + + @raise XmpValueError: if the conversion fails + """ + if xtype.startswith('bag '): + # FIXME: make the value a notifying list. + if value == '': + return [] + values = value.split(', ') + return map(lambda x: XmpTag._convert_to_python(x, xtype[4:]), values) + + elif xtype == 'Boolean': + if value == 'True': + return True + elif value == 'False': + return False + else: + raise XmpValueError(value, xtype) + + elif xtype == 'Choice': + # TODO + raise NotImplementedError('XMP conversion for type [%s]' % xtype) + + elif xtype == 'Colorant': + # TODO + raise NotImplementedError('XMP conversion for type [%s]' % xtype) + + elif xtype == 'Date': + match = XmpTag._date_re.match(value) + if match is None: + raise XmpValueError(value, xtype) + gd = match.groupdict() + if gd['month'] is not None: + month = int(gd['month']) + else: + month = 1 + if gd['day'] is not None: + day = int(gd['day']) + else: + day = 1 + if gd['time'] is None: + try: + return datetime.date(int(gd['year']), month, day) + except ValueError: + raise XmpValueError(value, xtype) + else: + if gd['minutes'] is None: + # Malformed time + raise XmpValueError(value, xtype) + if gd['seconds'] is not None: + seconds = int(gd['seconds']) + else: + seconds = 0 + if gd['decimal'] is not None: + microseconds = int(float('0.%s' % gd['decimal']) * 1E6) + else: + microseconds = 0 + if gd['tzd'] == 'Z': + tzinfo = FixedOffset() + else: + tzinfo = FixedOffset(gd['sign'], int(gd['ohours']), + int(gd['ominutes'])) + try: + return datetime.datetime(int(gd['year']), month, day, + int(gd['hours']), int(gd['minutes']), + seconds, microseconds, tzinfo) + except ValueError: + raise XmpValueError(value, xtype) + + elif xtype == 'Dimensions': + # TODO + raise NotImplementedError('XMP conversion for type [%s]' % xtype) + + elif xtype == 'Font': + # TODO + raise NotImplementedError('XMP conversion for type [%s]' % xtype) + + elif xtype == 'Integer': + try: + return int(value) + except ValueError: + raise XmpValueError(value, xtype) + + elif xtype == 'Lang Alt': + matches = value.split('lang="') + nb = len(matches) + if nb < 2 or matches[0] != '': + raise XmpValueError(value, xtype) + result = {} + for i, match in enumerate(matches[1:]): + try: + qualifier, text = match.split('" ', 1) + except ValueError: + raise XmpValueError(value, xtype) + else: + if not text.rstrip().endswith(','): + if (i < nb - 2): + # If not the last match, it should end with a comma + raise XmpValueError(value, xtype) + else: + result[qualifier] = text + try: + result[qualifier] = unicode(text, 'utf-8') + except TypeError: + raise XmpValueError(value, xtype) + else: + try: + result[qualifier] = unicode(text.rstrip()[:-1], 'utf-8') + except TypeError: + raise XmpValueError(value, xtype) + return result + + elif xtype == 'Locale': + # TODO + # See RFC 3066 + raise NotImplementedError('XMP conversion for type [%s]' % xtype) + + elif xtype == 'MIMEType': + try: + mtype, msubtype = value.split('/', 1) + except ValueError: + raise XmpValueError(value, xtype) + else: + return {'type': mtype, 'subtype': msubtype} + + elif xtype == 'Real': + # TODO + raise NotImplementedError('XMP conversion for type [%s]' % xtype) + + elif xtype in ('ProperName', 'Text'): + try: + return unicode(value, 'utf-8') + except TypeError: + raise XmpValueError(value, xtype) + + elif xtype == 'Thumbnail': + # TODO + raise NotImplementedError('XMP conversion for type [%s]' % xtype) + + elif xtype in ('URI', 'URL'): + return value + + elif xtype == 'XPath': + # TODO + raise NotImplementedError('XMP conversion for type [%s]' % xtype) + + raise NotImplementedError('XMP conversion for type [%s]' % xtype) + + @staticmethod + def _convert_to_string(value, xtype): + """ + Convert a value to its corresponding string representation, suitable to + pass to libexiv2. + + @param value: the value to be converted + @type value: depends on xtype (DOCME) + @param xtype: the XMP type of the value + @type xtype: C{str} + + @return: the value converted to its corresponding string representation + @rtype: C{str} + + @raise XmpValueError: if the conversion fails + """ + if xtype.startswith('bag '): + if type(value) in (list, tuple): + return ', '.join(map(lambda x: XmpTag._convert_to_string(x, xtype[4:]), value)) + else: + raise XmpValueError(value, xtype) + + elif xtype == 'Boolean': + if type(value) is bool: + return str(value) + else: + raise XmpValueError(value, xtype) + + elif xtype == 'Date': + if type(value) is datetime.date: + return value.isoformat() + elif type(value) is datetime.datetime: + if value.hour == 0 and value.minute == 0 and \ + value.second == 0 and value.microsecond == 0 and \ + (value.tzinfo is None or value.tzinfo == FixedOffset()): + return value.strftime('%Y-%m-%d') + elif value.second == 0 and value.microsecond == 0: + return value.strftime('%Y-%m-%dT%H:%M%Z') + elif value.microsecond == 0: + return value.strftime('%Y-%m-%dT%H:%M:%S%Z') + else: + r = value.strftime('%Y-%m-%dT%H:%M:%S.') + r += str(int(value.microsecond) / 1E6)[2:] + r += value.strftime('%Z') + return r + else: + raise XmpValueError(value, xtype) + + elif xtype == 'Integer': + if type(value) in (int, long): + return str(value) + else: + raise XmpValueError(value, xtype) + + elif xtype == 'Lang Alt': + if type(value) is dict and len(value) > 0: + r = '' + for key, avalue in value.iteritems(): + if type(key) is unicode: + try: + rkey = key.encode('utf-8') + except UnicodeEncodeError: + raise XmpValueError(value, xtype) + elif type(key) is str: + rkey = key + else: + raise XmpValueError(value, xtype) + if type(avalue) is unicode: + try: + ravalue = avalue.encode('utf-8') + except UnicodeEncodeError: + raise XmpValueError(value, xtype) + elif type(avalue) is str: + ravalue = avalue + else: + raise XmpValueError(value, xtype) + r += 'lang="%s" %s, ' % (rkey, ravalue) + return r[:-2] + else: + raise XmpValueError(value, xtype) + + elif xtype == 'MIMEType': + if type(value) is dict: + try: + return '%s/%s' % (value['type'], value['subtype']) + except KeyError: + raise XmpValueError(value, xtype) + else: + raise XmpValueError(value, xtype) + + elif xtype in ('ProperName', 'Text', 'URI', 'URL'): + if type(value) is unicode: + try: + return value.encode('utf-8') + except UnicodeEncodeError: + raise XmpValueError(value, xtype) + elif type(value) is str: + return value + else: + raise XmpValueError(value, xtype) + + raise NotImplementedError('XMP conversion for type [%s]' % xtype) + + def to_string(self): + """ + Return a string representation of the XMP tag suitable to pass to + libexiv2 to set the value of the tag. + + @rtype: C{str} + """ + return XmpTag._convert_to_string(self.value, self.type) + + def __str__(self): + """ + Return a string representation of the XMP tag for debugging purposes. + + @rtype: C{str} + """ + r = 'Key = ' + self.key + os.linesep + \ + 'Name = ' + self.name + os.linesep + \ + 'Label = ' + self.label + os.linesep + \ + 'Description = ' + self.description + os.linesep + \ + 'Type = ' + self.type + os.linesep + \ + 'Values = ' + str(self.values) + return r + |