diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 763e49c..2758c4e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,15 @@ +0.3.4-dev (unreleased) +====================== + +Added +----- + +* Added a setting to change the default memory limit for individual + images. If a big image won't load, increase this limit. This + setting can be overridden by Qt's default environment variable + QT_IMAGEIO_MAXALLOC + + 0.3.3 - 2024-05-05 ================== diff --git a/beeref/__main__.py b/beeref/__main__.py index 919683d..a3653b5 100755 --- a/beeref/__main__.py +++ b/beeref/__main__.py @@ -107,6 +107,7 @@ def main(): settings = BeeSettings() logger.info(f'Using settings: {settings.fileName()}') logger.info(f'Logging to: {logfile_name()}') + settings.on_startup() args = CommandlineArgs(with_check=True) # Force checking assert not args.debug_raise_error, args.debug_raise_error diff --git a/beeref/config/settings.py b/beeref/config/settings.py index 70608cf..1d767c5 100644 --- a/beeref/config/settings.py +++ b/beeref/config/settings.py @@ -15,9 +15,10 @@ import argparse import logging +import os import os.path -from PyQt6 import QtCore +from PyQt6 import QtCore, QtGui from beeref import constants @@ -117,6 +118,12 @@ class BeeSettings(QtCore.QSettings): 'cast': int, 'validate': lambda x: 0 <= x <= 200, }, + 'Items/image_allocation_limit': { + 'default': QtGui.QImageReader.allocationLimit(), + 'cast': int, + 'validate': lambda x: x >= 0, + 'post_save_callback': QtGui.QImageReader.setAllocationLimit, + } } def __init__(self): @@ -132,6 +139,26 @@ class BeeSettings(QtCore.QSettings): constants.APPNAME, constants.APPNAME) + def on_startup(self): + """Settings to be applied on application startup.""" + + if os.environ.get('QT_IMAGEIO_MAXALLOC'): + alloc = int(os.environ['QT_IMAGEIO_MAXALLOC']) + else: + alloc = self.valueOrDefault('Items/image_allocation_limit') + QtGui.QImageReader.setAllocationLimit(alloc) + + def setValue(self, key, value): + super().setValue(key, value) + if key in self.FIELDS and 'post_save_callback' in self.FIELDS[key]: + self.FIELDS[key]['post_save_callback'](value) + + def remove(self, key): + super().remove(key) + if key in self.FIELDS and 'post_save_callback' in self.FIELDS[key]: + value = self.valueOrDefault(key) + self.FIELDS[key]['post_save_callback'](value) + def valueOrDefault(self, key): """Get the value for key, or the default value specified in FIELDS. diff --git a/beeref/fileio/errors.py b/beeref/fileio/errors.py index 0fa1ca2..04235d7 100644 --- a/beeref/fileio/errors.py +++ b/beeref/fileio/errors.py @@ -13,6 +13,12 @@ # You should have received a copy of the GNU General Public License # along with BeeRef. If not, see . + +IMG_LOADING_ERROR_MSG = ( + 'Unknown format or too big?\n' + 'Check Settings -> Miscellaneous -> Maximum Image Size') + + class BeeFileIOError(Exception): def __init__(self, msg, filename): self.msg = msg diff --git a/beeref/fileio/sql.py b/beeref/fileio/sql.py index a8077c6..b0d6a83 100644 --- a/beeref/fileio/sql.py +++ b/beeref/fileio/sql.py @@ -35,7 +35,7 @@ from PyQt6 import QtGui from beeref import constants from beeref.items import BeePixmapItem -from .errors import BeeFileIOError +from .errors import BeeFileIOError, IMG_LOADING_ERROR_MSG from .schema import SCHEMA, USER_VERSION, MIGRATIONS, APPLICATION_ID @@ -52,7 +52,7 @@ def handle_sqlite_errors(func): def wrapper(self, *args, **kwargs): try: func(self, *args, **kwargs) - except sqlite3.Error as e: + except (sqlite3.Error, BeeFileIOError) as e: logger.exception(f'Error while reading/writing {self.filename}') try: # Try to roll back transaction if there is any @@ -210,8 +210,15 @@ class SQLiteIO: } if data['type'] == 'pixmap': - data['item'] = BeePixmapItem(QtGui.QImage()) - data['item'].pixmap_from_bytes(row[9]) + item = BeePixmapItem(QtGui.QImage()) + item.pixmap_from_bytes(row[9]) + if item.pixmap().isNull(): + item = data['data']['text'] = ( + f'Image could not be loaded: {item.filename}\n' + + IMG_LOADING_ERROR_MSG) + data['type'] = 'text' + data['item'] = item + data['data']['is_editable'] = False self.scene.add_item_later(data) diff --git a/beeref/items.py b/beeref/items.py index 7e0ecfe..cac6740 100644 --- a/beeref/items.py +++ b/beeref/items.py @@ -85,7 +85,7 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem): TYPE = 'pixmap' CROP_HANDLE_SIZE = 15 - def __init__(self, image, filename=None): + def __init__(self, image, filename=None, **kwargs): super().__init__(QtGui.QPixmap.fromImage(image)) self.save_id = None self.filename = filename @@ -602,13 +602,13 @@ class BeeTextItem(BeeItemMixin, QtWidgets.QGraphicsTextItem): TYPE = 'text' - def __init__(self, text=None): + def __init__(self, text=None, is_editable=True, **kwargs): super().__init__(text or "Text") self.save_id = None logger.debug(f'Initialized {self}') self.is_image = False self.init_selectable() - self.is_editable = True + self.is_editable = is_editable self.edit_mode = False self.setDefaultTextColor(QtGui.QColor(*COLORS['Scene:Text'])) diff --git a/beeref/main_controls.py b/beeref/main_controls.py index 1301027..4848a2f 100644 --- a/beeref/main_controls.py +++ b/beeref/main_controls.py @@ -80,7 +80,7 @@ class MainControlsMixin: elif mimedata.hasImage(): event.acceptProposedAction() else: - msg = 'Attempted drop not an image' + msg = 'Attempted drop not an image or image too big' logger.info(msg) widgets.BeeNotification(self.control_target, msg) diff --git a/beeref/view.py b/beeref/view.py index a8fda7b..31b22e3 100644 --- a/beeref/view.py +++ b/beeref/view.py @@ -26,6 +26,7 @@ from beeref import commands from beeref.config import CommandlineArgs, BeeSettings, KeyboardSettings from beeref import constants from beeref import fileio +from beeref.fileio.errors import IMG_LOADING_ERROR_MSG from beeref.fileio.export import exporter_registry from beeref import widgets from beeref.items import BeePixmapItem, BeeTextItem @@ -532,13 +533,12 @@ class BeeGraphicsView(MainControlsMixin, errornames = [ f'
  • {fn}
  • ' for fn in errors] errornames = '' % '\n'.join(errornames) - msg = ('{errors} image(s) out of {total} ' - 'could not be opened:'.format( - errors=len(errors), total=len(errors))) + num = len(errors) + msg = f'{num} image(s) could not be opened.
    ' QtWidgets.QMessageBox.warning( self, 'Problem loading images', - msg + errornames) + msg + IMG_LOADING_ERROR_MSG + errornames) self.scene.add_queued_items() self.scene.arrange_optimal() self.undo_stack.endMacro() @@ -631,7 +631,7 @@ class BeeGraphicsView(MainControlsMixin, self.undo_stack.push(commands.InsertItems(self.scene, [item], pos)) return - msg = 'No image data or text in clipboard' + msg = 'No image data or text in clipboard or image too big' logger.info(msg) widgets.BeeNotification(self, msg) diff --git a/beeref/widgets/settings.py b/beeref/widgets/settings.py index e36f09e..c6bdf40 100644 --- a/beeref/widgets/settings.py +++ b/beeref/widgets/settings.py @@ -135,6 +135,15 @@ class ArrangeGapWidget(IntegerGroup): MAX = 200 +class AllocationLimitWidget(IntegerGroup): + TITLE = 'Maximum Image Size:' + HELPTEXT = ('The maximum image size that can be loaded (in megabytes). ' + 'Set to 0 for no limitation.') + KEY = 'Items/image_allocation_limit' + MIN = 0 + MAX = 10000 + + class SettingsDialog(QtWidgets.QDialog): def __init__(self, parent): super().__init__(parent) @@ -147,6 +156,7 @@ class SettingsDialog(QtWidgets.QDialog): misc.setLayout(misc_layout) misc_layout.addWidget(ImageStorageFormatWidget(), 0, 0) misc_layout.addWidget(ArrangeGapWidget(), 0, 1) + misc_layout.addWidget(AllocationLimitWidget(), 1, 0) tabs.addTab(misc, '&Miscellaneous') layout = QtWidgets.QVBoxLayout() diff --git a/tests/config/test_settings.py b/tests/config/test_settings.py index a78625b..eede452 100644 --- a/tests/config/test_settings.py +++ b/tests/config/test_settings.py @@ -1,9 +1,12 @@ +import os import os.path import tempfile -from unittest.mock import patch +from unittest.mock import patch, MagicMock import pytest +from PyQt6 import QtGui + from beeref.config.settings import CommandlineArgs @@ -35,6 +38,52 @@ def test_command_line_args_get_unknown(): CommandlineArgs._instance = None +def test_settings_on_startup_sets_alloc_from_settings(settings): + settings.setValue('Items/image_allocation_limit', 66) + QtGui.QImageReader.setAllocationLimit(100) + settings.on_startup() + assert QtGui.QImageReader.allocationLimit() == 66 + + +def test_settings_on_startup_sets_alloc_from_environment(settings): + settings.setValue('Items/image_allocation_limit', 66) + os.environ['QT_IMAGEIO_MAXALLOC'] = '42' + QtGui.QImageReader.setAllocationLimit(100) + settings.on_startup() + assert QtGui.QImageReader.allocationLimit() == 42 + + +def test_settings_set_value_without_callback(settings): + settings.FIELDS = {'foo/bar': {}} + settings.setValue('foo/bar', 100) + assert settings.value('foo/bar') == 100 + + +def test_settings_set_value_with_callback(settings): + foo_callback = MagicMock() + settings.FIELDS = {'foo/bar': {'post_save_callback': foo_callback}} + settings.setValue('foo/bar', 100) + foo_callback.assert_called_once_with(100) + assert settings.value('foo/bar') == 100 + + +def test_settings_remove_without_callback(settings): + settings.FIELDS = {'foo/bar': {}} + settings.remove('foo/bar') + assert settings.value('foo/bar') is None + + +def test_settings_remove_with_callback(settings): + foo_callback = MagicMock() + settings.FIELDS = { + 'foo/bar': {'default': 66, + 'post_save_callback': foo_callback} + } + settings.remove('foo/bar') + foo_callback.assert_called_once_with(66) + assert settings.value('foo/bar') is None + + def test_settings_value_or_default_gets_default(settings): assert settings.valueOrDefault('Items/image_storage_format') == 'best' diff --git a/tests/fileio/test_sql.py b/tests/fileio/test_sql.py index fa2ef31..6203fee 100644 --- a/tests/fileio/test_sql.py +++ b/tests/fileio/test_sql.py @@ -120,21 +120,21 @@ def test_all_migrations(tmpfile): assert result[3] == b'bla' -def test_sqliteio_ẁrite_meta_application_id(tmpfile): +def test_sqliteio_write_meta_application_id(tmpfile): io = SQLiteIO(tmpfile, MagicMock(), create_new=True) io.write_meta() result = io.fetchone('PRAGMA application_id') assert result[0] == schema.APPLICATION_ID -def test_sqliteio_ẁrite_meta_user_version(tmpfile): +def test_sqliteio_write_meta_user_version(tmpfile): io = SQLiteIO(tmpfile, MagicMock(), create_new=True) io.write_meta() result = io.fetchone('PRAGMA user_version') assert result[0] == schema.USER_VERSION -def test_sqliteio_ẁrite_meta_foreign_keys(tmpfile): +def test_sqliteio_write_meta_foreign_keys(tmpfile): io = SQLiteIO(tmpfile, MagicMock(), create_new=True) io.write_meta() result = io.fetchone('PRAGMA foreign_keys') @@ -463,6 +463,8 @@ def test_sqliteio_read_reads_readonly_text_item(tmpfile, view): view.scene.add_queued_items() assert len(view.scene.items()) == 1 item = view.scene.items()[0] + assert isinstance(item, BeeTextItem) + assert item.is_editable is True assert item.isSelected() is False assert item.save_id == 1 assert item.pos().x() == 22.2 @@ -493,6 +495,7 @@ def test_sqliteio_read_reads_readonly_pixmap_item(tmpfile, view, imgdata3x3): view.scene.add_queued_items() assert len(view.scene.items()) == 1 item = view.scene.items()[0] + assert isinstance(item, BeePixmapItem) assert item.isSelected() is False assert item.save_id == 1 assert item.pos().x() == 22.2 @@ -510,6 +513,30 @@ def test_sqliteio_read_reads_readonly_pixmap_item(tmpfile, view, imgdata3x3): assert view.scene.items_to_add.empty() is True +def test_sqliteio_read_reads_readonly_pixmap_item_error(tmpfile, view): + io = SQLiteIO(tmpfile, view.scene, create_new=True) + io.create_schema_on_new() + io.ex('INSERT INTO items ' + '(type, x, y, z, scale, rotation, flip, data) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?) ', + ('pixmap', 22.2, 33.3, 0.22, 3.4, 45, -1, + json.dumps({'filename': 'bee.png'}))) + io.ex('INSERT INTO sqlar (item_id, data) VALUES (?, ?)', + (1, b'not an image')) + io.connection.commit() + del io + + io = SQLiteIO(tmpfile, view.scene, readonly=True) + io.read() + view.scene.add_queued_items() + assert len(view.scene.items()) == 1 + item = view.scene.items()[0] + assert isinstance(item, BeeTextItem) + assert item.is_editable is False + item.toPlainText().startswith('Unknown') + assert view.scene.items_to_add.empty() is True + + def test_sqliteio_read_updates_progress(tmpfile, view): worker = MagicMock(canceled=False) io = SQLiteIO(tmpfile, view.scene, create_new=True, diff --git a/tests/test_main.py b/tests/test_main.py index e0cfcbf..7c73fd1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -29,12 +29,16 @@ def test_beerefapplication_fileopenevent(open_mock, qapp, main_window): @patch('beeref.__main__.BeeRefApplication') @patch('beeref.__main__.CommandlineArgs') -def test_main(args_mock, app_mock, qapp): +@patch('beeref.config.BeeSettings.on_startup') +def test_main(startup_mock, args_mock, app_mock, qapp): app_mock.return_value = qapp args_mock.return_value.filename = None args_mock.return_value.loglevel = 'WARN' args_mock.return_value.debug_raise_error = '' + with patch.object(qapp, 'exec') as exec_mock: main() - args_mock.assert_called_once_with(with_check=True) exec_mock.assert_called_once_with() + + args_mock.assert_called_once_with(with_check=True) + startup_mock.assert_called()