Skip to content

Commit

Permalink
Images can now be embedded as PNG or JPG
Browse files Browse the repository at this point in the history
  • Loading branch information
rbreu committed Nov 24, 2023
1 parent e4cac1c commit 7bef122
Show file tree
Hide file tree
Showing 17 changed files with 333 additions and 22 deletions.
9 changes: 8 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -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
==================
Expand Down
11 changes: 3 additions & 8 deletions CONTRIBUTING.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::
Expand Down
4 changes: 1 addition & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions beeref/actions/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions beeref/actions/menu_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
{
'menu': '&Settings',
'items': [
'settings',
'open_settings_dir',
],
},
Expand Down
27 changes: 27 additions & 0 deletions beeref/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())

Expand Down
6 changes: 3 additions & 3 deletions beeref/fileio/sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) '
Expand Down
24 changes: 22 additions & 2 deletions beeref/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions beeref/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions beeref/widgets.py → beeref/widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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()

Expand Down
128 changes: 128 additions & 0 deletions beeref/widgets/settings.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
flake8==6.1.0
pybadges==3.0.1
yamllint==1.33.0
pytest-cov==4.1.0
5 changes: 5 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[coverage:run]
source = beeref

[tool:pytest]
addopts = --cov-report html --cov-config=setup.cfg
23 changes: 20 additions & 3 deletions tests/fileio/test_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 7bef122

Please sign in to comment.