diff --git a/Makefile b/Makefile index 64421c3..985623c 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,15 @@ +.PHONY: install +install: + pip install -e ".[dev]" + +.PHONY: uninstall +uninstall: + yes Y | pip uninstall rmqrcode + +.PHONY: test +test: + python -m pytest + .PHONY: lint lint: flake8 src diff --git a/README.md b/README.md index 6602109..9ecbab0 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# An rMQR Code Generator +# Rectangular Micro QR Code (rMQR Code) Generator ![reop-url](https://user-images.githubusercontent.com/14174940/172978619-accbf9d0-9dd8-4b19-b47e-ad139a68dcc9.png) -The rMQR Code is a rectangular two-dimensional barcode. This package can generate an rMQR Code image. This is implemented based on [ISO/IEC 23941: Rectangular Micro QR Code (rMQR) bar code symbology specification](https://www.iso.org/standard/77404.html). +The rMQR Code is a rectangular two-dimensional barcode. This is easy to print in narrow space compared to conventional QR Code. This package can generate an rMQR Code image. This is implemented based on [ISO/IEC 23941: Rectangular Micro QR Code (rMQR) bar code symbology specification](https://www.iso.org/standard/77404.html). [![pytest](https://github.com/OUDON/rmqrcode-python/actions/workflows/python-app.yml/badge.svg?branch=main)](https://github.com/OUDON/rmqrcode-python/actions/workflows/python-app.yml) ![PyPI](https://img.shields.io/pypi/v/rmqrcode?color=blue) @@ -48,7 +48,7 @@ optional arguments: Strategy how to determine rMQR Code size. ``` -### Generate rMQR Code +### Generate rMQR Code in scripts Alternatively, you can also use in python scripts: ```py from rmqrcode import rMQR @@ -71,7 +71,7 @@ The `fit_strategy` parameter is enum value of `rmqrcode.FitStrategy` to specify - **`FitStrategy.MINIMIZE_HEIGHT`**: Try to minimize height. - **`FitStrategy.BALANCED`**: Try to keep balance of width and height. -Here is an example of images genereated by each fit strategies for data `Test test test`: +Here is an example of images generated by each fit strategies for data `Test test test`: ![Example of fit strategies](https://user-images.githubusercontent.com/14174940/175759120-7fb5ec71-c258-4646-9b91-6865b3eeac3f.png) ### Save as image @@ -88,8 +88,8 @@ image.save("my_qr.png") ### Select rMQR Code size manually To select rMQR Code size manually, use `rMQR()` constructor. ```py +from rmqrcode import rMQR, ErrorCorrectionLevel qr = rMQR('R11x139', ErrorCorrectionLevel.H) -qr.make("https://oudon.xyz") ``` `R11x139` means 11 rows and 139 columns. The following table shows available combinations. @@ -103,22 +103,43 @@ qr.make("https://oudon.xyz") |R15|❌|✅|✅|✅|✅|✅| |R17|❌|✅|✅|✅|✅|✅| +### Encoding Modes and Segments -## 🛠️ Under the Hood -### Encoding modes +The rMQR Code has the four encoding modes Numeric, Alphanumeric, Byte and Kanji to convert data efficiently. We can select encoding mode for each data segment separately. +The following example shows how to encode data "123Abc". The first segment is for "123" in the Numeric mode. The second segment is for "Abc" in the Byte mode. +We can select an encoding mode by passing the `encoder_class` argument to the `rMQR#add_segment` method. In this example, the length of bits after encoding is 47 in the case combined with the Numeric mode and the Byte mode, which is shorter than 56 in the Byte mode only. -The rMQR Code has the four encoding modes Numeric, Alphanumeric, Byte and Kanji to convert data efficiently. However, this package supoprts only Byte mode currently. +```py +from rmqrcode import rMQR, ErrorCorrectionLevel, encoder +qr = rMQR('R7x43', ErrorCorrectionLevel.M) +qr.add_segment("123", encoder_class=encoder.NumericEncoder) +qr.add_segment("Abc", encoder_class=encoder.ByteEncoder) +qr.make() +``` + +The value for `encoder_class` is listed in the below table. + +|Mode|Value of encoder_class|Characters| +|-|-|-| +|Numeric|NumericEncoder|0-9| +|Alphanumeric|AlphanumericEncoder|0-9 A-Z \s $ % * + - . / :| +|Byte|ByteEncoder|Any| +|Kanji|KanjiEncoder|from 0x8140 to 0x9FFC, from 0xE040 to 0xEBBF in Shift JIS value| + +### Optimal Segmentation +The `rMQR.fit` method mentioned above computes the optimal segmentation. +For example, the data "123Abc" is divided into the following two segments. -|Mode|Supported?| -|-|:-:| -|Numeric| | -|Alphanumeric|| -|Byte|✅| -|Kanji|| +|Segment No.|Data|Encoding Mode| +|-|-|-| +|Segment1|123|Numeric| +|Segment2|Abc|Byte| +In the case of other segmentation like "123A bc", the length of the bit string after +encoding will be longer than the above optimal case. -## 🤝 Contiributing -Any suggestions are welcome! If you are interesting in contiributing, please read [CONTRIBUTING](https://github.com/OUDON/rmqrcode-python/blob/develop/CONTRIBUTING.md). +## 🤝 Contributing +Any suggestions are welcome! If you are interesting in contributing, please read [CONTRIBUTING](https://github.com/OUDON/rmqrcode-python/blob/develop/CONTRIBUTING.md). ## 📚 References diff --git a/example.py b/example.py index 16f7eb2..983c57d 100644 --- a/example.py +++ b/example.py @@ -2,6 +2,7 @@ from rmqrcode import ErrorCorrectionLevel from rmqrcode import QRImage from rmqrcode import FitStrategy +from rmqrcode import encoder import logging @@ -24,9 +25,11 @@ def main(): print(qr) # Determine rMQR version manually - # version = 'R13x99' + # version = 'R7x43' # qr = rMQR(version, error_correction_level) - # qr.make(data) + # qr.add_segment("123", encoder_class=encoder.NumericEncoder) + # qr.add_segment("Abc", encoder_class=encoder.ByteEncoder) + # qr.make() # print(qr) # Save as png diff --git a/setup.cfg b/setup.cfg index cf930d6..94f2368 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = rmqrcode -version = 0.2.0 +version = 0.3.0 author = Takahiro Tomita author_email = ttp8101@gmail.com description = An rMQR Code Generetor diff --git a/src/rmqrcode/__init__.py b/src/rmqrcode/__init__.py index aca9a2f..1e05407 100644 --- a/src/rmqrcode/__init__.py +++ b/src/rmqrcode/__init__.py @@ -1,5 +1,21 @@ +from . import encoder from .format.error_correction_level import ErrorCorrectionLevel from .qr_image import QRImage -from .rmqrcode import DataTooLongError, FitStrategy, IllegalVersionError, rMQR +from .rmqrcode import ( + DataTooLongError, + FitStrategy, + IllegalVersionError, + NoSegmentError, + rMQR, +) -__all__ = ("rMQR", "DataTooLongError", "FitStrategy", "IllegalVersionError", "QRImage", "ErrorCorrectionLevel") +__all__ = ( + "rMQR", + "DataTooLongError", + "FitStrategy", + "IllegalVersionError", + "NoSegmentError", + "QRImage", + "ErrorCorrectionLevel", + "encoder", +) diff --git a/src/rmqrcode/console.py b/src/rmqrcode/console.py index 5220c64..66c7bfc 100644 --- a/src/rmqrcode/console.py +++ b/src/rmqrcode/console.py @@ -25,8 +25,8 @@ def _make_qr(data, ecc, version, fit_strategy): qr = rMQR(version, ecc) except IllegalVersionError: _show_error_and_exit("Error: Illegal version.") - qr.make(data) - + qr.add_segment(data) + qr.make() return qr diff --git a/src/rmqrcode/encoder/__init__.py b/src/rmqrcode/encoder/__init__.py index e69de29..3ed1e17 100644 --- a/src/rmqrcode/encoder/__init__.py +++ b/src/rmqrcode/encoder/__init__.py @@ -0,0 +1,7 @@ +from .alphanumeric_encoder import AlphanumericEncoder +from .byte_encoder import ByteEncoder +from .encoder_base import IllegalCharacterError +from .kanji_encoder import KanjiEncoder +from .numeric_encoder import NumericEncoder + +__all__ = ("ByteEncoder", "NumericEncoder", "IllegalCharacterError", "AlphanumericEncoder", "KanjiEncoder") diff --git a/src/rmqrcode/encoder/alphanumeric_encoder.py b/src/rmqrcode/encoder/alphanumeric_encoder.py new file mode 100644 index 0000000..2a0839f --- /dev/null +++ b/src/rmqrcode/encoder/alphanumeric_encoder.py @@ -0,0 +1,92 @@ +import re + +from .encoder_base import EncoderBase + + +class AlphanumericEncoder(EncoderBase): + CHARACTER_MAP = { + "0": 0, + "1": 1, + "2": 2, + "3": 3, + "4": 4, + "5": 5, + "6": 6, + "7": 7, + "8": 8, + "9": 9, + "A": 10, + "B": 11, + "C": 12, + "D": 13, + "E": 14, + "F": 15, + "G": 16, + "H": 17, + "I": 18, + "J": 19, + "K": 20, + "L": 21, + "M": 22, + "N": 23, + "O": 24, + "P": 25, + "Q": 26, + "R": 27, + "S": 28, + "T": 29, + "U": 30, + "V": 31, + "W": 32, + "X": 33, + "Y": 34, + "Z": 35, + " ": 36, + "$": 37, + "%": 38, + "*": 39, + "+": 40, + "-": 41, + ".": 42, + "/": 43, + ":": 44, + } + + @classmethod + def mode_indicator(cls): + return "010" + + @classmethod + def _encoded_bits(cls, data): + res = "" + data_grouped = cls._group_by_2characters(data) + for s in data_grouped: + if len(s) == 2: + value = cls.CHARACTER_MAP[s[0]] * 45 + cls.CHARACTER_MAP[s[1]] + res += bin(value)[2:].zfill(11) + elif len(s) == 1: + value = cls.CHARACTER_MAP[s[0]] + res += bin(value)[2:].zfill(6) + return res + + @classmethod + def _group_by_2characters(cls, data): + res = [] + while data != "": + res.append(data[:2]) + data = data[2:] + return res + + @classmethod + def length(cls, data, character_count_indicator_length): + return ( + len(cls.mode_indicator()) + character_count_indicator_length + 11 * (len(data) // 2) + 6 * (len(data) % 2) + ) + + @classmethod + def characters_num(cls, data): + return len(data) + + @classmethod + def is_valid_characters(cls, data): + return bool(re.match(r"^[0-9A-Z\s\$\%\*\+\-\.\/\:]*$", data)) diff --git a/src/rmqrcode/encoder/byte_encoder.py b/src/rmqrcode/encoder/byte_encoder.py index ed6bbdf..99a9518 100644 --- a/src/rmqrcode/encoder/byte_encoder.py +++ b/src/rmqrcode/encoder/byte_encoder.py @@ -1,21 +1,27 @@ -class ByteEncoder: - MODE_INDICATOR = "011" +from .encoder_base import EncoderBase - @staticmethod - def _encoded_bits(s): + +class ByteEncoder(EncoderBase): + @classmethod + def mode_indicator(cls): + return "011" + + @classmethod + def _encoded_bits(cls, s): res = "" encoded = s.encode("utf-8") for byte in encoded: res += bin(byte)[2:].zfill(8) return res - @staticmethod - def encode(data, character_count_length): - res = ByteEncoder.MODE_INDICATOR - res += bin(len(data))[2:].zfill(character_count_length) - res += ByteEncoder._encoded_bits(data) - return res + @classmethod + def length(cls, data, character_count_indicator_length): + return len(cls.mode_indicator()) + character_count_indicator_length + 8 * len(data.encode("utf-8")) - @staticmethod - def length(data): + @classmethod + def characters_num(cls, data): return len(data.encode("utf-8")) + + @classmethod + def is_valid_characters(cls, data): + return True # Any characters can encode in the Byte Mode diff --git a/src/rmqrcode/encoder/encoder_base.py b/src/rmqrcode/encoder/encoder_base.py new file mode 100644 index 0000000..a5220fd --- /dev/null +++ b/src/rmqrcode/encoder/encoder_base.py @@ -0,0 +1,105 @@ +from abc import ABC, abstractmethod + + +class EncoderBase(ABC): + """An abstract class for encoders""" + + @classmethod + @abstractmethod + def mode_indicator(cls): + """Mode indicator defined in the Table 2. + + Returns: + str: Mode indicator like "001". + + """ + raise NotImplementedError() + + @classmethod + @abstractmethod + def encode(cls, data, character_count_indicator_length): + """Encodes data and returns it. + + Args: + data (str): Data to encode. + character_count_indicator_length: (int): Number of bits of character + count indicator defined in the Table 3. + + Returns: + str: Encoded binary as string. + + Raises: + IllegalCharacterError: If the data includes illegal character. + + """ + if not cls.is_valid_characters(data): + raise IllegalCharacterError + + res = cls.mode_indicator() + res += bin(cls.characters_num(data))[2:].zfill(character_count_indicator_length) + res += cls._encoded_bits(data) + return res + + @classmethod + @abstractmethod + def _encoded_bits(cls, data): + """Encodes data and returns it. + + This method encodes the raw data without the meta data like the mode + indicator, the number of data characters and so on. + + Args: + data (str): Data to encode. + + Returns: + str: Encoded binary as string. + + """ + raise NotImplementedError() + + @classmethod + @abstractmethod + def length(cls, data): + """Compute the length of the encoded bits. + + Args: + data (str): Data to encode. + + Returns: + int: The length of the encoded bits. + + """ + raise NotImplementedError() + + @classmethod + @abstractmethod + def characters_num(cls, data): + """Returns the number of the characters of the data. + + Args: + data (str): The data to encode. + + Returns: + int: The number of the characters of the data. + + """ + raise NotImplementedError() + + @classmethod + @abstractmethod + def is_valid_characters(cls, data): + """Checks wether the data does not include invalid character. + + Args: + data (str): Data to validate. + + Returns: + bool: Validation result. + + """ + raise NotImplementedError() + + +class IllegalCharacterError(ValueError): + "A class represents an error raised when the given data includes illegal character." + pass diff --git a/src/rmqrcode/encoder/kanji_encoder.py b/src/rmqrcode/encoder/kanji_encoder.py new file mode 100644 index 0000000..ba5fc65 --- /dev/null +++ b/src/rmqrcode/encoder/kanji_encoder.py @@ -0,0 +1,49 @@ +from .encoder_base import EncoderBase, IllegalCharacterError + + +class KanjiEncoder(EncoderBase): + @classmethod + def mode_indicator(cls): + return "100" + + @classmethod + def _encoded_bits(cls, data): + res = "" + for c in data: + shift_jis = c.encode("shift-jis") + hex_value = shift_jis[0] * 256 + shift_jis[1] + + if hex_value >= 0x8140 and hex_value <= 0x9FFC: + sub = 0x8140 + elif hex_value >= 0xE040 and hex_value <= 0xEBBF: + sub = 0xC140 + else: + raise IllegalCharacterError() + + msb = (hex_value - sub) >> 8 + lsb = (hex_value - sub) & 255 + encoded_value = msb * 0xC0 + lsb + res += bin(encoded_value)[2:].zfill(13) + return res + + @classmethod + def length(cls, data, character_count_indicator_length): + return len(cls.mode_indicator()) + character_count_indicator_length + 13 * len(data) + + @classmethod + def characters_num(cls, data): + return len(data.encode("shift_jis")) // 2 + + @classmethod + def is_valid_characters(cls, data): + for c in data: + try: + shift_jis = c.encode("shift_jis") + except UnicodeEncodeError: + return False + if len(shift_jis) < 2: + return False + hex_value = shift_jis[0] * 256 + shift_jis[1] + if (0x8140 > hex_value and 0x9FFC < hex_value) or (0xE040 > hex_value and 0xEBBF < hex_value): + return False + return True diff --git a/src/rmqrcode/encoder/numeric_encoder.py b/src/rmqrcode/encoder/numeric_encoder.py new file mode 100644 index 0000000..208e7f5 --- /dev/null +++ b/src/rmqrcode/encoder/numeric_encoder.py @@ -0,0 +1,48 @@ +import re + +from .encoder_base import EncoderBase + + +class NumericEncoder(EncoderBase): + @classmethod + def mode_indicator(cls): + return "001" + + @classmethod + def _encoded_bits(cls, data): + res = "" + data_grouped = cls._group_by_3characters(data) + for num in data_grouped: + if len(num) == 3: + res += bin(int(num))[2:].zfill(10) + elif len(num) == 2: + res += bin(int(num))[2:].zfill(7) + elif len(num) == 1: + res += bin(int(num))[2:].zfill(4) + return res + + @classmethod + def _group_by_3characters(cls, data): + res = [] + while data != "": + res.append(data[:3]) + data = data[3:] + return res + + @classmethod + def length(cls, data, character_count_indicator_length): + if len(data) % 3 == 0: + r = 0 + elif len(data) % 3 == 1: + r = 4 + elif len(data) % 3 == 2: + r = 7 + return len(cls.mode_indicator()) + character_count_indicator_length + 10 * (len(data) // 3) + r + + @classmethod + def characters_num(cls, data): + return len(data) + + @classmethod + def is_valid_characters(cls, data): + return bool(re.match(r"^[0-9]*$", data)) diff --git a/src/rmqrcode/errors.py b/src/rmqrcode/errors.py new file mode 100644 index 0000000..c8a9769 --- /dev/null +++ b/src/rmqrcode/errors.py @@ -0,0 +1,13 @@ +class DataTooLongError(ValueError): + "A class represents an error raised when the given data is too long." + pass + + +class IllegalVersionError(ValueError): + "A class represents an error raised when the given version name is illegal." + pass + + +class NoSegmentError(ValueError): + "A class represents an error raised when no segments are add" + pass diff --git a/src/rmqrcode/format/data_capacities.py b/src/rmqrcode/format/data_capacities.py index 304f067..adeb1d7 100644 --- a/src/rmqrcode/format/data_capacities.py +++ b/src/rmqrcode/format/data_capacities.py @@ -5,6 +5,10 @@ "R7x43": { "height": 7, "width": 43, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 48, + ErrorCorrectionLevel.H: 24, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 5, @@ -15,6 +19,10 @@ "R7x59": { "height": 7, "width": 59, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 96, + ErrorCorrectionLevel.H: 56, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 11, @@ -25,6 +33,10 @@ "R7x77": { "height": 7, "width": 77, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 160, + ErrorCorrectionLevel.H: 80, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 19, @@ -35,6 +47,10 @@ "R7x99": { "height": 7, "width": 99, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 224, + ErrorCorrectionLevel.H: 112, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 27, @@ -45,6 +61,10 @@ "R7x139": { "height": 7, "width": 139, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 352, + ErrorCorrectionLevel.H: 192, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 42, @@ -55,6 +75,10 @@ "R9x43": { "height": 9, "width": 43, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 96, + ErrorCorrectionLevel.H: 56, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 11, @@ -65,6 +89,10 @@ "R9x59": { "height": 9, "width": 59, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 168, + ErrorCorrectionLevel.H: 88, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 20, @@ -75,6 +103,10 @@ "R9x77": { "height": 9, "width": 77, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 248, + ErrorCorrectionLevel.H: 136, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 30, @@ -85,6 +117,10 @@ "R9x99": { "height": 9, "width": 99, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 336, + ErrorCorrectionLevel.H: 176, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 40, @@ -95,6 +131,10 @@ "R9x139": { "height": 9, "width": 139, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 504, + ErrorCorrectionLevel.H: 264, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 61, @@ -105,6 +145,10 @@ "R11x27": { "height": 11, "width": 27, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 56, + ErrorCorrectionLevel.H: 40, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 6, @@ -115,6 +159,10 @@ "R11x43": { "height": 11, "width": 43, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 152, + ErrorCorrectionLevel.H: 88, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 18, @@ -125,6 +173,10 @@ "R11x59": { "height": 11, "width": 59, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 248, + ErrorCorrectionLevel.H: 120, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 30, @@ -135,6 +187,10 @@ "R11x77": { "height": 11, "width": 77, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 344, + ErrorCorrectionLevel.H: 184, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 41, @@ -145,6 +201,10 @@ "R11x99": { "height": 11, "width": 99, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 456, + ErrorCorrectionLevel.H: 232, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 55, @@ -155,6 +215,10 @@ "R11x139": { "height": 11, "width": 139, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 672, + ErrorCorrectionLevel.H: 336, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 82, @@ -165,6 +229,10 @@ "R13x27": { "height": 13, "width": 27, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 96, + ErrorCorrectionLevel.H: 56, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 11, @@ -175,6 +243,10 @@ "R13x43": { "height": 13, "width": 43, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 216, + ErrorCorrectionLevel.H: 104, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 26, @@ -185,6 +257,10 @@ "R13x59": { "height": 13, "width": 59, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 304, + ErrorCorrectionLevel.H: 160, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 36, @@ -195,6 +271,10 @@ "R13x77": { "height": 13, "width": 77, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 424, + ErrorCorrectionLevel.H: 232, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 51, @@ -205,6 +285,10 @@ "R13x99": { "height": 13, "width": 99, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 584, + ErrorCorrectionLevel.H: 280, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 71, @@ -215,6 +299,10 @@ "R13x139": { "height": 13, "width": 139, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 848, + ErrorCorrectionLevel.H: 432, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 104, @@ -225,6 +313,10 @@ "R15x43": { "height": 15, "width": 43, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 264, + ErrorCorrectionLevel.H: 120, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 31, @@ -235,6 +327,10 @@ "R15x59": { "height": 15, "width": 59, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 384, + ErrorCorrectionLevel.H: 208, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 46, @@ -245,6 +341,10 @@ "R15x77": { "height": 15, "width": 77, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 536, + ErrorCorrectionLevel.H: 248, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 65, @@ -255,6 +355,10 @@ "R15x99": { "height": 15, "width": 99, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 704, + ErrorCorrectionLevel.H: 384, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 86, @@ -265,6 +369,10 @@ "R15x139": { "height": 15, "width": 139, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 1016, + ErrorCorrectionLevel.H: 552, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 125, @@ -275,6 +383,10 @@ "R17x43": { "height": 17, "width": 43, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 312, + ErrorCorrectionLevel.H: 168, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 37, @@ -285,6 +397,10 @@ "R17x59": { "height": 17, "width": 59, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 448, + ErrorCorrectionLevel.H: 224, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 54, @@ -295,6 +411,10 @@ "R17x77": { "height": 17, "width": 77, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 624, + ErrorCorrectionLevel.H: 304, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 76, @@ -305,6 +425,10 @@ "R17x99": { "height": 17, "width": 99, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 800, + ErrorCorrectionLevel.H: 448, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 98, @@ -315,6 +439,10 @@ "R17x139": { "height": 17, "width": 139, + "number_of_data_bits": { + ErrorCorrectionLevel.M: 1216, + ErrorCorrectionLevel.H: 608, + }, "capacity": { "Byte": { ErrorCorrectionLevel.M: 150, diff --git a/src/rmqrcode/format/rmqr_versions.py b/src/rmqrcode/format/rmqr_versions.py index 6861772..c35c472 100644 --- a/src/rmqrcode/format/rmqr_versions.py +++ b/src/rmqrcode/format/rmqr_versions.py @@ -1,3 +1,4 @@ +from ..encoder import AlphanumericEncoder, ByteEncoder, KanjiEncoder, NumericEncoder from .error_correction_level import ErrorCorrectionLevel rMQRVersions = { @@ -6,7 +7,12 @@ "height": 7, "width": 43, "remainder_bits": 0, - "character_count_length": 3, + "character_count_indicator_length": { + NumericEncoder: 4, + AlphanumericEncoder: 3, + ByteEncoder: 3, + KanjiEncoder: 2, + }, "codewords_total": 13, "blocks": { ErrorCorrectionLevel.M: [ @@ -30,7 +36,12 @@ "height": 7, "width": 59, "remainder_bits": 3, - "character_count_length": 4, + "character_count_indicator_length": { + NumericEncoder: 5, + AlphanumericEncoder: 5, + ByteEncoder: 4, + KanjiEncoder: 3, + }, "codewords_total": 21, "blocks": { ErrorCorrectionLevel.M: [ @@ -54,7 +65,12 @@ "height": 7, "width": 77, "remainder_bits": 5, - "character_count_length": 5, + "character_count_indicator_length": { + NumericEncoder: 6, + AlphanumericEncoder: 5, + ByteEncoder: 5, + KanjiEncoder: 4, + }, "codewords_total": 32, "blocks": { ErrorCorrectionLevel.M: [ @@ -78,7 +94,12 @@ "height": 7, "width": 99, "remainder_bits": 6, - "character_count_length": 5, + "character_count_indicator_length": { + NumericEncoder: 7, + AlphanumericEncoder: 6, + ByteEncoder: 5, + KanjiEncoder: 5, + }, "codewords_total": 44, "blocks": { ErrorCorrectionLevel.M: [ @@ -102,7 +123,12 @@ "height": 7, "width": 139, "remainder_bits": 1, - "character_count_length": 6, + "character_count_indicator_length": { + NumericEncoder: 7, + AlphanumericEncoder: 6, + ByteEncoder: 6, + KanjiEncoder: 5, + }, "codewords_total": 68, "blocks": { ErrorCorrectionLevel.M: [ @@ -126,7 +152,12 @@ "height": 9, "width": 43, "remainder_bits": 2, - "character_count_length": 4, + "character_count_indicator_length": { + NumericEncoder: 5, + AlphanumericEncoder: 5, + ByteEncoder: 4, + KanjiEncoder: 3, + }, "codewords_total": 21, "blocks": { ErrorCorrectionLevel.M: [ @@ -150,7 +181,12 @@ "height": 9, "width": 59, "remainder_bits": 3, - "character_count_length": 5, + "character_count_indicator_length": { + NumericEncoder: 6, + AlphanumericEncoder: 5, + ByteEncoder: 5, + KanjiEncoder: 4, + }, "codewords_total": 33, "blocks": { ErrorCorrectionLevel.M: [ @@ -174,7 +210,12 @@ "height": 9, "width": 77, "remainder_bits": 1, - "character_count_length": 5, + "character_count_indicator_length": { + NumericEncoder: 7, + AlphanumericEncoder: 6, + ByteEncoder: 5, + KanjiEncoder: 5, + }, "codewords_total": 49, "blocks": { ErrorCorrectionLevel.M: [ @@ -203,7 +244,12 @@ "height": 9, "width": 99, "remainder_bits": 4, - "character_count_length": 6, + "character_count_indicator_length": { + NumericEncoder: 7, + AlphanumericEncoder: 6, + ByteEncoder: 6, + KanjiEncoder: 5, + }, "codewords_total": 66, "blocks": { ErrorCorrectionLevel.M: [ @@ -227,7 +273,12 @@ "height": 9, "width": 139, "remainder_bits": 5, - "character_count_length": 6, + "character_count_indicator_length": { + NumericEncoder: 8, + AlphanumericEncoder: 7, + ByteEncoder: 6, + KanjiEncoder: 6, + }, "codewords_total": 99, "blocks": { ErrorCorrectionLevel.M: [ @@ -256,7 +307,12 @@ "height": 11, "width": 27, "remainder_bits": 2, - "character_count_length": 3, + "character_count_indicator_length": { + NumericEncoder: 4, + AlphanumericEncoder: 4, + ByteEncoder: 3, + KanjiEncoder: 2, + }, "codewords_total": 15, "blocks": { ErrorCorrectionLevel.M: [ @@ -280,7 +336,12 @@ "height": 11, "width": 43, "remainder_bits": 1, - "character_count_length": 5, + "character_count_indicator_length": { + NumericEncoder: 6, + AlphanumericEncoder: 5, + ByteEncoder: 5, + KanjiEncoder: 4, + }, "codewords_total": 31, "blocks": { ErrorCorrectionLevel.M: [ @@ -304,7 +365,12 @@ "height": 11, "width": 59, "remainder_bits": 0, - "character_count_length": 5, + "character_count_indicator_length": { + NumericEncoder: 7, + AlphanumericEncoder: 6, + ByteEncoder: 5, + KanjiEncoder: 5, + }, "codewords_total": 47, "blocks": { ErrorCorrectionLevel.M: [ @@ -333,7 +399,12 @@ "height": 11, "width": 77, "remainder_bits": 2, - "character_count_length": 6, + "character_count_indicator_length": { + NumericEncoder: 7, + AlphanumericEncoder: 6, + ByteEncoder: 6, + KanjiEncoder: 5, + }, "codewords_total": 67, "blocks": { ErrorCorrectionLevel.M: [ @@ -362,7 +433,12 @@ "height": 11, "width": 99, "remainder_bits": 7, - "character_count_length": 6, + "character_count_indicator_length": { + NumericEncoder: 8, + AlphanumericEncoder: 7, + ByteEncoder: 6, + KanjiEncoder: 6, + }, "codewords_total": 89, "blocks": { ErrorCorrectionLevel.M: [ @@ -396,7 +472,12 @@ "height": 11, "width": 139, "remainder_bits": 6, - "character_count_length": 7, + "character_count_indicator_length": { + NumericEncoder: 8, + AlphanumericEncoder: 7, + ByteEncoder: 7, + KanjiEncoder: 6, + }, "codewords_total": 132, "blocks": { ErrorCorrectionLevel.M: [ @@ -419,7 +500,12 @@ "version_indicator": 0b10000, "height": 13, "width": 27, - "character_count_length": 4, + "character_count_indicator_length": { + NumericEncoder: 5, + AlphanumericEncoder: 5, + ByteEncoder: 4, + KanjiEncoder: 3, + }, "remainder_bits": 4, "codewords_total": 21, "blocks": { @@ -444,7 +530,12 @@ "height": 13, "width": 43, "remainder_bits": 1, - "character_count_length": 5, + "character_count_indicator_length": { + NumericEncoder: 6, + AlphanumericEncoder: 6, + ByteEncoder: 5, + KanjiEncoder: 5, + }, "codewords_total": 41, "blocks": { ErrorCorrectionLevel.M: [ @@ -468,7 +559,12 @@ "height": 13, "width": 59, "remainder_bits": 6, - "character_count_length": 6, + "character_count_indicator_length": { + NumericEncoder: 7, + AlphanumericEncoder: 6, + ByteEncoder: 6, + KanjiEncoder: 5, + }, "codewords_total": 60, "blocks": { ErrorCorrectionLevel.M: [ @@ -492,7 +588,12 @@ "height": 13, "width": 77, "remainder_bits": 4, - "character_count_length": 6, + "character_count_indicator_length": { + NumericEncoder: 7, + AlphanumericEncoder: 7, + ByteEncoder: 6, + KanjiEncoder: 6, + }, "codewords_total": 85, "blocks": { ErrorCorrectionLevel.M: [ @@ -526,7 +627,12 @@ "height": 13, "width": 99, "remainder_bits": 3, - "character_count_length": 7, + "character_count_indicator_length": { + NumericEncoder: 8, + AlphanumericEncoder: 7, + ByteEncoder: 7, + KanjiEncoder: 6, + }, "codewords_total": 113, "blocks": { ErrorCorrectionLevel.M: [ @@ -560,7 +666,12 @@ "height": 13, "width": 139, "remainder_bits": 0, - "character_count_length": 7, + "character_count_indicator_length": { + NumericEncoder: 8, + AlphanumericEncoder: 8, + ByteEncoder: 7, + KanjiEncoder: 7, + }, "codewords_total": 166, "blocks": { ErrorCorrectionLevel.M: [ @@ -594,7 +705,12 @@ "height": 15, "width": 43, "remainder_bits": 1, - "character_count_length": 6, + "character_count_indicator_length": { + NumericEncoder: 7, + AlphanumericEncoder: 6, + ByteEncoder: 6, + KanjiEncoder: 5, + }, "codewords_total": 51, "blocks": { ErrorCorrectionLevel.M: [ @@ -623,7 +739,12 @@ "height": 15, "width": 59, "remainder_bits": 4, - "character_count_length": 6, + "character_count_indicator_length": { + NumericEncoder: 7, + AlphanumericEncoder: 7, + ByteEncoder: 6, + KanjiEncoder: 5, + }, "codewords_total": 74, "blocks": { ErrorCorrectionLevel.M: [ @@ -647,7 +768,12 @@ "height": 15, "width": 77, "remainder_bits": 6, - "character_count_length": 7, + "character_count_indicator_length": { + NumericEncoder: 8, + AlphanumericEncoder: 7, + ByteEncoder: 7, + KanjiEncoder: 6, + }, "codewords_total": 103, "blocks": { ErrorCorrectionLevel.M: [ @@ -681,7 +807,12 @@ "height": 15, "width": 99, "remainder_bits": 7, - "character_count_length": 7, + "character_count_indicator_length": { + NumericEncoder: 8, + AlphanumericEncoder: 7, + ByteEncoder: 7, + KanjiEncoder: 6, + }, "codewords_total": 136, "blocks": { ErrorCorrectionLevel.M: [ @@ -705,7 +836,12 @@ "height": 15, "width": 139, "remainder_bits": 2, - "character_count_length": 7, + "character_count_indicator_length": { + NumericEncoder: 9, + AlphanumericEncoder: 8, + ByteEncoder: 7, + KanjiEncoder: 7, + }, "codewords_total": 199, "blocks": { ErrorCorrectionLevel.M: [ @@ -739,7 +875,12 @@ "height": 17, "width": 43, "remainder_bits": 1, - "character_count_length": 6, + "character_count_indicator_length": { + NumericEncoder: 7, + AlphanumericEncoder: 6, + ByteEncoder: 6, + KanjiEncoder: 5, + }, "codewords_total": 61, "blocks": { ErrorCorrectionLevel.M: [ @@ -768,7 +909,12 @@ "height": 17, "width": 59, "remainder_bits": 2, - "character_count_length": 6, + "character_count_indicator_length": { + NumericEncoder: 8, + AlphanumericEncoder: 7, + ByteEncoder: 6, + KanjiEncoder: 6, + }, "codewords_total": 88, "blocks": { ErrorCorrectionLevel.M: [ @@ -792,7 +938,12 @@ "height": 17, "width": 77, "remainder_bits": 0, - "character_count_length": 7, + "character_count_indicator_length": { + NumericEncoder: 8, + AlphanumericEncoder: 7, + ByteEncoder: 7, + KanjiEncoder: 6, + }, "codewords_total": 122, "blocks": { ErrorCorrectionLevel.M: [ @@ -821,7 +972,12 @@ "height": 17, "width": 99, "remainder_bits": 3, - "character_count_length": 7, + "character_count_indicator_length": { + NumericEncoder: 8, + AlphanumericEncoder: 8, + ByteEncoder: 7, + KanjiEncoder: 6, + }, "codewords_total": 160, "blocks": { ErrorCorrectionLevel.M: [ @@ -850,7 +1006,12 @@ "height": 17, "width": 139, "remainder_bits": 4, - "character_count_length": 8, + "character_count_indicator_length": { + NumericEncoder: 9, + AlphanumericEncoder: 8, + ByteEncoder: 8, + KanjiEncoder: 7, + }, "codewords_total": 232, "blocks": { ErrorCorrectionLevel.M: [ diff --git a/src/rmqrcode/rmqrcode.py b/src/rmqrcode/rmqrcode.py index 5117d8e..74dc670 100644 --- a/src/rmqrcode/rmqrcode.py +++ b/src/rmqrcode/rmqrcode.py @@ -18,9 +18,11 @@ import logging -from .encoder.byte_encoder import ByteEncoder +from . import encoder +from . import segments as qr_segments from .enums.color import Color from .enums.fit_strategy import FitStrategy +from .errors import DataTooLongError, IllegalVersionError, NoSegmentError from .format.alignment_pattern_coordinates import AlignmentPatternCoordinates from .format.data_capacities import DataCapacities from .format.error_correction_level import ErrorCorrectionLevel @@ -73,14 +75,17 @@ def fit(data, ecc=ErrorCorrectionLevel.M, fit_strategy=FitStrategy.BALANCED): """ logger = rMQR._init_logger() - data_length = ByteEncoder.length(data) ok_versions = [] determined_width = set() determined_height = set() logger.debug("Select rMQR Code version") for version_name, qr_version in DataCapacities.items(): - if data_length <= qr_version["capacity"]["Byte"][ecc]: + optimizer = qr_segments.SegmentOptimizer() + optimized_segments = optimizer.compute(data, version_name) + data_length = qr_segments.compute_length(optimized_segments, version_name) + + if data_length <= qr_version["number_of_data_bits"][ecc]: width, height = qr_version["width"], qr_version["height"] if width not in determined_width and height not in determined_height: determined_width.add(width) @@ -90,6 +95,7 @@ def fit(data, ecc=ErrorCorrectionLevel.M, fit_strategy=FitStrategy.BALANCED): "version": version_name, "width": width, "height": height, + "segments": optimized_segments, } ) logger.debug(f"ok: {version_name}") @@ -116,9 +122,14 @@ def sort_key(x): logger.debug(f"selected: {selected}") qr = rMQR(selected["version"], ecc) - qr.make(data) + qr.add_segments(selected["segments"]) + qr.make() return qr + def _optimized_segments(self, data): + optimizer = qr_segments.SegmentOptimizer() + return optimizer.compute(data, self.version_name()) + def __init__(self, version, ecc, with_quiet_zone=True, logger=None): self._logger = logger or rMQR._init_logger() @@ -131,24 +142,99 @@ def __init__(self, version, ecc, with_quiet_zone=True, logger=None): self._width = qr_version["width"] self._error_correction_level = ecc self._qr = [[Color.UNDEFINED for x in range(self._width)] for y in range(self._height)] + self._segments = [] - def make(self, data): - """Makes an rMQR Code for given data + def add_segment(self, data, encoder_class=encoder.ByteEncoder): + """Adds the segment. + + A segment consists of data and an encoding mode. Args: - data (str): Data string. + data (str): The data. + encoder_class (abc.ABCMeta): Pass a subclass of EncoderBase to select encoding mode. + Using ByteEncoder by default. Returns: void + + """ + self._segments.append({"data": data, "encoder_class": encoder_class}) + + def add_segments(self, segments): + for segment in segments: + self.add_segment(segment["data"], segment["encoder_class"]) + + def make(self): + """Makes an rMQR Code for stored segments. + + This method makes an rMQR Code for stored segments. Before call this, + you need add segments at least one by the add_segment method. + + Returns: + void + + Raises: + NoSegmentError: If no segment are stored. + """ + if len(self._segments) < 1: + raise NoSegmentError() + try: + encoded_data = self._encode_data() + except DataTooLongError: + raise DataTooLongError() + self._put_finder_pattern() self._put_corner_finder_pattern() self._put_alignment_pattern() self._put_timing_pattern() self._put_version_information() - mask_area = self._put_data(data) + mask_area = self._put_data(encoded_data) self._apply_mask(mask_area) + def _encode_data(self): + """Encodes the data. + + This method encodes the data for added segments. This method concatenates the + encoded data of each segments. Finally, this concatenates the terminator if possible. + + Returns: + str: The encoded data. + + """ + qr_version = rMQRVersions[self.version_name()] + data_bits_max = DataCapacities[self.version_name()]["number_of_data_bits"][self._error_correction_level] + + res = "" + for segment in self._segments: + character_count_indicator_length = qr_version["character_count_indicator_length"][segment["encoder_class"]] + res += segment["encoder_class"].encode(segment["data"], character_count_indicator_length) + res = self._append_terminator_if_possible(res, data_bits_max) + + if len(res) > data_bits_max: + raise DataTooLongError("The data is too long.") + + return res + + def _append_terminator_if_possible(self, data, data_bits_max): + """Appends the terminator. + + This method appends the terminator at the end of data and returns the + appended string. The terminator shall be omitted if the length of string + after appending the terminator greater than the rMQR code capacity. + + Args: + data: The data. + data_bits_max: The max length of data bits. + + Returns: + str: The string after appending the terminator. + + """ + if len(data) + 3 <= data_bits_max: + data += "000" + return data + def version_name(self): """Returns the version name. @@ -227,7 +313,6 @@ def to_list(self, with_quiet_zone=True): list: Converted list. """ - res = [] if with_quiet_zone: for y in range(self.QUIET_ZONE_MODULES): @@ -411,33 +496,27 @@ def _compute_version_info(self): version_information_data = version_information_data << 12 | reminder_polynomial return version_information_data - def _put_data(self, data): + def _put_data(self, encoded_data): """Symbol character placement. - This method puts data into the encoding region of the rMQR Code. Also this - method computes a two-dimensional list shows where encoding region at the + This method puts data into the encoding region of the rMQR Code. The data + should be encoded by NumericEncoder, AlphanumericEncoder, ByteEncoder or KanjiEncoder. + Also this method computes a two-dimensional list shows where encoding region at the same time. And returns the list. - See: "7.7.3 Symbol character placement" in the ISO/IEC 23941. Args: - data (str): Data string. + encoded_data (str): The data after encoding. Expected all segments are joined. Returns: list: A two-dimensional list shows where encoding region. """ - qr_version = rMQRVersions[self.version_name()] - - character_count_length = qr_version["character_count_length"] - codewords_total = qr_version["codewords_total"] - encoded_data = self._convert_to_bites_data(data, character_count_length, codewords_total) codewords = split_into_8bits(encoded_data) - if len(codewords) > codewords_total: - raise DataTooLongError("The data is too long.") - # Add the remainder codewords + qr_version = rMQRVersions[self.version_name()] + codewords_total = qr_version["codewords_total"] while True: if len(codewords) >= codewords_total: break @@ -537,15 +616,6 @@ def _split_into_blocks(self, codewords, blocks_definition): return data_codewords_per_block, rs_codewords_per_block - def _convert_to_bites_data(self, data, character_count_length, codewords_total): - encoded_data = ByteEncoder.encode(data, character_count_length) - - # Terminator (may be truncated) - if len(encoded_data) + 3 <= codewords_total * 8: - encoded_data += "000" - - return encoded_data - def _apply_mask(self, mask_area): """Data masking. @@ -591,13 +661,3 @@ def validate_version(version_name): """ return version_name in rMQRVersions - - -class DataTooLongError(ValueError): - "A class represents an error raised when the given data is too long." - pass - - -class IllegalVersionError(ValueError): - "A class represents an error raised when the given version name is illegal." - pass diff --git a/src/rmqrcode/segments.py b/src/rmqrcode/segments.py new file mode 100644 index 0000000..1ca5b40 --- /dev/null +++ b/src/rmqrcode/segments.py @@ -0,0 +1,200 @@ +from . import encoder +from .errors import DataTooLongError +from .format.rmqr_versions import rMQRVersions + +encoders = [ + encoder.NumericEncoder, + encoder.AlphanumericEncoder, + encoder.ByteEncoder, + encoder.KanjiEncoder, +] + + +def compute_length(segments, version_name): + """Computes the sum of length of the segments. + + Args: + segments (list): The list of segment. + version_name (str): The version name. + + Returns: + int: The sum of the length of the segments. + + """ + return sum( + map( + lambda s: s["encoder_class"].length( + s["data"], rMQRVersions[version_name]["character_count_indicator_length"][s["encoder_class"]] + ), + segments, + ) + ) + + +class SegmentOptimizer: + """A class for computing optimal segmentation of the given data by dynamic programming. + + Attributes: + MAX_CHARACTER (int): The maximum characters of the given data. + INF (int): Large enough value. This is used as initial value of the dynamic programming table. + + """ + + MAX_CHARACTER = 360 + INF = 100000 + + def __init__(self): + self.dp = [[[self.INF for n in range(3)] for mode in range(4)] for length in range(self.MAX_CHARACTER + 1)] + self.parents = [[[-1 for n in range(3)] for mode in range(4)] for length in range(self.MAX_CHARACTER + 1)] + + def compute(self, data, version): + """Computes the optimize segmentation for the given data. + + Args: + data (str): The data to encode. + version (str): The version name. + + Returns: + list: The list of segments. + + Raises: + rmqrcode.DataTooLongError: If the data is too long to encode. + + """ + if len(data) > self.MAX_CHARACTER: + raise DataTooLongError() + + self.qr_version = rMQRVersions[version] + self._compute_costs(data) + best_index = self._find_best(data) + path = self._reconstruct_path(best_index) + segments = self._compute_segments(path, data) + return segments + + def _compute_costs(self, data): + """Computes costs by dynamic programming. + + This method computes costs of the dynamic programming table. Define + dp[n][mode][unfilled_length] as the minimize bit length when encode only + the `n`-th leading characters which the last character is encoded in `mode` + and the remainder bits length is `unfilled_length`. + + Args: + data (str): The data to encode. + + Returns: + void + + """ + for mode in range(len(encoders)): + encoder_class = encoders[mode] + character_count_indicator_length = self.qr_version["character_count_indicator_length"][encoder_class] + self.dp[0][mode][0] = encoder_class.length("", character_count_indicator_length) + self.parents[0][mode][0] = (0, 0, 0) + + for n in range(0, len(data)): + for mode in range(4): + for unfilled_length in range(3): + if self.dp[n][mode][unfilled_length] == self.INF: + continue + + for new_mode in range(4): + if not encoders[new_mode].is_valid_characters(data[n]): + continue + + encoder_class = encoders[new_mode] + character_count_indicator_length = self.qr_version["character_count_indicator_length"][ + encoder_class + ] + if new_mode == mode: + # Keep the mode + if encoder_class == encoder.NumericEncoder: + new_length = (unfilled_length + 1) % 3 + cost = 4 if unfilled_length == 0 else 3 + elif encoder_class == encoder.AlphanumericEncoder: + new_length = (unfilled_length + 1) % 2 + cost = 6 if unfilled_length == 0 else 5 + elif encoder_class == encoder.ByteEncoder: + new_length = 0 + cost = 8 + elif encoder_class == encoder.KanjiEncoder: + new_length = 0 + cost = 13 + else: + # Change the mode + if encoder_class in [encoder.NumericEncoder, encoder.AlphanumericEncoder]: + new_length = 1 + elif encoder_class in [encoder.ByteEncoder, encoder.KanjiEncoder]: + new_length = 0 + cost = encoders[new_mode].length(data[n], character_count_indicator_length) + + if self.dp[n][mode][unfilled_length] + cost < self.dp[n + 1][new_mode][new_length]: + self.dp[n + 1][new_mode][new_length] = self.dp[n][mode][unfilled_length] + cost + self.parents[n + 1][new_mode][new_length] = (n, mode, unfilled_length) + + def _find_best(self, data): + """Find the index which has the minimum costs. + + Args: + data (str): The data to encode. + + Returns: + tuple: The best index as tuple (n, mode, unfilled_length). + + """ + best = self.INF + best_index = (-1, -1) + for mode in range(4): + for unfilled_length in range(3): + if self.dp[len(data)][mode][unfilled_length] < best: + best = self.dp[len(data)][mode][unfilled_length] + best_index = (len(data), mode, unfilled_length) + return best_index + + def _reconstruct_path(self, best_index): + """Reconstructs the path. + + Args: + best_index: The best index computed by self._find_best(). + + Returns: + list: The path of minimum cost in the dynamic programming table. + + """ + path = [] + index = best_index + while index[0] != 0: + path.append(index) + index = self.parents[index[0]][index[1]][index[2]] + path.reverse() + return path + + def _compute_segments(self, path, data): + """Computes the segments. + + This method computes the segments. The adjacent characters has same mode are merged. + + Args: + path (list): The path computed by self._reconstruct_path(). + data (str): The data to encode. + + Returns: + list: The list of segments. + + """ + segments = [] + current_segment_data = "" + current_mode = -1 + for p in path: + if current_mode == -1: + current_mode = p[1] + current_segment_data += data[p[0] - 1] + elif current_mode == p[1]: + current_segment_data += data[p[0] - 1] + else: + segments.append({"data": current_segment_data, "encoder_class": encoders[current_mode]}) + current_segment_data = data[p[0] - 1] + current_mode = p[1] + if current_mode != -1: + segments.append({"data": current_segment_data, "encoder_class": encoders[current_mode]}) + return segments diff --git a/tests/encoder/alphanumeric_encoder_test.py b/tests/encoder/alphanumeric_encoder_test.py new file mode 100644 index 0000000..1f621a2 --- /dev/null +++ b/tests/encoder/alphanumeric_encoder_test.py @@ -0,0 +1,22 @@ +from rmqrcode.encoder import AlphanumericEncoder, IllegalCharacterError + +import pytest + + +class TestAlphaNumericEncoder: + def test_encode(self): + encoded = AlphanumericEncoder.encode("AC-42", 5) + assert encoded == "010001010011100111011100111001000010" + + def test_encode_raises_invalid_character_error(self): + with pytest.raises(IllegalCharacterError) as e: + AlphanumericEncoder.encode("abc123", 5) + + def test_length(self): + encoded_length = AlphanumericEncoder.length("AC-42", 5) + assert encoded_length is 36 + + def test_is_valid_characters(self): + assert AlphanumericEncoder.is_valid_characters("AC-42") is True + assert AlphanumericEncoder.is_valid_characters("abc123") is False + assert AlphanumericEncoder.is_valid_characters("📌") is False diff --git a/tests/encoder/byte_encoder_test.py b/tests/encoder/byte_encoder_test.py new file mode 100644 index 0000000..2dfa4e4 --- /dev/null +++ b/tests/encoder/byte_encoder_test.py @@ -0,0 +1,18 @@ +from rmqrcode.encoder import ByteEncoder, IllegalCharacterError + +import pytest + + +class TestNumericEncoder: + def test_encode(self): + encoded = ByteEncoder.encode("📌", 5) + assert encoded == "0110010011110000100111111001001110001100" + + def test_length(self): + encoded_length = ByteEncoder.length("📌", 5) + assert encoded_length is 40 + + def test_is_valid_characters(self): + assert ByteEncoder.is_valid_characters("0123456789") is True + assert ByteEncoder.is_valid_characters("A1234!678@") is True + assert ByteEncoder.is_valid_characters("📌") is True diff --git a/tests/encoder/kanji_encoder_test.py b/tests/encoder/kanji_encoder_test.py new file mode 100644 index 0000000..debb7fd --- /dev/null +++ b/tests/encoder/kanji_encoder_test.py @@ -0,0 +1,22 @@ +from rmqrcode.encoder import KanjiEncoder, IllegalCharacterError + +import pytest + + +class TestKanjiEncoder: + def test_encode(self): + encoded = KanjiEncoder.encode("点茗", 5) + assert encoded == "1000001001101100111111101010101010" + + def test_encode_raises_invalid_character_error(self): + with pytest.raises(IllegalCharacterError) as e: + KanjiEncoder.encode("abc123", 5) + + def test_length(self): + encoded_length = KanjiEncoder.length("点茗", 5) + assert encoded_length is 34 + + def test_is_valid_characters(self): + assert KanjiEncoder.is_valid_characters("点茗") is True + assert KanjiEncoder.is_valid_characters("abc") is False + assert KanjiEncoder.is_valid_characters("📌") is False diff --git a/tests/encoder/numeric_encoder_test.py b/tests/encoder/numeric_encoder_test.py new file mode 100644 index 0000000..4870e19 --- /dev/null +++ b/tests/encoder/numeric_encoder_test.py @@ -0,0 +1,22 @@ +from rmqrcode.encoder import NumericEncoder, IllegalCharacterError + +import pytest + + +class TestNumericEncoder: + def test_encode(self): + encoded = NumericEncoder.encode("0123456789012345", 5) + assert encoded == "00110000000000110001010110011010100110111000010100111010100101" + + def test_encode_raises_invalid_character_error(self): + with pytest.raises(IllegalCharacterError) as e: + NumericEncoder.encode("ABC123", 5) + + def test_length(self): + encoded_length = NumericEncoder.length("0123456789012345", 5) + assert encoded_length is 62 + + def test_is_valid_characters(self): + assert NumericEncoder.is_valid_characters("0123456789") is True + assert NumericEncoder.is_valid_characters("A1234!678@") is False + assert NumericEncoder.is_valid_characters("📌") is False diff --git a/tests/rmqrcode_test.py b/tests/rmqrcode_test.py index c075f77..58d4361 100644 --- a/tests/rmqrcode_test.py +++ b/tests/rmqrcode_test.py @@ -1,7 +1,11 @@ -from rmqrcode import rMQR -from rmqrcode import ErrorCorrectionLevel -from rmqrcode import DataTooLongError -from rmqrcode import IllegalVersionError +from rmqrcode import ( + rMQR, + encoder, + ErrorCorrectionLevel, + DataTooLongError, + IllegalVersionError, + NoSegmentError, +) import pytest @@ -11,8 +15,9 @@ def test_fit(self): qr = rMQR.fit("abc") def test_make(self): - qr = rMQR('R13x99', ErrorCorrectionLevel.M) - qr.make("abc") + qr = rMQR("R13x99", ErrorCorrectionLevel.M) + qr.add_segment("abc") + qr.make() assert len(qr.to_list(with_quiet_zone=True)) is 17 assert len(qr.to_list(with_quiet_zone=True)[0]) is 103 @@ -20,10 +25,66 @@ def test_make(self): assert len(qr.to_list(with_quiet_zone=False)) is 13 assert len(qr.to_list(with_quiet_zone=False)[0]) is 99 - def test_raise_too_long_error(self): + def test_raise_no_segment_error(self): + with pytest.raises(NoSegmentError) as e: + qr = rMQR("R13x99", ErrorCorrectionLevel.M) + qr.make() + + def test_can_make_max_length_numeric_encoder(self): + s = "1" * 361 + qr = rMQR("R17x139", ErrorCorrectionLevel.M) + qr.add_segment(s, encoder_class=encoder.NumericEncoder) + qr.make() + + def test_raise_too_long_error_numeric_encoder(self): + with pytest.raises(DataTooLongError) as e: + s = "1" * 362 + qr = rMQR("R17x139", ErrorCorrectionLevel.M) + qr.add_segment(s, encoder_class=encoder.NumericEncoder) + qr.make() + + def test_can_make_max_length_alphanumeric_encoder(self): + s = "A" * 219 + qr = rMQR("R17x139", ErrorCorrectionLevel.M) + qr.add_segment(s, encoder_class=encoder.AlphanumericEncoder) + qr.make() + + def test_raise_too_long_error_alphanumeric_encoder(self): + with pytest.raises(DataTooLongError) as e: + s = "A" * 220 + qr = rMQR("R17x139", ErrorCorrectionLevel.M) + qr.add_segment(s, encoder_class=encoder.AlphanumericEncoder) + qr.make() + + def test_can_make_max_length_byte_encoder(self): + s = "a" * 150 + qr = rMQR("R17x139", ErrorCorrectionLevel.M) + qr.add_segment(s, encoder_class=encoder.ByteEncoder) + qr.make() + + def test_raise_too_long_error_byte_encoder(self): + with pytest.raises(DataTooLongError) as e: + s = "a" * 151 + qr = rMQR("R17x139", ErrorCorrectionLevel.M) + qr.add_segment(s, encoder_class=encoder.ByteEncoder) + qr.make() + + def test_can_make_max_length_kanji_encoder(self): + s = "漢" * 92 + qr = rMQR("R17x139", ErrorCorrectionLevel.M) + qr.add_segment(s, encoder_class=encoder.KanjiEncoder) + qr.make() + + def test_raise_too_long_error_kanji_encoder(self): + with pytest.raises(DataTooLongError) as e: + s = "漢" * 93 + qr = rMQR("R17x139", ErrorCorrectionLevel.M) + qr.add_segment(s, encoder_class=encoder.KanjiEncoder) + qr.make() + + def test_raise_too_long_error_fit(self): with pytest.raises(DataTooLongError) as e: - s = "a".ljust(200, "a") - rMQR.fit(s) + rMQR.fit("a" * 200) def test_raise_invalid_version_error(self): with pytest.raises(IllegalVersionError) as e: diff --git a/tests/segments_test.py b/tests/segments_test.py new file mode 100644 index 0000000..073d230 --- /dev/null +++ b/tests/segments_test.py @@ -0,0 +1,18 @@ +from rmqrcode.segments import SegmentOptimizer, compute_length +from rmqrcode import encoder +import pytest + + +class TestSegments: + def test_can_optimize_segments(self): + optimizer = SegmentOptimizer() + segments = optimizer.compute("123Abc", "R7x43") + assert segments == [ + {"data": "123", "encoder_class": encoder.NumericEncoder}, + {"data": "Abc", "encoder_class": encoder.ByteEncoder}, + ] + + def test_compute_length(self): + optimizer = SegmentOptimizer() + segments = optimizer.compute("123Abc", "R7x43") + assert compute_length(segments, "R7x43") is 47