diff --git a/pyproject.toml b/pyproject.toml index a4869612..83d3783d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ dynamic = ["version"] [project.optional-dependencies] -all = ["brotli", "feedgenerator", "zopfli", "cryptography"] +all = ["brotli", "feedgenerator", "zopfli", "cryptography", "pillow-heif"] tests = ["pytest", "pytest-cov"] docs = ["Sphinx>=4.1.0", "furo", "cryptography"] diff --git a/src/sigal/gallery.py b/src/sigal/gallery.py index 51cb5d71..addddb2a 100644 --- a/src/sigal/gallery.py +++ b/src/sigal/gallery.py @@ -45,7 +45,13 @@ from PIL import Image as PILImage from . import image, signals, video -from .image import get_exif_tags, get_image_metadata, get_size, process_image +from .image import ( + EXIF_EXTENSIONS, + get_exif_tags, + get_image_metadata, + get_size, + process_image, +) from .settings import Status, get_thumb from .utils import ( Devnull, @@ -264,7 +270,7 @@ def exif(self): datetime_format = self.settings["datetime_format"] return ( get_exif_tags(self.raw_exif, datetime_format=datetime_format) - if self.raw_exif and self.src_ext in (".jpg", ".jpeg") + if self.raw_exif and self.src_ext in EXIF_EXTENSIONS else None ) @@ -289,7 +295,7 @@ def _get_markdown_metadata(self): @cached_property def raw_exif(self): """If not `None`, contains the raw EXIF tags.""" - if self.src_ext in (".jpg", ".jpeg"): + if self.src_ext in EXIF_EXTENSIONS: return self.file_metadata["exif"] @cached_property diff --git a/src/sigal/image.py b/src/sigal/image.py index 83a994ae..f7d154b6 100644 --- a/src/sigal/image.py +++ b/src/sigal/image.py @@ -44,11 +44,21 @@ from pilkit.processors import Transpose from pilkit.utils import save_image +try: + from pillow_heif import HeifImagePlugin # noqa: F401 + + HAS_HEIF = True +except ImportError: + HAS_HEIF = False + from . import signals, utils +from .settings import Status # Force loading of truncated files ImageFile.LOAD_TRUNCATED_IMAGES = True +EXIF_EXTENSIONS = (".jpg", ".jpeg", ".heic") + def _has_exif_tags(img): return hasattr(img, "info") and "exif" in img.info @@ -66,7 +76,7 @@ def _read_image(file_path): for w in caught_warnings: logger.warning( - f"PILImage reported a warning for file {file_path}\n" + f"Pillow reported a warning for file {file_path}\n" f"{w.category}: {w.message}" ) return im @@ -180,6 +190,11 @@ def process_image(media): options = media.settings["jpg_options"] elif media.src_ext == ".png": options = {"optimize": True} + elif media.src_ext == ".heic" and not HAS_HEIF: + logger.warning( + f"cannot open {media.src_path}, pillow-heif is needed to open .heic files" + ) + return Status.FAILURE else: options = {} @@ -220,7 +235,10 @@ def get_exif_data(filename): try: with warnings.catch_warnings(record=True) as caught_warnings: - exif = img._getexif() or {} + exif = {} + exifdata = img.getexif() + if exifdata: + exif = exifdata._get_merged_dict() except ZeroDivisionError: logger.warning("Failed to read EXIF data.") return None @@ -290,7 +308,7 @@ def get_image_metadata(filename): logger.error("Could not open image %s metadata: %s", filename, e) else: try: - if os.path.splitext(filename)[1].lower() in (".jpg", ".jpeg"): + if os.path.splitext(filename)[1].lower() in EXIF_EXTENSIONS: exif = get_exif_data(img) except Exception as e: logger.warning("Could not read EXIF data from %s: %s", filename, e) diff --git a/src/sigal/settings.py b/src/sigal/settings.py index 2ed1b4fd..c26be32e 100644 --- a/src/sigal/settings.py +++ b/src/sigal/settings.py @@ -42,7 +42,16 @@ "google_tag_manager": "", "ignore_directories": [], "ignore_files": [], - "img_extensions": [".jpg", ".jpeg", ".png", ".gif", ".tif", ".tiff", ".webp"], + "img_extensions": [ + ".jpg", + ".jpeg", + ".png", + ".gif", + ".heic", + ".tif", + ".tiff", + ".webp", + ], "img_processor": "ResizeToFit", "img_size": (640, 480), "img_format": None, diff --git a/src/sigal/templates/sigal.conf.py b/src/sigal/templates/sigal.conf.py index 77505a6f..4c02597d 100644 --- a/src/sigal/templates/sigal.conf.py +++ b/src/sigal/templates/sigal.conf.py @@ -66,7 +66,7 @@ # map_height = '500px' # File extensions that should be treated as images -# img_extensions = ['.jpg', '.jpeg', '.png', '.gif'] +# img_extensions = [".jpg", ".jpeg", ".png", ".gif", ".heic", ".tif", ".tiff", ".webp"] # Pilkit processor used to resize the image # (see http://pilkit.readthedocs.org/en/latest/#processors) diff --git a/tests/sample/pictures/dir1/test1/outdoor.heic b/tests/sample/pictures/dir1/test1/outdoor.heic new file mode 100644 index 00000000..2632594c Binary files /dev/null and b/tests/sample/pictures/dir1/test1/outdoor.heic differ diff --git a/tests/test_gallery.py b/tests/test_gallery.py index cf6978c2..7ff334c1 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -10,6 +10,13 @@ from sigal.gallery import Album, Gallery, Image, Media, Video from sigal.video import SubprocessException +try: + from pillow_heif import HeifImagePlugin # noqa: F401 + + HAS_HEIF = True +except ImportError: + HAS_HEIF = False + CURRENT_DIR = os.path.dirname(__file__) REF = { @@ -301,7 +308,11 @@ def test_gallery(settings, tmp_path, caplog): gal = Gallery(settings, ncpu=1) gal.build() - assert re.match(r"CSS file .* could not be found", caplog.records[3].message) + + if HAS_HEIF: + assert re.match(r"CSS file .* could not be found", caplog.records[3].message) + else: + assert re.match(r"CSS file .* could not be found", caplog.records[4].message) with open(tmp_path / "my.css", mode="w") as f: f.write("color: red") diff --git a/tests/test_image.py b/tests/test_image.py index 133ca516..fac4fc84 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -202,6 +202,18 @@ def test_get_exif_tags(): assert "gps" not in simple +def test_get_heic_exif_tags(): + pytest.importorskip("pillow_heif") + test_image = "outdoor.heic" + src_file = os.path.join( + CURRENT_DIR, "sample", "pictures", "dir1", "test1", test_image + ) + data = get_exif_data(src_file) + simple = get_exif_tags(data, datetime_format="%d/%m/%Y") + assert simple["Make"] == "samsung" + assert simple["datetime"] == "01/09/2024" + + def test_get_iptc_data(caplog): test_image = "1.jpg" src_file = os.path.join(CURRENT_DIR, "sample", "pictures", "iptcTest", test_image) diff --git a/tests/test_zip.py b/tests/test_zip.py index a5450cc9..ea098b6d 100644 --- a/tests/test_zip.py +++ b/tests/test_zip.py @@ -24,16 +24,11 @@ def test_zipped_correctly(tmpdir): gallery = make_gallery(destination=outpath, zip_gallery="archive.zip") gallery.build() - zipf = os.path.join(outpath, "test1", "archive.zip") + zipf = os.path.join(outpath, "test2", "archive.zip") assert os.path.isfile(zipf) zip_file = zipfile.ZipFile(zipf, "r") - expected = ( - "11.jpg", - "CMB_Timeline300_no_WMAP.jpg", - "flickr_jerquiaga_2394751088_cc-by-nc.jpg", - "example.gif", - ) + expected = ("21.tiff", "22.jpg", "CMB_Timeline300_no_WMAP.jpg") for filename in zip_file.namelist(): assert filename in expected