diff --git a/.github/workflows/test_and_release.yml b/.github/workflows/test_and_release.yml index 8d96478..c3a6b3c 100644 --- a/.github/workflows/test_and_release.yml +++ b/.github/workflows/test_and_release.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v2 @@ -40,7 +40,7 @@ jobs: needs: build strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v2 @@ -54,7 +54,7 @@ jobs: if [ -f requirements-test.txt ]; then pip install -r requirements-test.txt; fi - name: Test run: | - python setup.py test + py.test --verbose # https://github.com/actions/starter-workflows/blob/main/ci/python-publish.yml release: diff --git a/README.md b/README.md index 622c8be..566941a 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ This is a pure-Python library to generate [Aztec Code](https://en.wikipedia.org/ - `v0.11` - fix docstrings - change default `module_size` in image output to 2 pixels; ZXing can't read with `module_size=1` - +- `v0.12` + - support for svg files in `save` method ## Installation @@ -110,6 +111,8 @@ Originally written by [Dmitry Alimov (delimtry)](https://github.com/delimitry). Updates, bug fixes, Python 3-ification, and careful `bytes`-vs.-`str` handling by [Daniel Lenski (dlenski)](https://github.com/dlenski). +Support for SVG files by [Mateusz Bilicki (zazzik1)](https://github.com/zazzik1). + ## License: Released under [The MIT License](https://github.com/delimitry/aztec_code_generator/blob/master/LICENSE). diff --git a/aztec_code_generator.py b/aztec_code_generator.py index 1b5f22b..52239cf 100755 --- a/aztec_code_generator.py +++ b/aztec_code_generator.py @@ -6,7 +6,7 @@ Aztec code generator. - :copyright: (c) 2016-2018 by Dmitry Alimov. + :copyright: (c) 2016-2022 by Dmitry Alimov. :license: The MIT License (MIT), see LICENSE for more details. """ @@ -17,6 +17,8 @@ import codecs from collections import namedtuple from enum import Enum +from io import IOBase + try: from PIL import Image, ImageDraw @@ -163,14 +165,14 @@ def prod(x, y, log, alog, gf): - """ Product x times y """ + """Product x times y.""" if not x or not y: return 0 return alog[(log[x] + log[y]) % (gf - 1)] def reed_solomon(wd, nd, nc, gf, pp): - """ Calculate error correction codewords + """Calculate error correction codewords. Algorithm is based on Aztec Code bar code symbology specification from GOST-R-ISO-MEK-24778-2010 (Russian) @@ -178,11 +180,13 @@ def reed_solomon(wd, nd, nc, gf, pp): codewords, all within GF(gf) where ``gf`` is a power of 2 and ``pp`` is the value of its prime modulus polynomial. - :param wd: data codewords (in/out param) - :param nd: number of data codewords - :param nc: number of error correction codewords - :param gf: Galois Field order - :param pp: prime modulus polynomial value + :param list[int] wd: Data codewords (in/out param). + :param int nd: Number of data codewords. + :param int nc: Number of error correction codewords. + :param int gf: Galois Field order. + :param int pp: Prime modulus polynomial value. + + :return: None. """ # generate log and anti log tables log = {0: 1 - gf} @@ -192,7 +196,7 @@ def reed_solomon(wd, nd, nc, gf, pp): if alog[i] >= gf: alog[i] ^= pp log[alog[i]] = i - # generate polynomial coeffs + # generate polynomial coefficients c = {0: 1} for i in range(1, nc + 1): c[i] = 0 @@ -388,10 +392,11 @@ def find_optimal_sequence(data, encoding=None): def optimal_sequence_to_bits(optimal_sequence): - """ Convert optimal sequence to bits + """Convert optimal sequence to bits. + + :param list[str|int] optimal_sequence: Input optimal sequence. - :param optimal_sequence: input optimal sequence - :return: string with bits + :return: String with bits. """ out_bits = '' mode = prev_mode = Mode.UPPER @@ -458,12 +463,13 @@ def optimal_sequence_to_bits(optimal_sequence): def get_data_codewords(bits, codeword_size): - """ Get codewords stream from data bits sequence - Bit stuffing and padding are used to avoid all-zero and all-ones codewords + """Get codewords stream from data bits sequence. + Bit stuffing and padding are used to avoid all-zero and all-ones codewords. - :param bits: input data bits - :param codeword_size: codeword size in bits - :return: data codewords + :param str bits: Input data bits. + :param int codeword_size: Codeword size in bits. + + :return: Data codewords. """ codewords = [] sub_bits = '' @@ -492,9 +498,7 @@ def get_data_codewords(bits, codeword_size): def get_config_from_table(size, compact): """ Get config with given size and compactness flag - :param size: matrix size - :param compact: compactness flag - :return: dict with config + :return: Dict with config. """ try: return configs[(size, compact)] @@ -521,9 +525,56 @@ def find_suitable_matrix_size(data, ec_percent=23, encoding=None): return size, compact, optimal_sequence raise Exception('Data too big to fit in one Aztec code!') + +class SvgFactory: + def __init__(self, data): + """ Do not call it directly, use the create_svg method instead + + :param data: String representation of the image + """ + self.svg_str = data + + @staticmethod + def create_svg(matrix, border=1, matching_fn=lambda x: x == 1): + """ Creates the image in SVG format based on the two dimensional array + + :param matrix: Two dimensional array of data + :param border: Border width (px) + :param matching_fn: Function to differenciate ones from zeros in the matrix + :return: An instance of SvgFactory + """ + d = '' + for y, line in enumerate(matrix): + dx = 0 + x0 = None + for x, char in enumerate(line): + if matching_fn(char): + dx += 1 + if x0 is None: + x0 = x + if x0 is not None and (x + 1 >= len(line) or not matching_fn(line[x + 1])): + d += f" M{x0 + border} {y + border} h{dx}" + dx = 0 + x0 = None + size = len(matrix[0]) + (2 * border) + data = f'' + return SvgFactory(data) + + def save(self, filename): + """ Save SVG to image file + + :param filename: output image filename or file object + :return: None + """ + if isinstance(filename, IOBase) or hasattr(filename, 'write'): + return filename.write(self.svg_str) + with open(filename, 'w') as file: + file.write(self.svg_str) + + class AztecCode(object): """ - Aztec code generator + Aztec code generator. """ def __init__(self, data, size=None, compact=None, ec_percent=23, encoding=None): @@ -558,6 +609,15 @@ def __create_matrix(self): """ Create Aztec code matrix with given size """ self.matrix = [array.array('B', (0 for jj in range(self.size))) for ii in range(self.size)] + def __is_svg_file(self, filename, format): + """ Detects if the file is in SVG format + :param filename: image filename (or file object, with format) + :param format: image format (PNG, SVG, etc.) or None + """ + return (format is not None and format.lower() == 'svg') or (format is None and \ + (isinstance(filename, str) and filename.lower().endswith('.svg')) or \ + (isinstance(filename, IOBase) or hasattr(filename, 'write')) and filename.name.lower().endswith('.svg')) + def save(self, filename, module_size=2, border=0, format=None): """ Save matrix to image file @@ -566,6 +626,8 @@ def save(self, filename, module_size=2, border=0, format=None): :param border: barcode border size in modules. :param format: Pillow image format, such as 'PNG' """ + if self.__is_svg_file(filename, format): + return SvgFactory.create_svg(self.matrix, border).save(filename) self.image(module_size, border).save(filename, format=format) def image(self, module_size=2, border=0): @@ -608,7 +670,7 @@ def print_fancy(self, border=0): print(ul) def __add_finder_pattern(self): - """ Add bulls-eye finder pattern """ + """Add bulls-eye finder pattern.""" center = self.size // 2 ring_radius = 5 if self.compact else 7 for x in range(-ring_radius, ring_radius): @@ -616,7 +678,7 @@ def __add_finder_pattern(self): self.matrix[center + y][center + x] = (max(abs(x), abs(y)) + 1) % 2 def __add_orientation_marks(self): - """ Add orientation marks to matrix """ + """Add orientation marks to matrix.""" center = self.size // 2 ring_radius = 5 if self.compact else 7 # add orientation marks @@ -631,7 +693,7 @@ def __add_orientation_marks(self): self.matrix[center + ring_radius - 1][center + ring_radius + 0] = 1 def __add_reference_grid(self): - """ Add reference grid to matrix """ + """Add reference grid to matrix.""" if self.compact: return center = self.size // 2 @@ -646,11 +708,12 @@ def __add_reference_grid(self): self.matrix[center + y][center + x] = (x + y + 1) % 2 def __get_mode_message(self, layers_count, data_cw_count): - """ Get mode message + """Get mode message. - :param layers_count: number of layers - :param data_cw_count: number of data codewords - :return: mode message codewords + :param int layers_count: Number of layers. + :param int data_cw_count: Number of data codewords. + + :return: Mode message codewords. """ if self.compact: # for compact mode - 2 bits with layers count and 6 bits with data codewords count @@ -672,9 +735,11 @@ def __get_mode_message(self, layers_count, data_cw_count): return codewords def __add_mode_info(self, data_cw_count): - """ Add mode info to matrix + """Add mode info to matrix. + + :param int data_cw_count: Number of data codewords. - :param data_cw_count: number of data codewords. + :return: None. """ config = get_config_from_table(self.size, self.compact) layers_count = config.layers @@ -832,7 +897,7 @@ def __add_data(self, data, encoding): return data_cw_count def __encode_data(self): - """ Encode data """ + """Encode data.""" self.__add_finder_pattern() self.__add_orientation_marks() self.__add_reference_grid() diff --git a/requirements-test.txt b/requirements-test.txt index 47fb2b9..68185e3 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,4 +1,3 @@ -pillow>=3.0,<6.0; python_version < '3.5' -pillow>=3.0,<8.0; python_version >= '3.5' and python_version < '3.6' -pillow>=8.0; python_version >= '3.6' +pytest>=8.3.3 +pillow>=8.0 zxing>=0.13 diff --git a/setup.py b/setup.py index b2de56c..ae80931 100755 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ sys.exit("Python 3.4+ is required; you are using %s" % sys.version) setup(name="aztec_code_generator", - version="0.11", + version="0.12", description='Aztec Code generator in Python', long_description=open('README.md').read(), long_description_content_type='text/markdown', diff --git a/test_aztec_code_generator.py b/test_aztec_code_generator.py index 9b23fa5..a4f838d 100644 --- a/test_aztec_code_generator.py +++ b/test_aztec_code_generator.py @@ -2,9 +2,18 @@ #-*- coding: utf-8 -*- import unittest +from unittest.mock import patch, mock_open, MagicMock from aztec_code_generator import ( - reed_solomon, find_optimal_sequence, optimal_sequence_to_bits, get_data_codewords, encoding_to_eci, - Mode, Latch, Shift, Misc, + Mode, + Latch, + Shift, + Misc, + reed_solomon, + find_optimal_sequence, + optimal_sequence_to_bits, + get_data_codewords, + encoding_to_eci, + SvgFactory, AztecCode, ) @@ -190,6 +199,117 @@ def test_barcode_readability_eci(self): self._encode_and_decode(r, 'The price is €4', encoding='utf-8') self._encode_and_decode(r, 'אין לי מושג', encoding='iso8859-8') +class TestSvgFactory(unittest.TestCase): + def test_init(self): + data = 'example svg data' + instance = SvgFactory(data) + self.assertEqual(instance.svg_str, data) + + @patch('builtins.open', new_callable=mock_open) + def test_save(self, mock): + data = '' + filename = 'example_filename.svg' + instance = SvgFactory(data) + mock.reset_mock() + instance.save(filename) + mock.assert_called_once_with(filename, "w") + mock().write.assert_called_once_with(data) + + def test_save_with_provided_file_handler(self): + data = '' + with NamedTemporaryFile(mode='w+') as fp: + instance = SvgFactory(data) + instance.save(fp) + fp.flush() + fp.seek(0) + saved_content = fp.read() + self.assertEqual(saved_content, data) + + def test_create_svg(self): + CASES = [ + dict( + matrix = [[1, 1, 1], [0 ,0 ,0 ], [1, 1, 1]], + snapshot = '', + ), + dict( + matrix = [[1, 0, 0], [0, 1, 0], [0, 0 ,1]], + snapshot = '', + ), + dict( + matrix = [[1, 0], [0, 1]], + snapshot = '' + ), + dict( + matrix = [[1, 0], [0, 1]], + border = 3, + snapshot = '' + ), + dict( + matrix = [['#', ' '], [' ', '#']], + matching_fn = lambda x: x == '#', + snapshot = '' + ), + ] + for case in CASES: + if "border" in case: + instance = SvgFactory.create_svg(case["matrix"], border=case["border"]) + elif "matching_fn" in case: + instance = SvgFactory.create_svg(case["matrix"], matching_fn=case["matching_fn"]) + else: + instance = SvgFactory.create_svg(case["matrix"]) + self.assertIsInstance(instance, SvgFactory) + self.assertEqual( + instance.svg_str, + case["snapshot"], + 'should match snapshot' + ) + +class TestAztecCode(unittest.TestCase): + def test_save_should_use_PIL_if_not_SVG(self): + aztec_code = AztecCode('example data') + class Image: + def save(): + pass + image_mock = Image() + image_mock.save = MagicMock() + with patch.object(aztec_code, 'image', return_value=image_mock): + # filename .png, format None + aztec_code.save('image.png') + image_mock.save.assert_called_once() + image_mock.save.reset_mock() + + # filename .jpg, format None + aztec_code.save('image.jpg') + image_mock.save.assert_called_once() + image_mock.save.reset_mock() + + # filename .svg, format 'PNG' + aztec_code.save('image.svg', format='PNG') + image_mock.save.assert_called_once() + + def test_save_should_support_SVG(self): + """ Should call SvgFactory.save for SVG files """ + mock_svg_factory_save = MagicMock() + SvgFactory.save = mock_svg_factory_save + aztec_code = AztecCode('example data') + + # filename .svg, format None + filename = 'file.svg' + aztec_code.save(filename) + mock_svg_factory_save.assert_called_once_with(filename) + mock_svg_factory_save.reset_mock() + + # filename != .svg, format 'SVG' + filename = 'file.png' + aztec_code.save(filename, format='SVG') + mock_svg_factory_save.assert_called_once_with(filename) + mock_svg_factory_save.reset_mock() + + # filename is a file object, format 'svg' + with NamedTemporaryFile() as fp: + aztec_code.save(fp, format='svg') + mock_svg_factory_save.assert_called_once_with(fp) + if __name__ == '__main__': unittest.main(verbosity=2)