From 7bef12289669de8ee465fbff825b8923e0d976db Mon Sep 17 00:00:00 2001 From: Rebecca Breu Date: Fri, 24 Nov 2023 23:04:33 +0100 Subject: [PATCH] Images can now be embedded as PNG or JPG --- CHANGELOG.rst | 9 +- CONTRIBUTING.rst | 11 +- README.rst | 4 +- beeref/actions/actions.py | 5 + beeref/actions/menu_structure.py | 1 + beeref/config.py | 27 +++++ beeref/fileio/sql.py | 6 +- beeref/items.py | 24 +++- beeref/view.py | 3 + beeref/{widgets.py => widgets/__init__.py} | 2 + beeref/widgets/settings.py | 128 +++++++++++++++++++++ requirements/dev.txt | 1 + setup.cfg | 5 + tests/fileio/test_sql.py | 23 +++- tests/items/test_pixmapitem.py | 78 ++++++++++++- tests/test_config.py | 22 ++++ tests/test_view.py | 6 + 17 files changed, 333 insertions(+), 22 deletions(-) rename beeref/{widgets.py => widgets/__init__.py} (98%) create mode 100644 beeref/widgets/settings.py create mode 100644 setup.cfg diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 985890d..ca1737a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,7 +1,14 @@ 0.3.1 - (not released yet) ========================== -tbd +Added +----- + +* Embedded images can now be JPG or PNG. By default, small images and + images with an alpha channel will be stored as PNG, the rest as + JPG. In the newly created settings dialog, this behaviour can be + changed to always use PNG (the former behaviour) always JPG. + 0.3.0 - 2023-11-23 ================== diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 5ac6b43..bd3f806 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -29,19 +29,14 @@ Install additional development requirements:: Run unittests with:: - pytest . + pytest --cov . + +This will also generate a coverage report: ``htmlcov/index.html``. Run codechecks with:: flake8 . -Run unittests with coverage report:: - - coverage run --source=beeref -m pytest - coverage html - -If your browser doesn't open automatically, view ``htmlcov/index.html``. - Beeref files are sqlite databases, so they can be inspected with any sqlite browser. For debugging options, run:: diff --git a/README.rst b/README.rst index d0cf09e..e7e71aa 100644 --- a/README.rst +++ b/README.rst @@ -64,9 +64,7 @@ Features Regarding the bee file format ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Currently, all images are embedded into the bee file as png files. While png is a lossless format, it may also produce larger file sizes than compressed jpg files, so bee files may become bigger than the imported images on their own. More embedding options are to come later. - -The bee file format is a sqlite database inside which the images are stored in an sqlar table—meaning they can be extracted with the `sqlite command line program `_:: +All images are embedded into the bee file as PNG or JPG. The bee file format is a sqlite database inside which the images are stored in an sqlar table—meaning they can be extracted with the `sqlite command line program `_:: sqlite3 myfile.bee -Axv diff --git a/beeref/actions/actions.py b/beeref/actions/actions.py index e956a45..0b21ecc 100644 --- a/beeref/actions/actions.py +++ b/beeref/actions/actions.py @@ -278,6 +278,11 @@ 'checkable': True, 'callback': 'on_action_always_on_top', }, + { + 'id': 'settings', + 'text': '&Settings', + 'callback': 'on_action_settings', + }, { 'id': 'open_settings_dir', 'text': 'Open Settings Folder', diff --git a/beeref/actions/menu_structure.py b/beeref/actions/menu_structure.py index 5943a28..ee198de 100644 --- a/beeref/actions/menu_structure.py +++ b/beeref/actions/menu_structure.py @@ -103,6 +103,7 @@ { 'menu': '&Settings', 'items': [ + 'settings', 'open_settings_dir', ], }, diff --git a/beeref/config.py b/beeref/config.py index f8735be..d3c55c2 100644 --- a/beeref/config.py +++ b/beeref/config.py @@ -93,8 +93,23 @@ def __getattribute__(self, name): return getattr(self._args, name) +class BeeSettingsEvents(QtCore.QObject): + restore_defaults = QtCore.pyqtSignal() + + +# We want to send and receive settings events globally, not per +# BeeSettings instance. Since we can't instantiate BeeSettings +# globally on module level (because the Qt app doesn't exist yet), we +# use this events proxy +settings_events = BeeSettingsEvents() + + class BeeSettings(QtCore.QSettings): + DEFAULTS = { + 'FileIO/image_storage_format': 'best', + } + def __init__(self): settings_format = QtCore.QSettings.Format.IniFormat settings_scope = QtCore.QSettings.Scope.UserScope @@ -108,6 +123,18 @@ def __init__(self): constants.APPNAME, constants.APPNAME) + def valueOrDefault(self, key, type=None): + val = self.value(key, type) + if val is None: + val = self.DEFAULTS.get(key) + return val + + def restore_defaults(self): + logger.debug('Restoring settings to defaults') + for key in self.DEFAULTS.keys(): + self.remove(key) + settings_events.restore_defaults.emit() + def fileName(self): return os.path.normpath(super().fileName()) diff --git a/beeref/fileio/sql.py b/beeref/fileio/sql.py index 25106f8..7768370 100644 --- a/beeref/fileio/sql.py +++ b/beeref/fileio/sql.py @@ -274,13 +274,13 @@ def insert_item(self, item): item.save_id = self.cursor.lastrowid if hasattr(item, 'pixmap_to_bytes'): - pixmap = item.pixmap_to_bytes() + pixmap, imgformat = item.pixmap_to_bytes() if item.filename: basename = os.path.splitext(os.path.basename(item.filename))[0] - name = '%04d-%s.png' % (item.save_id, basename) + name = f'{item.save_id:04}-{basename}.{imgformat}' else: - name = '%04d.png' % item.save_id + name = f'{item.save_id:04}.{imgformat}' self.ex( 'INSERT INTO sqlar (item_id, name, mode, sz, data) ' diff --git a/beeref/items.py b/beeref/items.py index ca6f001..0b44a95 100644 --- a/beeref/items.py +++ b/beeref/items.py @@ -23,6 +23,7 @@ from PyQt6.QtCore import Qt from beeref import commands +from beeref.config import BeeSettings from beeref.constants import COLORS from beeref.selection import SelectableMixin @@ -91,6 +92,7 @@ def __init__(self, image, filename=None): self.is_croppable = True self.crop_mode = False self.init_selectable() + self.settings = BeeSettings() @classmethod def create_from_data(self, **kwargs): @@ -129,14 +131,32 @@ def get_extra_save_data(self): self.crop.width(), self.crop.height()]} + def get_imgformat(self, img): + """Determines the format for storing this image.""" + + formt = self.settings.valueOrDefault('FileIO/image_storage_format') + if formt not in ('png', 'jpg', 'best'): + formt = 'best' + + if formt == 'best': + if (img.hasAlphaChannel() + or (img.height() < 200 and img.width() < 200)): + formt = 'png' + else: + formt = 'jpg' + + logger.debug(f'Found format {formt} for {self}') + return formt + def pixmap_to_bytes(self): """Convert the pixmap data to PNG bytestring.""" barray = QtCore.QByteArray() buffer = QtCore.QBuffer(barray) buffer.open(QtCore.QIODevice.OpenModeFlag.WriteOnly) img = self.pixmap().toImage() - img.save(buffer, 'PNG') - return barray.data() + imgformat = self.get_imgformat(img) + img.save(buffer, imgformat.upper()) + return (barray.data(), imgformat) def setPixmap(self, pixmap): super().setPixmap(pixmap) diff --git a/beeref/view.py b/beeref/view.py index 8b83c17..0216ace 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -384,6 +384,9 @@ def on_action_quit(self): logger.info('User quit. Exiting...') self.app.quit() + def on_action_settings(self): + widgets.settings.SettingsDialog(self) + def on_action_help(self): widgets.HelpDialog(self) diff --git a/beeref/widgets.py b/beeref/widgets/__init__.py similarity index 98% rename from beeref/widgets.py rename to beeref/widgets/__init__.py index 7b50639..d609b87 100644 --- a/beeref/widgets.py +++ b/beeref/widgets/__init__.py @@ -22,6 +22,7 @@ from beeref import constants from beeref.config import logfile_name, BeeSettings from beeref.main_controls import MainControlsMixin +from beeref.widgets import settings # noqa: F401 logger = logging.getLogger(__name__) @@ -158,6 +159,7 @@ def __init__(self, parent): super().__init__(parent) self.setWindowTitle(f'{constants.APPNAME} Help') docdir = os.path.join(os.path.dirname(__file__), + '..', 'documentation') tabs = QtWidgets.QTabWidget() diff --git a/beeref/widgets/settings.py b/beeref/widgets/settings.py new file mode 100644 index 0000000..1923881 --- /dev/null +++ b/beeref/widgets/settings.py @@ -0,0 +1,128 @@ +# This file is part of BeeRef. +# +# BeeRef 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 3 of the License, or +# (at your option) any later version. +# +# BeeRef 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 BeeRef. If not, see . + +from functools import partial +import logging + +from PyQt6 import QtWidgets + +from beeref import constants +from beeref.config import BeeSettings, settings_events + + +logger = logging.getLogger(__name__) + + +class ImageStorageFormatWidget(QtWidgets.QGroupBox): + KEY = 'FileIO/image_storage_format' + OPTIONS = ( + ('best', 'Best Guess', + ('Small images and images with alpha channel are stored as png,' + ' everything else as jpg')), + ('png', 'Always PNG', 'Lossless, but large bee file'), + ('jpg', 'Always JPG', + 'Small bee file, but lossy and no transparency support')) + + def __init__(self, parent): + super().__init__('Image Storage Format:') + parent.settings_widgets.append(self) + self.settings = BeeSettings() + settings_events.restore_defaults.connect(self.on_restore_defaults) + layout = QtWidgets.QVBoxLayout() + self.setLayout(layout) + helptxt = QtWidgets.QLabel( + 'How images are stored inside bee files.' + ' Changes will only take effect on newly saved images.') + helptxt.setWordWrap(True) + layout.addWidget(helptxt) + + self.ignore_values_changed = True + self.buttons = {} + for (value, label, helptext) in self.OPTIONS: + btn = QtWidgets.QRadioButton(label) + self.buttons[value] = btn + btn.setToolTip(helptext) + btn.toggled.connect( + partial(self.on_values_changed, value=value, button=btn)) + if value == self.settings.valueOrDefault(self.KEY): + btn.setChecked(True) + layout.addWidget(btn) + + self.ignore_values_changed = False + + def on_values_changed(self, value, button): + if self.ignore_values_changed: + return + + if value != self.settings.valueOrDefault(self.KEY): + logger.debug(f'Setting {self.KEY} changed to: {value}') + self.settings.setValue(self.KEY, value) + + def on_restore_defaults(self): + new_value = self.settings.valueOrDefault(self.KEY) + self.ignore_values_changed = True + for value, btn in self.buttons.items(): + btn.setChecked(value == new_value) + self.ignore_values_changed = False + + +class SettingsDialog(QtWidgets.QDialog): + def __init__(self, parent): + super().__init__(parent) + self.setWindowTitle(f'{constants.APPNAME} Settings') + tabs = QtWidgets.QTabWidget() + + self.settings_widgets = [] + + # Miscellaneous + misc = QtWidgets.QWidget() + misc_layout = QtWidgets.QGridLayout() + misc.setLayout(misc_layout) + misc_layout.addWidget(ImageStorageFormatWidget(self), 0, 0) + tabs.addTab(misc, '&Miscellaneous') + + layout = QtWidgets.QVBoxLayout() + self.setLayout(layout) + layout.addWidget(tabs) + + # Bottom row of buttons + buttons = QtWidgets.QWidget() + btn_layout = QtWidgets.QHBoxLayout() + buttons.setLayout(btn_layout) + reset_btn = QtWidgets.QPushButton('&Restore Defaults') + reset_btn.setAutoDefault(False) + reset_btn.clicked.connect(self.on_restore_defaults) + btn_layout.addWidget(reset_btn) + + close_btn = QtWidgets.QPushButton('&Close') + close_btn.setAutoDefault(True) + close_btn.clicked.connect(self.on_close) + btn_layout.addWidget(close_btn) + btn_layout.insertStretch(1) + + layout.addWidget(buttons) + self.show() + + def on_close(self, *args, **kwargs): + self.close() + + def on_restore_defaults(self, *args, **kwargs): + reply = QtWidgets.QMessageBox.question( + self, + 'Restore defaults?', + 'Do you want to restore all settings to their default values?') + + if reply == QtWidgets.QMessageBox.StandardButton.Yes: + BeeSettings().restore_defaults() diff --git a/requirements/dev.txt b/requirements/dev.txt index 3a83d3b..f0da705 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -6,3 +6,4 @@ flake8==6.1.0 pybadges==3.0.1 yamllint==1.33.0 +pytest-cov==4.1.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..e008106 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[coverage:run] +source = beeref + +[tool:pytest] +addopts = --cov-report html --cov-config=setup.cfg \ No newline at end of file diff --git a/tests/fileio/test_sql.py b/tests/fileio/test_sql.py index cf813bb..16656c9 100644 --- a/tests/fileio/test_sql.py +++ b/tests/fileio/test_sql.py @@ -225,7 +225,7 @@ def test_sqliteio_write_inserts_new_text_item(tmpfile, view): assert result[9] is None -def test_sqliteio_write_inserts_new_pixmap_item(tmpfile, view): +def test_sqliteio_write_inserts_new_pixmap_item_png(tmpfile, view): item = BeePixmapItem(QtGui.QImage(), filename='bee.jpg') view.scene.addItem(item) item.setScale(1.3) @@ -234,7 +234,7 @@ def test_sqliteio_write_inserts_new_pixmap_item(tmpfile, view): item.setRotation(33) item.do_flip() item.crop = QtCore.QRectF(5, 5, 100, 80) - item.pixmap_to_bytes = MagicMock(return_value=b'abc') + item.pixmap_to_bytes = MagicMock(return_value=(b'abc', 'png')) io = SQLiteIO(tmpfile, view.scene, create_new=True) io.write() @@ -259,6 +259,23 @@ def test_sqliteio_write_inserts_new_pixmap_item(tmpfile, view): assert result[9] == '0001-bee.png' +def test_sqliteio_write_inserts_new_pixmap_item_jpg(tmpfile, view): + item = BeePixmapItem(QtGui.QImage(), filename='bee.jpg') + view.scene.addItem(item) + item.pixmap_to_bytes = MagicMock(return_value=(b'abc', 'jpg')) + io = SQLiteIO(tmpfile, view.scene, create_new=True) + io.write() + + assert item.save_id == 1 + result = io.fetchone( + 'SELECT type, sqlar.data, sqlar.name ' + 'FROM items ' + 'INNER JOIN sqlar on sqlar.item_id = items.id') + assert result[0] == 'pixmap' + assert result[1] == b'abc' + assert result[2] == '0001-bee.jpg' + + def test_sqliteio_write_inserts_new_pixmap_item_without_filename( tmpfile, view, item): view.scene.addItem(item) @@ -316,7 +333,7 @@ def test_sqliteio_write_updates_existing_pixmap_item(tmpfile, view): item.setRotation(33) item.save_id = 1 item.crop = QtCore.QRectF(5, 5, 80, 100) - item.pixmap_to_bytes = MagicMock(return_value=b'abc') + item.pixmap_to_bytes = MagicMock(return_value=(b'abc', 'png')) io = SQLiteIO(tmpfile, view.scene, create_new=True) io.write() item.setScale(0.7) diff --git a/tests/items/test_pixmapitem.py b/tests/items/test_pixmapitem.py index f5eed4e..8578c40 100644 --- a/tests/items/test_pixmapitem.py +++ b/tests/items/test_pixmapitem.py @@ -81,9 +81,83 @@ def test_get_extra_save_data(item): } -def test_pixmap_to_bytes(qapp, imgfilename3x3): +def test_get_imgformat_test_with_real_image( + qapp, imgfilename3x3, item, settings): + settings.setValue('FileIO/image_storage_format', 'best') + img = QtGui.QImage(imgfilename3x3) + assert item.get_imgformat(img) == 'png' + + +def test_get_imgformat_unknown_option_defaults_to_best( + qapp, imgfilename3x3, item, settings): + settings.setValue('FileIO/image_storage_format', 'foo') + img = QtGui.QImage(imgfilename3x3) + assert item.get_imgformat(img) == 'png' + + +def test_get_imgformat_jpg_for_large_nonalpha_image_when_setting_best( + qapp, settings, item): + settings.setValue('FileIO/image_storage_format', 'best') + img = MagicMock( + hasAlphaChannel=MagicMock(return_value=False), + height=MagicMock(return_value=1600), + width=MagicMock(return_value=1200)) + assert item.get_imgformat(img) == 'jpg' + + +def test_get_imgformat_png_for_large_alpha_image_when_setting_best( + qapp, settings, item): + settings.setValue('FileIO/image_storage_format', 'best') + img = MagicMock( + hasAlphaChannel=MagicMock(return_value=True), + height=MagicMock(return_value=1600), + width=MagicMock(return_value=1200)) + assert item.get_imgformat(img) == 'png' + + +def test_get_imgformat_png_for_small_nonalpha_image_when_setting_best( + qapp, settings, item): + settings.setValue('FileIO/image_storage_format', 'best') + img = MagicMock( + hasAlphaChannel=MagicMock(return_value=False), + height=MagicMock(return_value=100), + width=MagicMock(return_value=100)) + assert item.get_imgformat(img) == 'png' + + +def test_get_imgformat_jpg_when_setting_jpg( + qapp, settings, item): + settings.setValue('FileIO/image_storage_format', 'jpg') + img = MagicMock( + hasAlphaChannel=MagicMock(return_value=True), + height=MagicMock(return_value=100), + width=MagicMock(return_value=100)) + assert item.get_imgformat(img) == 'jpg' + + +def test_get_imgformat_png_when_setting_png( + qapp, settings, item): + settings.setValue('FileIO/image_storage_format', 'png') + img = MagicMock( + hasAlphaChannel=MagicMock(return_value=False), + height=MagicMock(return_value=1600), + width=MagicMock(return_value=1020)) + assert item.get_imgformat(img) == 'png' + + +def test_pixmap_to_bytes_png(qapp, imgfilename3x3): item = BeePixmapItem(QtGui.QImage(imgfilename3x3)) - assert item.pixmap_to_bytes().startswith(b'\x89PNG') + data, imgformat = item.pixmap_to_bytes() + assert imgformat == 'png' + assert data.startswith(b'\x89PNG') + + +def test_pixmap_to_bytes_jpg(qapp, imgfilename3x3, settings): + settings.setValue('FileIO/image_storage_format', 'jpg') + item = BeePixmapItem(QtGui.QImage(imgfilename3x3)) + data, imgformat = item.pixmap_to_bytes() + assert imgformat == 'jpg' + assert data.startswith(b'\xff\xd8\xff\xe0\x00\x10JFIF') def test_pixmap_from_bytes(qapp, item, imgfilename3x3): diff --git a/tests/test_config.py b/tests/test_config.py index 1eb9b72..405178d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -35,6 +35,28 @@ def test_command_line_args_get_unknown(): CommandlineArgs._instance = None +def test_settings_value_or_default_gets_default(settings): + assert settings.valueOrDefault('FileIO/image_storage_format') == 'best' + + +def test_settings_value_or_default_gets_overriden_value(settings): + settings.setValue('FileIO/image_storage_format', 'png') + assert settings.valueOrDefault('FileIO/image_storage_format') == 'png' + + +def test_restore_defaults_restores(settings): + settings.setValue('FileIO/image_storage_format', 'png') + settings.restore_defaults() + assert settings.contains('FileIO/image_storage_format') is False + + +def test_restore_defaults_leaves_other_settings(settings): + settings.setValue('foo/bar', 'baz') + settings.restore_defaults() + assert settings.contains('foo/bar') is True + assert settings.value('foo/bar') == 'baz' + + def test_settings_recent_files_get_empty(settings): settings.get_recent_files() == [] diff --git a/tests/test_view.py b/tests/test_view.py index dbd1ab4..23df9c3 100644 --- a/tests/test_view.py +++ b/tests/test_view.py @@ -292,6 +292,12 @@ def test_on_action_save_when_no_filename(save_as_mock, view, imgfilename3x3): view.scene.cancel_crop_mode.assert_called_once_with() +@patch('beeref.widgets.settings.SettingsDialog.show') +def test_on_action_settings(show_mock, view): + view.on_action_settings() + show_mock.assert_called_once() + + @patch('beeref.widgets.HelpDialog.show') def test_on_action_help(show_mock, view): view.on_action_help()