Skip to content

Commit

Permalink
fix: compatibility with older versions of Pillow
Browse files Browse the repository at this point in the history
  • Loading branch information
dairiki committed Apr 23, 2023
1 parent 35cc259 commit 5522837
Show file tree
Hide file tree
Showing 4 changed files with 103 additions and 80 deletions.
75 changes: 61 additions & 14 deletions lektor/imagetools.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@

import dataclasses
import io
import numbers
import os
import posixpath
import re
from contextlib import contextmanager
from datetime import datetime
from enum import Enum
from enum import IntEnum
from fractions import Fraction
from functools import partial
from functools import wraps
from pathlib import Path
from types import ModuleType
from types import SimpleNamespace
from typing import Any
from typing import BinaryIO
from typing import ClassVar
Expand All @@ -22,10 +27,10 @@
from typing import TYPE_CHECKING
from xml.etree import ElementTree as etree

import PIL.ExifTags
import PIL.Image
import PIL.ImageCms
import PIL.ImageOps
from PIL import ExifTags

from lektor.utils import deprecated
from lektor.utils import get_dependent_url
Expand All @@ -35,6 +40,21 @@

PILLOW_VERSION_INFO = tuple(map(int, PIL.__version__.split(".")))

if PILLOW_VERSION_INFO >= (9, 4):
ExifTags: ModuleType | SimpleNamespace = PIL.ExifTags
else:

def _reverse_map(mapping: Mapping[int, str]) -> dict[str, int]:
return dict(map(reversed, mapping.items())) # type: ignore[arg-type]

ExifTags = SimpleNamespace(
Base=IntEnum("Base", _reverse_map(PIL.ExifTags.TAGS)),
GPS=IntEnum("GPS", _reverse_map(PIL.ExifTags.GPSTAGS)),
IFD=IntEnum("IFD", [("Exif", 34665), ("GPSInfo", 34853)]),
TAGS=PIL.ExifTags.TAGS,
GPSTAGS=PIL.ExifTags.GPSTAGS,
)

if PILLOW_VERSION_INFO >= (8, 0):
exif_transpose = PIL.ImageOps.exif_transpose
else:
Expand Down Expand Up @@ -64,7 +84,7 @@ def exif_transpose(image: PIL.Image.Image) -> PIL.Image.Image:
"""
exif = image.getexif()
orientation = exif.get(0x0112)
orientation = exif.get(ExifTags.Base.Orientation)
if orientation not in _TRANSPOSE_FOR_ORIENTATION:
return image.copy()
transposed_image = image.transpose(_TRANSPOSE_FOR_ORIENTATION[orientation])
Expand Down Expand Up @@ -167,22 +187,41 @@ def _to_string(value):
return value


def _to_float(value, precision=4):
return round(float(value), precision)
def _to_rational(value):
# NB: Older versions of Pillow return (numerator, denominator) tuples
# for EXIF rational numbers. New version return a Fraction instance.
if isinstance(value, numbers.Rational):
return value
if isinstance(value, tuple) and len(value) == 2:
return Fraction(*value)
raise ValueError(f"Can not convert {value!r} to Rational")


def _to_float(value):
if not isinstance(value, numbers.Real):
value = _to_rational(value)
return float(value)


def _to_focal_length(value):
return f"{float(value):g}mm"
return f"{_to_float(value):g}mm"


def _to_degrees(coords, hemisphere):
degrees, minutes, seconds = coords
degrees = float(degrees) + minutes / 60 + seconds / 3600
degrees, minutes, seconds = map(_to_float, coords)
degrees = degrees + minutes / 60 + seconds / 3600
if hemisphere in {"S", "W"}:
degrees = -degrees
return degrees


def _to_altitude(altitude, altitude_ref):
value = _to_float(altitude)
if altitude_ref == b"\x01":
value = -value
return value


def _default_none(wrapped):
@wraps(wrapped)
def wrapper(self):
Expand Down Expand Up @@ -218,6 +257,14 @@ def _exif_ifd(self):

@property
def _gpsinfo_ifd(self):
# On older Pillow versions, get_ifd(GPSinfo) returns None.
# Prior to somewhere around Pillow 8.2.0, the GPS IFD was accessible at
# the top level. Try that first.
#
# https://pillow.readthedocs.io/en/stable/releasenotes/8.2.0.html#image-getexif-exif-and-gps-ifd
gps_ifd = self._exif.get(ExifTags.IFD.GPSInfo)
if isinstance(gps_ifd, dict):
return gps_ifd
return self._exif.get_ifd(ExifTags.IFD.GPSInfo)

@property
Expand Down Expand Up @@ -261,29 +308,29 @@ def lens(self):
@property
@_default_none
def aperture(self):
return _to_float(self._exif_ifd[ExifTags.Base.ApertureValue])
return round(_to_float(self._exif_ifd[ExifTags.Base.ApertureValue]), 4)

@property
@_default_none
def f_num(self):
return _to_float(self._exif_ifd[ExifTags.Base.FNumber])
return round(_to_float(self._exif_ifd[ExifTags.Base.FNumber]), 4)

@property
@_default_none
def f(self):
value = self._exif_ifd[ExifTags.Base.FNumber]
return f"ƒ/{float(value):g}"
value = _to_float(self._exif_ifd[ExifTags.Base.FNumber])
return f"ƒ/{value:g}"

@property
@_default_none
def exposure_time(self):
value = self._exif_ifd[ExifTags.Base.ExposureTime]
value = _to_rational(self._exif_ifd[ExifTags.Base.ExposureTime])
return f"{value.numerator}/{value.denominator}"

@property
@_default_none
def shutter_speed(self):
value = self._exif_ifd[ExifTags.Base.ShutterSpeedValue]
value = _to_float(self._exif_ifd[ExifTags.Base.ShutterSpeedValue])
return f"1/{2 ** value:.0f}"

@property
Expand All @@ -304,7 +351,7 @@ def flash_info(self):
@property
@_default_none
def iso(self):
return self._exif_ifd[ExifTags.Base.ISOSpeedRatings]
return _to_float(self._exif_ifd[ExifTags.Base.ISOSpeedRatings])

@property
@_default_none
Expand Down
Binary file added tests/exif-test-3.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/exif-test-4.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
108 changes: 42 additions & 66 deletions tests/test_imagetools.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,15 @@
from lektor.imagetools import _get_thumbnail_url_path
from lektor.imagetools import _parse_svg_units_px
from lektor.imagetools import _save_position
from lektor.imagetools import _to_altitude
from lektor.imagetools import _to_degrees
from lektor.imagetools import _to_flash_description
from lektor.imagetools import _to_float
from lektor.imagetools import _to_focal_length
from lektor.imagetools import _to_rational
from lektor.imagetools import _to_string
from lektor.imagetools import compute_dimensions
from lektor.imagetools import CropBox
from lektor.imagetools import EXIFInfo
from lektor.imagetools import get_image_info
from lektor.imagetools import ImageSize
from lektor.imagetools import make_image_thumbnail
Expand Down Expand Up @@ -65,51 +66,59 @@ def test_combine_make(make, model, expected):


@pytest.mark.parametrize(
"value, expected",
"coerce, value, expected",
[
(0x41, "Flash fired, red-eye reduction mode"),
(0x100, "Flash did not fire (256)"),
(0x101, "Flash fired (257)"),
(-1, "Flash fired (-1)"),
(_to_string, "a b", "a b"),
(_to_string, "a\xc2\xa0b", "a\xa0b"),
(_to_string, "a\xa0b", "a\xa0b"),
#
(_to_rational, 42, 42),
(_to_rational, Fraction("22/7"), Fraction("22/7")),
(_to_rational, (3, 2), Fraction("3/2")),
#
(_to_float, 42, 42),
(_to_float, 1.5, 1.5),
(_to_float, Fraction("22/7"), approx(3.142857)),
(_to_float, (7, 4), 1.75),
#
(_to_flash_description, 0x41, "Flash fired, red-eye reduction mode"),
(_to_flash_description, 0x100, "Flash did not fire (256)"),
(_to_flash_description, 0x101, "Flash fired (257)"),
(_to_flash_description, -1, "Flash fired (-1)"),
#
(_to_focal_length, Fraction("45/2"), "22.5mm"),
(_to_focal_length, (521, 10), "52.1mm"),
],
)
def test_to_flash_description(value, expected):
assert _to_flash_description(value) == expected
def test_coersion(coerce, value, expected):
assert coerce(value) == expected


@pytest.mark.parametrize(
"value, expected",
"coords, hemisphere, expected",
[
("a b", "a b"),
("a\xc2\xa0b", "a\xa0b"),
("a\xa0b", "a\xa0b"),
((Fraction(45), Fraction(15), Fraction(30)), "N", approx(45.2583333)),
((Fraction(45), Fraction(61, 2), Fraction(0)), "S", approx(-45.5083333)),
((122, 0, 0), "W", -122),
((Fraction("45/2"), 0, 0), "E", 22.5),
(((45, 2), (30, 1), (0, 1)), "N", approx(23)),
],
)
def test_to_string(value, expected):
assert _to_string(value) == expected


def test_to_float():
assert _to_float(Fraction("22/3")) == approx(7.3333, rel=1e-8)


def test_to_focal_length():
assert _to_focal_length(Fraction("45/2")) == "22.5mm"
def test_to_degrees(coords, hemisphere, expected):
assert _to_degrees(coords, hemisphere) == expected


@pytest.mark.parametrize(
"coords, hemisphere, expected",
"altitude, altitude_ref, expected",
[
(("45", "15", "30"), "N", approx(45.2583333)),
(("45", "61/2", "0"), "S", approx(-45.5083333)),
(("122", "0", "0"), "W", -122),
(("45/2", "0", "0"), "E", 22.5),
(Fraction("1234/10"), b"\x00", approx(123.4)),
(Fraction("1234/10"), None, approx(123.4)),
((1234, 10), b"\x00", approx(123.4)),
(Fraction("123/10"), b"\x01", approx(-12.3)),
],
)
def test_to_degrees(coords, hemisphere, expected):
assert (
_to_degrees(tuple(Fraction(coord) for coord in coords), hemisphere) == expected
)
def test_to_altitude(altitude, altitude_ref, expected):
assert _to_altitude(altitude, altitude_ref) == expected


TEST_JPG_EXIF_INFO = {
Expand Down Expand Up @@ -201,6 +210,8 @@ def test_to_degrees(coords, hemisphere, expected):
"longitude": None,
"shutter_speed": None,
},
HERE / "exif-test-3.jpg": {"altitude": approx(-85.9)}, # negative altitude
HERE / "exif-test-4.jpg": {"altitude": approx(85.9)}, # This no GPSAltitudeRef tag:
}


Expand All @@ -223,41 +234,6 @@ def test_read_exif_unrecognized_image():
assert not exif_info


def make_exif(gps_data):
"""Construct a PIL.Image.Exif instance from GPS IFD data"""
ifd0 = PIL.Image.Exif()
ifd0[PIL.ExifTags.IFD.GPSInfo] = dict(gps_data)
exif = PIL.Image.Exif()
exif.load(ifd0.tobytes())
return exif


@pytest.mark.parametrize(
"gps_data, expected",
[
(
{
PIL.ExifTags.GPS.GPSAltitude: Fraction("1234/10"),
PIL.ExifTags.GPS.GPSAltitudeRef: b"\x00",
},
approx(123.4),
),
(
{
PIL.ExifTags.GPS.GPSAltitude: Fraction("123/10"),
PIL.ExifTags.GPS.GPSAltitudeRef: b"\x01",
},
approx(-12.3),
),
({PIL.ExifTags.GPS.GPSAltitude: Fraction("1234/10")}, approx(123.4)),
],
)
def test_EXIFInfo_altitude(gps_data, expected):
exif = make_exif(gps_data)
exif_info = EXIFInfo(exif)
assert exif_info.altitude == expected


@pytest.mark.parametrize(
"dimension, pixels",
[
Expand Down

0 comments on commit 5522837

Please sign in to comment.