diff --git a/docs/changelog.rst b/docs/changelog.rst index 0340db7de..fb67cbf02 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -39,6 +39,15 @@ Added: * The ``:copy-image`` command which copies the selected image to the clipboard. The flags ``--width``, ``--height`` and ``--size`` allow to scale the copied image. Thanks `@jcjgraf`_! +* Series of internal metadata keys. They are aimed to provide additional metadata + information which is not provided by the external metadata libraries. All these keys + start with a leading ``Vimiv``: + + * ``Vimiv.FileSize`` File size + * ``Vimiv.FileType`` File type + * ``Vimiv.XDimension`` X dimension in pixel + * ``Vimiv.YDimension`` Y dimension in pixel + Changed: ^^^^^^^^ @@ -53,6 +62,7 @@ Fixed: * Expanding tilde to home directory when using the ``:write`` command. Thanks `@jcjgraf`_ for pointing this out! * Completion for aliases. +* Crash when extracting metadata using piexif from a non JPEG or TIFF image. Thanks `@BachoSeven`_ for pointing this out! v0.8.0 (2021-01-18) diff --git a/docs/documentation/exif.rst b/docs/documentation/exif.rst deleted file mode 100644 index 2b4416af0..000000000 --- a/docs/documentation/exif.rst +++ /dev/null @@ -1,65 +0,0 @@ -Exif -==== - -Vimiv provides optional exif support if either `pyexiv2`_ or `piexif`_ is available. If -this is the case: - -#. Exif metadata is automatically copied from source to destination when writing images - to disk. -#. The ``:metadata`` command and the corresponding ``i``-keybinding is available. -#. The ``{exif-date-time}`` statusbar module is available. - -.. include:: pyexiv2.rst - -Advantages of the different exif libraries ------------------------------------------- - -`Pyexiv2`_ is the more powerful of the two options. One large advantage is that with -pyexiv2 ``:metadata`` formats exif data into human readable format, for example -``FocalLength: 5.0 mm`` where `piexif`_ would only give ``FocalLength: 5.0``. However, -given it is written as python bindings to the c++ api of `exiv2`_, the installation is -more involved compared to the pure python `piexif`_ module. - -We recommend to use `pyexiv2`_ if the installation is not too involved on your system -and `piexif`_ as a fallback solution or in case you don't need the full power of -`pyexiv2`_ and prefer something more lightweight. - - -Moving from piexif to pyexiv2 ------------------------------ - -As pyexiv2 is the more powerful option compared to piexif, vimiv will prefer pyexiv2 -over piexif. Therefore, to switch to pyexiv2 simply install it on your system and vimiv -will use it automatically. If you have defined custom metadata sets in your config, you -may have to adjust them to use the full path to any key. See the next section for more -information on this. - - -Customizing metadata keysets ----------------------------- - -You can configure the information displayed by the ``:metadata`` command by adding your -own key sets to the ``METADATA`` section in your configfile like this:: - - keys2 = Override,Second,Set - keys4 = New,Fourth,Set - -where the values must be a comma-separated list of valid metadata keys. - -In case you are using `pyexiv2`_ you can find a complete overview of valid keys on the -`exiv2 webpage `_. You can choose any of the exif -or IPTC keys. It is considered best-practice to use the full path to any key, e.g. -``Exif.Image.FocalLength``, but for convenience the short version of the key, e.g. -``FocalLength``, also works for the keys in ``Exif.Image`` or ``Exif.Photo``. - -`Piexif`_ unfortunately always uses the short form of the key, i.e. everything that -comes after the last ``.`` character. In case you pass the full path, vimiv will remove -everything up to and including the last ``.`` character and match only the short form. - -You can get a list of valid metadata keys for the current image using the -``:metadata-list-keys`` command. - - -.. _exiv2: https://www.exiv2.org/index.html -.. _pyexiv2: https://python3-exiv2.readthedocs.io -.. _piexif: https://pypi.org/project/piexif/ diff --git a/docs/documentation/index.rst b/docs/documentation/index.rst index 59d87d854..0352a9550 100644 --- a/docs/documentation/index.rst +++ b/docs/documentation/index.rst @@ -10,7 +10,7 @@ The following documents are available: getting_started commands configuration/index - exif + metadata contributing migrating diff --git a/docs/documentation/metadata.rst b/docs/documentation/metadata.rst new file mode 100644 index 000000000..01b5d9c7c --- /dev/null +++ b/docs/documentation/metadata.rst @@ -0,0 +1,91 @@ +Metadata +======== + +Vimiv provides optional metadata support if either `pyexiv2`_ or `piexif`_ is +available. If this is the case: + +#. The ``:metadata`` command and the corresponding ``i``-keybinding is available. +#. Image metadata is automatically copied from source to destination when writing images + to disk. +#. The ``{exif-date-time}`` statusbar module is available. + +.. include:: pyexiv2.rst + +Advantages of the different metadata libraries +---------------------------------------------- + +In short, `pyexiv2`_ is much more powerful than `piexif`_, though also more involved to +install. + +.. table:: Comparison of the two libraries + :widths: 20 15 20 45 + + ======================= ============== ==================== ===================================================================== + PROPERTY `piexif`_ `pyexiv2`_ Note + ======================= ============== ==================== ===================================================================== + Exif Support True True pyexiv2 can extract way more data for the same image + ICMP Support False True + XMP Suppport False True + Output Formatting False True e.g. ``FNumber: 63/10`` vs ``FNumber: F6.3`` + Supported File Types JPEG, TIFF All common types + Ease of installation Simple More complicated pyexiv2 requires some dependencies including the C++ library `exiv2`_ + ======================= ============== ==================== ===================================================================== + + +We recommend to use `pyexiv2`_ if the installation is not too involved on your system +and `piexif`_ as a fallback solution or in case you don't need the full power of +`pyexiv2`_ and prefer something more lightweight. + + +Moving from piexif to pyexiv2 +----------------------------- + +As `pyexiv2`_ is the more powerful option compared to `piexif`_, vimiv will prefer +`pyexiv2`_ over `piexif`_. Therefore, to switch to `pyexiv2`_ simply install it on your +system and vimiv will use it automatically. If you have defined custom metadata sets in +your config, you may have to adjust them to use the full path to any key. See the next +section for more information on this. + + +Customizing metadata keysets +---------------------------- + +You can configure the information displayed by the ``:metadata`` command by adding your +own key sets to the ``METADATA`` section in your configfile like this:: + + keys2 = Override,Second,Set + keys4 = New,Fourth,Set + +where the values must be a comma-separated list of valid metadata keys. + +In case you are using `pyexiv2`_ you can find an overview of valid Exif, IPTC and XMP +keys on the `exiv2 webpage `_. It is considered +best-practice to use the full path to any key, e.g. ``Exif.Image.FocalLength``, but for convenience the short version of the key, e.g. ``FocalLength``, also works for the keys +in ``Exif.Image`` or ``Exif.Photo``. + +`Piexif`_ unfortunately always uses the short form of the key, i.e. everything that +comes after the last ``.`` character. In case you pass the full path, vimiv will remove +everything up to and including the last ``.`` character and match only the short form. + +On top of the library specific keys, vimiv also provides a series of additional +metadata keys. They can be treated completely equivalently to the library keys. + +.. table:: Complete list of internal metadata keys + :widths: 20 80 + + ======================= ================================= + Key Description + ======================= ================================= + ``Vimiv.FileSize`` File size + ``Vimiv.FileType`` File type + ``Vimiv.XDimension`` X dimension in pixel + ``Vimiv.YDimension`` Y dimension in pixel + ======================= ================================= + +You can get a list of valid metadata keys for the current image using the +``:metadata-list-keys`` command. + + +.. _exiv2: https://www.exiv2.org/index.html +.. _pyexiv2: https://python3-exiv2.readthedocs.io +.. _piexif: https://pypi.org/project/piexif/ diff --git a/tests/conftest.py b/tests/conftest.py index cf7e9bdc9..8672494c0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,7 @@ import pytest -from vimiv.imutils import exif +from vimiv.imutils import metadata CI = "CI" in os.environ @@ -24,10 +24,10 @@ ) EXIF_MARKERS = ( - ("exif", exif.has_exif_support, "Only run with exif support"), - ("noexif", not exif.has_exif_support, "Only run without exif support"), - ("pyexiv2", exif.pyexiv2 is not None, "Only run with pyexiv2"), - ("piexif", exif.piexif is not None, "Only run with piexif"), + ("exif", metadata.has_metadata_support, "Only run with exif support"), + ("noexif", not metadata.has_metadata_support, "Only run without exif support"), + ("pyexiv2", metadata.pyexiv2 is not None, "Only run with pyexiv2"), + ("piexif", metadata.piexif is not None, "Only run with piexif"), ) # fmt: on @@ -39,7 +39,7 @@ def apply_platform_markers(item): def apply_exif_markers(item): """Apply markers that skip tests depending on specific exif support.""" - if os.path.basename(item.fspath) in ("test_exif.py",): + if os.path.basename(item.fspath) in ("test_metadata.py",): for marker_name in "exif", "pyexiv2", "piexif": marker = getattr(pytest.mark, marker_name) item.add_marker(marker) @@ -168,13 +168,13 @@ def tmpdir(): @pytest.fixture() def piexif(monkeypatch): """Pytest fixture to ensure only piexif is available.""" - monkeypatch.setattr(exif, "pyexiv2", None) + monkeypatch.setattr(metadata, "pyexiv2", None) @pytest.fixture() def noexif(monkeypatch, piexif): """Pytest fixture to ensure no exif library is available.""" - monkeypatch.setattr(exif, "piexif", None) + monkeypatch.setattr(metadata, "piexif", None) @pytest.fixture() @@ -182,11 +182,30 @@ def add_exif_information(): """Fixture to retrieve a helper function that adds exif content to an image.""" def add_exif_information_impl(path: str, content): - assert exif.piexif is not None, "piexif required to add exif information" - exif_dict = exif.piexif.load(path) + assert metadata.piexif is not None, "piexif required to add exif information" + exif_dict = metadata.piexif.load(path) for ifd, ifd_dict in content.items(): for key, value in ifd_dict.items(): exif_dict[ifd][key] = value - exif.piexif.insert(exif.piexif.dump(exif_dict), path) + metadata.piexif.insert(metadata.piexif.dump(exif_dict), path) return add_exif_information_impl + + +@pytest.fixture() +def add_metadata_information(): + """Fixture to retrieve a helper function that adds metadata content to an image.""" + + def add_metadata_information_impl(path: str, content): + assert ( + metadata.pyexiv2 is not None + ), "pyexiv2 required to add metadata information" + _metadata = metadata.pyexiv2.ImageMetadata(path) + _metadata.read() + + for tag in content.values(): + _metadata[tag.key] = tag.value + + _metadata.write() + + return add_metadata_information_impl diff --git a/tests/unit/imutils/test_exif.py b/tests/unit/imutils/test_exif.py deleted file mode 100644 index de4df289d..000000000 --- a/tests/unit/imutils/test_exif.py +++ /dev/null @@ -1,61 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sw=4 et sts=4 - -# This file is part of vimiv. -# Copyright 2017-2021 Christian Karl (karlch) -# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details. - -"""Tests for vimiv.imutils.exif.""" - -import pytest - -from vimiv.imutils import exif - - -@pytest.fixture(params=[exif.ExifHandler, exif._ExifHandlerPiexif]) -def exif_handler(request): - """Parametrized pytest fixture to yield the different exif handlers.""" - yield request.param - - -def test_check_exif_dependency(): - default = None - assert exif.check_exif_dependancy(default) == default - - -def test_check_exif_dependency_piexif(piexif): - default = None - assert exif.check_exif_dependancy(default) == exif._ExifHandlerPiexif - - -def test_check_exif_dependency_noexif(noexif): - default = None - assert exif.check_exif_dependancy(default) == exif._ExifHandlerBase - - -@pytest.mark.parametrize( - "methodname, args", - ( - ("copy_exif", ("dest.jpg",)), - ("exif_date_time", ()), - ("get_formatted_exif", ([],)), - ("get_keys", ()), - ), -) -def test_handler_base_raises(methodname, args): - handler = exif._ExifHandlerBase() - method = getattr(handler, methodname) - with pytest.raises(exif.UnsupportedExifOperation): - method(*args) - - -@pytest.mark.parametrize( - "handler, expected_msg", - ( - (exif.ExifHandler, "not supported by pyexiv2"), - (exif._ExifHandlerPiexif, "not supported by piexif"), - (exif._ExifHandlerBase, "not supported. Please install"), - ), -) -def test_handler_exception_customization(handler, expected_msg): - with pytest.raises(exif.UnsupportedExifOperation, match=expected_msg): - handler.raise_exception("test operation") diff --git a/tests/unit/imutils/test_metadata.py b/tests/unit/imutils/test_metadata.py new file mode 100644 index 000000000..3b6ac49a2 --- /dev/null +++ b/tests/unit/imutils/test_metadata.py @@ -0,0 +1,385 @@ +# vim: ft=python fileencoding=utf-8 sw=4 et sts=4 + +# This file is part of vimiv. +# Copyright 2017-2021 Christian Karl (karlch) +# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details. + +"""Tests for vimiv.imutils.metadata.""" + +from fractions import Fraction +from PyQt5.QtGui import QPixmap +import pytest + +from vimiv.imutils import metadata +from vimiv.imutils.metadata import MetadataHandler + +try: + import pyexiv2 +except ImportError: + pyexiv2 = None + + +EXTERNAL_CONTENT = { + "Exif.Image.Copyright": pyexiv2.exif.ExifTag( + "Exif.Image.Copyright", "vimiv-AUTHORS-2021" + ), + "Exif.Image.DateTime": pyexiv2.exif.ExifTag( + "Exif.Image.DateTime", "2017-12-16 16:21:57" + ), + "Exif.Image.Make": pyexiv2.exif.ExifTag("Exif.Image.Make", "vimiv"), + "Exif.Photo.ApertureValue": pyexiv2.exif.ExifTag( + "Exif.Photo.ApertureValue", Fraction(4) + ), + "Exif.Photo.ExposureTime": pyexiv2.exif.ExifTag( + "Exif.Photo.ExposureTime", Fraction(1, 25) + ), + "Exif.Photo.FocalLength": pyexiv2.exif.ExifTag( + "Exif.Photo.FocalLength", Fraction(600) + ), + "Exif.Photo.ISOSpeedRatings": pyexiv2.exif.ExifTag( + "Exif.Photo.ISOSpeedRatings", [320] + ), + "Exif.GPSInfo.GPSAltitude": pyexiv2.exif.ExifTag( + "Exif.GPSInfo.GPSAltitude", Fraction(2964) + ), + "Iptc.Application2.Program": pyexiv2.iptc.IptcTag( + "Iptc.Application2.Program", ["Vimiv"] + ), + "Iptc.Application2.Keywords": pyexiv2.iptc.IptcTag( + "Iptc.Application2.Keywords", ["ImageViewer", "Application", "Linux"] + ), + "Xmp.xmpRights.Owner": pyexiv2.xmp.XmpTag( + "Xmp.xmpRights.Owner", ["vimiv-AUTHORS-2021"] + ), + "Xmp.xmp.Rating": pyexiv2.xmp.XmpTag("Xmp.xmp.Rating", 5), +} + + +INTERNAL_CONTENT = { + "Vimiv.XDimension": 300, + "Vimiv.YDimension": 300, + "Vimiv.FileType": "jpg", +} + + +# TODO: Write as variable +def _full_content(): + # Merge both dicts + # return external_content | internal_content # Only working for Py 3.9 + content = EXTERNAL_CONTENT.copy() + content.update(INTERNAL_CONTENT) + return content + + +FULL_CONTENT = _full_content() + + +INVALID_KEYS = ["Invalid", "Invalid.Key", "Not.Valid.2"] + + +TEST_KEYS = [ + list(FULL_CONTENT.keys()), + ["Exif.Image.Copyright", "Exif.Image.Make"], + ["Exif.GPSInfo.GPSAltitude", "Vimiv.XDimension", "Vimiv.YDimension"], + INVALID_KEYS + ["Exif.Photo.FocalLength"], + ["Iptc.Application2.Program", "Iptc.Application2.Keywords"], + ["Xmp.xmp.Rating", "Exif.Photo.ISOSpeedRatings", "Iptc.Application2.Program"], + ["Xmp.xmp.Rating", "Xmp.xmpRights.Owner"], +] + + +@pytest.fixture() +def dummy_image(qapp, tmp_path): + path = tmp_path + + def _get_img(name="image.jpg"): + filename = str(path / name) + QPixmap(300, 300).save(filename) + return filename + + return _get_img + + +@pytest.fixture +def main_handler_exiv2(): + return lambda s: MetadataHandler(s) + + +@pytest.fixture +def main_handler_piexif(main_handler_exiv2): + def aux(img: str): + handler = main_handler_exiv2(img) + handler._ext_handler = metadata._ExternalKeyHandlerPiexif(img) + return handler + + return aux + + +# Todo: Cleanup fixture by reusing main_handler_* fixtures +@pytest.fixture(params=[True, False]) +def main_handler(request, main_handler_exiv2): + if request.param: + return lambda s: MetadataHandler(s) + + def aux(img: str): + handler = main_handler_exiv2(img) + handler._ext_handler = metadata._ExternalKeyHandlerPiexif(img) + return handler + + return aux + + +@pytest.fixture( + params=[metadata.ExternalKeyHandler, metadata._ExternalKeyHandlerPiexif] +) +def external_handler(request): + """Parametrized pytest fixture to yield the different external handlers.""" + yield request.param + + +def test_check_external_dependency(): + default = None + assert metadata.check_external_dependency(default) == default + + +def test_check_external_dependency_piexif(piexif): + default = None + assert ( + metadata.check_external_dependency(default) + == metadata._ExternalKeyHandlerPiexif + ) + + +def test_check_external_dependency_noexif(noexif): + default = None + assert ( + metadata.check_external_dependency(default) == metadata._ExternalKeyHandlerBase + ) + + +@pytest.mark.parametrize( + "methodname, args", + ( + ("copy_metadata", ("dest.jpg",)), + ("get_date_time", ()), + ("fetch_key", ([],)), + ("get_keys", ()), + ), +) +def test_handler_base_raises(methodname, args): + handler = metadata._ExternalKeyHandlerBase() + method = getattr(handler, methodname) + with pytest.raises(metadata.UnsupportedMetadataOperation): + method(*args) + + +@pytest.mark.parametrize( + "handler, expected_msg", + ( + (metadata.ExternalKeyHandler, "not supported by pyexiv2"), + (metadata._ExternalKeyHandlerPiexif, "not supported by piexif"), + (metadata._ExternalKeyHandlerBase, "not supported. Please install"), + ), +) +def test_handler_exception_customization(handler, expected_msg): + with pytest.raises(metadata.UnsupportedMetadataOperation, match=expected_msg): + handler.raise_exception("test operation") + + +def value_match(required_value, actual_value): + try: + val = required_value.human_value + except AttributeError: + try: + val = required_value.raw_value + except AttributeError: + try: + val = required_value.value + except AttributeError: + val = required_value + + # Iptc is a list with byte elements of any type + # Xmp can be a list with elements of any type + try: + try: + joined_list = ", ".join([str(e.decode()) for e in val]) + except AttributeError: + joined_list = ", ".join([str(e) for e in val]) + except TypeError: + joined_list = "" + + assert actual_value in (val, joined_list) + + +def value_match_piexif(required_value, actual_value): + try: + val = required_value.raw_value + except AttributeError: + try: + val = required_value.value + except AttributeError: + val = required_value + + assert actual_value == val + + +# TODO add more test cases +@pytest.mark.parametrize( + "metadata_key, expected_key, expected_value", + [("Exif.Image.Copyright", "Exif.Image.Copyright", "vimiv-AUTHORS-2021")], +) +def test_metadatahandler_fetch_key_exiv2( + main_handler_exiv2, + dummy_image, + add_metadata_information, + metadata_key, + expected_key, + expected_value, +): + src = dummy_image() + add_metadata_information(src, EXTERNAL_CONTENT) + handler = main_handler_exiv2(src) + + fetched_key, _, fetched_value = handler.fetch_key(metadata_key) + assert fetched_key == expected_key + assert fetched_value == expected_value + + +# TODO add more test cases +@pytest.mark.parametrize( + "metadata_key, expected_key, expected_value", + [("Exif.Image.Copyright", "Exif.Image.Copyright", "vimiv-AUTHORS-2021")], +) +def test_metadatahandler_fetch_key_piexif( + main_handler_piexif, + dummy_image, + add_metadata_information, + metadata_key, + expected_key, + expected_value, +): + src = dummy_image() + add_metadata_information(src, EXTERNAL_CONTENT) + handler = main_handler_piexif(src) + + if "iptc" in metadata_key.lower() or "xmp" in metadata_key.lower(): + return + + fetched_key, _, fetched_value = handler.fetch_key(metadata_key) + short_key = metadata_key.rpartition(".")[-1] + assert fetched_key in (metadata_key, short_key) + assert fetched_value == expected_value + + +@pytest.mark.parametrize("metadata_key", INVALID_KEYS) +def test_metadatahandler_fetch_key_invalid( + main_handler, dummy_image, add_metadata_information, metadata_key +): + src = dummy_image() + add_metadata_information(src, EXTERNAL_CONTENT) + handler = main_handler(src) + + with pytest.raises(KeyError): + _, _, _ = handler.fetch_key(metadata_key) + + +@pytest.mark.parametrize("current_keys", TEST_KEYS) +def test_metadatahandler_fetch_keys_exiv2( + main_handler_exiv2, add_metadata_information, dummy_image, current_keys +): + src = dummy_image() + add_metadata_information(src, EXTERNAL_CONTENT) + handler = main_handler_exiv2(src) + + valid_keys = [key for key in current_keys if key in FULL_CONTENT.keys()] + fetched = handler.fetch_keys(current_keys) + assert len(fetched) == len(valid_keys) + + for current_key in valid_keys: + assert current_key in fetched + value_match(FULL_CONTENT[current_key], fetched[current_key][1]) + + +@pytest.mark.parametrize("current_keys", TEST_KEYS) +def test_metadatahandler_fetch_keys_piexif( + main_handler_piexif, add_metadata_information, dummy_image, current_keys +): + src = dummy_image() + add_metadata_information(src, EXTERNAL_CONTENT) + handler = main_handler_piexif(src) + + valid_keys = [ + key + for key in current_keys + if key in FULL_CONTENT.keys() + and not ("iptc" in key.lower() or "xmp" in key.lower()) + ] + + fetched = handler.fetch_keys(current_keys) + assert len(fetched) == len(valid_keys) + + for current_key in valid_keys: + short_key = current_key.rpartition(".")[-1] + assert current_key in fetched or short_key in fetched + # Internal keys are still in long form + try: + value_match_piexif(FULL_CONTENT[current_key], fetched[short_key][1]) + except KeyError: + value_match_piexif(FULL_CONTENT[current_key], fetched[current_key][1]) + + +def test_metadatahandler_get_keys(main_handler, add_metadata_information, dummy_image): + src = dummy_image() + add_metadata_information(src, EXTERNAL_CONTENT) + handler = main_handler(src) + + available_keys = list(FULL_CONTENT.keys()) + + if isinstance(handler._external_handler, metadata._ExternalKeyHandlerPiexif): + available_keys = [ + e for e in available_keys if not ("iptc" in e.lower() or "xmp" in e.lower()) + ] + + fetched_keys = list(handler.get_keys()) + + # Available_keys does not contain all possible keys + assert len(fetched_keys) >= len(available_keys) + + for key in available_keys: + short_key = key.rpartition(".")[-1] + assert key in fetched_keys or short_key in fetched_keys + + +@pytest.mark.parametrize( + "metadata, expected_date", + [ + ( + { + "Exif.Image.DateTime": pyexiv2.exif.ExifTag( + "Exif.Image.DateTime", "2017-12-16 16:21:57" + ) + }, + "2017-12-16 16:21:57", + ), + ({}, ""), + ], +) +def test_external_keyhandler_get_date_time( + external_handler, dummy_image, add_metadata_information, metadata, expected_date +): + src = dummy_image("image.jpg") + add_metadata_information(src, metadata) + handler = external_handler(src) + assert handler.get_date_time() == expected_date + + +def test_external_keyhandler_copy_metadata( + external_handler, dummy_image, add_metadata_information +): + src = dummy_image("src.jpg") + add_metadata_information(src, EXTERNAL_CONTENT) + src_handler = external_handler(src) + + dest = dummy_image("dest.jpg") + src_handler.copy_metadata(dest) + assert len(list(metadata.MetadataHandler(dest).get_keys())) >= len(FULL_CONTENT) diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py index 639935ee2..c3c8e30f4 100644 --- a/tests/unit/test_version.py +++ b/tests/unit/test_version.py @@ -9,7 +9,7 @@ import pytest from vimiv import version -from vimiv.imutils import exif +from vimiv.imutils import metadata @pytest.fixture @@ -27,12 +27,12 @@ def test_no_svg_support_info(no_svg_support): @pytest.mark.pyexiv2 def test_pyexiv2_info(): - assert exif.pyexiv2.__version__ in version.info() + assert metadata.pyexiv2.__version__ in version.info() @pytest.mark.piexif def test_piexif_info(): - assert exif.piexif.VERSION in version.info() + assert metadata.piexif.VERSION in version.info() def test_no_exif_support_info(noexif): diff --git a/vimiv/gui/mainwindow.py b/vimiv/gui/mainwindow.py index 9260a9110..43e000d5e 100644 --- a/vimiv/gui/mainwindow.py +++ b/vimiv/gui/mainwindow.py @@ -50,7 +50,7 @@ def __init__(self): self._overlays.append(KeyhintWidget(self)) self._overlays.append(Message(self)) self._overlays.append(CommandWidget(self)) - if MetadataWidget is not None: # Not defined if there is no exif support + if MetadataWidget is not None: # Not defined if there is no metadata support self._overlays.append(MetadataWidget(self)) # Connect signals api.status.signals.update.connect(self._set_title) diff --git a/vimiv/gui/metadatawidget.py b/vimiv/gui/metadatawidget.py index 59b4138e5..1b0ec93aa 100644 --- a/vimiv/gui/metadatawidget.py +++ b/vimiv/gui/metadatawidget.py @@ -13,27 +13,27 @@ from PyQt5.QtWidgets import QLabel, QSizePolicy from vimiv import api, utils -from vimiv.imutils import exif +from vimiv.imutils import metadata from vimiv.config import styles _logger = utils.log.module_logger(__name__) -if exif.has_exif_support: +if metadata.has_metadata_support: class MetadataWidget(QLabel): """Overlay widget to display image metadata. - The display of the widget can be toggled by command. It is filled with exif - metadata information of the current image. + The display of the widget can be toggled by command. It is filled with metadata + information of the current image. Attributes: _mainwindow_bottom: y-coordinate of the bottom of the mainwindow. _mainwindow_width: width of the mainwindow. - _path: Absolute path of the current image to load exif metadata of. + _path: Absolute path of the current image to load metadata of. _current_set: Holds a string of the currently selected keyset. - _handler: ExifHandler for _path or None. Use the handler property to access. + _handler: MetadataHandler for _path or None. Accessed via handler property. """ STYLESHEET = """ @@ -58,7 +58,7 @@ def __init__(self, parent): self._mainwindow_width = 0 self._path = "" self._current_set = "" - self._handler: Optional[exif.ExifHandler] = None + self._handler: Optional[metadata.MetadataHandler] = None api.signals.new_image_opened.connect(self._on_image_opened) api.settings.metadata.current_keyset.changed.connect(self._update_text) @@ -66,16 +66,16 @@ def __init__(self, parent): self.hide() @property - def handler(self) -> exif.ExifHandler: - """Return the ExifHandler for the current path.""" + def handler(self) -> metadata.MetadataHandler: + """Return the MetadataHandler for the current path.""" if self._handler is None: - self._handler = exif.ExifHandler(self._path) + self._handler = metadata.MetadataHandler(self._path) return self._handler @api.keybindings.register("i", "metadata", mode=api.modes.IMAGE) @api.commands.register(mode=api.modes.IMAGE) def metadata(self, count: Optional[int] = None): - """Toggle display of exif metadata of current image. + """Toggle metadata display of current image. **count:** Select the key set to display instead. @@ -122,6 +122,7 @@ def metadata_list_keys(self, n_cols: int = 3, to_term: bool = False): """ keys = sorted(set(self.handler.get_keys())) + _logger.debug("Successfully got keys") if to_term: print(*keys, sep="\n") elif n_cols < 1: @@ -134,6 +135,7 @@ def metadata_list_keys(self, n_cols: int = 3, to_term: bool = False): self.setText(table) self._update_geometry() self.show() + _logger.debug("Displaying keys in %d columns.", columns) def update_geometry(self, window_width, window_bottom): """Adapt location when main window geometry changes.""" @@ -154,15 +156,17 @@ def _update_text(self): if self._current_set == api.settings.metadata.current_keyset.value: return _logger.debug( - "%s: reading exif of %s", self.__class__.__qualname__, self._path + "%s: reading metadata of %s", self.__class__.__qualname__, self._path ) keys = [ e.strip() for e in api.settings.metadata.current_keyset.value.split(",") ] _logger.debug(f"Read metadata.current_keys {keys}") - formatted_exif = self.handler.get_formatted_exif(keys) - if formatted_exif: - self.setText(utils.format_html_table(formatted_exif.values())) + data = self.handler.fetch_keys(keys) + _logger.debug("Fetched metadata") + + if data: + self.setText(utils.format_html_table(data.values())) else: self.setText("No matching metadata found") self._update_geometry() @@ -178,5 +182,5 @@ def _on_image_opened(self, path: str): self._update_text() -else: # No exif support # pragma: no cover # Covered in another CI +else: # No metadata support # pragma: no cover # Covered in another CI MetadataWidget = None # type: ignore diff --git a/vimiv/imutils/__init__.py b/vimiv/imutils/__init__.py index d69bcc45a..0e8893d7a 100644 --- a/vimiv/imutils/__init__.py +++ b/vimiv/imutils/__init__.py @@ -38,7 +38,7 @@ the appropriate Qt widget. """ -from vimiv.imutils import exif +from vimiv.imutils import metadata from vimiv.imutils.edit_handler import EditHandler from vimiv.imutils.filelist import current, pathlist from vimiv.imutils.filelist import SignalHandler as _FilelistSignalHandler diff --git a/vimiv/imutils/_file_handler.py b/vimiv/imutils/_file_handler.py index ae2ebe8f2..85994538e 100644 --- a/vimiv/imutils/_file_handler.py +++ b/vimiv/imutils/_file_handler.py @@ -162,9 +162,9 @@ def write_pixmap(self, pixmap, path=None, original_path=None, parallel=True): def write_pixmap(pixmap, path, original_path): """Write pixmap to file. - This requires both the path to write to and the original path as Exif data + This requires both the path to write to and the original path as metadata may be copied from the original path to the new copy. The procedure is to - write the path to a temporary file first, transplant the Exif data to the + write the path to a temporary file first, transplant the metadata to the temporary file if possible and finally rename the temporary file to the final path. The renaming is done as it is an atomic operation and we may be overriding the existing file. @@ -172,7 +172,7 @@ def write_pixmap(pixmap, path, original_path): Args: pixmap: The QPixmap to write. path: Path to write the pixmap to. - original_path: Original path of the opened pixmap to retrieve exif information. + original_path: Original path of the opened pixmap to retrieve metadata. """ try: _can_write(pixmap, path) @@ -210,10 +210,10 @@ def _write(pixmap, path, original_path): handle, filename = tempfile.mkstemp(suffix=ext) os.close(handle) pixmap.save(filename) - # Copy exif info from original file to new file + # Copy metadata info from original file to new file try: - imutils.exif.ExifHandler(original_path).copy_exif(filename) - except imutils.exif.UnsupportedExifOperation: + imutils.metadata.ExternalKeyHandler(original_path).copy_metadata(filename) + except imutils.metadata.UnsupportedMetadataOperation: pass shutil.move(filename, path) # Check if valid image was created diff --git a/vimiv/imutils/exif.py b/vimiv/imutils/exif.py deleted file mode 100644 index a6c8ee05f..000000000 --- a/vimiv/imutils/exif.py +++ /dev/null @@ -1,290 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sw=4 et sts=4 - -# This file is part of vimiv. -# Copyright 2017-2021 Christian Karl (karlch) -# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details. - -"""Utility functions and classes for exif handling. - -All exif related tasks are implemented in this module. The heavy lifting is done using -one of the supported exif libraries, i.e. -* piexif (https://pypi.org/project/piexif/) and -* pyexiv2 (https://pypi.org/project/py3exiv2/). -""" - -import contextlib -import itertools -from typing import Any, Dict, Tuple, NoReturn, Sequence, Iterable - -from vimiv.utils import log, lazy, is_hex - -pyexiv2 = lazy.import_module("pyexiv2", optional=True) -piexif = lazy.import_module("piexif", optional=True) -_logger = log.module_logger(__name__) - -ExifDictT = Dict[Any, Tuple[str, str]] - - -class UnsupportedExifOperation(NotImplementedError): - """Raised if an exif operation is not supported by the used library if any.""" - - -class _ExifHandlerBase: - """Handler to load and copy exif information of a single image. - - This class provides the interface for handling exif support. By default none of the - operations are implemented. Instead it is up to a child class which wraps around one - of the supported exif libraries to implement the methods it can. - """ - - MESSAGE_SUFFIX = ". Please install pyexiv2 or piexif for exif support." - - def __init__(self, _filename=""): - pass - - def copy_exif(self, _dest: str, _reset_orientation: bool = True) -> None: - """Copy exif information from current image to dest. - - Args: - dest: Path to write the exif information to. - reset_orientation: If true, reset the exif orientation tag to normal. - """ - self.raise_exception("Copying exif data") - - def exif_date_time(self) -> str: - """Get exif creation date and time as formatted string.""" - self.raise_exception("Retrieving exif date-time") - - def get_formatted_exif(self, _desired_keys: Sequence[str]) -> ExifDictT: - """Get a dictionary of formatted exif values.""" - self.raise_exception("Getting formatted exif data") - - def get_keys(self) -> Iterable[str]: - """Retrieve the name of all exif keys available.""" - self.raise_exception("Getting exif keys") - - @classmethod - def raise_exception(cls, operation: str) -> NoReturn: - """Raise an exception for a not implemented exif operation.""" - msg = f"{operation} is not supported{cls.MESSAGE_SUFFIX}" - _logger.warning(msg, once=True) - raise UnsupportedExifOperation(msg) - - -class _ExifHandlerPiexif(_ExifHandlerBase): - """Implementation of ExifHandler based on piexif.""" - - MESSAGE_SUFFIX = " by piexif." - - def __init__(self, filename=""): - super().__init__(filename) - try: - self._metadata = piexif.load(filename) - except FileNotFoundError: - _logger.debug("File %s not found", filename) - self._metadata = None - - def get_formatted_exif(self, desired_keys: Sequence[str]) -> ExifDictT: - desired_keys = [key.rpartition(".")[2] for key in desired_keys] - exif = dict() - - try: - for ifd in self._metadata: - if ifd == "thumbnail": - continue - - for tag in self._metadata[ifd]: - keyname = piexif.TAGS[ifd][tag]["name"] - keytype = piexif.TAGS[ifd][tag]["type"] - val = self._metadata[ifd][tag] - _logger.debug( - f"name: {keyname}\ - type: {keytype}\ - value: {val}\ - tag: {tag}" - ) - if keyname not in desired_keys: - _logger.debug(f"Ignoring key {keyname}") - continue - if keytype in ( - piexif.TYPES.Byte, - piexif.TYPES.Short, - piexif.TYPES.Long, - piexif.TYPES.SByte, - piexif.TYPES.SShort, - piexif.TYPES.SLong, - piexif.TYPES.Float, - piexif.TYPES.DFloat, - ): # integer and float - exif[keyname] = (keyname, str(val)) - elif keytype in ( - piexif.TYPES.Ascii, - piexif.TYPES.Undefined, - ): # byte encoded - exif[keyname] = (keyname, val.decode()) - elif keytype in ( - piexif.TYPES.Rational, - piexif.TYPES.SRational, - ): # (int, int) <=> numerator, denominator - exif[keyname] = (keyname, f"{val[0]}/{val[1]}") - - except (piexif.InvalidImageDataError, KeyError): - return {} - - return exif - - def get_keys(self) -> Iterable[str]: - return ( - piexif.TAGS[ifd][tag]["name"] - for ifd in self._metadata - if ifd != "thumbnail" - for tag in self._metadata[ifd] - ) - - def copy_exif(self, dest: str, reset_orientation: bool = True) -> None: - try: - if reset_orientation: - with contextlib.suppress(KeyError): - self._metadata["0th"][ - piexif.ImageIFD.Orientation - ] = ExifOrientation.Normal - exif_bytes = piexif.dump(self._metadata) - piexif.insert(exif_bytes, dest) - _logger.debug("Successfully wrote exif data for '%s'", dest) - except piexif.InvalidImageDataError: # File is not a jpg - _logger.debug("File format for '%s' does not support exif", dest) - except ValueError: - _logger.debug("No exif data in '%s'", dest) - - def exif_date_time(self) -> str: - with contextlib.suppress( - piexif.InvalidImageDataError, FileNotFoundError, KeyError - ): - return self._metadata["0th"][piexif.ImageIFD.DateTime].decode() - return "" - - -def check_exif_dependancy(handler): - """Decorator for ExifHandler which requires the optional pyexiv2 module. - - If pyexiv2 is available, the class is left as it is. If pyexiv2 is not available - but the less powerful piexif module is, _ExifHandlerPiexif is returned instead. - If none of the two modules are available, the base implementation which always - throws an exception is returned. - - Args: - handler: The class to be decorated. - """ - if pyexiv2: - return handler - - if piexif: - return _ExifHandlerPiexif - - _logger.warning( - "There is no exif support and therefore:\n" - "1. Exif data is lost when writing images to disk.\n" - "2. The `:metadata` command and associated `i` keybinding is not available.\n" - "3. The {exif-date-time} statusbar module is not available.\n\n" - "Please install pyexiv2 or piexif to silence this warning.\n" - "For more information see\n" - "https://karlch.github.io/vimiv-qt/documentation/exif.html\n" - ) - - return _ExifHandlerBase - - -@check_exif_dependancy -class ExifHandler(_ExifHandlerBase): - """Main ExifHandler implementation based on pyexiv2.""" - - MESSAGE_SUFFIX = " by pyexiv2." - - def __init__(self, filename=""): - super().__init__(filename) - try: - self._metadata = pyexiv2.ImageMetadata(filename) - self._metadata.read() - except FileNotFoundError: - _logger.debug("File %s not found", filename) - - def get_formatted_exif(self, desired_keys: Sequence[str]) -> ExifDictT: - exif = dict() - - for base_key in desired_keys: - # For backwards compability, assume it has one of the following prefixes - for prefix in ["", "Exif.Image.", "Exif.Photo."]: - key = f"{prefix}{base_key}" - try: - key_name = self._metadata[key].name - - try: - key_value = self._metadata[key].human_value - - # Not all metadata (i.e. IPTC) provide human_value, take raw_value - except AttributeError: - value = self._metadata[key].raw_value - - # For IPTC the raw_value is a list of strings - if isinstance(value, list): - key_value = ", ".join(value) - else: - key_value = value - - exif[key] = (key_name, key_value) - break - - except KeyError: - _logger.debug("Key %s is invalid for the current image", key) - - return exif - - def get_keys(self) -> Iterable[str]: - return (key for key in self._metadata if not is_hex(key.rpartition(".")[2])) - - def copy_exif(self, dest: str, reset_orientation: bool = True) -> None: - if reset_orientation: - with contextlib.suppress(KeyError): - self._metadata["Exif.Image.Orientation"] = ExifOrientation.Normal - try: - dest_image = pyexiv2.ImageMetadata(dest) - dest_image.read() - - # File types restrict the metadata type they can store. - # Try copying all types one by one and skip if it fails. - for copy_args in set(itertools.permutations((True, False, False, False))): - with contextlib.suppress(ValueError): - self._metadata.copy(dest_image, *copy_args) - - dest_image.write() - - _logger.debug("Successfully wrote exif data for '%s'", dest) - except FileNotFoundError: - _logger.debug("Failed to write exif data. Destination '%s' not found", dest) - except OSError as e: - _logger.debug("Failed to write exif data for '%s': '%s'", dest, str(e)) - - def exif_date_time(self) -> str: - with contextlib.suppress(KeyError): - return self._metadata["Exif.Image.DateTime"].raw_value - return "" - - -has_exif_support = ExifHandler != _ExifHandlerBase - - -class ExifOrientation: - """Namespace for exif orientation tags. - - For more information see: http://jpegclub.org/exif_orientation.html. - """ - - Unspecified = 0 - Normal = 1 - HorizontalFlip = 2 - Rotation180 = 3 - VerticalFlip = 4 - Rotation90HorizontalFlip = 5 - Rotation90 = 6 - Rotation90VerticalFlip = 7 - Rotation270 = 8 diff --git a/vimiv/imutils/filelist.py b/vimiv/imutils/filelist.py index 578e2b895..49a57db91 100644 --- a/vimiv/imutils/filelist.py +++ b/vimiv/imutils/filelist.py @@ -127,8 +127,8 @@ def exif_date_time() -> str: be used as basis to work with. """ try: - return imutils.exif.ExifHandler(current()).exif_date_time() - except imutils.exif.UnsupportedExifOperation: + return imutils.metadata.ExternalKeyHandler(current()).get_date_time() + except imutils.metadata.UnsupportedMetadataOperation: return "" diff --git a/vimiv/imutils/metadata.py b/vimiv/imutils/metadata.py new file mode 100644 index 000000000..2a962087a --- /dev/null +++ b/vimiv/imutils/metadata.py @@ -0,0 +1,440 @@ +# vim: ft=python fileencoding=utf-8 sw=4 et sts=4 + +# This file is part of vimiv. +# Copyright 2017-2021 Christian Karl (karlch) +# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details. + +"""Utility functions and classes for metadata handling. + +All metadata related tasks are implemented in this module. The heavy lifting is done +using one of the supported metadata libraries, i.e. +* piexif (https://pypi.org/project/piexif/) and +* pyexiv2 (https://pypi.org/project/py3exiv2/). +""" + +import contextlib +import itertools +from typing import Any, Dict, Tuple, NoReturn, Sequence, Iterable, Optional +from PyQt5.QtGui import QImageReader + +from vimiv.utils import log, lazy, is_hex, files + +pyexiv2 = lazy.import_module("pyexiv2", optional=True) +piexif = lazy.import_module("piexif", optional=True) +_logger = log.module_logger(__name__) + + +class _InternalKeyHandler(dict): + """Handler to load all internal keys of a single image. + + Attributes: + _path: Apsolute path of the image to load the metadata of. + _reader: QImageReader or None of _path. Accessed via reder property. + """ + + def __init__(self, path: str): + super().__init__( + { + "vimiv.filesize": ("Vimiv.FileSize", self._get_filesize), + "vimiv.xdimension": ("Vimiv.XDimension", self._get_xdimension), + "vimiv.ydimension": ("Vimiv.YDimension", self._get_ydimension), + "vimiv.filetype": ("Vimiv.FileType", self._get_filetype), + } + ) + self._path = path + self._reader: Optional[QImageReader] = None + + @property + def reader(self) -> QImageReader: + """Return QImageReader instance of _path.""" + if self._reader is None: + self._reader = QImageReader(self._path) + return self._reader + + def _get_filesize(self): + """Get the file size.""" + return files.get_size_file(self._path) + + def _get_filetype(self): + """Get the file type.""" + return files.imghdr.what(self._path) + + def _get_xdimension(self): + """Get the x dimension in pixels.""" + return self.reader.size().width() + + def _get_ydimension(self): + """Get the y dimension in pixels.""" + return self.reader.size().height() + + def __getitem__(self, key: str) -> Tuple[str, str, str]: + """Entrypoint to extract the key of the image at _path. + + Args: + key: internal key to fetch. + + Throws: + KeyError + """ + key, func = super().__getitem__(key.lower()) + return (key, key, func()) + + def get_keys(self) -> Iterable[str]: + """Returns a sequence of all available metadata keys.""" + return (key for key, _ in self.values()) + + +class UnsupportedMetadataOperation(NotImplementedError): + """Raised if a metadata operation is not supported by the used library if any.""" + + +class _ExternalKeyHandlerBase: + """Handler to load and copy metadata information of a single image. + + This class provides the interface for handling metadata support. By default none of + the operations are implemented. Instead it is up to a child class which wraps + around one of the supported metadata libraries to implement the methods it can. + """ + + MESSAGE_SUFFIX = ". Please install pyexiv2 or piexif for metadata support." + + def __init__(self, filename=""): + self.filename = filename + + def copy_metadata(self, _dest: str, _reset_orientation: bool = True) -> None: + """Copy metadata information from current image to dest. + + Args: + dest: Path to write the metadata information to. + reset_orientation: If true, reset the exif orientation tag to normal. + """ + self.raise_exception("Copying metadata data") + + def get_date_time(self) -> str: + """Get exif creation date and time as formatted string.""" + self.raise_exception("Retrieving exif date-time") + + def fetch_key(self, _base_key: str) -> Tuple[str, str, str]: + """Fetch a single metadata key. + + Args: + _base_key: metadata key to fetch. + + Throws: + KeyError + """ + self.raise_exception("Getting formatted keys") + + def get_keys(self) -> Iterable[str]: + """Retrieve the name of all metadata keys available.""" + self.raise_exception("Getting metadata keys") + + @classmethod + def raise_exception(cls, operation: str) -> NoReturn: + """Raise an exception for a not implemented metadata operation.""" + msg = f"{operation} is not supported{cls.MESSAGE_SUFFIX}" + _logger.warning(msg, once=True) + raise UnsupportedMetadataOperation(msg) + + +class _ExternalKeyHandlerPiexif(_ExternalKeyHandlerBase): + """Implementation of ExternalKeyHandler based on piexif.""" + + MESSAGE_SUFFIX = " by piexif." + + def __init__(self, filename=""): + super().__init__(filename) + try: + self._metadata = piexif.load(filename) + except FileNotFoundError: + _logger.debug("File %s not found", filename) + self._metadata = None + except piexif.InvalidImageDataError: + log.warning( + "Piexif only supports the file types JPEG and TIFF.
\n" + "Please install pyexiv2 for better file type support.
\n" + "For more information see
\n" + "https://karlch.github.io/vimiv-qt/documentation/exif.html", + once=True, + ) + self._metadata = None + + def fetch_key(self, base_key: str) -> Tuple[str, str, str]: + key = base_key.rpartition(".")[2] + + if self._metadata is None: + raise KeyError(f"Key '{base_key}' not found as there is no metadata") + + for ifd in self._metadata: + if ifd == "thumbnail": + continue + + for tag in self._metadata[ifd]: + keyname = piexif.TAGS[ifd][tag]["name"] + keytype = piexif.TAGS[ifd][tag]["type"] + val = self._metadata[ifd][tag] + _logger.debug( + f"name: {keyname}\ + type: {keytype}\ + value: {val}\ + tag: {tag}" + ) + if keyname != key: + continue + if keytype in ( + piexif.TYPES.Byte, + piexif.TYPES.Short, + piexif.TYPES.Long, + piexif.TYPES.SByte, + piexif.TYPES.SShort, + piexif.TYPES.SLong, + piexif.TYPES.Float, + piexif.TYPES.DFloat, + ): # integer and float + return (keyname, keyname, str(val)) + if keytype in ( + piexif.TYPES.Ascii, + piexif.TYPES.Undefined, + ): # byte encoded + return (keyname, keyname, val.decode()) + if keytype in ( + piexif.TYPES.Rational, + piexif.TYPES.SRational, + ): # (int, int) <=> numerator, denominator + return (keyname, keyname, f"{val[0]}/{val[1]}") + + raise KeyError(f"Key '{base_key}' not found") + + def get_keys(self) -> Iterable[str]: + if self._metadata is None: + return [] + + return ( + piexif.TAGS[ifd][tag]["name"] + for ifd in self._metadata + if ifd != "thumbnail" + for tag in self._metadata[ifd] + ) + + def copy_metadata(self, dest: str, reset_orientation: bool = True) -> None: + if self._metadata is None: + return + + try: + if reset_orientation: + with contextlib.suppress(KeyError): + self._metadata["0th"][ + piexif.ImageIFD.Orientation + ] = ExifOrientation.Normal + metadata_bytes = piexif.dump(self._metadata) + piexif.insert(metadata_bytes, dest) + _logger.debug("Successfully wrote metadata data for '%s'", dest) + except piexif.InvalidImageDataError: # File is not a jpg + _logger.debug("File format for '%s' does not support metadata", dest) + except ValueError: + _logger.debug("No metadata data in '%s'", dest) + + def get_date_time(self) -> str: + if self._metadata is None: + return "" + + with contextlib.suppress( + piexif.InvalidImageDataError, FileNotFoundError, KeyError + ): + return self._metadata["0th"][piexif.ImageIFD.DateTime].decode() + return "" + + +def check_external_dependency(handler): + """Decorator for ExternalKeyHandler which requires the optional pyexiv2 module. + + If pyexiv2 is available, the class is left as it is. If pyexiv2 is not available + but the less powerful piexif module is, _ExternalKeyHandlerPiexif is returned + instead. If none of the two modules are available, the base implementation which + always throws an exception is returned. + + Args: + handler: The class to be decorated. + """ + + if pyexiv2: + return handler + + if piexif: + return _ExternalKeyHandlerPiexif + + _logger.warning( + "There is no metadata support and therefore:\n" + "1. Exif/IPTC data is lost when writing images to disk.\n" + "2. The `:metadata` command and associated `i` keybinding is not available.\n" + "3. The {exif-date-time} statusbar module is not available.\n\n" + "Please install pyexiv2 or piexif to silence this warning.\n" + "For more information see\n" + "https://karlch.github.io/vimiv-qt/documentation/exif.html\n" + ) + + return _ExternalKeyHandlerBase + + +@check_external_dependency +class ExternalKeyHandler(_ExternalKeyHandlerBase): + """Main ExternalKeyHandler implementation based on pyexiv2.""" + + MESSAGE_SUFFIX = " by pyexiv2." + + def __init__(self, filename=""): + super().__init__(filename) + try: + self._metadata = pyexiv2.ImageMetadata(filename) + self._metadata.read() + except FileNotFoundError: + _logger.debug("File %s not found", filename) + + def fetch_key(self, base_key: str) -> Tuple[str, str, str]: + # For backwards compability, assume it has one of the following prefixes + for prefix in ["", "Exif.Image.", "Exif.Photo."]: + key = f"{prefix}{base_key}" + key_name = self._metadata[key].name + + try: + key_value = self._metadata[key].human_value + + # Not all metadata (i.e. IPTC) provide human_value, take raw_value + except AttributeError: + value = self._metadata[key].raw_value + + # For IPTC the raw_value is a list of strings + if isinstance(value, list): + key_value = ", ".join(value) + else: + key_value = value + + return (key, key_name, key_value) + raise KeyError(f"Key '{base_key}' not found") + + def get_keys(self) -> Iterable[str]: + """Return a iteable of all available metadata keys.""" + return (key for key in self._metadata if not is_hex(key.rpartition(".")[2])) + + def copy_metadata(self, dest: str, reset_orientation: bool = True) -> None: + if reset_orientation: + with contextlib.suppress(KeyError): + self._metadata["Exif.Image.Orientation"] = ExifOrientation.Normal + try: + dest_image = pyexiv2.ImageMetadata(dest) + dest_image.read() + + # File types restrict the metadata type they can store. + # Try copying all types one by one and skip if it fails. + for copy_args in set(itertools.permutations((True, False, False, False))): + with contextlib.suppress(ValueError): + self._metadata.copy(dest_image, *copy_args) + + dest_image.write() + + _logger.debug("Successfully wrote metadata data for '%s'", dest) + except FileNotFoundError: + _logger.debug( + "Failed to write metadata data. Destination '%s' not found", dest + ) + except OSError as e: + _logger.debug("Failed to write metadata data for '%s': '%s'", dest, str(e)) + + def get_date_time(self) -> str: + with contextlib.suppress(KeyError): + return self._metadata["Exif.Image.DateTime"].raw_value + return "" + + +has_metadata_support = ExternalKeyHandler != _ExternalKeyHandlerBase + + +class MetadataHandler: + """Handler to load and copy metadata information of a single image. + + This class provides the interface for handling metadata support. By default none of + the operations are implemented. Instead it is up to a child class which wraps + around one of the supported metadata libraries to implement the methods it can. + """ + + def __init__(self, filename=""): + self.filename = filename + self._int_handler: Optional[_InternalKeyHandler] = None + self._ext_handler: Optional[ExternalKeyHandler] = None + + @property + def _internal_handler(self) -> _InternalKeyHandler: + """Returns an instance of _InternalKeyHandler.""" + if self._int_handler is None: + self._int_handler = _InternalKeyHandler(self.filename) + return self._int_handler + + @property + def _external_handler(self) -> ExternalKeyHandler: + """Returns an instance of ExternalKeyHandler.""" + if self._ext_handler is None: + self._ext_handler = ExternalKeyHandler(self.filename) + return self._ext_handler + + def fetch_keys(self, desired_keys: Sequence[str]) -> Dict[Any, Tuple[str, str]]: + """Extracts a list of metadata keys. + + Args: + desired_keys: list of metadata keys to extract. + + Throws: + UnsupportedMetadataOperation + """ + metadata = dict() + + for base_key in desired_keys: + base_key = base_key.strip() + + try: + key, key_name, key_value = self.fetch_key(base_key) + metadata[key] = key_name, key_value + except (KeyError, TypeError): + _logger.debug("Invalid key '%s'", base_key) + + return metadata + + def fetch_key(self, key: str) -> Tuple[str, str, str]: + """Extracts a single metadata key. + + Args: + key: single metadata key to extract. + + Throws: + UnsupportedMetadataOperation + KeyError + """ + if key.lower().startswith("vimiv"): + return self._internal_handler[key] + return self._external_handler.fetch_key(key) + + def get_keys(self) -> Iterable[str]: + """Retrieve the name of all metadata keys available. + + Throws: + UnsupportedMetadataOperation + """ + return itertools.chain( + self._internal_handler.get_keys(), self._external_handler.get_keys() + ) + + +class ExifOrientation: + """Namespace for exif orientation tags. + + For more information see: http://jpegclub.org/exif_orientation.html. + """ + + Unspecified = 0 + Normal = 1 + HorizontalFlip = 2 + Rotation180 = 3 + VerticalFlip = 4 + Rotation90HorizontalFlip = 5 + Rotation90 = 6 + Rotation90VerticalFlip = 7 + Rotation270 = 8 diff --git a/vimiv/version.py b/vimiv/version.py index 4a1499e0d..b6e0e6c9c 100644 --- a/vimiv/version.py +++ b/vimiv/version.py @@ -18,7 +18,7 @@ from PyQt5.QtCore import QT_VERSION_STR, PYQT_VERSION_STR import vimiv -from vimiv.imutils import exif +from vimiv.imutils import metadata from vimiv.utils import xdg, run_qprocess, lazy QtSvg = lazy.import_module("PyQt5.QtSvg", optional=True) @@ -42,8 +42,9 @@ def info() -> str: f"Qt: {QT_VERSION_STR}\n" f"PyQt: {PYQT_VERSION_STR}\n\n" f"Svg Support: {bool(QtSvg)}\n" - f"Pyexiv2: {exif.pyexiv2.__version__ if exif.pyexiv2 is not None else None}\n" - f"Piexif: {exif.piexif.VERSION if exif.piexif is not None else None}" + "Pyexiv2: " + f"{metadata.pyexiv2.__version__ if metadata.pyexiv2 is not None else None}\n" + f"Piexif: {metadata.piexif.VERSION if metadata.piexif is not None else None}" )