Images can now be embedded as PNG or JPG

This commit is contained in:
Rebecca Breu 2023-11-24 23:04:33 +01:00
parent e4cac1c346
commit 7bef122896
17 changed files with 333 additions and 22 deletions

View file

@ -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
==================

View file

@ -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::

View file

@ -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 <https://www.sqlite.org/cli.html>`_::
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 <https://www.sqlite.org/cli.html>`_::
sqlite3 myfile.bee -Axv

View file

@ -278,6 +278,11 @@ actions = [
'checkable': True,
'callback': 'on_action_always_on_top',
},
{
'id': 'settings',
'text': '&Settings',
'callback': 'on_action_settings',
},
{
'id': 'open_settings_dir',
'text': 'Open Settings Folder',

View file

@ -103,6 +103,7 @@ menu_structure = [
{
'menu': '&Settings',
'items': [
'settings',
'open_settings_dir',
],
},

View file

@ -93,8 +93,23 @@ class CommandlineArgs:
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 @@ class BeeSettings(QtCore.QSettings):
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())

View file

@ -274,13 +274,13 @@ class SQLiteIO:
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) '

View file

@ -23,6 +23,7 @@ from PyQt6 import QtCore, QtGui, QtWidgets
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 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem):
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 @@ class BeePixmapItem(BeeItemMixin, QtWidgets.QGraphicsPixmapItem):
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)

View file

@ -384,6 +384,9 @@ class BeeGraphicsView(MainControlsMixin,
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)

View file

@ -22,6 +22,7 @@ from PyQt6.QtCore import Qt
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 @@ class HelpDialog(QtWidgets.QDialog):
super().__init__(parent)
self.setWindowTitle(f'{constants.APPNAME} Help')
docdir = os.path.join(os.path.dirname(__file__),
'..',
'documentation')
tabs = QtWidgets.QTabWidget()

128
beeref/widgets/settings.py Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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()

View file

@ -6,3 +6,4 @@
flake8==6.1.0
pybadges==3.0.1
yamllint==1.33.0
pytest-cov==4.1.0

5
setup.cfg Normal file
View file

@ -0,0 +1,5 @@
[coverage:run]
source = beeref
[tool:pytest]
addopts = --cov-report html --cov-config=setup.cfg

View file

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

View file

@ -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):

View file

@ -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() == []

View file

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