diff --git a/brother_ql_web/__init__.py b/brother_ql_web/__init__.py index e69de29..83a0375 100644 --- a/brother_ql_web/__init__.py +++ b/brother_ql_web/__init__.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import logging +from typing import Any + + +def patch_deprecation_warning() -> None: + """ + Avoid the deprecation warning from `brother_ql.devicedependent`. This has been + fixed in the Git version, but not in PyPI one: + https://github.com/pklaus/brother_ql/commit/5c2b72b18bcf436c116f180a9147cbb6805958f5 + """ + original_logger = logging.getLogger("brother_ql.devicedependent").warning + + def warn(message: str, *args: Any, **kwargs: Any) -> None: + if ( + message + == "deprecation warning: brother_ql.devicedependent is deprecated and will be removed in a future release" # noqa: E501 + ): + return + original_logger(message, *args, **kwargs) + + logging.getLogger("brother_ql.devicedependent").warn = warn # type: ignore[assignment,method-assign] # noqa: E501 + + +patch_deprecation_warning() + + +import brother_ql.conversion # noqa: E402 +import PIL # noqa: E402 + + +# Renamed in version 2.7.0: +# https://pillow.readthedocs.io/en/stable/releasenotes/2.7.0.html#antialias-renamed-to-lanczos +brother_ql.conversion.Image.ANTIALIAS = PIL.Image.LANCZOS # type: ignore[attr-defined] + + +__all__: list[str] = [] diff --git a/brother_ql_web/labels.py b/brother_ql_web/labels.py index be13b51..39c3541 100644 --- a/brother_ql_web/labels.py +++ b/brother_ql_web/labels.py @@ -26,8 +26,8 @@ class LabelParameters: configuration: Configuration - font_family: str - font_style: str + font_family: str | None = None + font_style: str | None = None text: str = "" font_size: int = 100 label_size: str = "62" @@ -40,7 +40,9 @@ class LabelParameters: margin_left: int = 35 margin_right: int = 35 label_count: int = 1 - high_quality: bool = True + # TODO: Not yet taken into account. The number of dots in each direction has to be + # doubled. The generator/calculation methods have to be updated accordingly. + high_quality: bool = False @property def kind(self) -> FormFactor: @@ -73,12 +75,13 @@ def fill_color(self) -> tuple[int, int, int]: def font_path(self) -> str: try: if self.font_family is None or self.font_style is None: + assert self.configuration.label.default_font is not None self.font_family = self.configuration.label.default_font.family self.font_style = self.configuration.label.default_font.style fonts = utils.collect_fonts(self.configuration) path = fonts[self.font_family][self.font_style] except KeyError: - raise LookupError("Couln't find the font & style") + raise LookupError("Couldn't find the font & style") return path @property @@ -209,13 +212,15 @@ def generate_label( if save_image_to: image.save(save_image_to) - red: bool = False + red: bool = "red" in parameters.label_size rotate: int | str = 0 if parameters.kind == ENDLESS_LABEL: rotate = 0 if parameters.orientation == "standard" else 90 elif parameters.kind in (ROUND_DIE_CUT_LABEL, DIE_CUT_LABEL): rotate = "auto" - red = "red" in parameters.label_size + + if parameters.high_quality: + logger.warning("High quality mode is not implemented for now.") qlr = BrotherQLRaster(configuration.printer.model) create_label( @@ -226,7 +231,7 @@ def generate_label( threshold=parameters.threshold, cut=True, rotate=rotate, - dpi_600=parameters.high_quality, + dpi_600=False, ) return qlr @@ -240,7 +245,7 @@ def print_label( ) -> None: backend = backend_class(configuration.printer.printer) for i in range(parameters.label_count): - logger.info("Printing label %d of %d ...", i, parameters.label_count) + logger.info("Printing label %d of %d ...", i + 1, parameters.label_count) backend.write(qlr.data) backend.dispose() del backend diff --git a/tests/__init__.py b/tests/__init__.py index 1246eca..69d879c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,34 +1,10 @@ -import logging from functools import cached_property from pathlib import Path from unittest import TestCase as _TestCase -from typing import Any from brother_ql_web.configuration import Configuration -def patch_deprecation_warning() -> None: - """ - Avoid the deprecation warning from `brother_ql.devicedependent`. This has been - fixed in the Git version, but not in PyPI one: - https://github.com/pklaus/brother_ql/commit/5c2b72b18bcf436c116f180a9147cbb6805958f5 - """ - original_logger = logging.getLogger("brother_ql.devicedependent").warning - - def warn(message: str, *args: Any, **kwargs: Any) -> None: - if ( - message - == "deprecation warning: brother_ql.devicedependent is deprecated and will be removed in a future release" # noqa: E501 - ): - return - original_logger(message, *args, **kwargs) - - logging.getLogger("brother_ql.devicedependent").warn = warn # type: ignore[assignment,method-assign] # noqa: E501 - - -patch_deprecation_warning() - - class TestCase(_TestCase): @cached_property def example_configuration_path(self) -> str: diff --git a/tests/data/hello_world.png b/tests/data/hello_world.png new file mode 100644 index 0000000..5608007 Binary files /dev/null and b/tests/data/hello_world.png differ diff --git a/tests/data/hello_world__label_size_62__rotated.data b/tests/data/hello_world__label_size_62__rotated.data new file mode 100644 index 0000000..1543673 Binary files /dev/null and b/tests/data/hello_world__label_size_62__rotated.data differ diff --git a/tests/data/hello_world__label_size_62__standard.data b/tests/data/hello_world__label_size_62__standard.data new file mode 100644 index 0000000..d8ca354 Binary files /dev/null and b/tests/data/hello_world__label_size_62__standard.data differ diff --git a/tests/data/hello_world__label_size_62red__standard.data b/tests/data/hello_world__label_size_62red__standard.data new file mode 100644 index 0000000..05067bc Binary files /dev/null and b/tests/data/hello_world__label_size_62red__standard.data differ diff --git a/tests/data/hello_world__label_size_62x29.data b/tests/data/hello_world__label_size_62x29.data new file mode 100644 index 0000000..0bbbbd8 Binary files /dev/null and b/tests/data/hello_world__label_size_62x29.data differ diff --git a/tests/data/hello_world__label_size_62x29__rotated.data b/tests/data/hello_world__label_size_62x29__rotated.data new file mode 100644 index 0000000..0bbbbd8 Binary files /dev/null and b/tests/data/hello_world__label_size_62x29__rotated.data differ diff --git a/tests/data/hello_world__label_size_62x29__standard.data b/tests/data/hello_world__label_size_62x29__standard.data new file mode 100644 index 0000000..778a2a2 Binary files /dev/null and b/tests/data/hello_world__label_size_62x29__standard.data differ diff --git a/tests/test_labels.py b/tests/test_labels.py index 196b822..ac5083e 100644 --- a/tests/test_labels.py +++ b/tests/test_labels.py @@ -1,29 +1,355 @@ -from unittest import TestCase +from importlib.resources import as_file, files +from tempfile import NamedTemporaryFile +from unittest import mock + +from brother_ql.backends.generic import BrotherQLBackendGeneric +from brother_ql.labels import FormFactor +from brother_ql.raster import BrotherQLRaster +from brother_ql_web import labels +from brother_ql_web.configuration import Font +from PIL import Image, ImageChops, ImageFont + +from tests import TestCase class LabelParametersTestCase(TestCase): - pass + def test_kind(self) -> None: + parameters = labels.LabelParameters( + configuration=self.example_configuration, + label_size="38", + ) + self.assertEqual(FormFactor.ENDLESS, parameters.kind) + parameters.label_size = "62x29" + self.assertEqual(FormFactor.DIE_CUT, parameters.kind) + + def test_scale_margin(self) -> None: + parameters = labels.LabelParameters( + configuration=self.example_configuration, + font_size=37, + margin_top=10, + margin_bottom=25, + margin_left=33, + margin_right=57, + ) + self.assertEqual(3, parameters.margin_top_scaled) # 3.7 + self.assertEqual(9, parameters.margin_bottom_scaled) # 9.25 + self.assertEqual(12, parameters.margin_left_scaled) # 12.21 + self.assertEqual(21, parameters.margin_right_scaled) # 21.09 + + def test_fill_color(self) -> None: + parameters = labels.LabelParameters( + configuration=self.example_configuration, + label_size="62", + ) + self.assertEqual((0, 0, 0), parameters.fill_color) + parameters.label_size = "62red" + self.assertEqual((255, 0, 0), parameters.fill_color) + + def test_font_path(self) -> None: + parameters = labels.LabelParameters( + configuration=self.example_configuration, + font_family=None, + font_style=None, + ) + parameters.configuration.label.default_font = Font( + family="DejaVu Serif", style="Book" + ) + + # 1) Fallback to default. + self.assertEqual( + "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf", parameters.font_path + ) + + # 2) Retrieve existing. + parameters.font_family = "Roboto" + parameters.font_style = "Medium" + self.assertEqual( + "/usr/share/fonts/truetype/roboto/unhinted/RobotoTTF/Roboto-Medium.ttf", + parameters.font_path, + ) + + # 3) Retrieve missing. + parameters.font_family = "Custom family" + parameters.font_style = "Regular" + with self.assertRaisesRegex( + expected_exception=LookupError, + expected_regex=r"^Couldn't find the font & style$", + ): + parameters.font_path + + def test_width_height(self) -> None: + parameters = labels.LabelParameters( + configuration=self.example_configuration, + ) + + # 1) Unknown label size. + parameters.label_size = "1337" + with self.assertRaisesRegex( + expected_exception=LookupError, expected_regex=r"^Unknown label_size$" + ): + parameters.width_height + + # 2) Width > height. Handle standard and rotated. + parameters.label_size = "62x29" + self.assertEqual((696, 271), parameters.width_height) + self.assertEqual(696, parameters.width) + self.assertEqual(271, parameters.height) + + parameters.orientation = "rotated" + self.assertEqual((271, 696), parameters.width_height) + self.assertEqual(271, parameters.width) + self.assertEqual(696, parameters.height) + + # 3) Height > width. Handle standard and rotated. + parameters.label_size = "39x48" + parameters.orientation = "standard" + self.assertEqual((495, 425), parameters.width_height) + self.assertEqual(495, parameters.width) + self.assertEqual(425, parameters.height) + + parameters.orientation = "rotated" + self.assertEqual((425, 495), parameters.width_height) + self.assertEqual(425, parameters.width) + self.assertEqual(495, parameters.height) + + # 4) Endless labels. + parameters.label_size = "62" + parameters.orientation = "standard" + self.assertEqual((696, 0), parameters.width_height) + self.assertEqual(696, parameters.width) + self.assertEqual(0, parameters.height) + + parameters.orientation = "rotated" + self.assertEqual((0, 696), parameters.width_height) + self.assertEqual(0, parameters.width) + self.assertEqual(696, parameters.height) class DetermineImageDimensionsTestCase(TestCase): - pass + def test_determine_image_dimensions(self) -> None: + parameters = labels.LabelParameters( + configuration=self.example_configuration, + font_family="Roboto", + font_style="Medium", + ) + image_font = ImageFont.truetype(parameters.font_path, parameters.font_size) + text = "Test text" + + # 1) Fixed size labels. + parameters.label_size = "62x29" + parameters.orientation = "standard" + result = labels._determine_image_dimensions( + text=text, image_font=image_font, parameters=parameters + ) + self.assertEqual((696, 271, 391, 72), result) + + parameters.orientation = "rotated" + result = labels._determine_image_dimensions( + text=text, image_font=image_font, parameters=parameters + ) + self.assertEqual((271, 696, 391, 72), result) + + # 2) Endless labels. + parameters.label_size = "62" + parameters.orientation = "standard" + result = labels._determine_image_dimensions( + text=text, image_font=image_font, parameters=parameters + ) + self.assertEqual((696, 141, 391, 72), result) + + parameters.orientation = "rotated" + result = labels._determine_image_dimensions( + text=text, image_font=image_font, parameters=parameters + ) + self.assertEqual((461, 696, 391, 72), result) class DetermineTextOffsetsTestCase(TestCase): - pass + def test_determine_text_offsets(self) -> None: + parameters = labels.LabelParameters( + configuration=self.example_configuration, + font_family="Roboto", + font_style="Medium", + ) + + # 1) Die cut/fixed size label. + parameters.label_size = "62x29" + parameters.orientation = "standard" + result = labels._determine_text_offsets( + height=271, width=696, text_height=72, text_width=391, parameters=parameters + ) + self.assertEqual((152, 88), result) + + parameters.orientation = "rotated" + result = labels._determine_text_offsets( + width=271, height=696, text_height=72, text_width=391, parameters=parameters + ) + self.assertEqual((0, 301), result) + + # 2) Endless label. + parameters.label_size = "62" + parameters.orientation = "standard" + result = labels._determine_text_offsets( + height=141, width=696, text_height=72, text_width=391, parameters=parameters + ) + self.assertEqual((152, 24), result) + + parameters.orientation = "rotated" + result = labels._determine_text_offsets( + height=696, width=461, text_height=72, text_width=391, parameters=parameters + ) + self.assertEqual((35, 301), result) class CreateLabelImageTestCase(TestCase): - pass + def test_create_label_image(self) -> None: + parameters = labels.LabelParameters( + configuration=self.example_configuration, + font_family="Roboto", + font_style="Medium", + text="Hello World!", + label_size="62", + ) + image = labels.create_label_image(parameters) + self.addCleanup(image.close) + reference = files("tests") / "data" / "hello_world.png" + with as_file(reference) as path: + with Image.open(path) as target_image: + self.assertEqual(target_image.mode, image.mode) + self.assertEqual(target_image.size, image.size) + difference = ImageChops.difference(target_image, image) + for index, pixel in enumerate(difference.getdata()): + self.assertEqual((0, 0, 0), pixel, index) class ImageToPngBytesTestCase(TestCase): - pass + def test_image_to_png_bytes(self) -> None: + reference = files("tests") / "data" / "hello_world.png" + with as_file(reference) as path: + with Image.open(path) as image: + actual = labels.image_to_png_bytes(image) + expected = path.read_bytes() + self.assertEqual(expected, actual) class GenerateLabelTestCase(TestCase): - pass + @mock.patch("brother_ql.raster.logger.warning") + @mock.patch("brother_ql.conversion.logger.warning") + def test_generate_label(self, _: mock.Mock, __: mock.Mock) -> None: + parameters = labels.LabelParameters( + configuration=self.example_configuration, + font_family="Roboto", + font_style="Medium", + text="Hello World!", + ) + + # 1) Save image. + with NamedTemporaryFile(suffix=".png") as save_to: + result = labels.generate_label( + parameters=parameters, + configuration=parameters.configuration, + save_image_to=save_to.name, + ) + reference = files("tests") / "data" / "hello_world.png" + with as_file(reference) as path: + save_to.seek(0) + self.assertEqual(path.read_bytes(), save_to.read()) + self.assertTrue(result.data) + + # 2) Endless label with standard orientation. + parameters.label_size = "62" + parameters.orientation = "standard" + result = labels.generate_label( + parameters=parameters, configuration=parameters.configuration + ) + reference = ( + files("tests") / "data" / "hello_world__label_size_62__standard.data" + ) + with as_file(reference) as path: + self.assertEqual(path.read_bytes(), result.data) + + # 3) Endless label with rotated orientation. + parameters.label_size = "62" + parameters.orientation = "rotated" + result = labels.generate_label( + parameters=parameters, configuration=parameters.configuration + ) + reference = files("tests") / "data" / "hello_world__label_size_62__rotated.data" + with as_file(reference) as path: + self.assertEqual(path.read_bytes(), result.data) + + # 4) Die cut label. + for orientation in ["standard", "rotated"]: + with self.subTest(orientation=orientation): + parameters.label_size = "62x29" + parameters.orientation = orientation + result = labels.generate_label( + parameters=parameters, + configuration=parameters.configuration, + ) + reference = ( + files("tests") + / "data" + / f"hello_world__label_size_62x29__{orientation}.data" + ) + with as_file(reference) as path: + self.assertEqual(path.read_bytes(), result.data) + + # 5) Red mode. + parameters.label_size = "62red" + parameters.orientation = "standard" + parameters.configuration.printer.model = "QL-800" + result = labels.generate_label( + parameters=parameters, configuration=parameters.configuration + ) + reference = ( + files("tests") / "data" / "hello_world__label_size_62red__standard.data" + ) + with as_file(reference) as path: + self.assertEqual(path.read_bytes(), result.data) class PrintLabelTestCase(TestCase): - pass + def test_print_label(self) -> None: + class Backend(BrotherQLBackendGeneric): + def __init__(self, device_specifier: str) -> None: + pass + + parameters = labels.LabelParameters( + configuration=self.example_configuration, + ) + qlr = BrotherQLRaster() + qlr.data = b"My dummy data" + + # 1) One label. + parameters.label_count = 1 + with mock.patch.object(labels.logger, "info") as info_mock, mock.patch.object( + Backend, "write" + ) as write_mock: + labels.print_label( + parameters=parameters, + qlr=qlr, + configuration=parameters.configuration, + backend_class=Backend, + ) + info_mock.assert_called_once_with("Printing label %d of %d ...", 1, 1) + write_mock.assert_called_once_with(b"My dummy data") + + # 2) Multiple labels. + parameters.label_count = 5 + with mock.patch.object(labels.logger, "info") as info_mock, mock.patch.object( + Backend, "write" + ) as write_mock: + labels.print_label( + parameters=parameters, + qlr=qlr, + configuration=parameters.configuration, + backend_class=Backend, + ) + info_mock.assert_has_calls( + [mock.call("Printing label %d of %d ...", i, 5) for i in range(1, 6)], + any_order=False, + ) + self.assertEqual(5, info_mock.call_count, info_mock.call_args_list) + write_mock.assert_has_calls([mock.call(b"My dummy data")] * 5) + self.assertEqual(5, write_mock.call_count, write_mock.call_args_list) diff --git a/tests/test_web.py b/tests/test_web.py index c44c555..cd3a40c 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -2,32 +2,58 @@ class GetConfigTestCase(TestCase): - pass + def test_get_config(self) -> None: + pass class IndexTestCase(TestCase): - pass + def test_index(self) -> None: + # Should redirect. + pass class ServeStaticTestCase(TestCase): - pass + def test_serve_static(self) -> None: + # Should be correct content. + pass class LabeldesignerTestCase(TestCase): - pass + def test_labeldesigner(self) -> None: + pass class GetLabelParametersTestCase(TestCase): - pass + def test_all_set(self) -> None: + pass + + def test_mostly_default_values(self) -> None: + pass class GetPreviewImageTestCase(TestCase): - pass + def test_base64(self) -> None: + pass + + def test_plain_bytes(self) -> None: + pass class PrintTextTestCase(TestCase): - pass + def test_lookup_error(self) -> None: + # TODO: Where can this error be raised? + pass + + def test_no_text(self) -> None: + pass + + def test_debug_mode(self) -> None: + pass + + def test_regular_mode(self) -> None: + pass class MainTestCase(TestCase): - pass + def test_main(self) -> None: + pass