diff --git a/picard/coverart/providers/local.py b/picard/coverart/providers/local.py index a5996a978..c3043997c 100644 --- a/picard/coverart/providers/local.py +++ b/picard/coverart/providers/local.py @@ -42,7 +42,7 @@ class ProviderOptionsLocal(ProviderOptions): """ HELP_URL = '/config/options_local_files.html' - _DEFAULT_LOCAL_COVER_ART_REGEX = r'^(?:cover|folder|albumart)(.*)\.(?:jpe?g|png|gif|tiff?)$' + _DEFAULT_LOCAL_COVER_ART_REGEX = r'^(?:cover|folder|albumart)(.*)\.(?:jpe?g|png|gif|tiff?|webp)$' options = [ config.TextOption("setting", "local_cover_regex", diff --git a/picard/ui/coverartbox.py b/picard/ui/coverartbox.py index 26aef1ca8..50ce5fccd 100644 --- a/picard/ui/coverartbox.py +++ b/picard/ui/coverartbox.py @@ -540,7 +540,7 @@ class CoverArtBox(QtWidgets.QGroupBox): def choose_local_file(self): file_chooser = QtWidgets.QFileDialog(self) file_chooser.setNameFilters([ - _("All supported image formats") + " (*.png *.jpg *.jpeg *.tif *.tiff *.gif *.pdf)", + _("All supported image formats") + " (*.png *.jpg *.jpeg *.tif *.tiff *.gif *.pdf *.webp)", _("All files") + " (*)", ]) if file_chooser.exec_(): diff --git a/picard/util/imageinfo.py b/picard/util/imageinfo.py index a58e5b0df..928fb10ce 100644 --- a/picard/util/imageinfo.py +++ b/picard/util/imageinfo.py @@ -4,6 +4,7 @@ # # Copyright (C) 2014, 2018, 2020 Laurent Monin # Copyright (C) 2017 Sambhav Kothari +# Copyright (C) 2021 Philipp Wolfer # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License @@ -23,6 +24,8 @@ from io import BytesIO import struct +from mutagen.tak import _LSBBitReader as LSBBitReader + class IdentificationError(Exception): pass @@ -41,7 +44,7 @@ class UnexpectedError(IdentificationError): def identify(data): - """Parse data for jpg, gif, png metadata + """Parse data for jpg, gif, png, webp and pdf metadata If successfully recognized, it returns a tuple with: - width - height @@ -108,6 +111,33 @@ def identify(data): except ValueError: pass + # WebP + elif data[:4] == b'RIFF' and data[8:12] == b'WEBP': + # See https://developers.google.com/speed/webp/docs/riff_container + format = data[12:16] + # Simple File Format (Lossy) + if format == b'VP8 ': + # TODO: Implement reading width and height + h, w = 0, 0 + # Simple File Format (Lossless) + elif format == b'VP8L': + if len(data) < 25: + raise NotEnoughData('Not enough data for WebP VP8L') + reader = LSBBitReader(BytesIO(data[21:25])) + w = reader.bits(14) + 1 + h = reader.bits(14) + 1 + # Extended File Format + elif format == b'VP8X': + if len(data) < 30: + raise NotEnoughData('Not enough data for WebP VP8X') + reader = LSBBitReader(BytesIO(data[24:30])) + w = reader.bits(24) + 1 + h = reader.bits(24) + 1 + else: + h, w = 0, 0 + mime = 'image/webp' + extension = '.webp' + # PDF elif data[:4] == b'%PDF': h, w = 0, 0 diff --git a/test/data/mb-vp8.webp b/test/data/mb-vp8.webp new file mode 100644 index 000000000..584c72b37 Binary files /dev/null and b/test/data/mb-vp8.webp differ diff --git a/test/data/mb-vp8l.webp b/test/data/mb-vp8l.webp new file mode 100644 index 000000000..836bbd3f3 Binary files /dev/null and b/test/data/mb-vp8l.webp differ diff --git a/test/data/mb-vp8x.webp b/test/data/mb-vp8x.webp new file mode 100644 index 000000000..23bc14f73 Binary files /dev/null and b/test/data/mb-vp8x.webp differ diff --git a/test/picardtestcase.py b/test/picardtestcase.py index 85a2b19c7..030f20feb 100644 --- a/test/picardtestcase.py +++ b/test/picardtestcase.py @@ -3,7 +3,7 @@ # Picard, the next-generation MusicBrainz tagger # # Copyright (C) 2018 Wieland Hoffmann -# Copyright (C) 2019-2020 Philipp Wolfer +# Copyright (C) 2019-2021 Philipp Wolfer # Copyright (C) 2020 Laurent Monin # # This program is free software; you can redistribute it and/or @@ -115,11 +115,15 @@ class PicardTestCase(unittest.TestCase): os.unlink(filepath) +def get_test_data_path(*paths): + return os.path.join('test', 'data', *paths) + + def create_fake_png(extra): """Creates fake PNG data that satisfies Picard's internal image type detection""" return b'\x89PNG\x0D\x0A\x1A\x0A' + (b'a' * 4) + b'IHDR' + struct.pack('>LL', 100, 100) + extra def load_test_json(filename): - with open(os.path.join('test', 'data', 'ws_data', filename), encoding='utf-8') as f: + with open(get_test_data_path('ws_data', filename), encoding='utf-8') as f: return json.load(f) diff --git a/test/test_util_imageinfo.py b/test/test_util_imageinfo.py new file mode 100644 index 000000000..169c6e2c9 --- /dev/null +++ b/test/test_util_imageinfo.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# +# Copyright (C) 2014, 2020 Laurent Monin +# Copyright (C) 2021 Philipp Wolfer +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + +from test.picardtestcase import ( + PicardTestCase, + get_test_data_path, +) + +from picard.util import imageinfo + + +class IdentifyTest(PicardTestCase): + + def test_gif(self): + file = get_test_data_path('mb.gif') + + with open(file, 'rb') as f: + self.assertEqual( + imageinfo.identify(f.read()), + (140, 96, 'image/gif', '.gif', 5806) + ) + + def test_png(self): + file = get_test_data_path('mb.png') + + with open(file, 'rb') as f: + self.assertEqual( + imageinfo.identify(f.read()), + (140, 96, 'image/png', '.png', 11137) + ) + + def test_jpeg(self): + file = get_test_data_path('mb.jpg') + + with open(file, 'rb') as f: + self.assertEqual( + imageinfo.identify(f.read()), + (140, 96, 'image/jpeg', '.jpg', 8550) + ) + + def test_webp_vp8(self): + file = get_test_data_path('mb-vp8.webp') + + with open(file, 'rb') as f: + self.assertEqual( + imageinfo.identify(f.read()), + (140, 96, 'image/webp', '.webp', 6178) + ) + + def test_webp_vp8l(self): + file = get_test_data_path('mb-vp8l.webp') + + with open(file, 'rb') as f: + self.assertEqual( + imageinfo.identify(f.read()), + (140, 96, 'image/webp', '.webp', 9432) + ) + + def test_webp_vp8x(self): + file = get_test_data_path('mb-vp8x.webp') + + with open(file, 'rb') as f: + self.assertEqual( + imageinfo.identify(f.read()), + (140, 96, 'image/webp', '.webp', 6858) + ) + + def test_webp_insufficient_data(self): + self.assertRaises(imageinfo.NotEnoughData, imageinfo.identify, b'RIFF\x00\x00\x00\x00WEBPVP8L') + self.assertRaises(imageinfo.NotEnoughData, imageinfo.identify, b'RIFF\x00\x00\x00\x00WEBPVP8X') + + def test_pdf(self): + file = get_test_data_path('mb.pdf') + + with open(file, 'rb') as f: + self.assertEqual( + imageinfo.identify(f.read()), + (0, 0, 'application/pdf', '.pdf', 10362) + ) + + def test_not_enough_data(self): + self.assertRaises(imageinfo.IdentificationError, + imageinfo.identify, "x") + self.assertRaises(imageinfo.NotEnoughData, imageinfo.identify, "x") + + def test_invalid_data(self): + self.assertRaises(imageinfo.IdentificationError, + imageinfo.identify, "x" * 20) + self.assertRaises(imageinfo.UnrecognizedFormat, + imageinfo.identify, "x" * 20) + + def test_invalid_png_data(self): + data = '\x89PNG\x0D\x0A\x1A\x0A' + "x" * 20 + self.assertRaises(imageinfo.IdentificationError, + imageinfo.identify, data) + self.assertRaises(imageinfo.UnrecognizedFormat, + imageinfo.identify, data) diff --git a/test/test_utils.py b/test/test_utils.py index 7456f3e1f..b6ca031aa 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -31,7 +31,6 @@ import builtins from collections import namedtuple from collections.abc import Iterator -import os.path import unittest from unittest.mock import Mock @@ -42,7 +41,6 @@ from picard.const.sys import IS_WIN from picard.util import ( extract_year_from_date, find_best_match, - imageinfo, is_absolute_path, iter_files_from_objects, iter_unique, @@ -290,63 +288,6 @@ class IsAbsolutePathTest(PicardTestCase): self.assertTrue(is_absolute_path('\\\\foo\\bar\\baz')) -class ImageInfoTest(PicardTestCase): - - def test_gif(self): - file = os.path.join('test', 'data', 'mb.gif') - - with open(file, 'rb') as f: - self.assertEqual( - imageinfo.identify(f.read()), - (140, 96, 'image/gif', '.gif', 5806) - ) - - def test_png(self): - file = os.path.join('test', 'data', 'mb.png') - - with open(file, 'rb') as f: - self.assertEqual( - imageinfo.identify(f.read()), - (140, 96, 'image/png', '.png', 11137) - ) - - def test_jpeg(self): - file = os.path.join('test', 'data', 'mb.jpg') - - with open(file, 'rb') as f: - self.assertEqual( - imageinfo.identify(f.read()), - (140, 96, 'image/jpeg', '.jpg', 8550) - ) - - def test_pdf(self): - file = os.path.join('test', 'data', 'mb.pdf') - - with open(file, 'rb') as f: - self.assertEqual( - imageinfo.identify(f.read()), - (0, 0, 'application/pdf', '.pdf', 10362) - ) - - def test_not_enough_data(self): - self.assertRaises(imageinfo.IdentificationError, - imageinfo.identify, "x") - self.assertRaises(imageinfo.NotEnoughData, imageinfo.identify, "x") - - def test_invalid_data(self): - self.assertRaises(imageinfo.IdentificationError, - imageinfo.identify, "x" * 20) - self.assertRaises(imageinfo.UnrecognizedFormat, - imageinfo.identify, "x" * 20) - - def test_invalid_png_data(self): - data = '\x89PNG\x0D\x0A\x1A\x0A' + "x" * 20 - self.assertRaises(imageinfo.IdentificationError, - imageinfo.identify, data) - self.assertRaises(imageinfo.UnrecognizedFormat, - imageinfo.identify, data) - - class CompareBarcodesTest(PicardTestCase): def test_same(self):