diff --git a/.github/workflows/pythonpackage-windows.yml b/.github/workflows/pythonpackage-windows.yml index bf51b33..edd1bd9 100644 --- a/.github/workflows/pythonpackage-windows.yml +++ b/.github/workflows/pythonpackage-windows.yml @@ -19,7 +19,7 @@ jobs: with: submodules: 'recursive' - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5.0.0 + uses: actions/setup-python@v5.1.1 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -44,12 +44,14 @@ jobs: env: ESCPOS_CAPABILITIES_FILE: D:\a\python-escpos\python-escpos\capabilities-data\dist\capabilities.json - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: - directory: ./coverage/reports/ env_vars: OS,PYTHON fail_ci_if_error: true - files: ./coverage.xml,!./cache + files: ./coverage.xml + exclude: "**/.mypy_cache" flags: unittests name: coverage-tox-${{ matrix.python-version }} verbose: true diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 4fb2bf4..992465e 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -22,7 +22,7 @@ jobs: with: submodules: 'recursive' - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5.0.0 + uses: actions/setup-python@v5.1.1 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -54,12 +54,14 @@ jobs: env: ESCPOS_CAPABILITIES_FILE: /home/runner/work/python-escpos/python-escpos/capabilities-data/dist/capabilities.json - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: - directory: ./coverage/reports/ env_vars: OS,PYTHON fail_ci_if_error: true - files: ./coverage.xml,!./cache + files: ./coverage.xml + exclude: "**/.mypy_cache" flags: unittests name: coverage-tox-${{ matrix.python-version }} verbose: true diff --git a/README.rst b/README.rst index 0e2121c..cfcebc2 100644 --- a/README.rst +++ b/README.rst @@ -100,4 +100,4 @@ Disclaimer None of the vendors cited in this project agree or endorse any of the patterns or implementations. -Its names are used only to maintain context. +Their names are used only to maintain context. diff --git a/examples/docker-flask/requirements.txt b/examples/docker-flask/requirements.txt index 18d5582..ce8f94e 100644 --- a/examples/docker-flask/requirements.txt +++ b/examples/docker-flask/requirements.txt @@ -4,9 +4,9 @@ blinker==1.6.2 click==8.1.3 Flask==2.3.2 itsdangerous==2.1.2 -Jinja2==3.1.3 +Jinja2==3.1.4 MarkupSafe==2.1.2 -Pillow==10.2.0 +Pillow==10.3.0 pycups==2.0.1 pypng==0.20220715.0 pyserial==3.5 @@ -17,4 +17,4 @@ PyYAML==6.0 qrcode==7.4.2 six==1.16.0 typing_extensions==4.5.0 -Werkzeug==3.0.1 \ No newline at end of file +Werkzeug==3.0.3 \ No newline at end of file diff --git a/src/escpos/cli.py b/src/escpos/cli.py index f70d922..2906c16 100644 --- a/src/escpos/cli.py +++ b/src/escpos/cli.py @@ -209,6 +209,38 @@ ESCPOS_COMMANDS: List[Dict[str, Any]] = [ }, ], }, + { + "parser": { + "name": "software_columns", + "help": "Print a list of texts arranged into columns", + }, + "defaults": { + "func": "software_columns", + }, + "arguments": [ + { + "option_strings": ("--text_list",), + "help": "list of texts to print", + "nargs": "+", + "type": str, + "required": True, + }, + { + "option_strings": ("--widths",), + "help": "list of column widths", + "nargs": "+", + "type": int, + "required": True, + }, + { + "option_strings": ("--align",), + "help": "list of column alignments", + "nargs": "+", + "type": str, + "required": True, + }, + ], + }, { "parser": { "name": "cut", diff --git a/src/escpos/config.py b/src/escpos/config.py index 17a2328..9ca5b7e 100644 --- a/src/escpos/config.py +++ b/src/escpos/config.py @@ -76,7 +76,18 @@ class Config: if "printer" in config: self._printer_config = config["printer"] - self._printer_name = self._printer_config.pop("type").title() + printer_name = self._printer_config.pop("type") + class_names = { + "usb": "Usb", + "serial": "Serial", + "network": "Network", + "file": "File", + "dummy": "Dummy", + "cupsprinter": "CupsPrinter", + "lp": "LP", + "win32raw": "Win32Raw", + } + self._printer_name = class_names.get(printer_name.lower(), printer_name) if not self._printer_name or not hasattr(printer, self._printer_name): raise exceptions.ConfigSyntaxError( diff --git a/src/escpos/escpos.py b/src/escpos/escpos.py index 945dd63..cc841ca 100644 --- a/src/escpos/escpos.py +++ b/src/escpos/escpos.py @@ -108,6 +108,8 @@ SW_BARCODE_NAMES = { for name in barcode.PROVIDED_BARCODES } +Alignment = Union[Literal["center", "left", "right"], str] + class Escpos(object, metaclass=ABCMeta): """ESC/POS Printer object. @@ -899,6 +901,115 @@ class Escpos(object, metaclass=ABCMeta): col_count = self.profile.get_columns(font) if columns is None else columns self.text(textwrap.fill(txt, col_count)) + @staticmethod + def _padding( + text: str, + width: int, + align: Alignment = "center", + ) -> str: + """Add fill space to meet the width. + + The align parameter sets the alignment of the text in space. + """ + align = align.lower() + if align == "center": + text = f"{text:^{width}}" + elif align == "left": + text = f"{text:<{width}}" + elif align == "right": + text = f"{text:>{width}}" + + return text + + @staticmethod + def _truncate(text: str, width: int, placeholder: str = ".") -> str: + """Truncate an string at a max width or leave it untouched. + + Add a placeholder at the end of the output text if it has been truncated. + """ + ph_len = len(placeholder) + max_len = width - ph_len + return f"{text[:max_len]}{placeholder}" if len(text) > width else text + + @staticmethod + def _repeat_last(iterable, max_iterations: int = 1000): + """Iterate over the items of a list repeating the last one until max_iterations.""" + i = 0 + while i < max_iterations: + try: + yield iterable[i] + except IndexError: + yield iterable[-1] + i += 1 + + def _rearrange_into_cols(self, text_list: list, widths: list[int]) -> list: + """Wrap and convert a list of strings into an array of text columns. + + Set the width of each column by passing a list of widths. + Wrap if possible and|or truncate strings longer than its column width. + Reorder the wrapped items into an array of text columns. + """ + n_cols = len(text_list) + wrapped = [ + textwrap.wrap(text, widths[i], break_long_words=False) + for i, text in enumerate(text_list) + ] + max_len = max(*[len(text_group) for text_group in wrapped]) + text_colums = [] + for i in range(max_len): + row = ["" for _ in range(n_cols)] + for j, item in enumerate(wrapped): + if i in range(len(item)): + row[j] = self._truncate(item[i], widths[j]) + text_colums.append(row) + return text_colums + + def _add_padding_into_cols( + self, + text_list: list[str], + widths: list[int], + align: list[Alignment], + ) -> list: + """Add padding, width and alignment into the items of a list of strings.""" + return [ + self._padding(text, widths[i], align[i]) for i, text in enumerate(text_list) + ] + + def software_columns( + self, + text_list: list, + widths: Union[list[int], int], + align: Union[list[Alignment], Alignment], + ) -> None: + """Print a list of strings arranged horizontally in columns. + + :param text_list: list of strings, each item in the list will be printed as a column. + + :param widths: width of each column by passing a list of widths, + or a single total width to arrange columns of the same size. + If the list of width items is shorter than the list of strings then + the last width of the list will be applied till the last string (column). + + :param align: alignment of the text into each column by passing a list of alignments, + or a single alignment for all the columns. + If the list of alignment items is shorter than the list of strings then + the last alignment of the list will be applied till the last string (column). + """ + n_cols = len(text_list) + + if isinstance(widths, int): + widths = [round(widths / n_cols)] + widths = list(self._repeat_last(widths, max_iterations=n_cols)) + + if isinstance(align, str): + align = [align] + align = list(self._repeat_last(align, max_iterations=n_cols)) + + columns = self._rearrange_into_cols(text_list, widths) + for row in columns: + padded = self._add_padding_into_cols(row, widths, align) + self.textln("".join(padded)) + def set( self, align: Optional[str] = None, @@ -936,8 +1047,8 @@ class Escpos(object, metaclass=ABCMeta): :param double_width: doubles the width of the text :param custom_size: uses custom size specified by width and height parameters. Cannot be used with double_width or double_height. - :param width: text width multiplier when custom_size is used, decimal range 1-8 - :param height: text height multiplier when custom_size is used, decimal range 1-8 + :param width: requires custom_size=True, text width multiplier when custom_size is used, decimal range 1-8 + :param height: requires custom_size=True, text height multiplier when custom_size is used, decimal range 1-8 :param density: print density, value from 0-8, if something else is supplied the density remains unchanged :param invert: True enables white on black printing :param smooth: True enables text smoothing. Effective on 4x4 size text and larger