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()