diff --git a/docs/changelog.rst b/docs/changelog.rst index 9cf90cfa7..3f98e4f8f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -105,6 +105,11 @@ Changed: supported by our testing framework, and 5.11 is out since July 2018. Code will likely still work with these versions, but as it is no longer tested, there is no guarantee. * The ``shuffle`` setting was moved into the ``sort`` group. +* Complete refactoring of metadata support. The handler functionality is moved out + to the plugin space, allowing for full flexibility in choosing a suitable backend. By + default, ``metadata_pyexiv2`` or ``metadata_piexif`` is loaded, if the respective + backend is installed. The default behaviour can be overridden by explicitly loading a + metadata plugin. Fixed: ^^^^^^ diff --git a/docs/documentation/exif.rst b/docs/documentation/exif.rst deleted file mode 100644 index 1c6ae17e0..000000000 --- a/docs/documentation/exif.rst +++ /dev/null @@ -1,66 +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 it -supports not only JPEG and TIFF images, but most common file types. In addition, -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/install.rst b/docs/documentation/install.rst index 1578b8d28..8f57c7831 100644 --- a/docs/documentation/install.rst +++ b/docs/documentation/install.rst @@ -129,10 +129,9 @@ Dependencies - QtSvg (optional for svg support) * `PyQt5 `_ 5.11.3 or newer * `setuptools `_ (for installation) -* `pyexiv2 `_ (optional for exif support) -* `piexif `_ (optional alternative for exif support) - -.. include:: pyexiv2.rst +* `pyexiv2 `_ (optional for metadata support) +* `piexif `_ (optional alternative for metadata + support) Package Names For Distributions ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/documentation/metadata.rst b/docs/documentation/metadata.rst new file mode 100644 index 000000000..5d408a172 --- /dev/null +++ b/docs/documentation/metadata.rst @@ -0,0 +1,144 @@ +Metadata +======== + +Vimiv provides optional metadata support. If enabled, then: + +#. The ``:metadata`` command and the corresponding ``i``-keybinding is available. It + displays the metadata of the current image. +#. The ``{exif-date-time}`` statusbar module is available. It displays the Exif + creation time of the current image. +#. Metadata is automatically copied from source to destination when writing images to + disk. + +Vimiv provides full flexibility to users in terms of what metadata backend to use. +Each backend comes with their advantages and disadvantages, and each user has different +requirements, as well as different package support. Therefore, vimiv provides metadata +backends as independent plugins, that can be loaded as one desires. In addition, users +have the ability to extend vimiv's metadata capabilities using +:ref:`custom plugins`, as described in +:ref:`a later section`. + +Vimiv comes with two default plugins: + +* ``metadata_piexif`` is based on `piexif`_ +* ``metadata_pyexiv2`` is based on `pyexiv2`_ + +In addition, there are the following user metadata plugins available: + +.. _user_metadata_plugins: + +.. table:: Overview of user plugins + :widths: 20 80 + + ===================================================================== =========== + Name Description + ===================================================================== =========== + `metadata_gexiv2 `_ Based on `gexiv2 `_ + ===================================================================== =========== + + +Enable Support +-------------- + +Metadata plugins are loaded as any other vimiv plugin. To enable one of the default +plugins, simply list its name in the ``PLUGINS`` section of the configuration file. In +addition, you need to ensure that the required backend is installed on your system. + +To enable a user metadata plugin, first you need to download it, and put it into the +plugin folder. Afterwards loading is equivalent to the default plugins. + +For more information on how to load plugins, please refer to the +:ref:`plugin section`. + +If no metadata plugin is specified, vimiv will load one of the two default plugins, if +the respective backend is installed. To disable this default behaviour, specify +``metadata = none`` in the ``PLUGINS`` section. + +.. warning:: + + Default loading may be dropped in a future version. If you rely on metadata support, + we recommend to explicitly specify what backend you want. + +.. note:: + Multiple metadata plugins can be registered at the same time. If they use distinct + keys, the value of both is combined in the output of ``:metadata``. + + +Comparison of Different Plugins +------------------------------- + +In short, ``metadata_pyexiv2`` is much more powerful than ``metadata_piexif``, though +the dependencies are also more involved to install. + +.. table:: Comparison of the two libraries + :widths: 20 15 20 45 + + ======================= =================== ==================== ===================================================================== + PROPERTY ``metadata_piexif`` ``metadata_pyexiv2`` Note + ======================= =================== ==================== ===================================================================== + Backend `piexif`_ `pyexiv2`_ + Exif Support True True pyexiv2 can potentially extract 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 Many common types + Ease of installation Simple More complicated pyexiv2 requires some dependencies including the C++ library `exiv2`_ + ======================= =================== ==================== ===================================================================== + +We recommend to use ``metadata_pyexiv2`` if the installation of `pyexiv2`_ is not too +involved on your system and ``metadata_piexif`` as a fallback solution or in case you +don't need the full power of `pyexiv2`_ and prefer something more lightweight. +Also consider the list of available +:ref:`user metadata plugins`. + + +Customize 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 = someKey,anotherOne,lastOne + keys4 = newKey,oneMore + +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, +IPTC, or XMP keys. + +`Piexif`_ uses a simplified form of the key. It does not use the ``Group.Subgroup`` +prefix, which is present in each of `pyexiv2`_'s keys. However, ``metadata_piexif`` +automatically does this truncation, if the provided keys are in the long form. + +The ``:metadata-list-keys`` command provides a list of all valid metadata keys, that +the currently loaded metadata plugins can read. This serves as an easy way to see what +keys are available in the image. + + +.. _create_own_plugins: + +Create Own Plugins +------------------ + +One can extend vimiv's metadata capabilities by creating own metadata plugins. This is +useful if you want to use a different metadata backend. + +The rough steps are the following: + +#. Create a plugin, that implements the abstract class + ``vimiv.imutils.metadata.MetadataPlugin`` + + #. Implement all required methods + + #. Optionally, also implement the non-required methods + +#. In the plugin's init function, register the plugin using + ``vimiv.imutils.metadata.register`` + +Please see the default metadata plugins for an example implementation. + + +.. _exiv2: https://www.exiv2.org/index.html +.. _pyexiv2: https://python3-exiv2.readthedocs.io +.. _piexif: https://pypi.org/project/piexif/ diff --git a/docs/documentation/pyexiv2.rst b/docs/documentation/pyexiv2.rst deleted file mode 100644 index c26ebfcd8..000000000 --- a/docs/documentation/pyexiv2.rst +++ /dev/null @@ -1,3 +0,0 @@ -.. warning:: - - There are multiple packages named `pyexiv2`. Make sure you install the right one. diff --git a/pytest.ini b/pytest.ini index 0c24c5d6a..fa90f61ab 100644 --- a/pytest.ini +++ b/pytest.ini @@ -5,9 +5,9 @@ faulthandler_timeout = 30 markers = current: Mark tests during development imageformats: Require retrieving images from the web to test additional formats - exif: Require exif support + metadata: Require metadata support piexif: Require piexif pyexiv2: Require pyexiv2 - noexif: Requires exif support NOT to be available + nometadata: Requires metadata support NOT to be available ci: Run test only on ci ci_skip: Skip test on ci diff --git a/tests/conftest.py b/tests/conftest.py index 67f43359c..91d505a10 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,38 +12,36 @@ import pytest -from vimiv.imutils import exif +from vimiv.plugins import metadata_pyexiv2, metadata_piexif CI = "CI" in os.environ +HAS_PIEXIF = metadata_piexif.piexif is not None +HAS_PYEXIV2 = metadata_pyexiv2.pyexiv2 is not None +HAS_METADATA = HAS_PIEXIF or HAS_PYEXIV2 + # fmt: off PLATFORM_MARKERS = ( ("ci", CI, "Only run on ci"), ("ci_skip", not CI, "Skipped on ci"), ) -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"), +METADATA_MARKERS = ( + ("metadata", HAS_METADATA, "Only run with metadata support"), + ("nometadata", not HAS_METADATA, "Only run without metadata support"), + ("piexif", HAS_PIEXIF, "Only run with piexif"), + ("pyexiv2", HAS_PYEXIV2, "Only run with pyexiv2"), ) # fmt: on -def apply_platform_markers(item): - """Apply markers that skip tests depending on the current platform.""" - apply_markers_helper(item, PLATFORM_MARKERS) - - -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",): - for marker_name in "exif", "pyexiv2", "piexif": - marker = getattr(pytest.mark, marker_name) +def apply_fixture_markers(item, *names): + """Helper function to mark all tests using specific fixtures with that mark.""" + for name in names: + marker = getattr(pytest.mark, name) + if name in item.fixturenames: item.add_marker(marker) - apply_markers_helper(item, EXIF_MARKERS) def apply_markers_helper(item, markers): @@ -60,8 +58,9 @@ def apply_markers_helper(item, markers): def pytest_collection_modifyitems(items): """Handle custom markers via pytest hook.""" for item in items: - apply_platform_markers(item) - apply_exif_markers(item) + apply_fixture_markers(item, "piexif", "pyexiv2") + apply_markers_helper(item, PLATFORM_MARKERS) + apply_markers_helper(item, METADATA_MARKERS) @pytest.fixture @@ -165,28 +164,17 @@ def tmpdir(): raise ValueError("Use the 'tmp_path' fixture instead of 'tmpdir'") -@pytest.fixture() -def piexif(monkeypatch): - """Pytest fixture to ensure only piexif is available.""" - monkeypatch.setattr(exif, "pyexiv2", None) - - -@pytest.fixture() -def noexif(monkeypatch, piexif): - """Pytest fixture to ensure no exif library is available.""" - monkeypatch.setattr(exif, "piexif", None) - - @pytest.fixture() 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) + import piexif + + exif_dict = 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) + piexif.insert(piexif.dump(exif_dict), path) return add_exif_information_impl diff --git a/tests/end2end/features/image/conftest.py b/tests/end2end/features/image/conftest.py index 8d893c613..82174a33c 100644 --- a/tests/end2end/features/image/conftest.py +++ b/tests/end2end/features/image/conftest.py @@ -19,6 +19,7 @@ @pytest.fixture() def exif_content(): + assert piexif is not None, "piexif required create exif information." return { "0th": { piexif.ImageIFD.Make: b"vimiv-testsuite", diff --git a/tests/end2end/features/image/metadata.feature b/tests/end2end/features/image/metadata.feature index 14911292f..677392d12 100644 --- a/tests/end2end/features/image/metadata.feature +++ b/tests/end2end/features/image/metadata.feature @@ -1,4 +1,4 @@ -@exif +@metadata Feature: Metadata widget displaying image exif information Scenario: Show metadata widget diff --git a/tests/end2end/features/image/test_metadata_bdd.py b/tests/end2end/features/image/test_metadata_bdd.py index 3c837b427..13d5ecdbb 100644 --- a/tests/end2end/features/image/test_metadata_bdd.py +++ b/tests/end2end/features/image/test_metadata_bdd.py @@ -7,32 +7,36 @@ import pytest import pytest_bdd as bdd -from vimiv.gui import metadatawidget +from vimiv.imutils import metadata bdd.scenarios("metadata.feature") @pytest.fixture -def metadata(): - return metadatawidget.MetadataWidget.instance +def metadatawidget(): + if metadata.has_metadata_support(): + from vimiv.gui.metadatawidget import MetadataWidget + + return MetadataWidget.instance + raise ValueError("No metadata support for metadata tests") @bdd.then("the metadata widget should be visible") -def check_metadata_widget_visible(metadata): - assert metadata.isVisible() +def check_metadata_widget_visible(metadatawidget): + assert metadatawidget.isVisible() @bdd.then("the metadata widget should not be visible") -def check_metadata_widget_not_visible(metadata): - assert not metadata.isVisible() +def check_metadata_widget_not_visible(metadatawidget): + assert not metadatawidget.isVisible() @bdd.then(bdd.parsers.parse("the metadata text should contain '{text}'")) -def check_text_in_metadata(metadata, text): - assert text in metadata.text() +def check_text_in_metadata(metadatawidget, text): + assert text in metadatawidget.text() @bdd.then(bdd.parsers.parse("the metadata text should not contain '{text}'")) -def check_text_not_in_metadata(metadata, text): - assert text not in metadata.text() +def check_text_not_in_metadata(metadatawidget, text): + assert text not in metadatawidget.text() diff --git a/tests/end2end/features/image/write.feature b/tests/end2end/features/image/write.feature index 49e829a42..245e2cc47 100644 --- a/tests/end2end/features/image/write.feature +++ b/tests/end2end/features/image/write.feature @@ -11,7 +11,7 @@ Feature: Write an image to disk | new_path.png | | new_path.tiff | - @exif + @metadata Scenario: Write image preserving exif information Given I open any image When I add exif information diff --git a/tests/end2end/features/misc/no_optional.feature b/tests/end2end/features/misc/no_optional.feature index 02163db8e..fa958e2ef 100644 --- a/tests/end2end/features/misc/no_optional.feature +++ b/tests/end2end/features/misc/no_optional.feature @@ -1,6 +1,6 @@ Feature: Ensure the application works correctly without optional dependencies - @noexif + @nometadata Scenario: No metadata command Given I open any image When I run metadata diff --git a/tests/integration/test_metadata.py b/tests/integration/test_metadata.py new file mode 100644 index 000000000..78c11c37b --- /dev/null +++ b/tests/integration/test_metadata.py @@ -0,0 +1,65 @@ +# vim: ft=python fileencoding=utf-8 sw=4 et sts=4 + +# This file is part of vimiv. +# Copyright 2017-2023 Christian Karl (karlch) +# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details. + +"""Tests for vimiv.imutils.metadata.""" + +import pytest + +from vimiv.imutils import metadata +from vimiv.plugins import metadata_piexif, metadata_pyexiv2 + + +@pytest.fixture(autouse=True) +def reset_to_default(cleanup_helper): + """Fixture to ensure everything is reset to default after testing.""" + registry = list(metadata._registry) + yield + metadata._registry = registry + + +@pytest.fixture +def nometadata(): + metadata._registry = [] + + +@pytest.fixture +def piexif(): + metadata._registry = [] + metadata_piexif.init() + + +@pytest.fixture +def pyexiv2(): + metadata._registry = [] + metadata_pyexiv2.init() + + +@pytest.mark.parametrize( + "methodname, args", + ( + ("copy_metadata", ("dest.jpg",)), + ("get_date_time", ()), + ("get_metadata", ([],)), + ("get_keys", ()), + ), +) +def test_handler_raises(nometadata, methodname, args): + assert not metadata.has_metadata_support() + + handler = metadata.MetadataHandler("path") + method = getattr(handler, methodname) + with pytest.raises(metadata.MetadataError): + method(*args) + + +def test_piexif_initializes(piexif): + assert metadata_piexif.MetadataPiexif in metadata._registry + assert metadata.has_metadata_support() + + +def test_pyexiv2_initializes(pyexiv2): + assert metadata_pyexiv2.MetadataPyexiv2 in metadata._registry + assert metadata.has_metadata_support() diff --git a/tests/unit/imutils/test_exif.py b/tests/unit/imutils/test_exif.py deleted file mode 100644 index e52708129..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-2023 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/test_version.py b/tests/unit/test_version.py index 5fbd284ee..c1810ab13 100644 --- a/tests/unit/test_version.py +++ b/tests/unit/test_version.py @@ -9,7 +9,6 @@ import pytest from vimiv import version -from vimiv.imutils import exif @pytest.fixture @@ -23,18 +22,3 @@ def test_svg_support_info(): def test_no_svg_support_info(no_svg_support): assert "svg support: false" in version.info().lower() - - -@pytest.mark.pyexiv2 -def test_pyexiv2_info(): - assert exif.pyexiv2.__version__ in version.info() - - -@pytest.mark.piexif -def test_piexif_info(): - assert exif.piexif.VERSION in version.info() - - -def test_no_exif_support_info(noexif): - assert "piexif: none" in version.info().lower() - assert "pyexiv2: none" in version.info().lower() diff --git a/vimiv/api/signals.py b/vimiv/api/signals.py index 1468d4cbf..aaf94a6e5 100644 --- a/vimiv/api/signals.py +++ b/vimiv/api/signals.py @@ -35,6 +35,8 @@ class _SignalHandler(QObject): svg_loaded: Emitted when the file handler loaded a new vector graphic. arg1: The path as the VectorGraphic class is constructed directly. arg2: True if it is only reloaded. + + plugins_loaded: Emitted when the user plugins have been loaded. """ # Emitted when new images should be loaded @@ -53,6 +55,9 @@ class _SignalHandler(QObject): movie_loaded = pyqtSignal(QMovie, bool) svg_loaded = pyqtSignal(str, bool) + # Plugins loaded + plugins_loaded = pyqtSignal() + _signal_handler = _SignalHandler() # Instance of Qt signal handler to work with @@ -65,3 +70,4 @@ class _SignalHandler(QObject): pixmap_loaded = _signal_handler.pixmap_loaded movie_loaded = _signal_handler.movie_loaded svg_loaded = _signal_handler.svg_loaded +plugins_loaded = _signal_handler.plugins_loaded diff --git a/vimiv/gui/mainwindow.py b/vimiv/gui/mainwindow.py index a32643734..dd626b357 100644 --- a/vimiv/gui/mainwindow.py +++ b/vimiv/gui/mainwindow.py @@ -20,7 +20,6 @@ from vimiv.gui.library import Library from vimiv.gui.thumbnail import ThumbnailView from vimiv.gui.message import Message -from vimiv.gui.metadatawidget import MetadataWidget from vimiv.gui.statusbar import StatusBar @@ -50,13 +49,12 @@ def __init__(self) -> None: 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 - self._overlays.append(MetadataWidget(self)) # Connect signals api.status.signals.update.connect(self._set_title) api.settings.statusbar.show.changed.connect(self._update_overlay_geometry) api.modes.MANIPULATE.first_entered.connect(self._init_manipulate) api.prompt.question_asked.connect(self._run_prompt) + api.signals.plugins_loaded.connect(self._init_metadata) @utils.slot def _init_manipulate(self): @@ -66,6 +64,16 @@ def _init_manipulate(self): manipulate_widget = Manipulate(self) self.add_overlay(manipulate_widget) + @utils.slot + def _init_metadata(self): + """Initialize metadata widget in case we have metadata support.""" + from vimiv.imutils import metadata + + if metadata.has_metadata_support(): + from vimiv.gui.metadatawidget import MetadataWidget + + self._overlays.append(MetadataWidget(self)) + @api.keybindings.register("f", "fullscreen", mode=api.modes.MANIPULATE) @api.keybindings.register("f", "fullscreen") @api.commands.register(mode=api.modes.MANIPULATE) diff --git a/vimiv/gui/metadatawidget.py b/vimiv/gui/metadatawidget.py index dd3bb8854..b20ba97f3 100644 --- a/vimiv/gui/metadatawidget.py +++ b/vimiv/gui/metadatawidget.py @@ -13,169 +13,169 @@ from PyQt5.QtWidgets import QLabel, QSizePolicy, QWidget 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: +class MetadataWidget(QLabel): + """Overlay widget to display image metadata. - class MetadataWidget(QLabel): - """Overlay widget to display image metadata. + The display of the widget can be toggled by command. It is filled with + metadata information of the current image. - The display of the widget can be toggled by command. It is filled with exif - 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 metadata of. + _current_set: Holds a string of the currently selected keyset. + _handler: MetadataHandler for _path or None. Use its property for access. + """ - 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. - _current_set: Holds a string of the currently selected keyset. - _handler: ExifHandler for _path or None. Use the handler property to access. - """ + STYLESHEET = """ + QLabel { + font: {statusbar.font}; + color: {statusbar.fg}; + background: {metadata.bg}; + padding: {metadata.padding}; + border-top-left-radius: {metadata.border_radius}; + } + """ - STYLESHEET = """ - QLabel { - font: {statusbar.font}; - color: {statusbar.fg}; - background: {metadata.bg}; - padding: {metadata.padding}; - border-top-left-radius: {metadata.border_radius}; - } - """ + @api.objreg.register + def __init__(self, parent: QWidget): + super().__init__(parent=parent) + styles.apply(self) + + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Minimum) + self.setTextFormat(Qt.RichText) + + self._mainwindow_bottom = 0 + self._mainwindow_width = 0 + self._path = "" + self._current_set = "" + 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) - @api.objreg.register - def __init__(self, parent: QWidget): - super().__init__(parent=parent) - styles.apply(self) + self.hide() - self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Minimum) - self.setTextFormat(Qt.RichText) + @property + def handler(self) -> metadata.MetadataHandler: + """Return the MetadataHandler for the current path.""" + if self._handler is None: + self._handler = metadata.MetadataHandler(self._path) + return self._handler - self._mainwindow_bottom = 0 - self._mainwindow_width = 0 - self._path = "" - self._current_set = "" - self._handler: Optional[exif.ExifHandler] = None + @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 metadata of current image. - api.signals.new_image_opened.connect(self._on_image_opened) - api.settings.metadata.current_keyset.changed.connect(self._update_text) + **count:** Select the key set to display instead. + + .. hint:: + 5 default key sets are provided and mapped to the counts 1-5. To + override them or add your own, extend 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. + """ + if count is not None: + try: + _logger.debug("Switch keyset") + new_keyset = api.settings.metadata.keysets[count] + api.settings.metadata.current_keyset.value = new_keyset + if not self.isVisible(): + _logger.debug("Showing widget") + self.raise_() + self.show() + except KeyError: + raise api.commands.CommandError(f"Invalid key set option {count}") + elif self.isVisible(): + _logger.debug("Hiding widget") self.hide() + else: + _logger.debug("Showing widget") + self._update_text() + self.raise_() + self.show() - @property - def handler(self) -> exif.ExifHandler: - """Return the ExifHandler for the current path.""" - if self._handler is None: - self._handler = exif.ExifHandler(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. - - **count:** Select the key set to display instead. - - .. hint:: - 5 default key sets are provided and mapped to the counts 1-5. To - override them or add your own, extend 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. - """ - - if count is not None: - try: - _logger.debug("Switch keyset") - new_keyset = api.settings.metadata.keysets[count] - api.settings.metadata.current_keyset.value = new_keyset - if not self.isVisible(): - _logger.debug("Showing widget") - self.raise_() - self.show() - except KeyError: - raise api.commands.CommandError(f"Invalid key set option {count}") - elif self.isVisible(): - _logger.debug("Hiding widget") - self.hide() - else: - _logger.debug("Showing widget") - self._update_text() - self.raise_() - self.show() - - @api.commands.register(mode=api.modes.IMAGE) - def metadata_list_keys(self, n_cols: int = 3, to_term: bool = False): - """Display a list of all valid metadata keys for the current image. - - **syntax:** ``:metadata-list-keys [--n-cols=NUMBER] [--to-term]`` - - optional arguments: - * ``--n-cols``: Number of columns used to display the keys. - * ``--to-term``: Print the keys to the terminal instead. - """ - - keys = sorted(set(self.handler.get_keys())) - if to_term: - print(*keys, sep="\n") - elif n_cols < 1: - raise api.commands.CommandError("Number of columns must be positive") - else: - columns = list(utils.split(keys, n_cols)) - table = utils.format_html_table( - itertools.zip_longest(*columns, fillvalue="") - ) - self.setText(table) - self._update_geometry() - self.show() - - def update_geometry(self, window_width, window_bottom): - """Adapt location when main window geometry changes.""" - self._mainwindow_width = window_width - self._mainwindow_bottom = window_bottom - self._update_geometry() + @api.commands.register(mode=api.modes.IMAGE) + def metadata_list_keys(self, n_cols: int = 3, to_term: bool = False): + """Display a list of all valid metadata keys for the current image. - def _update_geometry(self): - """Update geometry according to current text content and window location.""" - self.adjustSize() - y = self._mainwindow_bottom - self.height() - self.setGeometry( - self._mainwindow_width - self.width(), y, self.width(), self.height() - ) + **syntax:** ``:metadata-list-keys [--n-cols=NUMBER] [--to-term]`` - def _update_text(self): - """Update the metadata text if the current image has not been loaded.""" - if self._current_set == api.settings.metadata.current_keyset.value: - return - _logger.debug( - "%s: reading exif of %s", self.__class__.__qualname__, self._path + optional arguments: + * ``--n-cols``: Number of columns used to display the keys. + * ``--to-term``: Print the keys to the terminal instead. + """ + + keys = sorted(set(self.handler.get_keys())) + if to_term: + print(*keys, sep="\n") + elif n_cols < 1: + raise api.commands.CommandError("Number of columns must be positive") + else: + columns = list(utils.split(keys, n_cols)) + table = utils.format_html_table( + itertools.zip_longest(*columns, fillvalue="") ) - 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())) + self.setText(table) + self._update_geometry() + self.show() + + def update_geometry(self, window_width, window_bottom): + """Adapt location when main window geometry changes.""" + self._mainwindow_width = window_width + self._mainwindow_bottom = window_bottom + self._update_geometry() + + def _update_geometry(self): + """Update geometry according to current text content and window location.""" + self.adjustSize() + y = self._mainwindow_bottom - self.height() + self.setGeometry( + self._mainwindow_width - self.width(), y, self.width(), self.height() + ) + + def _update_text(self): + """Update the metadata text if the current image has not been loaded.""" + if self._current_set == api.settings.metadata.current_keyset.value: + return + _logger.debug( + "%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"Extracting metadata for keys: {keys}") + try: + data = self.handler.get_metadata(keys) + if data: + # Sort data according to order provided in config + sorted_data = [data[key] for key in keys if key in data] + self.setText(utils.format_html_table(sorted_data)) else: self.setText("No matching metadata found") - self._update_geometry() - self._current_set = api.settings.metadata.current_keyset.value - - @utils.slot - def _on_image_opened(self, path: str): - """Load new image and update text if the widget is currently visible.""" - self._path = path - self._current_set = "" - self._handler = None - if self.isVisible(): - self._update_text() - -else: - MetadataWidget = None # type: ignore + except metadata.MetadataError as e: + self.setText(str(e)) + self._update_geometry() + self._current_set = api.settings.metadata.current_keyset.value + + @utils.slot + def _on_image_opened(self, path: str): + """Load new image and update text if the widget is currently visible.""" + self._path = path + self._current_set = "" + self._handler = None + if self.isVisible(): + self._update_text() diff --git a/vimiv/imutils/__init__.py b/vimiv/imutils/__init__.py index 784d232b2..8211e12cd 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 5bfd1f8ea..2fe6fa8b0 100644 --- a/vimiv/imutils/_file_handler.py +++ b/vimiv/imutils/_file_handler.py @@ -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 + # Best-effort copy metadata info from original file to new file try: - imutils.exif.ExifHandler(original_path).copy_exif(filename) - except imutils.exif.UnsupportedExifOperation: + imutils.metadata.MetadataHandler(original_path).copy_metadata(filename) + except imutils.metadata.MetadataError: 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 add2a9289..000000000 --- a/vimiv/imutils/exif.py +++ /dev/null @@ -1,304 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sw=4 et sts=4 - -# This file is part of vimiv. -# Copyright 2017-2023 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 - 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 get_formatted_exif(self, desired_keys: Sequence[str]) -> ExifDictT: - desired_keys = [key.rpartition(".")[2] for key in desired_keys] - exif = {} - - if self._metadata is None: - return {} - - 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 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: - if self._metadata is None: - return - - 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 ValueError: - _logger.debug("No exif data in '%s'", dest) - - def exif_date_time(self) -> str: - if self._metadata is None: - return "" - - with contextlib.suppress(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 = {} - - 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 0acaef562..bcc2907fd 100644 --- a/vimiv/imutils/filelist.py +++ b/vimiv/imutils/filelist.py @@ -141,8 +141,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.MetadataHandler(current()).get_date_time() + except imutils.metadata.MetadataError: return "" diff --git a/vimiv/imutils/metadata.py b/vimiv/imutils/metadata.py new file mode 100644 index 000000000..a571d5185 --- /dev/null +++ b/vimiv/imutils/metadata.py @@ -0,0 +1,281 @@ +# vim: ft=python fileencoding=utf-8 sw=4 et sts=4 + +# This file is part of vimiv. +# Copyright 2017-2023 Christian Karl (karlch) +# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details. + +"""Utility functions and classes for metadata handling. + +This module provides a common interface for all metadata related functionalities. +`MetadataHandler` is used to interact with the metadata of the current image. It +relies on the functionality provided by optionally loaded metadata plugins. Such +plugins implements the `MetadataPlugin` abstract class and registers that class +using the `register` function. + +Module Attributes: + _registry: List of registered `MetadataPlugin` implementations. +""" + +import abc +import contextlib +import itertools +from typing import Dict, Tuple, NoReturn, Sequence, Iterable, Type, List + +from vimiv.utils import log + +_logger = log.module_logger(__name__) + +# Type returned by `MetadataHandler.get_metadata`. +# Key is the metadata key. Value is a tuple of descriptive name and value for that key. +MetadataDictT = Dict[str, Tuple[str, str]] + + +class MetadataPlugin(abc.ABC): + """Abstract class implemented by plugins to provide metadata capabilities. + + Implementations of this class are required to overwrite `__init__`, `name`, + `version`, `get_metadata` and `get_keys`. + The implementation of `copy_metadata` and `get_date_time` is optional. + """ + + @abc.abstractmethod + def __init__(self, _path: str) -> None: + """Initialize metadata handler for a specific image. + + Args: + _path: Path to current image. + """ + + @staticmethod + @abc.abstractmethod + def name() -> str: + """Get the name of the used backend. + + If no backend is used, return the name of the plugin. + """ + + @staticmethod + @abc.abstractmethod + def version() -> str: + """Get the version of the used backend. + + If no backend is used, return an empty string. + """ + + @abc.abstractmethod + def get_metadata(self, _keys: Sequence[str]) -> MetadataDictT: + """Get value of all desired keys for the current image. + + If no value is found for a certain key, do not include the key in the output. + + Args: + _keys: Keys of metadata to query the image for. + + Returns: + Dictionary with retrieved metadata. + """ + + @abc.abstractmethod + def get_keys(self) -> Iterable[str]: + """Get the keys for all metadata values available for the current image.""" + + def copy_metadata(self, _dest: str, _reset_orientation: bool = True) -> bool: + """Copy metadata from the current image to dest image. + + Args: + _dest: Path to write the metadata to. + _reset_orientation: If true, reset the exif orientation tag to normal. + + Returns: + Flag indicating if copy was successful. + """ + raise NotImplementedError + + def get_date_time(self) -> str: + """Get creation date and time of the current image as formatted string.""" + raise NotImplementedError + + +# Stores all registered metadata implementations. +_registry: List[Type[MetadataPlugin]] = [] + + +def has_metadata_support() -> bool: + """Indicate if `MetadataHandler` has `get_metadata()` and `get_keys()` capabilities. + + Returns: + True if at least one metadata plugins has been registered. + """ + return bool(_registry) + + +class MetadataHandler: + """Handle metadata related functionalities of images. + + Attributes: + _path: Path to current image. + """ + + def __init__(self, path: str): + self._path = path + + @property + def has_copy_metadata(self) -> bool: + """True if `MetadataHandler` has an implementation for `copy_metadata`.""" + return any(e.copy_metadata != MetadataPlugin.copy_metadata for e in _registry) + + @property + def has_get_date_time(self) -> bool: + """True if `MetadataHandler` has an implementation for `get_date_time`.""" + return any(e.get_date_time != MetadataPlugin.get_date_time for e in _registry) + + def get_metadata(self, keys: Sequence[str]) -> MetadataDictT: + """Get value of all desired keys from the current image. + + Use all registered metadata implementations to extract the metadata from the + current image. The output of all methods is combined. + + Args: + keys: Keys of metadata to query the image for. + + Returns: + Dictionary with retrieved metadata. + + Raises: + MetadataError + """ + if not has_metadata_support(): + MetadataHandler.raise_exception("get_metadata") + + out: MetadataDictT = {} + + for backend in _registry: + # TODO: from 3.9 on use: c = a | b + out = {**backend(self._path).get_metadata(keys), **out} + + return out + + def get_keys(self) -> Iterable[str]: + """Get the keys for all metadata values available for the current image. + + Uses all registered metadata implementations to extract the available keys for + the current image. The output of all methods is combined. + + Raises: + MetadataError + """ + if not has_metadata_support(): + MetadataHandler.raise_exception("get_keys") + + out: Iterable[str] = iter([]) + + for backend in _registry: + out = itertools.chain(out, backend(self._path).get_keys()) + + return out + + def copy_metadata(self, dest: str, reset_orientation: bool = True) -> None: + """Copy metadata from current image to dest. + + Uses all registered metadata implementations that support this operation. + + Args: + dest: Path to write the metadata to. + reset_orientation: If true, reset the exif orientation tag to normal. + + Raises: + MetadataError + """ + if not has_metadata_support() or not self.has_copy_metadata: + MetadataHandler.raise_exception("copy_metadata") + + failed = [] + + for backend in _registry: + with contextlib.suppress(NotImplementedError): + be = backend(self._path) + if not be.copy_metadata(dest, reset_orientation): + failed.append(be.name()) + + if failed: + _logger.warning( + f"The following plugins failed to copy metadata: " + f"{', '.join(failed)}
\n" + f"Some metadata may be missing in the destination image {dest}." + ) + + def get_date_time(self) -> str: + """Get creation date and time as formatted string. + + Uses the first registered metadata implementations that supports this operation. + + Raises: + MetadataError + """ + if not has_metadata_support() or not self.has_get_date_time: + MetadataHandler.raise_exception("get_date_time") + + for backend in _registry: + with contextlib.suppress(NotImplementedError): + out = backend(self._path).get_date_time() + # If we get an empty string, continue. We may get something better. + if out: + return out + return "" + + @staticmethod + def raise_exception(operation: str) -> NoReturn: + """Raise an exception if there is insufficient support for an operation.""" + msg = f"Running {operation} is not possible. Insufficient metadata support" + _logger.warning(msg, once=True) + raise MetadataError(msg) + + +class MetadataError(RuntimeError): + """Raised if for a function there is insufficient metadata support.""" + + +def register(plugin: Type[MetadataPlugin]) -> None: + """Register metadata plugin implementation. + + All registered metadata plugin implementations are available to the + `MetadataHandler`. + + Args: + plugin: Implementation of `MetadataPlugin`. + """ + + _logger.debug(f"Registring metadata plugin implementation {plugin.name()}") + if plugin in _registry: + _logger.warning( + f"Metadata plugin {plugin.name()} has already been registered. Ignoring it." + ) + return + + _registry.append(plugin) + + +def get_registrations() -> List[Tuple[str, str]]: + """List of all registered metadata plugin implementations. + + Returns: + List of tuples of the form (name of backend, version of backend). + """ + return [(e.name(), e.version()) for e in _registry] + + +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/plugins/__init__.py b/vimiv/plugins/__init__.py index 85009e1fe..a1f9ba959 100644 --- a/vimiv/plugins/__init__.py +++ b/vimiv/plugins/__init__.py @@ -69,12 +69,14 @@ from typing import Dict, List from vimiv.utils import xdg, log, quotedjoin +from vimiv import api _app_plugin_directory = os.path.dirname(__file__) _user_plugin_directory = xdg.vimiv_data_dir("plugins") _plugins: Dict[str, str] = { - "print": "default" + "print": "default", + "metadata": "default", } # key: name, value: additional information _loaded_plugins: Dict[str, types.ModuleType] = {} # key:name, value: loaded module _logger = log.module_logger(__name__) @@ -112,6 +114,7 @@ def load() -> None: _user_plugin_directory, ) _logger.debug("Plugin loading completed") + api.signals.plugins_loaded.emit() def cleanup() -> None: @@ -132,10 +135,18 @@ def cleanup() -> None: def add_plugins(**plugins: str) -> None: """Add plugins to the dictionary of plugins. + Note that the plugins are prepended, and take precedence above the existing plugins. + Args: plugins: Dictionary of plugin names with metadata to add to plugins. """ - _plugins.update(plugins) + global _plugins + + for plugin, info in _plugins.items(): + if plugin not in plugins: + plugins[plugin] = info + + _plugins = plugins def get_plugins() -> Dict[str, str]: diff --git a/vimiv/plugins/metadata.py b/vimiv/plugins/metadata.py new file mode 100644 index 000000000..b593df2d3 --- /dev/null +++ b/vimiv/plugins/metadata.py @@ -0,0 +1,40 @@ +# vim: ft=python fileencoding=utf-8 sw=4 et sts=4 + +# This file is part of vimiv. +# Copyright 2017-2023 Christian Karl (karlch) +# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details. + +"""Metadata plugin wrapping the available backends to only load one.""" + +from typing import Any + +from vimiv.plugins import metadata_piexif, metadata_pyexiv2 +from vimiv.utils import log +from vimiv.imutils import metadata + +_logger = log.module_logger(__name__) + + +def init(info: str, *_args: Any, **_kwargs: Any) -> None: + """Initialize metadata plugin depending on available backend. + + If any other backend has already been registered, do not register any new one. + """ + if metadata.has_metadata_support(): + _logger.debug( + "Not loading a default metadata backend, as one has been loaded manually" + ) + elif info.lower() == "none": + _logger.debug("Not auto-loading metadata support as per user-request") + elif metadata_pyexiv2.pyexiv2 is not None: + _logger.debug("Auto-loading pyexiv2 metadata plugin") + metadata_pyexiv2.init() + elif metadata_piexif.piexif is not None: + _logger.debug("Auto-loading piexif metadata plugin") + metadata_piexif.init() + else: + _logger.warning( + "Please install either py3exiv2 or piexif for metadata support.
\n" + "For more information see
\n" + "https://karlch.github.io/vimiv-qt/documentation/metadata.html", + ) diff --git a/vimiv/plugins/metadata_piexif.py b/vimiv/plugins/metadata_piexif.py new file mode 100644 index 000000000..bdf59afb9 --- /dev/null +++ b/vimiv/plugins/metadata_piexif.py @@ -0,0 +1,155 @@ +# vim: ft=python fileencoding=utf-8 sw=4 et sts=4 + +# This file is part of vimiv. +# Copyright 2017-2023 Christian Karl (karlch) +# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details. + +"""Metadata plugin based on piexif (https://pypi.org/project/piexif/) backend. + +Properties: +- Simple and easy to install. +- Limited image type support (JPG and TIFF only). +- No formatting of metadata. +- Can only handle Exif. +""" + +import contextlib +from typing import Any, Sequence, Iterable + +from vimiv.imutils import metadata +from vimiv.utils import log, lazy + +piexif = lazy.import_module("piexif", optional=True) + +_logger = log.module_logger(__name__) + + +class MetadataPiexif(metadata.MetadataPlugin): + """Provided metadata support based on piexif. + + Implements `get_metadata`, `get_keys`, `copy_metadata`, and `get_date_time`. + """ + + def __init__(self, path: str) -> None: + self._path = path + + try: + self._metadata = piexif.load(path) + except FileNotFoundError: + _logger.debug("File %s not found", path) + self._metadata = None + except piexif.InvalidImageDataError: + log.warning( + "Piexif only supports the file types JPEG and TIFF.
\n" + "Please use another metadata plugin for better file type support.
\n" + "For more information see
\n" + "https://karlch.github.io/vimiv-qt/documentation/metadata.html", + once=True, + ) + self._metadata = None + + @staticmethod + def name() -> str: + """Get the name of the used backend.""" + return "piexif" + + @staticmethod + def version() -> str: + """Get the version of the used backend.""" + return piexif.VERSION + + def get_metadata(self, desired_keys: Sequence[str]) -> metadata.MetadataDictT: + """Get value of all desired keys for the current image.""" + out = {} + + # The keys in the default config are of the form `group.subgroup.key`. However, + # piexif only uses `key` for the indexing. Strip `group.subgroup` prefix for the + # metadata extraction, but maintain the long key in the returned dict. + desired_keys_map = {key.rpartition(".")[2]: key for key in desired_keys} + + if self._metadata is None: + return {} + + 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] + if keyname not in desired_keys_map: + 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 + out[desired_keys_map[keyname]] = (keyname, str(val)) + elif keytype in ( + piexif.TYPES.Ascii, + piexif.TYPES.Undefined, + ): # byte encoded + out[desired_keys_map[keyname]] = (keyname, val.decode()) + elif keytype in ( + piexif.TYPES.Rational, + piexif.TYPES.SRational, + ): # (int, int) <=> numerator, denominator + out[desired_keys_map[keyname]] = (keyname, f"{val[0]}/{val[1]}") + + except KeyError: + return {} + + return out + + def get_keys(self) -> Iterable[str]: + """Get the keys for all metadata values available for the current image.""" + if self._metadata is None: + return iter([]) + + 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) -> bool: + """Copy metadata from the current image to dest image.""" + if self._metadata is None: + return False + + try: + if reset_orientation: + with contextlib.suppress(KeyError): + self._metadata["0th"][ + piexif.ImageIFD.Orientation + ] = metadata.ExifOrientation.Normal + exif_bytes = piexif.dump(self._metadata) + piexif.insert(exif_bytes, dest) + return True + except ValueError: + return False + + def get_date_time(self) -> str: + """Get creation date and time of the current image as formatted string.""" + if self._metadata is None: + return "" + + with contextlib.suppress(KeyError): + return self._metadata["0th"][piexif.ImageIFD.DateTime].decode() + return "" + + +def init(*_args: Any, **_kwargs: Any) -> None: + """Initialize piexif handler if piexif is available.""" + if piexif is not None: + metadata.register(MetadataPiexif) + else: + _logger.warning("Please install piexif to use this plugin") diff --git a/vimiv/plugins/metadata_pyexiv2.py b/vimiv/plugins/metadata_pyexiv2.py new file mode 100644 index 000000000..eeb8fa39b --- /dev/null +++ b/vimiv/plugins/metadata_pyexiv2.py @@ -0,0 +1,134 @@ +# vim: ft=python fileencoding=utf-8 sw=4 et sts=4 + +# This file is part of vimiv. +# Copyright 2017-2023 Christian Karl (karlch) +# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details. + +"""Metadata plugin based on pyexiv2 (https://pypi.org/project/py3exiv2/) backend. + +Properties: +- Shared libraries as dependencies. +- Formatted Metadata. +- Reads Exif, IPTC and XMP. +""" + +import contextlib +import itertools +from typing import Any, Sequence, Iterable + +from vimiv.imutils import metadata +from vimiv.utils import log, is_hex, lazy + +pyexiv2 = lazy.import_module("pyexiv2", optional=True) + +_logger = log.module_logger(__name__) + + +class MetadataPyexiv2(metadata.MetadataPlugin): + """Provides metadata support based on pyexiv2.""" + + def __init__(self, path: str) -> None: + self._path = path + + try: + self._metadata = pyexiv2.ImageMetadata(path) + self._metadata.read() + except FileNotFoundError: + _logger.debug("File %s not found", path) + self._metadata = None + + @staticmethod + def name() -> str: + """Get the name of the used backend.""" + return "pyexiv2" + + @staticmethod + def version() -> str: + """Get the version of the used backend.""" + return pyexiv2.__version__ + + def get_metadata(self, desired_keys: Sequence[str]) -> metadata.MetadataDictT: + """Get value of all desired keys for the current image.""" + + out = {} + + if self._metadata is None: + return {} + + for key in desired_keys: + 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 + + out[key] = (key_name, key_value) + break + + except KeyError: + _logger.debug("Key %s is invalid for the current image", key) + + return out + + def get_keys(self) -> Iterable[str]: + """Get the keys for all metadata values available for the current image.""" + if self._metadata is None: + return iter([]) + + return (key for key in self._metadata if not is_hex(key.rpartition(".")[2])) + + def copy_metadata(self, dest: str, reset_orientation: bool = True) -> bool: + """Copy metadata from the current image to dest image.""" + if self._metadata is None: + return False + + if reset_orientation: + with contextlib.suppress(KeyError): + self._metadata[ + "Exif.Image.Orientation" + ] = metadata.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() + return True + except FileNotFoundError: + _logger.debug("Failed to write metadata. Destination '%s' not found", dest) + except OSError as e: + _logger.debug("Failed to write metadata for '%s': '%s'", dest, str(e)) + return False + + def get_date_time(self) -> str: + """Get creation date and time of the current image as formatted string.""" + if self._metadata is None: + return "" + + with contextlib.suppress(KeyError): + return self._metadata["Exif.Image.DateTime"].raw_value + return "" + + +def init(*_args: Any, **_kwargs: Any) -> None: + """Initialize pyexiv2 handler if pyexiv2 is available.""" + if pyexiv2 is not None: + metadata.register(MetadataPyexiv2) + else: + _logger.warning("Please install py3exiv2 to use this plugin") diff --git a/vimiv/version.py b/vimiv/version.py index 814440288..857ce1b91 100644 --- a/vimiv/version.py +++ b/vimiv/version.py @@ -18,7 +18,6 @@ from PyQt5.QtCore import QT_VERSION_STR, PYQT_VERSION_STR import vimiv -from vimiv.imutils import exif from vimiv.utils import xdg, run_qprocess, lazy QtSvg = lazy.import_module("PyQt5.QtSvg", optional=True) @@ -42,8 +41,6 @@ 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}" )