Compare commits
119 Commits
8c59515b2f
...
464a1b7700
Author | SHA1 | Date |
---|---|---|
karlch | 464a1b7700 | |
karlch | abd5f5bec7 | |
karlch | 16d9194a3a | |
Christian Karl | f04a64feef | |
dependabot[bot] | c360419b91 | |
karlch | cf4a1db957 | |
karlch | ea51251a2f | |
karlch | 5e10abf0ad | |
Christian Karl | 39f7be230a | |
karlch | ff57aabfe9 | |
Christian Karl | 2b5b2a64ee | |
karlch | 02b8f237c9 | |
karlch | 0eab37e9ba | |
Christian Karl | 347079cd8d | |
karlch | 135dc28d94 | |
Christian Karl | 526dde7b11 | |
karlch | ee4d0f00a0 | |
karlch | b0a41a8381 | |
karlch | 0212b52579 | |
Christian Karl | 3ec77f04e2 | |
Christian Karl | facb142e50 | |
karlch | d06f9293e5 | |
karlch | 70b7c18a68 | |
karlch | 13a3d062d3 | |
karlch | 80840c0e79 | |
karlch | 4c111d3f39 | |
Christian Karl | bef5d71867 | |
buzzingwires | 3f287e8d15 | |
buzzingwires | fcf6cc8bac | |
buzzingwires | f27e246290 | |
Yutsuten | 9381589d8f | |
buzzingwires | 0669eaff39 | |
buzzingwires | f2da422c80 | |
dependabot[bot] | 600301d44a | |
Yutsuten | a20feaf700 | |
karlch | 73e2b79fcc | |
karlch | e023e05429 | |
karlch | 95e8a269f5 | |
karlch | 88bcd8a69a | |
Jean-Claude | cfb3b3a38e | |
Christian Karl | 0f39a5569d | |
buzzingwires | ab25bc1c60 | |
buzzingwires | f88a9b0cee | |
buzzingwires | 8227419a34 | |
buzzingwires | 8620bffada | |
karlch | cbcc268815 | |
karlch | 2b11b08798 | |
karlch | bca5d216d4 | |
karlch | 75e9bdb400 | |
karlch | 4c9b942e32 | |
karlch | da40fcc6a9 | |
Christian Karl | 0ceea493fc | |
karlch | d2c0112ef0 | |
karlch | 2629abb771 | |
Christian Karl | 1aa8f194d6 | |
karlch | bff129bfda | |
Jean-Claude | 816dc32b62 | |
Jean-Claude | ebfd9d6806 | |
Jean-Claude | 2d16f8942a | |
Jean-Claude | 106281711d | |
Jean-Claude | b9fb5dd379 | |
Jean-Claude | 2040326fbd | |
Jean-Claude | c3c16543ae | |
karlch | 1cbba4a3a5 | |
karlch | db95c4b6ae | |
karlch | 92669281de | |
dependabot[bot] | 715319db36 | |
dependabot[bot] | 4218da5fc0 | |
dependabot[bot] | 9d024d8fb8 | |
dependabot[bot] | 07266c6f3a | |
dependabot[bot] | 60d0a5b54c | |
dependabot[bot] | 40d8bc5073 | |
dependabot[bot] | 065265563e | |
dependabot[bot] | 40ac6b6573 | |
dependabot[bot] | be6079a826 | |
dependabot[bot] | 7a89ceedc4 | |
Christian Karl | a3acdac2e6 | |
Yutsuten | dd2b25863a | |
Yutsuten | e4c0877435 | |
Yutsuten | 1475b64d12 | |
Jean-Claude | fb66610094 | |
Jean-Claude | a22982fb7f | |
Jean-Claude | 18208132c8 | |
Jean-Claude | 7b3e2ee10f | |
Jean-Claude | 37d98097d5 | |
Jean-Claude | aa6a80a924 | |
Jean-Claude | 89dd893a0e | |
Jean-Claude | 409c80459e | |
Jean-Claude | 1132667948 | |
Jean-Claude | 5507de9c70 | |
Jean-Claude | 03fc90f408 | |
Jean-Claude | b3206b8e62 | |
Jean-Claude | 0aa4f8b7f9 | |
karlch | 4448981923 | |
karlch | a0fcb6b0b9 | |
karlch | 1555d20a05 | |
karlch | 01fa41d07e | |
karlch | 99d29adeb1 | |
karlch | 53e12e70c0 | |
karlch | 8a93af623f | |
karlch | 56c88dfea7 | |
karlch | 65fcb20ccc | |
karlch | 6bc17428d8 | |
karlch | fd6f951ad9 | |
karlch | 2d98a51d88 | |
karlch | b610237ff0 | |
Jean-Claude | 446f4bd588 | |
Jean-Claude | e2e9c4a050 | |
Jean-Claude | 2bd2ded1dc | |
Jean-Claude | 23c8fd140e | |
Jean-Claude | f2af1ded70 | |
Jean-Claude | 824fe8e3ca | |
Jean-Claude | f0740e50ef | |
Jean-Claude | 50cb5814fc | |
Jean-Claude | 26cf2b680e | |
Jean-Claude | 9fb1155ae7 | |
Jean-Claude | 6f3d885ca8 | |
Jean-Claude | 9de230d0d5 | |
Jean-Claude | d66107d391 |
|
@ -0,0 +1,18 @@
|
|||
[bumpversion]
|
||||
current_version = 0.9.0
|
||||
commit = True
|
||||
message = Release v{new_version}
|
||||
tag = True
|
||||
tag_name = v{new_version}
|
||||
|
||||
[bumpversion:file:vimiv/__init__.py]
|
||||
serialize = ({major}, {minor}, {patch})
|
||||
|
||||
[bumpversion:file:misc/org.karlch.vimiv.qt.metainfo.xml]
|
||||
search = <!-- Add new releases here -->
|
||||
replace = <!-- Add new releases here -->
|
||||
<release version="{new_version}" date="{now:%Y-%m-%d}"/>
|
||||
|
||||
[bumpversion:file:docs/changelog.rst]
|
||||
search = (unreleased)
|
||||
replace = ({now:%Y-%m-%d})
|
|
@ -1,5 +1,5 @@
|
|||
Contributing Guidelines
|
||||
=======================
|
||||
Contributing
|
||||
============
|
||||
|
||||
You want to contribute to vimiv? Great! Every little help counts and is appreciated!
|
||||
|
||||
|
|
|
@ -10,18 +10,12 @@ jobs:
|
|||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- python: "3.7"
|
||||
toxenv: pyqt511
|
||||
- python: "3.7"
|
||||
toxenv: pyqt512
|
||||
- python: "3.7"
|
||||
toxenv: pyqt513
|
||||
- python: "3.8"
|
||||
toxenv: pyqt513
|
||||
- python: "3.8"
|
||||
toxenv: pyqt514
|
||||
- python: "3.8"
|
||||
toxenv: pyqt515
|
||||
toxenv: pyqt515-piexif-pyexiv2-cov
|
||||
- python: "3.9"
|
||||
toxenv: pyqt513
|
||||
- python: "3.9"
|
||||
|
@ -43,7 +37,7 @@ jobs:
|
|||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3.5.2
|
||||
- uses: actions/checkout@v3.5.3
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
|
@ -52,7 +46,7 @@ jobs:
|
|||
~/.cache/pip
|
||||
key: "${{ matrix.toxenv }}_${{ hashFiles('misc/requirements/requirements*.txt') }}_${{ hashFiles('scripts/maybe_build_cextension.py') }}_${{ hashFiles('scripts/lint_tests.py') }}"
|
||||
- name: Set up ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4.6.0
|
||||
uses: actions/setup-python@v4.7.0
|
||||
with:
|
||||
python-version: "${{ matrix.python }}"
|
||||
- name: Install dependencies
|
||||
|
@ -60,6 +54,13 @@ jobs:
|
|||
sudo apt-get update && sudo apt-get install libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxcb-shape0
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r misc/requirements/requirements_tox.txt
|
||||
- name: Install dependencies for pyexiv2
|
||||
if: "contains(matrix.toxenv, 'pyexiv2')"
|
||||
run: |
|
||||
sudo apt-get install libexiv2-dev libboost-python-dev
|
||||
sudo ln -s /usr/lib/x86_64-linux-gnu/libboost_python3.so /usr/lib/x86_64-linux-gnu/libboost_python${PY//./}.so
|
||||
env:
|
||||
PY: ${{ matrix.python-version }}
|
||||
- name: Test with tox
|
||||
run: |
|
||||
tox -e "${{ matrix.toxenv }}"
|
||||
|
|
|
@ -10,7 +10,7 @@ jobs:
|
|||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3.5.2
|
||||
- uses: actions/checkout@v3.5.3
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
|
@ -18,7 +18,7 @@ jobs:
|
|||
~/.cache/pip
|
||||
key: "ghpages_${{ hashFiles('misc/requirements/requirements*.txt') }}_${{ hashFiles('scripts/src2rst.py') }}"
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v4.6.0
|
||||
uses: actions/setup-python@v4.7.0
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install tox
|
||||
|
|
|
@ -9,9 +9,9 @@ jobs:
|
|||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3.5.2
|
||||
- uses: actions/checkout@v3.5.3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4.6.0
|
||||
uses: actions/setup-python@v4.7.0
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install dependencies
|
||||
|
|
1
AUTHORS
|
@ -3,6 +3,7 @@ Wolfgang Popp (woefe) <mail at wolfgang-popp dot de>
|
|||
Ankur Sinha (sanjayankur31) <ankursinha at fedoraproject dot org>
|
||||
Jean-Claude Graf (jcjgraf) <jeanggi90 at gmail dot com>
|
||||
Mateus Etto (Yutsuten) <mateus dot etto at gmail dot com>
|
||||
buzzingwires (buzzingwires) <buzzingwires at outlook dot com>
|
||||
|
||||
All contributors of non-trivial code are listed here. Please send an email to
|
||||
<karlch at protonmail dot com> or open an issue / pull request if you feel like your
|
||||
|
|
|
@ -1,79 +1,54 @@
|
|||
/* Underline headers with differently sized blue lines */
|
||||
h1 {
|
||||
border-bottom: solid 4px #1F7DE6;
|
||||
html[data-theme="light"] {
|
||||
--pst-color-primary: #3F8DB4;
|
||||
--pst-color-info: #3F8DB4;
|
||||
|
||||
--pst-color-secondary: #7AACBC;
|
||||
--pst-color-link-hover: #7AACBC;
|
||||
|
||||
--pst-color-warning: #c79537;
|
||||
--pst-color-success: #4aa56f;
|
||||
--pst-color-attention: #f44336;
|
||||
--pst-color-danger: #f44336;
|
||||
|
||||
--pst-color-background: rgb(244, 243, 245);
|
||||
--pst-color-on-background: rgb(244, 243, 245);
|
||||
--pst-color-surface: rgb(234, 233, 235);
|
||||
--pst-color-on-surface: rgb(214, 213, 215);
|
||||
|
||||
--pst-color-text-base: #1F1D21;
|
||||
}
|
||||
|
||||
h2 {
|
||||
border-bottom: solid 2px #1F7DE6;
|
||||
html[data-theme="dark"] {
|
||||
--pst-color-secondary: #9FE2F6;
|
||||
--pst-color-link-hover: #9FE2F6;
|
||||
|
||||
--pst-color-primary: #89C3D4;
|
||||
--pst-color-info: #89C3D4;
|
||||
|
||||
--pst-color-warning: #c79537;
|
||||
--pst-color-success: #4aa56f;
|
||||
--pst-color-attention: #f44336;
|
||||
--pst-color-danger: #f44336;
|
||||
|
||||
--pst-color-background: #1F1D21;
|
||||
--pst-color-on-background: #2b292d;
|
||||
--pst-color-surface: #2e2c30;
|
||||
--pst-color-on-surface: #444246;
|
||||
|
||||
--pst-color-text-base: #F4F3F6;
|
||||
--pst-color-text-muted: rgb(208, 204, 212)
|
||||
}
|
||||
|
||||
h3, h4, h5, h6 {
|
||||
border-bottom: solid 1px #1F7DE6;
|
||||
.bordered-image-light img {
|
||||
border: 3px solid #3F8DB4;
|
||||
margin: 4px;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Add a margin around images */
|
||||
img {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
/* But not in the navbar on the top */
|
||||
.navbar img {
|
||||
margin: 0px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* Change navbar color to blue */
|
||||
.navbar {
|
||||
background: #1861B3;
|
||||
color: #FFFFFF;
|
||||
border-bottom: solid 4px #1F7DE6;
|
||||
}
|
||||
|
||||
.navbar-default .navbar-nav > li > a:hover,
|
||||
.navbar-default .navbar-nav > li > a:focus {
|
||||
background-color: #1F7DE6;
|
||||
}
|
||||
|
||||
.navbar-default .navbar-nav > .active > a,
|
||||
.navbar-default .navbar-nav > .active > a:hover,
|
||||
.navbar-default .navbar-nav > .active > a:focus {
|
||||
background-color: #1F7DE6;
|
||||
}
|
||||
|
||||
.navbar-default .navbar-toggle:hover,
|
||||
.navbar-default .navbar-toggle:focus {
|
||||
background-color: #1F7DE6;
|
||||
}
|
||||
|
||||
.navbar-default .navbar-nav > .open > a,
|
||||
.navbar-default .navbar-nav > .open > a:hover,
|
||||
.navbar-default .navbar-nav > .open > a:focus {
|
||||
background-color: #1F7DE6;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.navbar-default .navbar-nav .open .dropdown-menu > li > a:hover,
|
||||
.navbar-default .navbar-nav .open .dropdown-menu > li > a:focus {
|
||||
background-color: #1F7DE6;
|
||||
}
|
||||
.navbar-default .navbar-nav .open .dropdown-menu > .active > a,
|
||||
.navbar-default .navbar-nav .open .dropdown-menu > .active > a:hover,
|
||||
.navbar-default .navbar-nav .open .dropdown-menu > .active > a:focus {
|
||||
background-color: #1F7DE6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Change info box color from purple to dark blue */
|
||||
.alert-info {
|
||||
background-color: #1861B3;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.alert-info a {
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.alert-info a:hover {
|
||||
color: #BBBBBB;
|
||||
text-decoration: underline;
|
||||
.bordered-image-dark img {
|
||||
border: 3px solid #7AACBC;
|
||||
margin: 4px;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 676 KiB |
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 663 KiB |
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 1.2 MiB |
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 1.2 MiB |
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 563 KiB |
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 564 KiB |
After Width: | Height: | Size: 1.0 MiB |
After Width: | Height: | Size: 1.0 MiB |
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 922 KiB |
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 922 KiB |
After Width: | Height: | Size: 44 KiB |
|
@ -1,53 +0,0 @@
|
|||
{% extends "!layout.html" %}
|
||||
|
||||
{# Add navigation for next and previous page to footer #}
|
||||
{% block footer %}
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<p class="pull-right">
|
||||
{# Custom part starts here #}
|
||||
{%- if prev %}
|
||||
<a href="{{ prev.link|e }}" title="{{ _('Previous Chapter: ') + prev.title|striptags }}">
|
||||
{%- if theme_bootstrap_version == "2" -%}<span class="icon-chevron-left visible-tablet"></span>{%- endif -%}
|
||||
{%- if theme_bootstrap_version == "3" -%}<span class="glyphicon glyphicon-chevron-left visible-sm"></span>{%- endif -%}
|
||||
<span class="hidden-sm hidden-tablet">{{ "«"|safe }} {{ prev.title|striptags|truncate(length=16, killwords=True) }}</span>
|
||||
</a>
|
||||
|
||||
|
||||
{%- endif %}
|
||||
<a href="#">Back to top</a>
|
||||
{%- if next %}
|
||||
|
||||
|
||||
<a href="{{ next.link|e }}" title="{{ _('Next Chapter: ') + next.title|striptags }}">
|
||||
{%- if theme_bootstrap_version == "2" -%}<span class="icon-chevron-right visible-tablet"></span>{%- endif -%}
|
||||
{%- if theme_bootstrap_version == "3" -%}<span class="glyphicon glyphicon-chevron-right visible-sm"></span>{%- endif -%}
|
||||
<span class="hidden-sm hidden-tablet">{{ next.title|striptags|truncate(length=16, killwords=True) }} {{ "»"|safe }}</span>
|
||||
</a>
|
||||
{%- endif %}
|
||||
{# Custom part ends here #}
|
||||
|
||||
|
||||
{% if theme_source_link_position == "footer" %}
|
||||
<br/>
|
||||
{% include "sourcelink.html" %}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p>
|
||||
{%- if show_copyright %}
|
||||
{%- if hasdoc('copyright') %}
|
||||
{% trans path=pathto('copyright'), copyright=copyright|e %}© <a href="{{ path }}">Copyright</a> {{ copyright }}.{% endtrans %}<br/>
|
||||
{%- else %}
|
||||
{% trans copyright=copyright|e %}© Copyright {{ copyright }}.{% endtrans %}<br/>
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- if last_updated %}
|
||||
{% trans last_updated=last_updated|e %}Last updated on {{ last_updated }}.{% endtrans %}<br/>
|
||||
{%- endif %}
|
||||
{%- if show_sphinx %}
|
||||
{% trans sphinx_version=sphinx_version|e %}Created using <a href="http://sphinx-doc.org/">Sphinx</a> {{ sphinx_version }}.{% endtrans %}<br/>
|
||||
{%- endif %}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
{% endblock %}
|
|
@ -1,5 +0,0 @@
|
|||
{% extends "!navbar.html" %}
|
||||
|
||||
{# Disable search as it destroys the layout for smaller screens #}
|
||||
{% block navbarsearch %}
|
||||
{% endblock %}
|
|
@ -3,7 +3,11 @@ Changelog
|
|||
|
||||
All notable changes to vimiv are documented in this file.
|
||||
|
||||
v0.9.0 (unreleased)
|
||||
v0.10.0 (unreleased)
|
||||
--------------------
|
||||
|
||||
|
||||
v0.9.0 (2023-07-15)
|
||||
-------------------
|
||||
|
||||
Added:
|
||||
|
@ -89,6 +93,9 @@ Added:
|
|||
the changes with ``<return>`` and reject them with ``<escape>``. The
|
||||
``{transformation-info}`` status module displays the currently selected geometry of
|
||||
the original image. Thanks `@Yutsuten`_ for reviving this!
|
||||
* Add the ``none`` sorting type for the ``sort.image_order`` and ``sort.directory_order``
|
||||
options, implemented by `@buzzingwires`_
|
||||
* Add the ``thumbnail.save`` option, implemented by `@buzzingwires`_
|
||||
|
||||
Changed:
|
||||
^^^^^^^^
|
||||
|
@ -105,6 +112,14 @@ 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.
|
||||
* Vimiv now requires at least Python 3.8 and thus PyQt 5.13.2.
|
||||
* Qt logs of level warning / critical are now suppressed if the corresponding vimiv log
|
||||
level is higher.
|
||||
|
||||
Fixed:
|
||||
^^^^^^
|
||||
|
@ -530,3 +545,4 @@ Initial release of the Qt version.
|
|||
.. _@timsofteng: https://github.com/timsofteng
|
||||
.. _@kAldown: https://github.com/kaldown
|
||||
.. _@Yutsuten: https://github.com/Yutsuten
|
||||
.. _@buzzingwires: https://github.com/buzzingwires
|
||||
|
|
81
docs/conf.py
|
@ -24,7 +24,6 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
import sphinx_bootstrap_theme
|
||||
from sphinx.ext import autodoc
|
||||
|
||||
sys.path.insert(0, os.path.abspath(".."))
|
||||
|
@ -39,7 +38,7 @@ sys.path.insert(0, os.path.abspath(".."))
|
|||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon"]
|
||||
extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinxcontrib.images"]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ["_templates"]
|
||||
|
@ -91,9 +90,7 @@ todo_include_todos = False
|
|||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
#
|
||||
html_theme = "bootstrap"
|
||||
html_theme_path = sphinx_bootstrap_theme.get_html_theme_path()
|
||||
html_sidebars = {}
|
||||
html_theme = "pydata_sphinx_theme"
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
|
@ -106,19 +103,26 @@ html_static_path = ["_static"]
|
|||
#
|
||||
html_logo = "_static/vimiv/vimiv.svg"
|
||||
html_theme_options = {
|
||||
"bootswatch_theme": "cosmo",
|
||||
"navbar_title": "vimiv",
|
||||
"navbar_pagenav": True,
|
||||
"navbar_sidebarrel": False,
|
||||
"navbar_links": [
|
||||
("Docs", "documentation/index"),
|
||||
("Install", "documentation/install"),
|
||||
("Screenshots", "screenshots"),
|
||||
("Changelog", "changelog"),
|
||||
("Contributing", "documentation/contributing"),
|
||||
("GitHub", "https://github.com/karlch/vimiv-qt", True),
|
||||
"logo": {
|
||||
"text": "vimiv",
|
||||
},
|
||||
"icon_links": [
|
||||
{
|
||||
"name": "GitHub",
|
||||
"url": "https://github.com/karlch/vimiv-qt",
|
||||
"icon": "fa-brands fa-square-github",
|
||||
},
|
||||
{
|
||||
"name": "PyPI",
|
||||
"url": "https://pypi.org/project/vimiv",
|
||||
"icon": "fa-solid fa-box",
|
||||
},
|
||||
],
|
||||
"source_link_position": "footer",
|
||||
"navbar_start": ["navbar-logo"],
|
||||
}
|
||||
|
||||
html_sidebars = {
|
||||
"**": [],
|
||||
}
|
||||
|
||||
# -- Options for HTMLHelp output ------------------------------------------
|
||||
|
@ -127,31 +131,6 @@ html_theme_options = {
|
|||
htmlhelp_basename = "vimivdoc"
|
||||
|
||||
|
||||
# -- Options for LaTeX output ---------------------------------------------
|
||||
|
||||
latex_elements = {
|
||||
# The paper size ('letterpaper' or 'a4paper').
|
||||
#
|
||||
# 'papersize': 'letterpaper',
|
||||
# The font size ('10pt', '11pt' or '12pt').
|
||||
#
|
||||
# 'pointsize': '10pt',
|
||||
# Additional stuff for the LaTeX preamble.
|
||||
#
|
||||
# 'preamble': '',
|
||||
# Latex figure (float) alignment
|
||||
#
|
||||
# 'figure_align': 'htbp',
|
||||
}
|
||||
|
||||
# Grouping the document tree into LaTeX files. List of tuples
|
||||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
(master_doc, "vimiv.tex", "vimiv Documentation", "Christian Karl", "manual")
|
||||
]
|
||||
|
||||
|
||||
# -- Options for manual page output ---------------------------------------
|
||||
|
||||
# One entry per manual page. List of tuples
|
||||
|
@ -167,24 +146,6 @@ man_pages = [
|
|||
]
|
||||
|
||||
|
||||
# -- Options for Texinfo output -------------------------------------------
|
||||
|
||||
# Grouping the document tree into Texinfo files. List of tuples
|
||||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
(
|
||||
master_doc,
|
||||
"vimiv",
|
||||
"vimiv Documentation",
|
||||
author,
|
||||
"vimiv",
|
||||
"One line description of project.",
|
||||
"Miscellaneous",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def setup(app):
|
||||
app.add_css_file("custom.css")
|
||||
ignore_separator = "^" + 88 * "-" + "$"
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
Command Line Arguments
|
||||
======================
|
||||
|
||||
When starting vimiv form the command line you have the ability to pass a number of
|
||||
different argument to vimiv.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
In the following we present a few use cases of command line arguments.
|
||||
|
||||
* Start in library view with the thumbnail grid displayed::
|
||||
|
||||
vimiv * --command "enter thumbnail" --command "enter library"
|
||||
|
||||
* Start in read-only mode. This prevents accidental modification (renaming, moving, editing etc.) of any images::
|
||||
|
||||
vimiv --set read_only true
|
||||
|
||||
* Change WM_CLASS_INSTANCE to identify a vimiv instance::
|
||||
|
||||
vimiv --qt-args "--name myVimivInstance"
|
||||
|
||||
* Print the last selected image to STDOUT when quitting::
|
||||
|
||||
vimiv --output "%"
|
||||
|
||||
* Use vimiv as *Rofi for Images* to make a selection from candidate images::
|
||||
|
||||
mySel=$(echo $myCand | vimiv --input --output "%m" --command "enter thumbnail")
|
||||
|
||||
* Print debug logs of your amazing plugin you are writing and of the `api._mark` module which does not behave as you are expecting::
|
||||
|
||||
vimiv --debug myAmazingPlugin api._mark
|
||||
|
||||
Command Line Arguments
|
||||
----------------------
|
||||
|
||||
The general calling structure is:
|
||||
|
||||
.. include:: synopsis.rstsrc
|
||||
|
||||
The following is an exhaustive list of all available arguments:
|
||||
|
||||
.. include:: options.rstsrc
|
|
@ -7,7 +7,7 @@ to the ``$XDG_CONFIG_HOME/vimiv/keys.conf`` on first start where
|
|||
``$XDG_CONFIG_HOME`` is usually ``~/.config/`` if you have not updated it.
|
||||
|
||||
The configuration file is structured into sections. Each section corresponds to
|
||||
the mode in which the keybindings arevalid. In each section the keybindings are
|
||||
the mode in which the keybindings are valid. In each section the keybindings are
|
||||
defined using ``keybinding : command to bind to``. Therefore ``f : fullscreen``
|
||||
maps the ``f`` key to the ``fullscreen`` command. Special keys like space must
|
||||
be wrapped in tags in the form of ``<space>`` to allow to differentiate them
|
||||
|
|
|
@ -56,9 +56,11 @@ directories. The ordering principle is defined by the ``sort.image_order`` and
|
|||
natural Natural ordering by basename, i.e. image2.jpg comes before image11.jpg
|
||||
recently-modified Ordering by modification time (``mtime``)
|
||||
size Ordering by filesize, in bytes for images, in number of files for directories
|
||||
none Do not sort or reverse. Use the existing order of the images (including that of a previous sort type). This is mostly for keeping the order from the command line or stdin.
|
||||
========================= ===========
|
||||
|
||||
In addition, the ordering can be reversed using ``sort.reverse`` and the string-like
|
||||
orderings (``alphabetical`` and ``natural``) can be made case-insensitive using
|
||||
``sort.ignore_case``. When ``sort.shuffle`` is set, the image filelist is shuffled
|
||||
regardless of the other orderings, but the library remains ordered.
|
||||
``sort.ignore_case`` except for when the ``none`` ordering type is used.
|
||||
When ``sort.shuffle`` is set, the image filelist is shuffled regardless of the other
|
||||
orderings, but the library remains ordered.
|
||||
|
|
|
@ -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 <https://www.exiv2.org/metadata.html>`_. 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/
|
|
@ -1,5 +1,5 @@
|
|||
Documentation
|
||||
=============
|
||||
Docs
|
||||
====
|
||||
|
||||
The following documents are available:
|
||||
|
||||
|
@ -10,7 +10,8 @@ The following documents are available:
|
|||
getting_started
|
||||
commands
|
||||
configuration/index
|
||||
exif
|
||||
cl_options/index
|
||||
metadata
|
||||
contributing
|
||||
migrating
|
||||
|
||||
|
|
|
@ -81,6 +81,8 @@ should work.
|
|||
|
||||
.. include:: dependency_info.rst
|
||||
|
||||
.. include:: updating_icon_cache.rst
|
||||
|
||||
.. _install_using_tox:
|
||||
|
||||
Using Tox
|
||||
|
@ -123,16 +125,15 @@ You can now launch vimiv by running::
|
|||
Dependencies
|
||||
------------
|
||||
|
||||
* `Python <http://www.python.org/>`_ 3.6 or newer with development extension
|
||||
* `Qt <http://qt.io/>`_ 5.11.3 or newer
|
||||
* `Python <http://www.python.org/>`_ 3.8 or newer with development extension
|
||||
* `Qt <http://qt.io/>`_ 5.13.2 or newer
|
||||
- QtCore / qtbase
|
||||
- QtSvg (optional for svg support)
|
||||
* `PyQt5 <http://www.riverbankcomputing.com/software/pyqt/intro>`_ 5.11.3 or newer
|
||||
* `PyQt5 <http://www.riverbankcomputing.com/software/pyqt/intro>`_ 5.13.2 or newer
|
||||
* `setuptools <https://pypi.python.org/pypi/setuptools/>`_ (for installation)
|
||||
* `pyexiv2 <https://python3-exiv2.readthedocs.io>`_ (optional for exif support)
|
||||
* `piexif <https://pypi.org/project/piexif/>`_ (optional alternative for exif support)
|
||||
|
||||
.. include:: pyexiv2.rst
|
||||
* `pyexiv2 <https://python3-exiv2.readthedocs.io>`_ (optional for metadata support)
|
||||
* `piexif <https://pypi.org/project/piexif/>`_ (optional alternative for metadata
|
||||
support)
|
||||
|
||||
Package Names For Distributions
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
|
|
@ -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<Plugins>`, as described in
|
||||
:ref:`a later section<create_own_plugins>`.
|
||||
|
||||
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 <https://github.com/jcjgraf/vimiv_metadata-gexiv2>`_ Based on `gexiv2 <https://gitlab.gnome.org/GNOME/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<Plugins>`.
|
||||
|
||||
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<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 <https://www.exiv2.org/metadata.html>`_. 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/
|
|
@ -1,3 +0,0 @@
|
|||
.. warning::
|
||||
|
||||
There are multiple packages named `pyexiv2`. Make sure you install the right one.
|
|
@ -0,0 +1,6 @@
|
|||
.. note::
|
||||
|
||||
You may need to run ``gtk-update-icon-cache /usr/share/icons/hicolor`` (replace the
|
||||
path if installing the icons somewhere else) after the install to get the vimiv icon
|
||||
in your application launcher. It has been reported that the command was needed for
|
||||
`wofi <https://hg.sr.ht/~scoopta/wofi>`_.
|
|
@ -3,6 +3,12 @@
|
|||
.. image:: _static/vimiv/vimiv_banner_800.png
|
||||
:width: 400px
|
||||
:align: center
|
||||
:class: only-light
|
||||
|
||||
.. image:: _static/vimiv/vimiv_banner_darkmode_800.png
|
||||
:width: 400px
|
||||
:align: center
|
||||
:class: only-dark
|
||||
|
||||
.. note::
|
||||
|
||||
|
@ -10,8 +16,7 @@
|
|||
there are already many improvements compared to the `gtk version
|
||||
<https://github.com/karlch/vimiv>`_. The old version is only recommended if you
|
||||
require a more stable software. In case there is anything you miss here, please
|
||||
open an `issue on github <https://github.com/karlch/vimiv-qt/issues/>`_. Check the
|
||||
:ref:`roadmap` for more details.
|
||||
open an `issue on github <https://github.com/karlch/vimiv-qt/issues/>`_.
|
||||
|
||||
.. include:: description.rst
|
||||
|
||||
|
@ -26,19 +31,47 @@ Screenshots
|
|||
|
||||
Light theme:
|
||||
|
||||
.. image:: _static/scrots/image_light.png
|
||||
.. thumbnail:: _static/scrots/image_light.png
|
||||
:width: 300px
|
||||
:group: light-theme-screenshots-only-light
|
||||
:class: bordered-image-light only-light
|
||||
|
||||
.. image:: _static/scrots/thumbnail_light.png
|
||||
.. thumbnail:: _static/scrots/thumbnail_light.png
|
||||
:width: 300px
|
||||
:class: bordered-image-light only-light
|
||||
:group: light-theme-screenshots-only-light
|
||||
|
||||
.. thumbnail:: _static/scrots/image_light.png
|
||||
:width: 300px
|
||||
:class: bordered-image-dark only-dark
|
||||
:group: light-theme-screenshots-only-dark
|
||||
|
||||
.. thumbnail:: _static/scrots/thumbnail_light.png
|
||||
:width: 300px
|
||||
:class: bordered-image-dark only-dark
|
||||
:group: light-theme-screenshots-only-dark
|
||||
|
||||
Dark theme:
|
||||
|
||||
.. image:: _static/scrots/library_dark.png
|
||||
.. thumbnail:: _static/scrots/library_dark.png
|
||||
:width: 300px
|
||||
:class: bordered-image-light only-light
|
||||
:group: dark-theme-screenshots-only-light
|
||||
|
||||
.. image:: _static/scrots/command_dark.png
|
||||
.. thumbnail:: _static/scrots/command_dark.png
|
||||
:width: 300px
|
||||
:class: bordered-image-light only-light
|
||||
:group: dark-theme-screenshots-only-light
|
||||
|
||||
.. thumbnail:: _static/scrots/library_dark.png
|
||||
:width: 300px
|
||||
:class: bordered-image-dark only-dark
|
||||
:group: dark-theme-screenshots-only-dark
|
||||
|
||||
.. thumbnail:: _static/scrots/command_dark.png
|
||||
:width: 300px
|
||||
:class: bordered-image-dark only-dark
|
||||
:group: dark-theme-screenshots-only-dark
|
||||
|
||||
Contents
|
||||
--------
|
||||
|
@ -47,9 +80,10 @@ Contents
|
|||
:maxdepth: 2
|
||||
|
||||
documentation/index
|
||||
changelog
|
||||
roadmap
|
||||
documentation/install
|
||||
screenshots
|
||||
changelog
|
||||
documentation/contributing
|
||||
|
||||
.. include:: documentation/getting_help.rst
|
||||
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
.. _roadmap:
|
||||
|
||||
Roadmap
|
||||
=======
|
||||
|
||||
In the current beta-phase, a minor release is planned roughly every month. After version
|
||||
0.9 and once no major bugs are remaining, we can release version 1.0. This would then
|
||||
replace the gtk version as default. Once this has happened, vimiv should strictly apply
|
||||
`semantic versioning <https://semver.org/>`_.
|
||||
|
||||
The Gtk version will retrieve critical bugfixes at least until version 1.0 has been
|
||||
released. No new features will be implemented there, though.
|
|
@ -4,29 +4,86 @@ Screenshots
|
|||
Dark Theme
|
||||
^^^^^^^^^^^
|
||||
|
||||
.. image:: _static/scrots/image_dark.png
|
||||
:width: 300px
|
||||
.. thumbnail:: _static/scrots/image_dark.png
|
||||
:group: dark-theme-screenshots-only-light
|
||||
:width: 400px
|
||||
:class: bordered-image-light only-light
|
||||
|
||||
.. image:: _static/scrots/thumbnail_dark.png
|
||||
:width: 300px
|
||||
.. thumbnail:: _static/scrots/thumbnail_dark.png
|
||||
:group: dark-theme-screenshots-only-light
|
||||
:width: 400px
|
||||
:class: bordered-image-light only-light
|
||||
|
||||
.. image:: _static/scrots/library_dark.png
|
||||
:width: 300px
|
||||
.. thumbnail:: _static/scrots/library_dark.png
|
||||
:group: dark-theme-screenshots-only-light
|
||||
:width: 400px
|
||||
:class: bordered-image-light only-light
|
||||
|
||||
.. thumbnail:: _static/scrots/command_dark.png
|
||||
:group: dark-theme-screenshots-only-light
|
||||
:width: 400px
|
||||
:class: bordered-image-light only-light
|
||||
|
||||
.. thumbnail:: _static/scrots/image_dark.png
|
||||
:group: dark-theme-screenshots-only-dark
|
||||
:width: 400px
|
||||
:class: bordered-image-dark only-dark
|
||||
|
||||
.. thumbnail:: _static/scrots/thumbnail_dark.png
|
||||
:group: dark-theme-screenshots-only-dark
|
||||
:width: 400px
|
||||
:class: bordered-image-dark only-dark
|
||||
|
||||
.. thumbnail:: _static/scrots/library_dark.png
|
||||
:group: dark-theme-screenshots-only-dark
|
||||
:width: 400px
|
||||
:class: bordered-image-dark only-dark
|
||||
|
||||
.. thumbnail:: _static/scrots/command_dark.png
|
||||
:group: dark-theme-screenshots-only-dark
|
||||
:width: 400px
|
||||
:class: bordered-image-dark only-dark
|
||||
|
||||
.. image:: _static/scrots/command_dark.png
|
||||
:width: 300px
|
||||
|
||||
Light Theme
|
||||
^^^^^^^^^^^
|
||||
|
||||
.. image:: _static/scrots/image_light.png
|
||||
:width: 300px
|
||||
.. thumbnail:: _static/scrots/image_light.png
|
||||
:group: light-theme-screenshots-only-light
|
||||
:width: 400px
|
||||
:class: bordered-image-light only-light
|
||||
|
||||
.. image:: _static/scrots/thumbnail_light.png
|
||||
:width: 300px
|
||||
.. thumbnail:: _static/scrots/thumbnail_light.png
|
||||
:group: light-theme-screenshots-only-light
|
||||
:width: 400px
|
||||
:class: bordered-image-light only-light
|
||||
|
||||
.. image:: _static/scrots/library_light.png
|
||||
:width: 300px
|
||||
.. thumbnail:: _static/scrots/library_light.png
|
||||
:group: light-theme-screenshots-only-light
|
||||
:width: 400px
|
||||
:class: bordered-image-light only-light
|
||||
|
||||
.. image:: _static/scrots/command_light.png
|
||||
:width: 300px
|
||||
.. thumbnail:: _static/scrots/command_light.png
|
||||
:group: light-theme-screenshots-only-light
|
||||
:width: 400px
|
||||
:class: bordered-image-light only-light
|
||||
|
||||
.. thumbnail:: _static/scrots/image_light.png
|
||||
:group: light-theme-screenshots-only-dark
|
||||
:width: 400px
|
||||
:class: bordered-image-dark only-dark
|
||||
|
||||
.. thumbnail:: _static/scrots/thumbnail_light.png
|
||||
:group: light-theme-screenshots-only-dark
|
||||
:width: 400px
|
||||
:class: bordered-image-dark only-dark
|
||||
|
||||
.. thumbnail:: _static/scrots/library_light.png
|
||||
:group: light-theme-screenshots-only-dark
|
||||
:width: 400px
|
||||
:class: bordered-image-dark only-dark
|
||||
|
||||
.. thumbnail:: _static/scrots/command_light.png
|
||||
:group: light-theme-screenshots-only-dark
|
||||
:width: 400px
|
||||
:class: bordered-image-dark only-dark
|
||||
|
|
|
@ -44,14 +44,16 @@
|
|||
<url type="help">https://karlch.github.io/vimiv-qt/documentation</url>
|
||||
<update_contact>ankursinha AT fedoraproject.org</update_contact>
|
||||
<releases>
|
||||
<!-- Add new releases here -->
|
||||
<release version="0.7.0" date="2020-05-17"/>
|
||||
<release version="0.6.1" date="2020-03-07"/>
|
||||
<release version="0.6.0" date="2020-03-07"/>
|
||||
<release version="0.5.0" date="2020-01-05"/>
|
||||
<release version="0.4.0" date="2019-12-01"/>
|
||||
<release version="0.3.0" date="2019-11-01"/>
|
||||
<release version="0.2.0" date="2019-10-01"/>
|
||||
<release version="0.1.0" date="2019-08-15"/>
|
||||
<!-- Add new releases here -->
|
||||
<release version="0.9.0" date="2023-07-15"/>
|
||||
<release version="0.8.0" date="2021-01-18"/>
|
||||
<release version="0.7.0" date="2020-05-17"/>
|
||||
<release version="0.6.1" date="2020-03-07"/>
|
||||
<release version="0.6.0" date="2020-03-07"/>
|
||||
<release version="0.5.0" date="2020-01-05"/>
|
||||
<release version="0.4.0" date="2019-12-01"/>
|
||||
<release version="0.3.0" date="2019-11-01"/>
|
||||
<release version="0.2.0" date="2019-10-01"/>
|
||||
<release version="0.1.0" date="2019-08-15"/>
|
||||
</releases>
|
||||
</component>
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
coverage==7.2.3
|
||||
pytest-cov==4.0.0
|
||||
coverage==7.2.7
|
||||
pytest-cov==4.1.0
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
sphinx==5.3.0
|
||||
sphinx-bootstrap-theme==0.8.1
|
||||
sphinx==7.0.1
|
||||
pydata-sphinx-theme==0.13.3
|
||||
sphinxcontrib-images==0.9.4
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
pylint==2.17.2
|
||||
pycodestyle==2.10.0
|
||||
pylint==2.17.4
|
||||
black==23.7.0
|
||||
pydocstyle==6.3.0
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
mypy==1.2.0
|
||||
mypy==1.4.1
|
||||
PyQt5-stubs==5.15.6.0
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
flaky==3.7.0
|
||||
pytest==7.3.1
|
||||
pytest-mock==3.10.0
|
||||
pytest==7.4.0
|
||||
pytest-mock==3.11.1
|
||||
pytest-qt==4.2.0
|
||||
pytest-xvfb==2.0.0
|
||||
pytest-xvfb==3.0.0
|
||||
pytest-bdd==6.1.1
|
||||
|
|
|
@ -1 +1 @@
|
|||
tox==4.4.12
|
||||
tox==4.6.4
|
||||
|
|
28
misc/vimiv.1
|
@ -27,12 +27,12 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
|||
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
|
||||
..
|
||||
.TH "VIMIV" "1" "Dec 26, 2021" "" "vimiv"
|
||||
.TH "VIMIV" "1" "Jan 08, 2022" "" "vimiv"
|
||||
.SH NAME
|
||||
vimiv \- an image viewer with vim-like keybindings
|
||||
.SH SYNOPSIS
|
||||
.sp
|
||||
\fBvimiv\fP [\fBPATH\fP] [\fB\-h\fP] [\fB\-\-help\fP] [\fB\-f\fP] [\fB\-\-fullscreen\fP] [\fB\-v\fP] [\fB\-\-version\fP] [\fB\-g\fP \fIWIDTHxHEIGHT\fP] [\fB\-\-geometry\fP \fIWIDTHxHEIGHT\fP] [\fB\-\-temp\-basedir\fP] [\fB\-\-config\fP \fIFILE\fP] [\fB\-\-keyfile\fP \fIFILE\fP] [\fB\-s\fP \fIOPTION\fP \fIVALUE\fP] [\fB\-\-set\fP \fIOPTION\fP \fIVALUE\fP] [\fB\-\-log\-level\fP \fILEVEL\fP] [\fB\-\-command\fP \fICOMMAND\fP] [\fB\-b\fP \fIDIRECTORY\fP] [\fB\-\-basedir\fP \fIDIRECTORY\fP] [\fB\-o\fP \fITEXT\fP] [\fB\-\-output\fP \fITEXT\fP] [\fB\-i\fP] [\fB\-\-input\fP] [\fB\-\-debug\fP \fIMODULE\fP] [\fB\-\-qt\-args\fP \fIARGS\fP]
|
||||
\fBvimiv\fP [\fBPATH\fP] [\fB\-\-help\fP|\fB\-h\fP] [\fB\-\-fullscreen\fP|\fB\-f\fP] [\fB\-\-version\fP|\fB\-v\fP] [\fB\-\-geometry\fP \fIWIDTHxHEIGHT\fP|\fB\-g\fP \fIWIDTHxHEIGHT\fP] [\fB\-\-temp\-basedir\fP] [\fB\-\-config\fP \fIFILE\fP] [\fB\-\-keyfile\fP \fIFILE\fP] [\fB\-\-set\fP \fIOPTION\fP \fIVALUE\fP|\fB\-s\fP \fIOPTION\fP \fIVALUE\fP] [\fB\-\-log\-level\fP \fILEVEL\fP] [\fB\-\-command\fP \fICOMMAND\fP] [\fB\-\-basedir\fP \fIDIRECTORY\fP|\fB\-b\fP \fIDIRECTORY\fP] [\fB\-\-output\fP \fITEXT\fP|\fB\-o\fP \fITEXT\fP] [\fB\-\-input\fP|\fB\-i\fP] [\fB\-\-debug\fP \fIMODULE\fP] [\fB\-\-qt\-args\fP \fIARGS\fP]
|
||||
.SH DESCRIPTION
|
||||
.sp
|
||||
Vimiv is an image viewer with vim\-like keybindings. It is written in python3
|
||||
|
@ -54,7 +54,7 @@ Complete customization with style sheets
|
|||
A much more complete documentation can be found under
|
||||
\fI\%https://karlch.github.io/vimiv\-qt/\fP\&.
|
||||
.SH OPTIONS
|
||||
.SS positional arguments
|
||||
.SS POSITIONAL ARGUMENTS
|
||||
.sp
|
||||
\fBPATH\fP
|
||||
.INDENT 0.0
|
||||
|
@ -62,30 +62,30 @@ A much more complete documentation can be found under
|
|||
Paths to open
|
||||
.UNINDENT
|
||||
.UNINDENT
|
||||
.SS optional arguments
|
||||
.SS OPTIONS
|
||||
.sp
|
||||
\fB\-h\fP, \fB\-\-help\fP
|
||||
\fB\-\-help\fP, \fB\-h\fP
|
||||
.INDENT 0.0
|
||||
.INDENT 3.5
|
||||
show this help message and exit
|
||||
.UNINDENT
|
||||
.UNINDENT
|
||||
.sp
|
||||
\fB\-f\fP, \fB\-\-fullscreen\fP
|
||||
\fB\-\-fullscreen\fP, \fB\-f\fP
|
||||
.INDENT 0.0
|
||||
.INDENT 3.5
|
||||
Start fullscreen
|
||||
.UNINDENT
|
||||
.UNINDENT
|
||||
.sp
|
||||
\fB\-v\fP, \fB\-\-version\fP
|
||||
\fB\-\-version\fP, \fB\-v\fP
|
||||
.INDENT 0.0
|
||||
.INDENT 3.5
|
||||
Print version information and exit
|
||||
.UNINDENT
|
||||
.UNINDENT
|
||||
.sp
|
||||
\fB\-g\fP \fIWIDTHxHEIGHT\fP, \fB\-\-geometry\fP \fIWIDTHxHEIGHT\fP
|
||||
\fB\-\-geometry\fP \fIWIDTHxHEIGHT\fP, \fB\-g\fP \fIWIDTHxHEIGHT\fP
|
||||
.INDENT 0.0
|
||||
.INDENT 3.5
|
||||
Set the starting geometry
|
||||
|
@ -113,7 +113,7 @@ Use FILE as keybinding file
|
|||
.UNINDENT
|
||||
.UNINDENT
|
||||
.sp
|
||||
\fB\-s\fP \fIOPTION\fP \fIVALUE\fP, \fB\-\-set\fP \fIOPTION\fP \fIVALUE\fP
|
||||
\fB\-\-set\fP \fIOPTION\fP \fIVALUE\fP, \fB\-s\fP \fIOPTION\fP \fIVALUE\fP
|
||||
.INDENT 0.0
|
||||
.INDENT 3.5
|
||||
Set a temporary setting
|
||||
|
@ -134,27 +134,27 @@ Run COMMAND on startup, usable multiple times
|
|||
.UNINDENT
|
||||
.UNINDENT
|
||||
.sp
|
||||
\fB\-b\fP \fIDIRECTORY\fP, \fB\-\-basedir\fP \fIDIRECTORY\fP
|
||||
\fB\-\-basedir\fP \fIDIRECTORY\fP, \fB\-b\fP \fIDIRECTORY\fP
|
||||
.INDENT 0.0
|
||||
.INDENT 3.5
|
||||
Directory to use for all storage
|
||||
.UNINDENT
|
||||
.UNINDENT
|
||||
.sp
|
||||
\fB\-o\fP \fITEXT\fP, \fB\-\-output\fP \fITEXT\fP
|
||||
\fB\-\-output\fP \fITEXT\fP, \fB\-o\fP \fITEXT\fP
|
||||
.INDENT 0.0
|
||||
.INDENT 3.5
|
||||
Wildcard expanded string to print to standard output upon quit
|
||||
.UNINDENT
|
||||
.UNINDENT
|
||||
.sp
|
||||
\fB\-i\fP, \fB\-\-input\fP
|
||||
\fB\-\-input\fP, \fB\-i\fP
|
||||
.INDENT 0.0
|
||||
.INDENT 3.5
|
||||
Read paths to open from standard input
|
||||
.UNINDENT
|
||||
.UNINDENT
|
||||
.SS development arguments
|
||||
.SS DEVELOPMENT ARGUMENTS
|
||||
.sp
|
||||
\fB\-\-debug\fP \fIMODULE\fP
|
||||
.INDENT 0.0
|
||||
|
@ -175,6 +175,6 @@ Arguments to pass to qt directly, use quotes to pass multiple arguments
|
|||
.SH AUTHOR
|
||||
Christian Karl
|
||||
.SH COPYRIGHT
|
||||
2017-2021, Christian Karl
|
||||
2017-2022, Christian Karl
|
||||
.\" Generated by docutils manpage writer.
|
||||
.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -50,17 +50,24 @@ class RSTFile:
|
|||
title: Title of the table.
|
||||
"""
|
||||
# Find out size of first column
|
||||
length = max([len(elem[0]) for elem in rows])
|
||||
ncols = len(rows[0])
|
||||
lengths = [max([len(elem[i]) for elem in rows]) for i in range(ncols)]
|
||||
empty_row = ["=" * n for n in lengths]
|
||||
|
||||
def format_row(row=empty_row):
|
||||
parts = " ".join(f"{elem:{n}s}" for elem, n in zip(row, lengths))
|
||||
return f" {parts}\n"
|
||||
|
||||
# Header
|
||||
self.write(".. table:: %s\n :widths: %s\n\n" % (title, widths))
|
||||
self.write(" %s ===========\n" % (length * "="))
|
||||
self.write(" %-*s %s\n" % (length, rows[0][0], rows[0][1]))
|
||||
self.write(" %s ===========\n" % (length * "="))
|
||||
self.write(f".. table:: {title}\n :widths: {widths}\n\n")
|
||||
self.write(format_row())
|
||||
self.write(format_row(rows[0]))
|
||||
self.write(format_row())
|
||||
# Content
|
||||
for row in rows[1:]:
|
||||
self.write(" %-*s %s\n" % (length, row[0], row[1]))
|
||||
self.write(format_row(row))
|
||||
# Footer
|
||||
self.write(" %s ===========\n" % (length * "="))
|
||||
self.write(format_row())
|
||||
|
||||
def _write_header(self):
|
||||
"""Write header to file explaining that the file was autogenerated."""
|
||||
|
|
|
@ -6,10 +6,13 @@
|
|||
|
||||
"""Generate reST documentation from source code docstrings."""
|
||||
|
||||
import collections
|
||||
import inspect
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import textwrap
|
||||
from typing import Any, Dict, List, NamedTuple
|
||||
|
||||
# Startup is imported to create all the commands and keybindings via their decorators
|
||||
from vimiv import api, parser, startup # pylint: disable=unused-import
|
||||
|
@ -65,12 +68,15 @@ def generate_settings():
|
|||
print("generating settings...")
|
||||
filename = "docs/documentation/configuration/settings_table.rstsrc"
|
||||
with RSTFile(filename) as f:
|
||||
rows = [("Setting", "Description")]
|
||||
rows = [("Setting", "Description", "Default", "Type")]
|
||||
for name in sorted(api.settings._storage.keys()):
|
||||
setting = api.settings._storage[name]
|
||||
if setting.desc: # Otherwise the setting is meant to be hidden
|
||||
rows.append((name, setting.desc))
|
||||
f.write_table(rows, title="Overview of settings", widths="30 70")
|
||||
default = str(setting.default)
|
||||
if len(default) > 20: # Cut very long defaults (exif)
|
||||
default = default[:17] + "..."
|
||||
rows.append((name, setting.desc, default, setting.typ.__name__))
|
||||
f.write_table(rows, title="Overview of settings", widths="20 60 15 5")
|
||||
|
||||
|
||||
def generate_keybindings():
|
||||
|
@ -93,105 +99,173 @@ def _gen_keybinding_rows(bindings):
|
|||
|
||||
def generate_commandline_options():
|
||||
"""Generate file including the command line options."""
|
||||
|
||||
argparser = parser.get_argparser()
|
||||
groups, titles = _get_options(argparser)
|
||||
# Synopsis
|
||||
arguments = _get_arguments(argparser)
|
||||
|
||||
# Synopsis Man Page
|
||||
filename_synopsis = "docs/manpage/synopsis.rstsrc"
|
||||
with open(filename_synopsis, "w", encoding="utf-8") as f:
|
||||
synopsis_options = ["[%s]" % (title) for title in titles]
|
||||
synopsis = "**vimiv** %s" % (" ".join(synopsis_options))
|
||||
f.write(synopsis)
|
||||
# Options
|
||||
f.write(_generate_synopsis(arguments, emph="*", sep=r"\ \|\ "))
|
||||
|
||||
# Synopsis Documentation
|
||||
filename_synopsis = "docs/documentation/cl_options/synopsis.rstsrc"
|
||||
with open(filename_synopsis, "w", encoding="utf-8") as f:
|
||||
raw_synopsis_doc = _generate_synopsis(arguments)
|
||||
synopsis_doc = "\n ".join(textwrap.wrap(raw_synopsis_doc, width=79))
|
||||
f.write(f".. code-block::\n\n {synopsis_doc}")
|
||||
|
||||
# Command Listing
|
||||
groups = collections.defaultdict(list)
|
||||
for arg in arguments:
|
||||
groups[arg.group].append(arg)
|
||||
|
||||
# Command Listing Man Page
|
||||
filename_options = "docs/manpage/options.rstsrc"
|
||||
with RSTFile(filename_options) as f:
|
||||
for title, argstr in groups.items():
|
||||
f.write_section(title)
|
||||
f.write(argstr)
|
||||
_generate_cli_man(groups, f)
|
||||
|
||||
# Command Listing Documentation
|
||||
filename_options = "docs/documentation/cl_options/options.rstsrc"
|
||||
with RSTFile(filename_options) as f:
|
||||
_generate_cli_doc(groups, f)
|
||||
|
||||
|
||||
def _get_options(argparser):
|
||||
"""Retrieve the options from the argument parser.
|
||||
class ParserArgument(NamedTuple):
|
||||
"""Storage class for a single command line argument."""
|
||||
|
||||
Returns:
|
||||
groups: Dictionary of group titles and argument strings.
|
||||
titles: List containing all argument titles.
|
||||
"""
|
||||
groups = {}
|
||||
titles = []
|
||||
for group in argparser._action_groups:
|
||||
argstr = ""
|
||||
for action in group._group_actions:
|
||||
argstr += (
|
||||
_format_positional(action, titles)
|
||||
if "positional" in group.title
|
||||
else _format_optional(action, titles)
|
||||
)
|
||||
groups[group.title] = argstr
|
||||
return groups, titles
|
||||
group: str
|
||||
longname: str
|
||||
shortname: str
|
||||
metavar: Any
|
||||
description: str
|
||||
|
||||
@property
|
||||
def is_positional(self):
|
||||
return self.longname is self.shortname is None
|
||||
|
||||
def get_names(self, formatter: str) -> List[str]:
|
||||
"""Retrieve list of long and shortname which are not None."""
|
||||
return [
|
||||
self._format(name, formatter)
|
||||
for name in [self.longname, self.shortname]
|
||||
if name is not None
|
||||
]
|
||||
|
||||
def get_metavar(self, formatter: str) -> str:
|
||||
"""Retrieve metavar."""
|
||||
if self.metavar is None:
|
||||
return ""
|
||||
if isinstance(self.metavar, tuple):
|
||||
return " ".join(self._format(e, formatter) for e in self.metavar)
|
||||
return self._format(self.metavar, formatter)
|
||||
|
||||
def get_name_metavar(
|
||||
self, name_formatter: str, metavar_formatter: str
|
||||
) -> List[str]:
|
||||
"""Retrieve all not-none names and append metavar."""
|
||||
names = self.get_names(name_formatter)
|
||||
metavar = self.get_metavar(metavar_formatter)
|
||||
lst = names if names else [metavar]
|
||||
return [f"{e} {metavar}" if metavar else e for e in lst]
|
||||
|
||||
def _format(self, element: str, formatter: str) -> str:
|
||||
"""Wrap the element into the format string."""
|
||||
return formatter + element + formatter
|
||||
|
||||
|
||||
def _format_optional(action, titles):
|
||||
"""Format optional argument neatly.
|
||||
def _get_arguments(argparser: parser.argparse.ArgumentParser) -> List[ParserArgument]:
|
||||
"""Retrieve all arguments from the passed argparser.
|
||||
|
||||
Args:
|
||||
action: The argparser action.
|
||||
titles: List of titles to update with the title(s) of this argument.
|
||||
argparser: Argument parser where the arguments get extracted from.
|
||||
|
||||
Returns:
|
||||
Formatted string of this argument.
|
||||
List of ParserArgument where each element represents one argument of the parser.
|
||||
"""
|
||||
_titles = _format_optional_title(action)
|
||||
titles.extend(_titles)
|
||||
title = ", ".join(_titles)
|
||||
desc = action.help
|
||||
return _format_option(title, desc)
|
||||
|
||||
def argument_from_action(group, action):
|
||||
if len(action.option_strings) == 2:
|
||||
shortname, longname = action.option_strings
|
||||
elif len(action.option_strings) == 1:
|
||||
longname = action.option_strings[0]
|
||||
shortname = None
|
||||
else:
|
||||
longname = shortname = None
|
||||
return ParserArgument(
|
||||
group=group.title,
|
||||
longname=longname,
|
||||
shortname=shortname,
|
||||
metavar=action.metavar,
|
||||
description=action.help,
|
||||
)
|
||||
|
||||
return [
|
||||
argument_from_action(group, action)
|
||||
for group in argparser._action_groups
|
||||
for action in group._group_actions
|
||||
]
|
||||
|
||||
|
||||
def _format_positional(action, titles):
|
||||
"""Format positional argument neatly.
|
||||
def _generate_synopsis(arguments: List[ParserArgument], emph: str = "", sep="|") -> str:
|
||||
"""Generate formatted synopsis of vimiv.
|
||||
|
||||
Args:
|
||||
action: The argparser action.
|
||||
titles: List of titles to update with the title of this argument.
|
||||
arguments: List of instances of ParserArgument.
|
||||
emph: Character used for emphasis.
|
||||
sep: String used to separate different versions of the same argument, e.g. -o
|
||||
and --output.
|
||||
Returns:
|
||||
Formatted string of this argument.
|
||||
Formatted synopsis for the man page.
|
||||
"""
|
||||
title = "**%s**" % (action.metavar)
|
||||
titles.append(title)
|
||||
desc = action.help
|
||||
return _format_option(title, desc)
|
||||
emph2 = 2 * emph
|
||||
synopsis = f"{emph2}vimiv{emph2}"
|
||||
for arg in arguments:
|
||||
if arg.is_positional:
|
||||
synopsis += f" [{arg.get_metavar(emph2)}]"
|
||||
else:
|
||||
command = sep.join(arg.get_name_metavar(emph2, emph))
|
||||
synopsis += f" [{command}]"
|
||||
return synopsis
|
||||
|
||||
|
||||
def _format_option(title, desc):
|
||||
"""Format an option neatly.
|
||||
def _generate_cli_man(groups: Dict[str, List[ParserArgument]], f: RSTFile) -> None:
|
||||
"""Generate commands listing with man page formatting.
|
||||
|
||||
Args:
|
||||
title: The title of this option.
|
||||
desc: The help description of this option.
|
||||
groups: List of ParserArgument sorted by their respective group.
|
||||
f: RSTFile to write to.
|
||||
"""
|
||||
return "%s\n\n %s\n\n" % (title, desc)
|
||||
for group in groups.keys():
|
||||
arguments = groups[group]
|
||||
|
||||
f.write_section(group.upper())
|
||||
for arg in arguments:
|
||||
if arg.is_positional:
|
||||
f.write(f"{arg.get_metavar('**')}\n\n\t{arg.description}\n\n")
|
||||
else:
|
||||
f.write(f"{', '.join(arg.get_name_metavar('**', '*'))}\n\n")
|
||||
f.write(f"\t{arg.description}\n\n")
|
||||
|
||||
|
||||
def _format_optional_title(action):
|
||||
"""Format the title of an optional argument neatly.
|
||||
|
||||
The title depends on the number of arguments this action requires.
|
||||
def _generate_cli_doc(groups: Dict[str, List[ParserArgument]], f: RSTFile) -> None:
|
||||
"""Generate commands listing with documentation formatting.
|
||||
|
||||
Args:
|
||||
action: The argparser action.
|
||||
Returns:
|
||||
Formatted title string of this argument.
|
||||
groups: List of ParserArgument sorted by their respective group.
|
||||
f: RSTFile to write to.
|
||||
"""
|
||||
formats = []
|
||||
for option in action.option_strings:
|
||||
if isinstance(action.metavar, str): # One argument
|
||||
title = "**%s** *%s*" % (option, action.metavar)
|
||||
elif isinstance(action.metavar, tuple): # Multiple arguments
|
||||
elems = ["*%s*" % (elem) for elem in action.metavar]
|
||||
title = "**%s** %s" % (option, " ".join(elems))
|
||||
else: # No arguments
|
||||
title = "**%s**" % (option)
|
||||
formats.append(title)
|
||||
return formats
|
||||
for group in groups.keys():
|
||||
arguments = groups[group]
|
||||
|
||||
rows = [("Command", "Description")]
|
||||
for arg in arguments:
|
||||
if arg.is_positional:
|
||||
name = arg.get_metavar("``")
|
||||
else:
|
||||
name = ", ".join(f"``{e}``" for e in arg.get_name_metavar("", ""))
|
||||
rows.append((name, arg.description))
|
||||
f.write_table(rows, title=group.capitalize(), widths="50 50")
|
||||
|
||||
|
||||
def generate_plugins():
|
||||
|
|
6
setup.py
|
@ -38,8 +38,8 @@ def read_from_init(name):
|
|||
|
||||
|
||||
setuptools.setup(
|
||||
python_requires=">=3.6",
|
||||
install_requires=["PyQt5>=5.9.2"],
|
||||
python_requires=">=3.8",
|
||||
install_requires=["PyQt5>=5.13.2"],
|
||||
packages=setuptools.find_packages(),
|
||||
ext_modules=[manipulate_module],
|
||||
entry_points={"gui_scripts": ["vimiv = vimiv.startup:main"]},
|
||||
|
@ -67,8 +67,6 @@ setuptools.setup(
|
|||
"Natural Language :: English",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
|
|
|
@ -12,38 +12,36 @@ import urllib.request
|
|||
|
||||
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
|
||||
|
|
|
@ -173,6 +173,13 @@ def start_vector_graphic(svg):
|
|||
start([svg])
|
||||
|
||||
|
||||
@bdd.given("I open an image and an animated gif")
|
||||
def start_image_and_animated_gif(tmp_path, gif):
|
||||
imagename = str(tmp_path / "image.png")
|
||||
create_image(imagename)
|
||||
start([imagename, gif])
|
||||
|
||||
|
||||
@bdd.given("I open images from multiple directories")
|
||||
def start_multiple_directories(tmp_path):
|
||||
dir1 = tmp_path / "dir1"
|
||||
|
|
|
@ -8,6 +8,19 @@ import pytest
|
|||
import pytest_bdd as bdd
|
||||
|
||||
from vimiv.parser import geometry
|
||||
from vimiv.imutils import _ImageFileHandler
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def file_handler():
|
||||
"""Fixture to retrieve the current instance of the edit handler."""
|
||||
return _ImageFileHandler.instance
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def edit_handler(file_handler):
|
||||
"""Fixture to retrieve the current instance of the edit handler."""
|
||||
return file_handler._edit_handler
|
||||
|
||||
|
||||
@bdd.then(bdd.parsers.parse("the image size should be {size}"))
|
||||
|
@ -25,3 +38,8 @@ def ensure_size_not(size, image):
|
|||
width_neq = expected.width() != image_rect.width()
|
||||
height_neq = expected.height() != image_rect.height()
|
||||
assert width_neq or height_neq
|
||||
|
||||
|
||||
@bdd.then("the image should not be edited")
|
||||
def ensure_not_edited(edit_handler):
|
||||
assert not edit_handler.changed
|
||||
|
|
|
@ -41,3 +41,8 @@ Feature: Crop an image.
|
|||
| 10 | -100 | 150x100+85+0 |
|
||||
# Ignored as dx/dy are outside of the image
|
||||
| 1000 | 1000 | 150x100+75+50 |
|
||||
|
||||
Scenario: Crop does not automatically consider a gif edited
|
||||
Given I open an image and an animated gif
|
||||
When I run next
|
||||
Then the image should not be edited
|
||||
|
|
|
@ -19,6 +19,7 @@ except ImportError:
|
|||
|
||||
@pytest.fixture()
|
||||
def exif_content():
|
||||
assert piexif is not None, "piexif required create exif information."
|
||||
return {
|
||||
"0th": {
|
||||
piexif.ImageIFD.Make: b"vimiv-testsuite",
|
||||
|
|
|
@ -8,6 +8,26 @@ Feature: Ordering the image filelist.
|
|||
And the image number 2 should be image_2.jpg
|
||||
And the image number 11 should be image_11.jpg
|
||||
|
||||
Scenario: Set none sorting after another sort
|
||||
Given I open 12 images without leading zeros in their name
|
||||
When I run set sort.image_order natural
|
||||
Then the image should have the index 1
|
||||
And the image number 1 should be image_1.jpg
|
||||
And the image number 2 should be image_2.jpg
|
||||
And the image number 11 should be image_11.jpg
|
||||
When I run set sort.image_order none
|
||||
Then the image should have the index 1
|
||||
And the image number 1 should be image_1.jpg
|
||||
And the image number 2 should be image_2.jpg
|
||||
And the image number 11 should be image_11.jpg
|
||||
|
||||
Scenario: Reverse none sorting
|
||||
Given I open 5 images
|
||||
When I run set sort.image_order none
|
||||
Then the image should have the index 1
|
||||
When I run set sort.reverse
|
||||
Then the image should have the index 1
|
||||
|
||||
Scenario: Reverse current filelist
|
||||
Given I open 5 images
|
||||
When I run set sort.reverse!
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@exif
|
||||
@metadata
|
||||
Feature: Metadata widget displaying image exif information
|
||||
|
||||
Scenario: Show metadata widget
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) <karlch at protonmail dot com>
|
||||
# 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()
|
|
@ -189,6 +189,17 @@ def test_order_setting_sort(
|
|||
assert sorted_values == expected_values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reverse", [True, False])
|
||||
@pytest.mark.parametrize("ignore_case", [True, False])
|
||||
def test_order_setting_sort_none(monkeypatch, reverse, ignore_case):
|
||||
values = ["a5.j", "A6.j", "a11.j", "a3.j"]
|
||||
monkeypatch.setattr(settings.sort.reverse, "value", reverse)
|
||||
monkeypatch.setattr(settings.sort.ignore_case, "value", ignore_case)
|
||||
o = settings.OrderSetting("order", "none")
|
||||
sorted_values = o.sort(values)
|
||||
assert sorted_values == values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ignore_case", [True, False])
|
||||
def test_order_setting_sort_ignore_case(monkeypatch, ignore_case):
|
||||
monkeypatch.setattr(settings.sort.ignore_case, "value", ignore_case)
|
||||
|
|
|
@ -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) <karlch at protonmail dot com>
|
||||
# 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")
|
|
@ -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()
|
||||
|
|
|
@ -11,6 +11,7 @@ from PyQt5.QtGui import QPixmap
|
|||
|
||||
import pytest
|
||||
|
||||
from vimiv.api import settings
|
||||
from vimiv.utils import thumbnail_manager
|
||||
|
||||
|
||||
|
@ -24,6 +25,28 @@ def manager(qtbot, tmp_path, mocker):
|
|||
yield thumbnail_manager.ThumbnailManager(None)
|
||||
|
||||
|
||||
def test_thumbnail_save_disabled(monkeypatch, qtbot, tmp_path, manager):
|
||||
monkeypatch.setattr(settings.thumbnail.save, "value", False)
|
||||
no_thumbnail_path = str(tmp_path / "no_thumbnail.jpg")
|
||||
QPixmap(300, 300).save(no_thumbnail_path, "jpg")
|
||||
manager.create_thumbnails_async([no_thumbnail_path])
|
||||
check_thumbails_created(qtbot, manager, 0)
|
||||
|
||||
|
||||
def test_thumbnail_save_disabled_no_delete_old(monkeypatch, qtbot, tmp_path, manager):
|
||||
monkeypatch.setattr(settings.thumbnail.save, "value", True)
|
||||
has_thumbnail_path = str(tmp_path / "has_thumbnail.jpg")
|
||||
QPixmap(300, 300).save(has_thumbnail_path, "jpg")
|
||||
manager.create_thumbnails_async([has_thumbnail_path])
|
||||
check_thumbails_created(qtbot, manager, 1)
|
||||
|
||||
monkeypatch.setattr(settings.thumbnail.save, "value", False)
|
||||
no_thumbnail_path = str(tmp_path / "no_thumbnail.jpg")
|
||||
QPixmap(300, 300).save(no_thumbnail_path, "jpg")
|
||||
manager.create_thumbnails_async([has_thumbnail_path, no_thumbnail_path])
|
||||
check_thumbails_created(qtbot, manager, 1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("n_paths", (1, 5))
|
||||
def test_create_n_thumbnails(qtbot, tmp_path, manager, n_paths):
|
||||
# Create images to create thumbnails of
|
||||
|
|
|
@ -23,7 +23,6 @@ def cached_method_cls(mocker):
|
|||
"""Fixture to retrieve a class with mock utilities and a cached method."""
|
||||
|
||||
class CachedMethodCls:
|
||||
|
||||
RESULT = 42
|
||||
|
||||
def __init__(self):
|
||||
|
@ -143,7 +142,6 @@ def test_parameter_names(function):
|
|||
@pytest.mark.parametrize("type_hint", ("int", int))
|
||||
def test_slot(type_hint):
|
||||
class Dummy(QObject):
|
||||
|
||||
signal = pyqtSignal(int)
|
||||
|
||||
def __init__(self):
|
||||
|
|
13
tox.ini
|
@ -10,8 +10,6 @@ basepython = {env:PYTHON:python3}
|
|||
deps =
|
||||
-r{toxinidir}/misc/requirements/requirements_tests.txt
|
||||
pyqt: -r{toxinidir}/misc/requirements/requirements.txt
|
||||
pyqt511: PyQt5==5.11.3
|
||||
pyqt512: PyQt5==5.12.3
|
||||
pyqt513: PyQt5==5.13.2
|
||||
pyqt514: PyQt5==5.14.2
|
||||
pyqt515: -r{toxinidir}/misc/requirements/requirements.txt
|
||||
|
@ -32,7 +30,7 @@ deps =
|
|||
commands =
|
||||
pylint vimiv scripts/pylint_checkers
|
||||
{toxinidir}/scripts/lint_tests.py tests
|
||||
pycodestyle vimiv tests scripts/pylint_checkers
|
||||
black --check --diff --exclude ".*syntax_error.*" vimiv tests scripts/pylint_checkers
|
||||
pydocstyle vimiv scripts/pylint_checkers
|
||||
allowlist_externals =
|
||||
{toxinidir}/scripts/lint_tests.py
|
||||
|
@ -80,14 +78,7 @@ deps = {[testenv:docs]deps}
|
|||
commands =
|
||||
{toxinidir}/scripts/src2rst.py
|
||||
sphinx-build -b man docs misc
|
||||
|
||||
# Settings for pycodestyle
|
||||
[pycodestyle]
|
||||
max-line-length = 88
|
||||
# E203: whitespace before ':' wrongly raised for slicing
|
||||
# E501: line too long checked by pylint
|
||||
# W503: line break before binary operator does not conform to pep8
|
||||
ignore = E203,E501,W503
|
||||
allowlist_externals = {[testenv:docs]allowlist_externals}
|
||||
|
||||
# Settings for check-manifest
|
||||
[check-manifest]
|
||||
|
|
|
@ -203,12 +203,7 @@ def get_by_name(name: str) -> Mode:
|
|||
|
||||
|
||||
class _ModeWidget(QWidget):
|
||||
"""Helper class defining the requirements for mode widgets.
|
||||
|
||||
This should in principle be solved using protocols, but these are only available
|
||||
starting from python 3.8 and we still support python 3.6.
|
||||
See https://docs.python.org/3/library/typing.html#typing.Protocol for more details.
|
||||
"""
|
||||
"""Helper class defining the requirements for mode widgets."""
|
||||
|
||||
def current(self) -> str:
|
||||
"""Return the current path valid for this mode."""
|
||||
|
|
|
@ -325,6 +325,7 @@ class OrderSetting(Setting):
|
|||
"alphabetical": str,
|
||||
"natural": natural_sort,
|
||||
"recently-modified": os.path.getmtime,
|
||||
"none": lambda x: 0,
|
||||
}
|
||||
|
||||
STR_ORDER_TYPES = "alphabetical", "natural"
|
||||
|
@ -469,6 +470,11 @@ class thumbnail: # pylint: disable=invalid-name
|
|||
"""Namespace for thumbnail related settings."""
|
||||
|
||||
size = ThumbnailSizeSetting("thumbnail.size", 128, desc="Size of thumbnails")
|
||||
save = BoolSetting(
|
||||
"thumbnail.save",
|
||||
True,
|
||||
desc="Save new thumbnails to disk in the shared icon cache for later use",
|
||||
)
|
||||
|
||||
|
||||
class slideshow: # pylint: disable=invalid-name
|
||||
|
|
|
@ -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 @@ image_changed = _signal_handler.image_changed
|
|||
pixmap_loaded = _signal_handler.pixmap_loaded
|
||||
movie_loaded = _signal_handler.movie_loaded
|
||||
svg_loaded = _signal_handler.svg_loaded
|
||||
plugins_loaded = _signal_handler.plugins_loaded
|
||||
|
|
|
@ -28,8 +28,8 @@ except ImportError: # pragma: no cover # PyQt is there in tests, using None is
|
|||
PYQT_VERSION = None # type: ignore
|
||||
|
||||
|
||||
PYTHON_REQUIRED_VERSION = (3, 6)
|
||||
PYQT_REQUIRED_VERSION = (5, 9, 2)
|
||||
PYTHON_REQUIRED_VERSION = (3, 8)
|
||||
PYQT_REQUIRED_VERSION = (5, 13, 2)
|
||||
ERR_CODE = 2
|
||||
|
||||
|
||||
|
|
|
@ -23,11 +23,15 @@ from vimiv import api
|
|||
def set_command(name: str, value: List[str]):
|
||||
"""Set an option.
|
||||
|
||||
**syntax:** ``:set name [value]``
|
||||
**syntax:** ``:set name[!] [[+|-]value]``
|
||||
|
||||
positional arguments:
|
||||
* ``name``: Name of the setting to set.
|
||||
* ``value``: Value to set the setting to. If not given, set to default.
|
||||
Append a ``!`` to toggle the value of boolean settings.
|
||||
* ``value``: Value to set the setting to.
|
||||
Prepend with ``+`` / ``-`` to increment/decrement the value of numerical
|
||||
settings.
|
||||
If not given, set to default.
|
||||
"""
|
||||
strvalue = " ".join(value) # List comes from nargs='*'
|
||||
try:
|
||||
|
|
|
@ -470,10 +470,7 @@ class LibraryModel(QStandardItemModel):
|
|||
"""
|
||||
get_size = files.get_size_directory if are_directories else files.get_size_file
|
||||
mark_prefix = api.mark.indicator + " "
|
||||
# See https://github.com/PyCQA/pylint/issues/7963
|
||||
# TODO remove once newer pylint version with fix was released
|
||||
enum_start = self.rowCount() + 1
|
||||
for i, path in enumerate(paths, start=enum_start):
|
||||
for i, path in enumerate(paths, start=self.rowCount() + 1):
|
||||
name = os.path.basename(path)
|
||||
if are_directories:
|
||||
name = utils.add_html(name + "/", "b")
|
||||
|
|
|
@ -20,7 +20,6 @@ from vimiv.gui.keyhintwidget import KeyhintWidget
|
|||
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 @@ class MainWindow(QWidget):
|
|||
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 @@ class MainWindow(QWidget):
|
|||
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)
|
||||
|
|
|
@ -13,169 +13,169 @@ from PyQt5.QtCore import Qt
|
|||
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 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.
|
||||
_current_set: Holds a string of the currently selected keyset.
|
||||
_handler: ExifHandler for _path or None. Use the handler property to 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 metadata of.
|
||||
_current_set: Holds a string of the currently selected keyset.
|
||||
_handler: MetadataHandler for _path or None. Use its property for access.
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
self.hide()
|
||||
|
||||
@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
|
||||
|
||||
@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.
|
||||
|
||||
**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.
|
||||
"""
|
||||
|
||||
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[exif.ExifHandler] = None
|
||||
|
||||
api.signals.new_image_opened.connect(self._on_image_opened)
|
||||
api.settings.metadata.current_keyset.changed.connect(self._update_text)
|
||||
|
||||
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.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.
|
||||
|
||||
@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.
|
||||
**syntax:** ``:metadata-list-keys [--n-cols=NUMBER] [--to-term]``
|
||||
|
||||
**count:** Select the key set to display instead.
|
||||
optional arguments:
|
||||
* ``--n-cols``: Number of columns used to display the keys.
|
||||
* ``--to-term``: Print the keys to the terminal 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
|
||||
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):
|
||||
"""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_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_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
|
||||
)
|
||||
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()))
|
||||
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
|
||||
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()
|
||||
|
||||
else:
|
||||
MetadataWidget = None # type: ignore
|
||||
@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()
|
||||
|
|
|
@ -38,7 +38,7 @@ The image widget in ``vimiv.gui.image`` connects to these signals and displays
|
|||
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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) <karlch at protonmail dot com>
|
||||
# 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.<br>\n"
|
||||
"Please install pyexiv2 for better file type support.<br>\n"
|
||||
"For more information see<br>\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
|
|
@ -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 ""
|
||||
|
||||
|
||||
|
|
|
@ -188,6 +188,8 @@ class Transform(QTransform):
|
|||
@property
|
||||
def changed(self):
|
||||
"""True if transformations have been applied."""
|
||||
if self.current.rect().isNull():
|
||||
return False
|
||||
transformed = not self.isIdentity()
|
||||
if self._original is None:
|
||||
return transformed
|
||||
|
|
|
@ -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) <karlch at protonmail dot com>
|
||||
# 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)}<br>\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
|
|
@ -69,12 +69,14 @@ import types
|
|||
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]:
|
||||
|
|
|
@ -6,8 +6,8 @@
|
|||
|
||||
"""Plugin enabling support for additional image formats.
|
||||
|
||||
Adds support for image formats what are not natively or by qtimageformats supported, but
|
||||
though some other QT module.
|
||||
Adds support for image formats that are not supported by Qt natively or by the
|
||||
qtimageformats add-on, but instead require some other Qt module.
|
||||
|
||||
The required Qt module is not installed by Vimiv and requires explicit installation.
|
||||
|
||||
|
|
|
@ -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) <karlch at protonmail dot com>
|
||||
# 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.<br>\n"
|
||||
"For more information see<br>\n"
|
||||
"https://karlch.github.io/vimiv-qt/documentation/metadata.html",
|
||||
)
|
|
@ -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) <karlch at protonmail dot com>
|
||||
# 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.<br>\n"
|
||||
"Please use another metadata plugin for better file type support.<br>\n"
|
||||
"For more information see<br>\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")
|
|
@ -0,0 +1,133 @@
|
|||
# vim: ft=python fileencoding=utf-8 sw=4 et sts=4
|
||||
|
||||
# This file is part of vimiv.
|
||||
# Copyright 2017-2023 Christian Karl (karlch) <karlch at protonmail dot com>
|
||||
# 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)
|
||||
|
||||
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")
|
|
@ -7,8 +7,8 @@
|
|||
|
||||
"""Image type detection based on magic bytes.
|
||||
|
||||
The following table shows which image types can be read by QT natively, while the second
|
||||
one shows which can be read when having the `qtimageformats` ad-on module installed.
|
||||
The following table shows which image types can be read by Qt natively, while the second
|
||||
one shows which can be read when having the `qtimageformats` add-on module installed.
|
||||
|
||||
This module allows to detect images of these types based on the magic bytes of the file.
|
||||
|
||||
|
@ -67,7 +67,7 @@ def detect(filename: str) -> Optional[str]:
|
|||
"""Determine type of image based on the magic bytes.
|
||||
|
||||
Evaluates each registered check function in the order they were registered. If
|
||||
registered with `priority`, then that check is evaluated before all checks with out
|
||||
registered with `priority`, then that check is evaluated before all checks without
|
||||
`priority`.
|
||||
|
||||
Args:
|
||||
|
|
|
@ -62,7 +62,6 @@ class QtReader(BaseReader):
|
|||
self._handler = QImageReader(path, file_format.encode())
|
||||
self._handler.setAutoTransform(True)
|
||||
if not self._handler.canRead():
|
||||
# TODO
|
||||
raise ValueError(f"'{path}' cannot be read as image")
|
||||
|
||||
@classmethod
|
||||
|
|
|
@ -47,7 +47,7 @@ In case you want to ensure that a log message is only logged a single time, pass
|
|||
import logging
|
||||
from typing import Dict, List, Optional, Any, Set
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal, QObject
|
||||
from PyQt5.QtCore import pyqtSignal, QObject, QLoggingCategory
|
||||
|
||||
import vimiv
|
||||
|
||||
|
@ -111,6 +111,11 @@ def setup_logging(level: int, *debug_modules: str) -> None:
|
|||
_app_logger.level = level
|
||||
_app_logger.handlers = [file_handler, console_handler, statusbar_loghandler]
|
||||
LazyLogger.handlers = [console_handler, file_handler]
|
||||
# Disable qt logging at higher log levels
|
||||
if level > logging.ERROR:
|
||||
QLoggingCategory.setFilterRules("*.warning=false\n*.critical=false")
|
||||
elif level > logging.WARNING:
|
||||
QLoggingCategory.setFilterRules("*.warning=false")
|
||||
# Setup debug logging for specific module loggers
|
||||
_debug_loggers.extend(debug_modules)
|
||||
for name, logger in _module_loggers.items():
|
||||
|
|
|
@ -22,6 +22,7 @@ from PyQt5.QtCore import QRunnable, pyqtSignal, QObject
|
|||
from PyQt5.QtGui import QIcon, QPixmap, QImage
|
||||
|
||||
import vimiv
|
||||
from vimiv import api
|
||||
from vimiv.utils import xdg, imagereader, Pool
|
||||
|
||||
|
||||
|
@ -131,6 +132,24 @@ class ThumbnailCreator(QRunnable):
|
|||
def _get_source_mtime(path: str) -> int:
|
||||
return int(os.path.getmtime(path))
|
||||
|
||||
def _save_thumbnail(self, image: QImage, thumbnail_path: str) -> None:
|
||||
"""Save the thumbnail file to the disk.
|
||||
|
||||
Args:
|
||||
image: The QImage representing the thumbnail.
|
||||
thumbnail_path: Path to which the thumbnail is stored.
|
||||
Returns:
|
||||
None.
|
||||
"""
|
||||
# First create temporary file and then move it. This avoids
|
||||
# problems with concurrent access of the thumbnail cache, since
|
||||
# "move" is an atomic operation
|
||||
handle, tmp_filename = tempfile.mkstemp(dir=self._manager.directory)
|
||||
os.close(handle)
|
||||
os.chmod(tmp_filename, 0o600)
|
||||
image.save(tmp_filename, format="png")
|
||||
os.replace(tmp_filename, thumbnail_path)
|
||||
|
||||
def _create_thumbnail(self, path: str, thumbnail_path: str) -> QPixmap:
|
||||
"""Create thumbnail for an image.
|
||||
|
||||
|
@ -153,14 +172,8 @@ class ThumbnailCreator(QRunnable):
|
|||
return self._manager.fail_pixmap
|
||||
for key, value in attributes.items():
|
||||
image.setText(key, value)
|
||||
# First create temporary file and then move it. This avoids
|
||||
# problems with concurrent access of the thumbnail cache, since
|
||||
# "move" is an atomic operation
|
||||
handle, tmp_filename = tempfile.mkstemp(dir=self._manager.directory)
|
||||
os.close(handle)
|
||||
os.chmod(tmp_filename, 0o600)
|
||||
image.save(tmp_filename, format="png")
|
||||
os.replace(tmp_filename, thumbnail_path)
|
||||
if api.settings.thumbnail.save:
|
||||
self._save_thumbnail(image, thumbnail_path)
|
||||
return QPixmap(image)
|
||||
|
||||
def _get_thumbnail_attributes(self, path: str, image: QImage) -> Dict[str, str]:
|
||||
|
|
|
@ -18,7 +18,6 @@ from typing import Optional
|
|||
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}"
|
||||
)
|
||||
|
||||
|
||||
|
|