Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace imghdr #650

Merged
merged 24 commits into from
Jul 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c17aa33
Implement own image detection based on magic bytes
jcjgraf Jul 1, 2023
c56eb86
Fix tests affected by removal of imghdr
jcjgraf Jul 1, 2023
a82664c
Remove reference to imghdr from docs
jcjgraf Jun 24, 2023
3f8bfb7
Add basic test for all types writable by QPixmap
jcjgraf Jun 30, 2023
c3e7719
Add runtime validation for registered file type checks
jcjgraf Jul 1, 2023
2a5bbb9
Update imageformats plugin for new imageheader
jcjgraf Jul 1, 2023
b3eb932
Simplify assertion thanks to new structure test registry
jcjgraf Jul 1, 2023
d0a8ba7
Add test for dynamic verify and fix bug
jcjgraf Jul 2, 2023
deae790
Add check for MNG
jcjgraf Jul 4, 2023
bcb18f3
Add passing of opened file to check function
jcjgraf Jul 4, 2023
3c0c8bc
Fix copyright header
jcjgraf Jul 4, 2023
c34aea0
Add check for XBM
jcjgraf Jul 5, 2023
b82c978
Add check for CUR
jcjgraf Jul 5, 2023
2521a90
Add check for TGA version 2
jcjgraf Jul 6, 2023
ec37dd4
Add note about WBMP being undetectable
jcjgraf Jul 6, 2023
7cbfb47
Fix tga check raising for some image types
jcjgraf Jul 6, 2023
b25cf4b
Fix xbm check assuming certain seek and fix regex
jcjgraf Jul 6, 2023
eaf0d1d
Add tests for imageheader module
jcjgraf Jul 6, 2023
63f8c95
Improve structure by moving verify check out wrapped function
jcjgraf Jul 6, 2023
60317dd
Improve comment about why prioritize external format check
jcjgraf Jul 6, 2023
564a4c6
Fix lint
jcjgraf Jul 6, 2023
528b0e9
Improve performance by using try instead of suppress
jcjgraf Jul 13, 2023
8c59515
Tests: remove duplication of tmpfile fixture
karlch Jul 13, 2023
ff57aab
Simple wording and typo changes
karlch Jul 13, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions docs/documentation/hacking.rst
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,7 @@ raw images using ``dcraw``.

To make this work, you need to implement two functions:

#. A function which checks if a path is of your filetype. The function must be of the
same form as used by the standard library module
`imghdr <https://docs.python.org/3/library/imghdr.html>`_.
#. A function which checks if a path is of your filetype.
#. The actual loading function which creates a ``QPixmap`` from the path.

Finally, you tell vimiv about the newly supported filetype::
Expand Down
11 changes: 5 additions & 6 deletions tests/end2end/features/image/test_imageopen_bdd.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,21 @@
# Copyright 2017-2023 Christian Karl (karlch) <karlch at protonmail dot com>
# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details.

import imghdr # pylint: disable=deprecated-module

import pytest_bdd as bdd

from vimiv import api
from vimiv.utils import imageheader


bdd.scenarios("imageopen.feature")


@bdd.when("I open broken images")
def open_broken_images(tmp_path):
_open_file(tmp_path, b"\211PNG\r\n\032\n") # PNG
_open_file(tmp_path, b"000000JFIF") # JPG
_open_file(tmp_path, b"\x89PNG\x0D\x0A\x1A\x0A") # PNG
_open_file(tmp_path, b"\xFF\xD8\xFF\xDB") # JPG
_open_file(tmp_path, b"GIF89a") # GIF
_open_file(tmp_path, b"II") # TIFF
_open_file(tmp_path, b"II\x2A\x00") # TIFF
_open_file(tmp_path, b"BM") # BMP


Expand All @@ -28,5 +27,5 @@ def _open_file(directory, data):
path = directory / "broken"
path.write_bytes(data)
filename = str(path)
assert imghdr.what(filename) is not None, "Invalid magic bytes in test setup"
assert imageheader.detect(filename) is not None, "Invalid magic bytes in test setup"
api.open_paths([filename])
13 changes: 4 additions & 9 deletions tests/end2end/features/plugins/test_plugins_bdd.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@
# Copyright 2017-2023 Christian Karl (karlch) <karlch at protonmail dot com>
# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details.

import contextlib
import imghdr # pylint: disable=deprecated-module

import pytest_bdd as bdd

from vimiv import plugins
from vimiv.utils import imageheader


bdd.scenarios("plugins.feature")
Expand All @@ -22,9 +20,6 @@ def load_plugin(name, info):

@bdd.then(bdd.parsers.parse("The {name} format should be supported"))
def check_format_supported(name):
for func in imghdr.tests:
with contextlib.suppress(IndexError):
format_name = func.__name__.split("_")[-1]
if format_name == name:
return
assert False, f"Image format {name} is not supported"
assert name in [
filetype for filetype, _ in imageheader._registry
], f"Image format {name} is not supported"
16 changes: 16 additions & 0 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# 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.

"""Fixtures for pytest unit tests."""

import pytest


@pytest.fixture()
def tmpfile(tmp_path):
path = tmp_path / "anything"
path.touch()
yield str(path)
42 changes: 1 addition & 41 deletions tests/unit/utils/test_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,14 @@
"""Tests for vimiv.utils.files."""

import collections
import imghdr # pylint: disable=deprecated-module
import os
import tarfile

from PyQt5.QtGui import QImageReader

import pytest

from vimiv.utils import files


SUPPORTED_IMAGE_FORMATS = ["jpg", "png", "gif", "svg", "cr2"]


@pytest.fixture()
def mockimghdr(mocker):
"""Fixture to mock imghdr.tests and QImageReader supportedImageFormats."""
mocker.patch.object(
QImageReader, "supportedImageFormats", return_value=SUPPORTED_IMAGE_FORMATS
)
yield mocker.patch("imghdr.tests", [])


@pytest.fixture()
def tmpfile(tmp_path):
path = tmp_path / "anything"
path.touch()
yield str(path)


@pytest.fixture()
def directory_tree(tmp_path):
"""Fixture to create a directory tree.
Expand Down Expand Up @@ -110,7 +88,7 @@ def test_directories_supported(mocker):
def test_images_supported(mocker):
mocker.patch("os.path.isdir", return_value=False)
mocker.patch("os.path.isfile", return_value=True)
mocker.patch("imghdr.what", return_value=True)
mocker.patch("vimiv.utils.imageheader.detect", return_value=True)
images, directories = files.supported(["a", "b"])
assert images == ["a", "b"]
assert not directories
Expand Down Expand Up @@ -173,21 +151,3 @@ def test_get_size_with_permission_error(mocker):
def test_listfiles(directory_tree):
expected = sorted(directory_tree.files)
assert expected == sorted(files.listfiles(str(directory_tree.root)))


@pytest.mark.parametrize("name", SUPPORTED_IMAGE_FORMATS)
def test_add_supported_format(mockimghdr, tmpfile, name):
files.add_image_format(name, _test_dummy)
assert mockimghdr, "No test added by add image format"
assert imghdr.what(tmpfile) == name


def test_add_unsupported_format(mockimghdr, tmpfile):
files.add_image_format("not_a_format", _test_dummy)
assert imghdr.what(tmpfile) is None
assert not mockimghdr, "Unsupported test not removed"


def _test_dummy(h, f):
"""Dummy image file test that always returns True."""
return True
92 changes: 92 additions & 0 deletions tests/unit/utils/test_imageheader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# 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.utils.imageheader."""

from PyQt5.QtGui import QPixmap, QImageReader, QImageWriter

import pytest


from vimiv.utils import imageheader

# Formats that are detectable and not detectable by this module
DETECT_FORMATS = [format for format, _ in imageheader._registry]
NOT_DETECT_FORMATS = ["svgz", "wbmp"]

# QT Formats that alias to others
QT_ALIAS_FORMATS = ["jpeg", "tif"]

# Formats that QT can create and that are also detectable
QT_WRITE_DETECT_FORMATS = [
d.data().decode()
for d in QImageWriter.supportedImageFormats()
if d.data().decode() not in NOT_DETECT_FORMATS
and d.data().decode() not in QT_ALIAS_FORMATS
and d.data().decode() != "cur" # CUR types created by QT are actually ICO?!
]

# Formats that QT can read
QT_READ_FORMATS = [
d.data().decode()
for d in QImageReader.supportedImageFormats()
if d.data().decode() not in QT_ALIAS_FORMATS
]


@pytest.fixture()
def mockimageheader(mocker):
"""Fixture to mock imageheader._registry and QImageReader supportedImageFormats."""
mocker.patch.object(
QImageReader, "supportedImageFormats", return_value=QT_READ_FORMATS
)
yield mocker.patch("vimiv.utils.imageheader._registry", [])


def create_image(filename: str, *, size=(300, 300)):
QPixmap(*size).save(filename)


@pytest.mark.parametrize("imagetype", QT_WRITE_DETECT_FORMATS)
def test_detect(qtbot, tmp_path, imagetype):
"""Only tests check functions for formats that QT can create samples for."""
name = f"img.{imagetype}"
filename = str(tmp_path / name)
create_image(filename)
assert imageheader.detect(filename) == imagetype


def _check_dummy(h, f):
"""Dummy image file check that always returns True."""
return True


@pytest.mark.parametrize("name", QT_READ_FORMATS)
def test_register_supported_format(mockimageheader, tmpfile, name):
imageheader.register(name, _check_dummy)
assert mockimageheader, "No test added by add image format"
assert imageheader.detect(tmpfile) == name


def test_register_unsupported_format(mockimageheader, tmpfile):
"""Test if check of invalid format gets removed after call to `detect`."""
imageheader.register("not_a_format", _check_dummy)
assert imageheader.detect(tmpfile) is None
assert not mockimageheader, "Unsupported test not removed"


def test_register_unsupported_format_not_verify(mockimageheader, tmpfile):
"""Test if check of invalid remains when registered without verification."""
dummy_format = "not_a_format"
imageheader.register(dummy_format, _check_dummy, validate=False)
assert imageheader.detect(tmpfile) == dummy_format
assert (dummy_format, _check_dummy) in imageheader._registry


@pytest.mark.parametrize("name", QT_READ_FORMATS)
def test_full_support(name):
"""Tests if all formats readable by QT are also detected."""
assert name in NOT_DETECT_FORMATS or name in DETECT_FORMATS, "Missing check"
8 changes: 5 additions & 3 deletions vimiv/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from typing import List, Iterable, Callable, BinaryIO
from PyQt5.QtGui import QPixmap

from vimiv.utils import files, imagereader
from vimiv.utils import files, imagereader, imageheader

from vimiv.api import (
commands,
Expand Down Expand Up @@ -84,7 +84,7 @@ def open_paths(paths: Iterable[str]) -> None:

def add_external_format(
file_format: str,
test_func: files.ImghdrTestFuncT,
test_func: imageheader.CheckFuncT,
load_func: Callable[[str], QPixmap],
) -> None:
"""Add support for new fileformat.
Expand All @@ -94,5 +94,7 @@ def add_external_format(
test_func: Function returning True if load_func supports this type.
load_func: Function to load a QPixmap from the passed path.
"""
files.add_image_format(file_format, test_func)
# Prioritize external formats over all default formats, to ensure that on signature
# collision, the explicitly registered handler is used.
imageheader.register(file_format, test_func, priority=True)
karlch marked this conversation as resolved.
Show resolved Hide resolved
imagereader.external_handler[file_format] = load_func
46 changes: 33 additions & 13 deletions vimiv/plugins/imageformats.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

"""Plugin enabling support for additional image formats.

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.

Activate it by adding::

imageformats = name, ...
Expand All @@ -26,29 +31,43 @@
``QImageReader.supportedImageFormats()``.
"""

from typing import Any, Optional, BinaryIO
from typing import Any, BinaryIO


from vimiv.utils import log, files
from vimiv.utils import log, imageheader

_logger = log.module_logger(__name__)


def test_cr2(header: bytes, _f: Optional[BinaryIO]) -> bool:
return header[:2] in (b"II", b"MM") and header[8:10] == b"CR"
def _test_cr2(h: bytes, _f: BinaryIO) -> bool:
"""Canon Raw 2 (CR2).

Extension: .cr2

def test_avif(header: bytes, _f: Optional[BinaryIO]) -> bool:
return header[4:12] in (b"ftypavif", b"ftypavis")
Magic bytes:
- 49 49 2A 00 10 00 00 00 43 52

Support: QtRaw https://gitlab.com/mardy/qtraw
"""
return h[:10] == b"\x49\x49\x2A\x00\x10\x00\x00\x00\x43\x52"


def test_jp2(header: bytes, _f: Optional[BinaryIO]) -> bool:
return header[:6] == b"\x00\x00\x00\x0cjP"
def _test_avif(h: bytes, _f: BinaryIO) -> bool:
"""AV1 Image File (AVIF).

Extension: .avif

Magic bytes:
- ?

Support: qt-qvif-image-plugin https://github.com/novomesk/qt-avif-image-plugin
"""
return h[4:12] in (b"ftypavif", b"ftypavis")


FORMATS = {
"cr2": test_cr2,
"avif": test_avif,
"jp2": test_jp2,
"cr2": _test_cr2,
"avif": _test_avif,
}


Expand All @@ -61,8 +80,9 @@ def init(names: str, *_args: Any, **_kwargs: Any) -> None:
for name in names.split(","):
name = name.lower().strip()
try:
test = FORMATS[name]
files.add_image_format(name, test)
check = FORMATS[name]
# Set priority as these types are explicitly enables
imageheader.register(name, check, priority=True)
_logger.debug("Added image format '%s'", name)
except KeyError:
_logger.error("Ignoring unknown image format '%s'", name)
Loading