diff --git a/README.md b/README.md index 3ec63ba6..510ffd00 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ OSXPhotos provides the ability to interact with and query Apple's Photos.app lib + [FaceInfo](#faceinfo) + [CommentInfo](#commentinfo) + [LikeInfo](#likeinfo) + + [AdjustmentsInfo](#adjustmentsinfo) + [Raw Photos](#raw-photos) + [Template Substitutions](#template-substitutions) + [Utility Functions](#utility-functions) @@ -1563,7 +1564,7 @@ Returns height of the photo in pixels. If image has been edited, returns height Returns width of the photo in pixels. If image has been edited, returns width of the edited image, otherwise returns width of the original image. See also [original_width](#original_width). #### `orientation` -Returns EXIF orientation value of the photo as integer. If image has been edited, returns orientation of the edited image, otherwise returns orientation of the original image. See also [original_orientation](#original_orientation). +Returns EXIF orientation value of the photo as integer. If image has been edited, returns orientation of the edited image, otherwise returns orientation of the original image. See also [original_orientation](#original_orientation). If orientation cannot be determined, returns 0 (this happens if osxphotos cannot decode the adjustment info for an edited image). #### `original_height` Returns height of the original photo in pixels. See also [height](#height). @@ -1583,6 +1584,9 @@ Returns `True` if the original image file is missing on disk, otherwise `False`. #### `hasadjustments` Returns `True` if the picture has been edited, otherwise `False` +#### `adjustments` +On Photos 5+, returns an [AdjustmentsInfo](#adjustmentsinfo) object representing the adjustments (edits) to the photo or None if there are no adjustments. On earlier versions of Photos, always returns None. + #### `external_edit` Returns `True` if the picture was edited in an external editor (outside Photos.app), otherwise `False` @@ -2381,9 +2385,26 @@ Returns a JSON representation of the FaceInfo instance. [PhotoInfo.likes](#likes) returns a list of LikeInfo objects for "likes" on shared photos. (Photos 5/MacOS 10.15+ only). The list of LikeInfo objects will be sorted in ascending order by date like was made. LikeInfo contains the following fields: - `datetime`: `datetime.datetime`, date/time like was made -- `user`: `str`, name of user who made the like +- `user`: `str`, name of user who made the like - `ismine`: `bool`, True if like was made by person who owns the Photos library being operated on +### AdjustmentsInfo +[PhotoInfo.adjustments](#adjustments) returns an AdjustmentsInfo object, if the photo has adjustments, or `None` if the photo does not have adjusments. AdjustmentsInfo has the following properties and methods: + +- `plist`: The adjustments plist file maintained by Photos as a dict. +- `data`: The raw, undecoded adjustments info as binary blob. +- `editor`: The editor bundle ID of the app which made the edits, e.g. `com.apple.photos`. +- `format_id`: The format identifier set by the app which made the edits, e.g. `com.apple.photos`. +- `base_version`: Version info set by the app which made the edits. +- `format_version`: Version info set by the app which made the edits. +- `timestamp`: Time stamp of the adjustment as a timezone-aware datetime.datetime object; None if no timestamp is set. +- `adjustments`: a list of dicts containing information about the decoded adjustments to the photo or None if adjustments could not be decoded. AdjustmentsInfo can decode adjustments made by Photos but cannot decode adjustments made by external plugins or apps. +- `adj_metadata`: a dict containing additional data about the photo decoded from the adjustment data. +- `adj_orientation`: the EXIF orientation of the edited photo decoded from the adjustment metadata. +- `adj_format_version`: version for adjustments format decoded from the adjustment data. +- `adj_version_info`: version info for the application which made the adjustments to the photo decoded from the adjustments data. +- `asdict()`: dict representation of the AdjustmentsInfo object; contains all properties with exception of `plist`. + ### Raw Photos Handling raw photos in `osxphotos` requires a bit of extra work. Raw photos in Photos can be imported in two different ways: 1) a single raw photo with no associated JPEG image is imported 2) a raw+JPEG pair is imported -- two separate images with same file stem (e.g. `IMG_0001.CR2` and `IMG_001.JPG`) are imported. diff --git a/osxphotos/adjustmentsinfo.py b/osxphotos/adjustmentsinfo.py new file mode 100644 index 00000000..d1b70878 --- /dev/null +++ b/osxphotos/adjustmentsinfo.py @@ -0,0 +1,162 @@ +""" AdjustmentsInfo class to read adjustments data for photos edited in Apple's Photos.app + In Catalina and Big Sur, the adjustments data (data about edits done to the photo) + is stored in a plist file in + ~/Pictures/Photos Library.photoslibrary/resources/renders/X/UUID.plist + where X is first character of the photo's UUID string and UUID is the full UUID, + e.g.: ~/Pictures/Photos Library.photoslibrary/resources/renders/3/30362C1D-192F-4CCD-9A2A-968F436DC0DE.plist + + Thanks to @neilpa who figured out how to decode this information: + Reference: https://github.com/neilpa/photohack/issues/4 +""" + +import datetime +import json +import plistlib +import zlib + +from .datetime_utils import datetime_naive_to_utc + + +class AdjustmentsDecodeError(Exception): + """Could not decode adjustments plist file""" + + def __init__(self, message): + self.message = message + super().__init__(self.message) + + +class AdjustmentsInfo: + def __init__(self, plist_file): + self._plist_file = plist_file + self._plist = self._load_plist_file(plist_file) + + self._base_version = self._plist.get("adjustmentBaseVersion", None) + self._data = self._plist.get("adjustmentData", None) + self._editor_bundle_id = self._plist.get("adjustmentEditorBundleID", None) + self._format_identifier = self._plist.get("adjustmentFormatIdentifier", None) + self._format_version = self._plist.get("adjustmentFormatVersion") + self._timestamp = self._plist.get("adjustmentTimestamp", None) + if self._timestamp and type(self._timestamp) == datetime.datetime: + self._timestamp = datetime_naive_to_utc(self._timestamp) + + try: + self._adjustments = self._decode_adjustments_from_plist(self._plist) + except Exception as e: + self._adjustments = None + + def _decode_adjustments_from_plist(self, plist): + """decode adjustmentData from Apple Photos adjusments + + Args: + plist: a plist dict as loaded by plistlib + + Returns: + decoded adjustmentsData as dict + """ + + return json.loads( + zlib.decompress(plist["adjustmentData"], -zlib.MAX_WBITS).decode() + ) + + def _load_plist_file(self, plist_file): + """Load plist file from disk + + Args: + plist_file: full path to plist file + + Returns: + plist as dict + """ + with open(str(plist_file), "rb") as fd: + plist_dict = plistlib.load(fd) + return plist_dict + + @property + def plist(self): + """The actual adjustments plist content as a dict """ + return self._plist + + @property + def data(self): + """The raw adjustments data as a binary blob """ + return self._data + + @property + def editor(self): + """The editor bundle ID for app/plug-in which made the adjustments """ + return self._editor_bundle_id + + @property + def format_id(self): + """The value of the adjustmentFormatIdentifier field in the plist """ + return self._format_identifier + + @property + def base_version(self): + """Value of adjustmentBaseVersion field """ + return self._base_version + + @property + def format_version(self): + """The value of the adjustmentFormatVersion in the plist """ + return self._format_version + + @property + def timestamp(self): + """The time stamp of the adjustment as timezone aware datetime.datetime object or None if no timestamp """ + return self._timestamp + + @property + def adjustments(self): + """List of adjustment dictionaries (or empty list if none or could not be decoded)""" + return self._adjustments["adjustments"] if self._adjustments else [] + + @property + def adj_metadata(self): + """Metadata dictionary or None if adjustment data could not be decoded""" + return self._adjustments["metadata"] if self._adjustments else None + + @property + def adj_orientation(self): + """EXIF orientation of image or 0 if none specified or None if adjustments could not be decoded""" + try: + return self._adjustments["metadata"]["orientation"] + except KeyError: + # no orientation field + return 0 + except TypeError: + # adjustments is None + return 0 + + @property + def adj_format_version(self): + """Format version for adjustments data (formatVersion field from adjustmentData) or None if adjustments could not be decoded""" + return self._adjustments["formatVersion"] if self._adjustments else None + + @property + def adj_version_info(self): + """version info for adjustments data or None if adjustments data could not be decoded""" + return self._adjustments["versionInfo"] if self._adjustments else None + + def asdict(self): + """Returns all adjustments info as dictionary""" + timestamp = self.timestamp + if type(timestamp) == datetime.datetime: + timestamp = timestamp.isoformat() + + return { + "data": self.data, + "editor": self.editor, + "format_id": self.format_id, + "base_version": self.base_version, + "format_version": self.format_version, + "adjustments": self.adjustments, + "metadata": self.adj_metadata, + "orientation": self.adj_orientation, + "adjustment_format_version": self.adj_format_version, + "version_info": self.adj_version_info, + "timestamp": timestamp, + } + + def __repr__(self): + return f"AdjustmentsInfo(plist_file='{self._plist_file}')" diff --git a/osxphotos/personinfo.py b/osxphotos/personinfo.py index f5d72863..a6148d20 100644 --- a/osxphotos/personinfo.py +++ b/osxphotos/personinfo.py @@ -11,16 +11,15 @@ MPRI_Reg_Rect = namedtuple("MPRI_Reg_Rect", ["x", "y", "h", "w"]) class PersonInfo: - """ Info about a person in the Photos library - """ + """Info about a person in the Photos library""" def __init__(self, db=None, pk=None): - """ Creates a new PersonInfo instance + """Creates a new PersonInfo instance Arguments: db: instance of PhotosDB object - pk: primary key value of person to initialize PersonInfo with - + pk: primary key value of person to initialize PersonInfo with + Returns: PersonInfo instance """ @@ -57,8 +56,8 @@ class PersonInfo: @property def face_info(self): - """ Returns a list of FaceInfo objects associated with this person sorted by quality score - Highest quality face is result[0] and lowest quality face is result[n] + """Returns a list of FaceInfo objects associated with this person sorted by quality score + Highest quality face is result[0] and lowest quality face is result[n] """ try: faces = self._db._db_faceinfo_person[self._pk] @@ -103,16 +102,15 @@ class PersonInfo: class FaceInfo: - """ Info about a face in the Photos library - """ + """Info about a face in the Photos library""" def __init__(self, db=None, pk=None): - """ Creates a new FaceInfo instance + """Creates a new FaceInfo instance Arguments: db: instance of PhotosDB object - pk: primary key value of face to init the object with - + pk: primary key value of face to init the object with + Returns: FaceInfo instance """ @@ -156,7 +154,7 @@ class FaceInfo: @property def center(self): - """ Coordinates, in PIL format, for center of face + """Coordinates, in PIL format, for center of face Returns: tuple of coordinates in form (x, y) @@ -165,7 +163,7 @@ class FaceInfo: @property def size_pixels(self): - """ Size of face in pixels (centered around center_x, center_y) + """Size of face in pixels (centered around center_x, center_y) Returns: size, in int pixels, of a circle drawn around the center of the face @@ -176,7 +174,7 @@ class FaceInfo: @property def mouth(self): - """ Coordinates, in PIL format, for mouth position + """Coordinates, in PIL format, for mouth position Returns: tuple of coordinates in form (x, y) @@ -185,7 +183,7 @@ class FaceInfo: @property def left_eye(self): - """ Coordinates, in PIL format, for left eye position + """Coordinates, in PIL format, for left eye position Returns: tuple of coordinates in form (x, y) @@ -194,7 +192,7 @@ class FaceInfo: @property def right_eye(self): - """ Coordinates, in PIL format, for right eye position + """Coordinates, in PIL format, for right eye position Returns: tuple of coordinates in form (x, y) @@ -223,7 +221,7 @@ class FaceInfo: @property def mwg_rs_area(self): - """ Get coordinates for Metadata Working Group Region Area. + """Get coordinates for Metadata Working Group Region Area. Returns: MWG_RS_Area named tuple with x, y, h, w where: @@ -249,7 +247,7 @@ class FaceInfo: @property def mpri_reg_rect(self): - """ Get coordinates for Microsoft Photo Region Rectangle. + """Get coordinates for Microsoft Photo Region Rectangle. Returns: MPRI_Reg_Rect named tuple with x, y, h, w where: @@ -278,7 +276,7 @@ class FaceInfo: return MPRI_Reg_Rect(x, y, h, w) def face_rect(self): - """ Get face rectangle coordinates for current version of the associated image + """Get face rectangle coordinates for current version of the associated image If image has been edited, rectangle applies to edited version, otherwise original version Coordinates in format and reference frame used by PIL @@ -321,12 +319,12 @@ class FaceInfo: return yaw def _fix_orientation(self, xy): - """ Translate an (x, y) tuple based on image orientation + """Translate an (x, y) tuple based on image orientation Arguments: xy: tuple of (x, y) coordinates for point to translate in format used by Photos (percent of height/width) - + Returns: (x, y) tuple of translated coordinates """ @@ -350,21 +348,24 @@ class FaceInfo: elif orientation == 7: x, y = y, x y = 1.0 - y - elif orientation ==8: + elif orientation == 8: x, y = y, x + elif orientation == 0: + # set by osxphotos if adjusted orientation cannot be read, assume it's 1 + y = 1.0 - y else: logging.warning(f"Unhandled orientation: {orientation}") return (x, y) def _make_point(self, xy): - """ Translate an (x, y) tuple based on image orientation + """Translate an (x, y) tuple based on image orientation and convert to image coordinates Arguments: xy: tuple of (x, y) coordinates for point to translate in format used by Photos (percent of height/width) - + Returns: (x, y) tuple of translated coordinates in pixels in PIL format/reference frame """ @@ -379,13 +380,13 @@ class FaceInfo: return (int(x * dx), int(y * dy)) def _make_point_with_rotation(self, xy): - """ Translate an (x, y) tuple based on image orientation and rotation + """Translate an (x, y) tuple based on image orientation and rotation and convert to image coordinates Arguments: xy: tuple of (x, y) coordinates for point to translate in format used by Photos (percent of height/width) - + Returns: (x, y) tuple of translated coordinates in pixels in PIL format/reference frame """ @@ -472,14 +473,14 @@ class FaceInfo: def rotate_image_point(x, y, xmid, ymid, angle): - """ rotate image point about xm, ym by angle in radians + """rotate image point about xm, ym by angle in radians Arguments: - x: x coordinate of point to rotate + x: x coordinate of point to rotate y: y coordinate of point to rotate xmid: x coordinate of center point to rotate about ymid: y coordinate of center point to rotate about - angle: angle in radians about which to coordinate, + angle: angle in radians about which to coordinate, counter-clockwise is positive Returns: diff --git a/osxphotos/photoinfo/photoinfo.py b/osxphotos/photoinfo/photoinfo.py index 49774719..f75e4d52 100644 --- a/osxphotos/photoinfo/photoinfo.py +++ b/osxphotos/photoinfo/photoinfo.py @@ -27,6 +27,7 @@ from .._constants import ( _PHOTOS_5_SHARED_PHOTO_PATH, _PHOTOS_5_VERSION, ) +from ..adjustmentsinfo import AdjustmentsInfo from ..albuminfo import AlbumInfo, ImportInfo from ..personinfo import FaceInfo, PersonInfo from ..phototemplate import PhotoTemplate @@ -510,6 +511,30 @@ class PhotoInfo: """ True if picture has adjustments / edits """ return self._info["hasAdjustments"] == 1 + @property + def adjustments(self): + """ Returns AdjustmentsInfo class for adjustment data or None if no adjustments; Photos 5+ only """ + if self._db._db_version <= _PHOTOS_4_VERSION: + return None + + if self.hasadjustments: + try: + return self._adjustmentinfo + except AttributeError: + library = self._db._library_path + directory = self._uuid[0] # first char of uuid + plist_file = ( + pathlib.Path(library) + / "resources" + / "renders" + / directory + / f"{self._uuid}.plist" + ) + if not plist_file.is_file(): + return None + self._adjustmentinfo = AdjustmentsInfo(plist_file) + return self._adjustmentinfo + @property def external_edit(self): """ Returns True if picture was edited outside of Photos using external editor """ @@ -823,8 +848,19 @@ class PhotoInfo: @property def orientation(self): - """ returns EXIF orientation of the current photo version as int """ - return self._info["orientation"] + """ returns EXIF orientation of the current photo version as int or 0 if current orientation cannot be determined """ + if self._db._db_version <= _PHOTOS_4_VERSION: + return self._info["orientation"] + + # For Photos 5+, try to get the adjusted orientation + if self.hasadjustments: + if self.adjustments: + return self.adjustments.adj_orientation + else: + # can't reliably determine orientation for edited photo if adjustmentinfo not available + return 0 + else: + return self._info["orientation"] @property def original_height(self): diff --git a/tests/test_bigsur_10_16_0_1.py b/tests/test_bigsur_10_16_0_1.py index 42df52ae..e9a1447d 100644 --- a/tests/test_bigsur_10_16_0_1.py +++ b/tests/test_bigsur_10_16_0_1.py @@ -66,6 +66,7 @@ UUID_DICT = { "hidden": "A1DD1F98-2ECD-431F-9AC9-5AFEFE2D3A5C", "not_hidden": "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51", "has_adjustments": "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51", + "adjustments_info": "7783E8E6-9CAC-40F3-BE22-81FB7051C266", "no_adjustments": "D05A5FE3-15FB-49A1-A15D-AB3DA6F8B068", "location": "DC99FBDD-7A52-4100-A5BB-344131646C30", "no_location": "6191423D-8DB8-4D4C-92BE-9BBBA308AAC4", @@ -107,6 +108,7 @@ UTI_ORIGINAL_DICT = { "4D521201-92AC-43E5-8F7C-59BC41C37A96": "public.jpeg", } + RawInfo = namedtuple( "RawInfo", [ @@ -1028,3 +1030,72 @@ def test_is_reference(photosdb): assert photo.isreference photo = photosdb.get_photo(UUID_NOT_REFERENCE) assert not photo.isreference + + +def test_adjustments(photosdb): + """ test adjustments/AdjustmentsInfo """ + from osxphotos.adjustmentsinfo import AdjustmentsInfo + + photo = photosdb.get_photo(UUID_DICT["adjustments_info"]) + adjustments = photo.adjustments + assert isinstance(adjustments, AdjustmentsInfo) + assert adjustments.asdict() == { + "data": b"mW\xdb\x92\xa3:\xb2\xfd\x17\xbfVG\x97\xc0`CG\xcc\x03 \x89\x9b\xc1 n\xc6S\xf3\x80\x01s5`\x83\x0b\xec\x1d\xfb\xdf\x8f\\\xdd}vO\xc4\xf0\x862\xb5r)%e.\xfd\xb5\xfa\xccoc\xd5wzw\xeeW?\xfeZ\x9d\xeeU\x9b\xd9\xf7\xcb)\xbf\xad~\xacX \xf1k\x81KW\xdfV\xc90\x84?]\xe9\xf8\x9a\x01\xdf\x99\xef\x0c\x03\xa8aL\xcb\xfc\x92\x90\xfc\xb3\xfaid\xbe\xad\x866\x99\xce\xfd\xedB=\xf7\xdea\xf5\xf7\xb7\xd5%\x9f\x92,\x99\x92W\x88K2N\xf9-\xaa\xb2\xa9\\\xfdX\x03\x96\xa3\x13\xaa!o\xab.\xff'\x02\x9d\xf7\x9d\xa3\xe8?\x9d\xb5\xbc*\xcai\xf5\x83\x03k\xf6\xdb\xaa\xbfUy7%\xd3\xcfp\x14\xfd\x15+\x99\xfe\x7f2e\x90d\xf5}\x9c.\xd4m\\\xfd\xf8\xf7_\xff\xc3#\xef\x92S\x9bg\xab\x1f\xd3\xed\x9e\xd3U\xe4\xd3Tu\xc5\xf8\"\xd8\x9f\xcf\xf4w\xd7\xa7I\xbb\xfb\x19\x17|\xfb5\xa8\xd1\xff\xf656~\rV\xddp\x9f~\xfb|g\x84\r\x107<\xe08\xb0\x05<\xc3\n\xbfg\xa1e\xe8\xc7\xfb-\xff\x03Hn\x93\xb4q\xfa\xaa\xfb\x13]\xbe\xbd\xa0\xba|\xfc\x89>\xbe\xd68NU\xfa\xc5j\x00\xec+\x08\x00[\x81[3\xeb-\xcbs\"`\xc4\r\xd8\xd2\xfc\xf1\xe0\xcb&\n`-\xb2\x0c\xbf\x116\xec\x96\xe3\x01e\x90\xdc\xa7>L\xda\xfb+\xf8wV\xe07\xe0\x8f\x8f&\xf3\xf4'\x11\x8a\xbef\xc4\xed\x86gh\xa6yQ`\x99-\xffm5\xf5]\xd2\x92\xa4+\xf2_A8~\xb3\xd9\xf2\x80\x05\x94\x05\xcbR\x8f\x81\xe5\xbfLk\x9e\x159\x86\xd9n8\x9a\x00A`\xd7\xd4$\n/\x13/\xfe\xf7G\x99}\xe5\xd1J\x06\xba\xdb\x18\x0ff\x06\x86\x93\x176n$\xf1\x07\xc24v$\xdb\x11\xc6\xf1`)\xe6`\xe0\xe2\x14\xc9\x97;f\xe2\xb2V\xfc\xad1\xa1\xaa\x01a\xa9G\x06n\x19G\x0f\xd56J\x98l\x8a\"1I[\x86=\xac[\xf8\xf1\xbe\xe0\x19\xb6\x81\x136Yl\xe0\xd4\t\x894\xa3\xe5\xa8G\xa8\x8c\xd5\xd0F\x91l\x1cP\x18\xc7\x91\xa2\xeaF\xd2\xda\xf1\xf2Y\xe0\xb5T\x9c\x82\xdb\x1cdb\xc8\xb4\xc71d\x99\xe3\x89\x19\x96\x83\x16\x9es\xf9\x02\xa1\xd7\xd8\xe1\xccG\xb8\x8d\x83\x10\xe2\x880\x99\x16\x82\xf2\xe0\xe1\x8c\xe2\xb6\x14\xb7\xf1\xa3\xe6\xa6;\xc9\xfc\xf9\xf1\x1el7\xb0\x1d\x9d\xa3L\xa6\xe0r\x0c\x12\x1c^\xc3O\xee6zg3:/i\xdb\xf2\x1c~\xa0#\x99E\n\x14'\xa1\xdc\xa4\xc8\xe7|\re\x07/,\r\nx\xf4\xda\xd6\x8c\xc0\x94\xfa\x98\xb1\x0eUX\x903\xb2\x95Bl\xed\x8ex\xe7\x90\xf4\xa1:\x84\xcd\xa3\xf8x7A\x90\x92\xb2\x01\x81\xd3\xefu\xd98\x937\xcal\x06\x16\xac\xadD\x93\xc5\xd8\xc3\xbf\x01\x073j\xa6\xd3\x17`\xc3\x17>\x8b\x9d\xb8\x00*\x8c\\\xe6\x13\x92\x8f\xf7P\xb3\xd8B\xc1c\xc8\x19\n\xb3x\x05>I\xfe\\\xea\x1e\x96\xf5 \\\xc8a\x1f\xf5$$\x95\xe9\x99\xb2\x1e7\xbf\x186t\xef\x98\x85\x022\xe7/\xc0\x92\xe6\xca\r\xec\xa856\xb6j\x05\x19\x91\x19[\x94\x0e\x8b\xbc\xefP\xe5\xfa\xa8\xb4\\\xd8\xc4\x07\"\xa7\x01\x0e\x8b\xd0{8g\xc3L\xabc0T_\x04\xc1\x8b \xc5\x0b~\xe1\xb5\xb8\x81Ql\xcb\x05s\x0f\x1d\x8e\xec\x89i I \x88\xe8\x1e\xea\xdc cx!\x8e\x037\\\xec\x90N\x8b@\x99\x1d\xf0\xa5h?\xde\x9b\xf5\xc7\xbb\xf7)\xfe\xc30`\xf2\xdf\x88\xc1\xc1\xb23\x99\x9f\x9ckA7k~\x13\xcdx\xbe\xf6\xb8\x05Qg\x9fF\xcc\xaf\xe91*\xaf\xafc\xd9\xf6QD\xc8\xcd\x98\xe6!\x82og\xef.m\x0eh\xe8\xber\x08~\xe5\x90\xc1\xad\xd3\xf5\xdd\xd1\xcb\x02=\xd2\xe3\\\xeeJ\xf5\xd2ge\xf9\xd6k]\\[^t\xd4\xd8&\xfa\x94\xa5\x9d\xf7\xf1n\x9b\x14\xe0\xa46Ex\x93\x13\x93$\xa5\xb5i\x9eG?\x13\xf7\x0c\xfax\xcf\xfd7\xe2\xb2X\xf3\x83\x1d\xe7\x15\xb3P\xc2\xc7L\xf62\x8aP4\x96\xdb\xc5m\xf4\xe7\xf8$>Mm\xa3\xdf\x1dy\xd8\xa0>\xa4K[r\x8cg\x07\xce\xc2\x1e>]\x97\x94;\x8c\xd2\xd9\xbb/\xa6\xa8\r\xee)]\xe6;~\xcc\x8e\xebrg\x18\x01\xef)\xbd%p\x9e\x1d$\x07\x0f\xb8\xc4\x83\xe33{\x17K\x82\xf7\x0c\x91m\xf7\xd7&x,\x18\x03\x17\x16\x14\xafD\xd4O\x1bP$yq9\xd5\x06k\xf5n\x15\xb0\xfa\xd3:%\x85x\x83\xc5\xec\xaa\x05G \xe9m8\x03\x0f{znW\x8d\xb8\x07\xa2\x12\xc0\xc9\xda\xadyk\x86W\x82\x96\xc0K\t\xbe\xe0]\x1a.\xb2\xeb\xaag\xdbi[\xb9\x0e\xa2\xe0v\xf4\x8e\x9d\x1e\r\xe1I\x0e\xa7\xb0\x93\xec\xd4\x05;\xe8YGSA7]\xe9k\xcb\x97\x04\xabf\xd2\x1c\xf3\xbc\xd65\xe7G\xb5u\xad\xa1w`\xb9\x9c\x902;:\x89\xb6\xda\xbe\t\x04\th\xd0M\x9d<\xb4Em\x9fE)n\x17\xd4I{U\x9a5H\xc0\xc5z\xcck\xf3\x19\xdf\xac\xaa\x99-\x08n\xb9\xaaX\xf1a\xfe<\xcb\x1b\xc50\xc3\x8a\x95C\x84'\x89\xcc\xd2\xc7{\x01\x8f\xbd\xad\xb9 \x83\xc5\xe8\xf0\x05{~\x9dj\x85\xf0\x8c1\xe8N63kH$[\xf5F\xc1n\\\xe0\xd6\xfa\xc7\xfb\xae\x1e\xef{%\x13\x0ew\xbb.\x9aHR\xf5\xb9\xe8\xaa\xe7Ek\xf5\xe3Pv\x9e\x7f\xbe\xf6\x1f\xef\x14Z\x84Rc\x99d\xd7\x99g\xcb\x16f\x96\xa0.\xf62Y\xa79\x02\x9e.\xcd.\xdd\xe3\xad\xe7O\xe6\x1e\x05O\x87U\xa5\xa3.\xf1\xb5\x9a(!geUe\xbc\x99b+\xdf\\q\xd2\x02\xeb\xe8\x93\xa7\x00\xdf\x90\x8dd\xd7\xc3\xfa|\xb8\xca\xbb\x1b\xdc\xb8\x81J\x9aBS\xac\xcc\x90\xe7\x0b]\xbbm>Zd\x06\xc5@\x14K\x80\x1c\xbe\x94\xf8\xba?\x8a\xa4\xae\xe8}U\x8f\xc1,\xe2\xce\x81\x89\xe0^\xe5\xda\xde[\x85\xbb\x90\xb9Sk+\x8b\xc9|\xa6X\x81\xeaI7\xa3\x96\xae\xb6\xd70\x06\xb4\xf2]\x01LL\xe6\xd6|\x8c\x0f\xf8\x10\x1c\\\x08v\xd0\xb7\x97J\xbd[\xbbA\xa8:E\xc9\x0c\xd3:\xb62\x1a\x91,xN\xb9\xae5/mvU\xf0i@\xbd\xde=\xd0\xd3|\xceO\x02ml\x054\x9c,=Q\xa9\x1fT2_\x0c\x90z\x88 \x11\xc1\xd4\xab\x19\xd5I\x8c0j\x96j\xaf\xf5s\xde/\xc5@\xf3d\xc3\x02\\\xe0C\xafm\x8f\xdbZ>X\x1cOx\xd3\xfd\xa0t\xfda\xef\x84\x12\xe7\xcd\x8c\x8c\\p\xd0\x15\xebnx\xdc\x90\x94\xa1m\xb43\x97\x11\xf1b\xe2\xb49>lF=\x02\xcf\xa9\xccReb\x0b\xcd\xc2\x11\xc9Vjx\xfaE\xf3\xfb\xd2\xa9\xac\x8fw\x0b\n\xa3\xa3\x8c`\xe7\x17\xb5\xed\xce'e\x89w\x98\x14\x17\xcd+\xc6\xfd\xe3\x99\x19\x96\x94'\xca\xd6\xd1\xb9\xa0\xbbz\xc4\xd1\xd6\\P\xca\xb6\x02\xd9`o\xcc\xe2\x15-c\xacW\xd6E\xaf\x82\xd2\xf0(\x91\xe7\xfcpa\x08)\xe1i\xaf\xe8\x9f\xb0L-\x9d\xb8>\x94\xa5\xce\xf1\rn\xc75\xf5\xe7\xe3\xf8i\xd9\xe3\xd4Vwl\\\xc6\xe2Z\x0e\x9f\xa8.N\xed\xd2\x16F\x07\x86\xbaB\x83^\xa5\xb5\xa9\x047c=\x8a~\xcd\xd8v\x10<\xddjba\xab{\xf4B\xda\xf8 Y\xca,d\x18\x8c\xe7\xa6Nh\x8f\xd0\x8a\xea\xe2\x85\xc5\xee\x96\x1e\x08\xd9\xf8\xe8V8\xe92\xad\xb1:\x97u\xc9\xae\xb19^\xac*\x9bi\xd9\x08\xbaY\xb8\xa3\x9d\xe4\xd2f\xb3W\xd3\xd1\x96\x17\n\xda\xa0 \x91\xb8\x806\xba\xd0Y\xc4Y3\xd1\x05\xc8y\x86\x19\xe9\x14\xca\xed\xa0^\xa4\xa4&r\xad\x19M\x98.\x0fO\xef\x80_\x95\xd9\x03\xb5\x16\xd9\xc9\xbeg\xec\xd3x\xeb\n\x11r{2\x16@\xc3r\xb0w\x88~\xc4\\p,\xe4DR\xc3\xb9X<\x07\xe2\xa4\x89J%j\xf11\x8e\xb22l \xa7\xdb\x07W\xac\xe0e\xca\xd63O[fo\x87\xee\xc7;\xd9\x89)-o'\x1d\xe3\xc6Q(0=\xe2\x81*\x1fe5\xe4\xce\xe3R\xdeq\x9aNu\x05cM\x9f\x93\xaa\xcc\x11\xe2]\xf7D\xc2\x16\xadu/p\x19\x0f\xe1\xc2I$6\x85\x9cD\x1e\x12\xdf\xe0~\x1e\xba\xf2\xb1\xd3TP\x9e\n1\x86Ea+.p\xd4\xb0/\x0bo\xbf\xc5\xbcs\x1c\xc6*\xfbbu\xe1\x16\xef \x1c\xb7\x96\x84%\xbf\\/DA\xe6xy\xc5\xadY\xfdD\xee\xcb&K\xdcR^\xf0\xe2JZ-\xd6\x82\xc8I\xac\x12\xf7\xb1\x8f\xd2\xf6\xfe\x0e\xfe}!\x89+\xee\x8f\x8f\x15\xf3\xf8'\x11\x86\xbe\xe4\xe5\xf5J\xe4Y\xa5EYZ\xf0k\xf1\xdbl\xec\xbb\xb4EiW\x16\xbf\x82\x08\xe2j\xcd\t\xb2\xb4\\\x8bk\xf1\xbd}\x0b\xf1\xcb\xb2\x14\x17\xb2\xc0\xf3\xeb\x95\xb0\xe6DIZ,\x99I\x96\xde&Q\xfe\xf7\xc7\x88}\x95\xd1N/l\xb3at\xd9\xe6\xdc\xe5\x88\xa3\xc6\x8f\x15q\x8f\xf8\xc6\x89U'\x860\xb9\xda\x1b\xf7b\xc1\xf2\x18\xab\xe7;\xe4\x13Ro\x82\xb5%\x83\xaa\xe1\x0e\xc4\x8c-\xd8\xf2\x9e\x19\xe9m\x9c\xf2\xf9\x18\xc7r\x9a\xb5\xfcb\xbfl\xb5\xcf\x0fbQ\xad\r\xbd\xa8\xc9\x13\x0bf^\x84\x94\t\xaa\x073\x06$\xd1#\x07\xc4\xaa\xb5\x07m\x92\xc4\x1b\xdd\xb4\xd2\xd6I\xa6G\t\x97Jy\x0co4\xcc\xc5\x88\x8f\x0eC\xb4\xe0\x0fG\xfe2\xed\x8d\xe8T\xa8gM\xc3\x8d\x13Q1fD\xa2H\x831\xe2s#\xe2\xc8\x1e\xc3\x9c\xe1\xb6\x0c\xb7\t\xe2\xe6fz\xe9\xf0\xf8\xfc\x08\xd7\xa2\xc6\x0f\xdeAEcx>\x84)\x8c\xae\xd1\x83\x1b\x86Mm\xc5\xa7)k[Q\x80Op\xc0\xaa\xca\x80\x92c\xa46\x19\x08\x84\xd0\x00\xf9\x1eG\xc4b\x80\x07\xdc\xb6\xdb\x98\x1b\xb3\x00\xf2\xf6\xbe\x8aJt\x02\xce\xa6\x94[\xb7C\xf8\x14\xa1>\xd2/Q\xf3,??\xb6\\\x98!\xd2p\xa1\xd7\xbb\xa6j\x9d\xd0\x9c1\xa3\x9c\xa3\xbd\xec\xd4P\xe5\x04\xc3\xdf\x80\x97m\xdc\x8c\xc7/\xc0F,\x83\x05\xf4\x92\x92\xd3\xb5\xd8\xe7\x1fZ\xf4\xf9\x11\x19\xf6\xa2\xdc\xc0!\x12\xac\r?\xc5%L\xa5\x90\x12\x13C\xd5\x0c\xa3\t\xed\xdd\xb8\xc7\x11\xaa\xb6x\xab\x9aI\xf3\x8ba\xc3\xf6\x8e\x9f\x18 \x7f\xfa\x02$\xacV~\xe8\xc4\xad\xb5rt;\xcc\x91\xca;\xb2\xb2\xa7\x93\xdb\x81\xa7\x1f\x00b#\xad\xc9\xf6\x08e!\x8c\xca\x18?\xbd\xc2J\xb3\xea\x10^\xaa/\x82\xdc\x9b \xc3\x0b\x7f\xe1\xb5\xb0\xd1\xe2\xc4QK\xf1\x1ey\x02r\xc9\xd6\x02HA\x00\x99\x18t~\x98\xf3\xa2\x94$!\x8a&'\x82\x93\xbf\xe7P\xbe\x87\xe7\xb2\xfd\xfch\x96\x9f\x1f\xf8!\xff\xc30\xe4\x8b\xdf\x88\xe1\xdevsU\x1c\xbdk\xc96\x8b\xce\xe5mB\xaf=l\xb9\xb8s\x8e7^\\\xb2cD\xae\xefc\xd9\xf6\xfb\x18E7k\xa4\x97X\x9b\x9f\xf0]Y\xed\xc1\xa5\xfb\xaa!\xf7\xab\x86\xb5)\xb9x5\xef\xfaP\x91\x02\xed\x00\x1c\xa7\xbf6\xe1\x93B\xc8!\x8d2<\x02|\x80\x8c\x1e\xc4\nN\xc8Xou\xfb\xe2W\xc9\xc2|\xf9\xc7\xb4\x94oo\x1c\x9d\nX#\xbd\xa3Q\x0eCl\x16\xce\xb3a\xd9\xc8\x9b0\x18\xed\xddR\xb4\x1f\xaf+\x82j\x883\x04\xcf\xf0\x98\xc5t\xf2}\xfd\xe4xm\xab\xd6a\x1c\xde\x0e\xf8\xd0\x99\xe7KtT\xa31\xea\x14'\xf3\xb9\x9d\x86\xedt\x8b\xc1`\xe2\xbe\xb6kE\xb2_bV@Q4\xba\xa6|Vk\xdf\x16{O#\xd3\x11l\xa8g\xa2tm\xb8M\xb8\xa6\x82\xa9\xf9\x99WD\x8el\xb8y\x9c\xc1v\x02\x9d\xe2\xea>54\xc4\x9d\xed']\xee\xb4\xecfW\r\xb55n(\xf4\x8d\x9d\xec\xe9\xe3\xa4\xae6\xd66\xaa\x16j\x04\xe1\xa8`\xaa|~\x9c\xb4K\xef\x18>\x97\xb3\x04=\xb1\\\x9c4?q6H\xe6\xad\x8b\xe9\xe5\x94_j\x88\x01\xe3Ar\xb8\x90\xf3kG\xd9\xd5\xc3\xdd\xc5D\xda\xdf\x9d\xbal\nEOh\xd9U\xaf\xb3\xc1\x9b\x87\x0b\xe9pp:\xf7s\xfa\xf9!k~co\xc9\xee\xbc=\xd9\xaeD\x17\x08t\t\xceU\x93U\x88\xc3\xa6B\x91\xa5\r\x12\xae\xc7\xad\x0b\x92\x97\xaf\xeb\xca\xc1TV\xb5\x9en\"\xc1\xce\xab\xca\x9ao\xe5vs\xf3\xe5\xd1\x08\xedC\x80^km\x0e\x1c\x80\xfc\x00\x9at\x7fUwW\xb0\xf5#\x1d5\xa5\xb1\xf1s\x0bq\x9d\x86\x04g\xfbl\xc16,/h\xe3K\x9a\x00\xcf\x04^\xdd\x83\xec\xd4\x15\xfb[\xf5CHe\xd8yZ*\xf9W\xb5s\\;C\x13\xa2\x9d^\xdby\x82\xe8IG}\xa8W`\xb0j\xe5\xe6\xe0\x86\xb74\xff\xb4+\xb9-$\xb4\xddm\x86\xa7\xf6Roa\xd6\x1c\x9e\x88\xd7\x0f\\\xe0=]b\xc0\xc4\x06T:\x00\xd5\xce-l\x9e\x8d\xba'^\xe5(\xb6&\r\xdef\xe0vA\xd38%w\xd4\xd4\xcc\x86\xa8<\x1b\xb8\x19\xdc\xe7+\xb7l\xa5H7\x9f\x1f\x9e)\x84\xdd\x15G\x9e\xb1\x14B\xa2:\x1bm\x11z\x16\x95\xaf`\x1a\x12\xf3iwf\x15\x12\x0b\xfbw\xebE\x9f\xbe\x16iv\xc0\xdd]FL#\x99m\x12?d'\xa9\xf3\x02K\xd8\tM\xfd\xa8\xf2\x87\xed\xf4\xf7\xb6zB\xeb<\x90+\x19\x1f\xe0U\x1e\xdb\xa9-\xad\x8e\xbb\xd4\x15\xb8\x9aUYoqx\xb3\x96\xc3<\xa8y\xc7i\xc2\x97_\x8d\x0b\xad51+\x8c\x03\xf7\x8a\xbd\xa1R\xae\x83\xe1\xd4\xd4\x05\xeb\x10FY\x9dqT\xeen\xef\x8bw\x15\x80[\xe6e\xd3\xb8\x84:%5Y,\xe1\xb6\xef\xec*\xa7\x10daG\xa5\x07\xd8J\xfe\x86\xa8\x9e\x9e\xf5\x8e:\xd9Xk@\x98*B\xc8\xda\\\xecM25Rp~ME\x0ey\xe5\x18\xa1\xf6\xa2\x9f\x95\xb4F\xb06\xac&\xca\xa6'6;.\xa8H\xfe\x04\xad\x8dw\xea\x1e[n\x92\xac\x91\x12\x03\x7f@\x83\xcf\x19\x10%\xaeG\xec\x03\x14\xc2C\xa9\xa6\x8a\xde\xd2r\xc2\x81\x06\xd3&&\x9b\xb8\x85\x87d\x9f\x93C\xa3\t\xa6\xb3\xf7\xe5J[\x8c\xf9\x92\x8a\xaca\xf6N\xe4\x7f~\xa0\x9d\x9c\xe1\xfbt2!l\xfcM)\xed\xd9\x11\x0fu\x94\xabz$\x9c\x86\x89\xdca\x96\x8cu\xa5%\x86I\x8f\x15\xa9\x00\x10}tDQ\x0b\r\x13\x87>\x1f\x00Xz\xa9\xb2\xc84A\xc1\x13\x95\x1b\xd8\xd3KG\x9e;C\xe7\xc8\xb1\x94\x13\x8d\x96\xac\xd7r\x9e\x1e\xf5\xa4\xc4\xee\x1a\x8a\xc2\xbe$\x0f\x15\xf6\xe1\xfeL\x12Y7)k\xe3\x0e\x01K\xc1\xb3\xd1\x96\x80\xa2q'*\xde\xb5'\x13\t\x04\xae\xa04\xdc\xb8MLv\x17\x9f\xff\xfcx\xee\xe6\xc6\xb5t7\ngh\xe1p\x1d\xab\xfb\xd3b=kD\x16\x81\xfb>H'\xa7\xd78\x01\x17\xaa\xab\x02\xd1\x0e\x11\x02s\x80\x05\x8f\xdd\xa6;v\xabF\x90\xca>\xb8\x98~J\x9e\x0bm! \x7f\x82\x0b\xe0\x0c~\xad\x08\xecW\x0c]\xaf2\xac\xad\xe9G)\x95\xae\xe0\x9c\xb0}\x96(\xe8B/\xa4\xbc\x08\xf6\xe10 H@\x04\xfc\x145Gv\xd7\xd8\x9a2?\x82\xbd\x106\xc8\xe2uI\xc9\xee\xbe|\xd2T!H\xe9