From a68c494439714562bed17c068473b40d26fc6759 Mon Sep 17 00:00:00 2001 From: greyllmmoder Date: Sun, 5 Apr 2026 12:58:17 +0530 Subject: [PATCH 1/2] feat: bootstrap main with production-ready aztec-py codebase --- .github/workflows/ci.yml | 70 +++ .gitignore | 89 ++++ CHANGELOG.md | 21 + CONTRIBUTORS.md | 28 + LICENSE.upstream | 21 + MANIFEST.in | 5 + PRODUCTION_CHECKLIST.md | 46 ++ README.md | 198 ++++++- aztec_code_generator.py | 12 + aztec_py/__init__.py | 39 ++ aztec_py/__main__.py | 70 +++ aztec_py/compat.py | 152 ++++++ aztec_py/core.py | 922 +++++++++++++++++++++++++++++++++ aztec_py/decode.py | 34 ++ aztec_py/error_correction.py | 5 + aztec_py/gs1.py | 65 +++ aztec_py/matrix.py | 5 + aztec_py/renderers/__init__.py | 6 + aztec_py/renderers/image.py | 41 ++ aztec_py/renderers/svg.py | 82 +++ aztec_py/rune.py | 113 ++++ pyproject.toml | 92 ++++ requirements-test.txt | 4 + requirements.txt | 0 scripts/decoder_matrix.py | 168 ++++++ tests/compat/fixtures.json | 86 +++ tests/conftest.py | 1 + tests/test_api_behaviour.py | 49 ++ tests/test_cli.py | 43 ++ tests/test_compat_matrix.py | 60 +++ tests/test_core.py | 218 ++++++++ tests/test_decode.py | 43 ++ tests/test_gs1.py | 45 ++ tests/test_modules.py | 15 + tests/test_property.py | 21 + tests/test_renderers.py | 42 ++ tests/test_rune.py | 53 ++ tests/test_validation.py | 25 + 38 files changed, 2983 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTORS.md create mode 100644 LICENSE.upstream create mode 100644 MANIFEST.in create mode 100644 PRODUCTION_CHECKLIST.md create mode 100755 aztec_code_generator.py create mode 100644 aztec_py/__init__.py create mode 100644 aztec_py/__main__.py create mode 100644 aztec_py/compat.py create mode 100755 aztec_py/core.py create mode 100644 aztec_py/decode.py create mode 100644 aztec_py/error_correction.py create mode 100644 aztec_py/gs1.py create mode 100644 aztec_py/matrix.py create mode 100644 aztec_py/renderers/__init__.py create mode 100644 aztec_py/renderers/image.py create mode 100644 aztec_py/renderers/svg.py create mode 100644 aztec_py/rune.py create mode 100644 pyproject.toml create mode 100644 requirements-test.txt create mode 100644 requirements.txt create mode 100644 scripts/decoder_matrix.py create mode 100644 tests/compat/fixtures.json create mode 100644 tests/conftest.py create mode 100644 tests/test_api_behaviour.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_compat_matrix.py create mode 100644 tests/test_core.py create mode 100644 tests/test_decode.py create mode 100644 tests/test_gs1.py create mode 100644 tests/test_modules.py create mode 100644 tests/test_property.py create mode 100644 tests/test_renderers.py create mode 100644 tests/test_rune.py create mode 100644 tests/test_validation.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b410d31 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: ci + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install ".[dev,image,pdf]" + + - name: Run tests + run: | + pytest -q + + quality: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install ".[dev,image,pdf]" + + - name: Lint + run: | + ruff check . + + - name: Type check + run: | + mypy --strict aztec_py + + build: + runs-on: ubuntu-latest + needs: quality + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Build package + run: | + python -m pip install build + python -m build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72364f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,89 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d324bd2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +## [1.0.0] - 2026-04-05 + +### Fixed +- CRLF encoding coverage and regressions aligned with upstream issue #5. +- Error-correction capacity sizing bug fixed for worst-case stuffing payloads (upstream issue #7). + +### Added +- Package split into `aztec_py` with modern `pyproject.toml` packaging. +- SVG output support based on upstream PR #6 by Zazzik1. +- CLI entry point (`aztec`) and `python -m aztec_py` runner. +- PDF output (`AztecCode.pdf()` and extension-aware `save()` behavior). +- `AztecRune` support for 0..255 values. +- Optional decode helper (`aztec_py.decode`) backed by `python-zxing`. +- Property-based tests with Hypothesis. +- Input validation for public API boundaries. + +### Changed +- Project metadata updated for fork lineage visibility. +- Added explicit attribution artifacts: `LICENSE.upstream` and `CONTRIBUTORS.md`. diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000..c3b6932 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,28 @@ +# Contributors & Lineage + +## Authors +- Originally written by Dmitry Alimov (delimitry). +- Updates, bug fixes, and active maintenance in this fork by greyllmmoder. + +## Current maintainer +- greyllmmoder - https://github.com/greyllmmoder + +## Upstream lineage (MIT license chain) +This project is a fork of **dlenski/aztec_code_generator** +(https://github.com/dlenski/aztec_code_generator), which is itself +a fork of **delimitry/aztec_code_generator** +(https://github.com/delimitry/aztec_code_generator). + +Both upstream projects are MIT licensed. The original upstream license +text is preserved in `LICENSE.upstream`. + +## What changed from upstream +- Fixed: CRLF encoding crash (upstream issue #5) +- Fixed: Error correction capacity calculation (upstream issue #7) +- Added: SVG renderer (based on upstream PR #6) +- Added: Type hints, docstrings, CLI, PDF output, Rune mode +- Restructured: Single-file module split into package + +## Third-party contributions incorporated +- SVG support: originally authored by Zazzik1 + (https://github.com/dlenski/aztec_code_generator/pull/6), MIT license diff --git a/LICENSE.upstream b/LICENSE.upstream new file mode 100644 index 0000000..87a92fe --- /dev/null +++ b/LICENSE.upstream @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Dmitry Alimov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..a3ada18 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include README.md +include requirements.txt +include LICENSE +include LICENSE.upstream +include CONTRIBUTORS.md diff --git a/PRODUCTION_CHECKLIST.md b/PRODUCTION_CHECKLIST.md new file mode 100644 index 0000000..e6202a2 --- /dev/null +++ b/PRODUCTION_CHECKLIST.md @@ -0,0 +1,46 @@ +# Production Checklist + +Use this checklist before shipping a new `aztec-py` version to production. + +## 1. Pre-Release Validation + +- [ ] `python -m pytest -q` +- [ ] `python -m ruff check .` +- [ ] `python -m mypy --strict aztec_py` +- [ ] `python -m build` +- [ ] `python scripts/decoder_matrix.py --report compat_matrix_report.md` +- [ ] If decode runtime is available in CI: `python scripts/decoder_matrix.py --strict-decode` + +## 2. Runtime Optional Dependencies + +- [ ] Verify `aztec-py[pdf]` installs and `AztecCode(...).pdf()` works. +- [ ] Verify `aztec-py[decode]` behavior: + - [ ] successful decode path when Java + ZXing are available + - [ ] clear error path when runtime/dependency is missing + +## 3. GS1 Integration Readiness + +- [ ] Build GS1 payloads using `GS1Element` + `build_gs1_payload`. +- [ ] Ensure variable-length elements are marked with `variable_length=True`. +- [ ] Confirm payloads with scanner app/hardware in target environment. + +## 4. Release Hygiene + +- [ ] Update `CHANGELOG.md`. +- [ ] Confirm `README.md` examples still execute. +- [ ] Verify version metadata in `pyproject.toml`. +- [ ] Build artifacts from clean working tree. + +## 5. Post-Release Smoke Checks + +- [ ] Install built wheel in fresh venv. +- [ ] `python -c "import aztec_py; print(aztec_py.__all__)"` +- [ ] `aztec "Hello" --format terminal` +- [ ] `aztec "Hello" --format svg > smoke.svg` +- [ ] `aztec "Hello" --format png --output smoke.png` + +## 6. Incident Guardrails + +- [ ] Keep compatibility fixture failures as release blockers. +- [ ] Log scanner model/runtime for each production decode issue. +- [ ] Add a regression fixture for every production bug before patching. diff --git a/README.md b/README.md index 364baf0..3fa8e2b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,196 @@ -# python-aztec +# aztec-py -MIT-licensed Aztec code project. +[![CI](https://github.com/greyllmmoder/python-aztec/actions/workflows/ci.yml/badge.svg)](https://github.com/greyllmmoder/python-aztec/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/aztec-py.svg)](https://pypi.org/project/aztec-py/) +[![Coverage](https://img.shields.io/badge/coverage-%3E=90%25-brightgreen)](https://github.com/greyllmmoder/python-aztec) +[![mypy](https://img.shields.io/badge/type_checked-mypy-blue)](https://mypy-lang.org/) + +Pure-Python Aztec Code 2D barcode generator. + +Forked from [`dlenski/aztec_code_generator`](https://github.com/dlenski/aztec_code_generator) +with production fixes, package modernization, SVG/PDF output, CLI tooling, and active maintenance. + +## What Is Aztec Code? + +Aztec Code is a compact 2D barcode format standardized in ISO/IEC 24778. It can encode text or +binary payloads with configurable error correction, and it does not require a quiet zone. + +## Install + +```bash +pip install aztec-py +``` + +Optional extras: + +```bash +pip install "aztec-py[pdf]" # PDF output +pip install "aztec-py[decode]" # Decode utility via python-zxing + Java +pip install "aztec-py[svg]" # lxml-backed SVG workflows (optional) +``` + +## Quick Start + +```python +from aztec_py import AztecCode + +code = AztecCode("Hello World") +code.save("hello.png", module_size=4) +print(code.svg()) +``` + +## Production Validation + +Run compatibility fixtures and generate a markdown report: + +```bash +python scripts/decoder_matrix.py --report compat_matrix_report.md +``` + +The script is skip-safe when decode runtime requirements (`zxing` + Java) are unavailable. +Use strict mode when decode checks are mandatory in CI: + +```bash +python scripts/decoder_matrix.py --strict-decode +``` + +Fixture source: `tests/compat/fixtures.json` +Release checklist: `PRODUCTION_CHECKLIST.md` + +## CLI + +```bash +aztec "Hello World" --format terminal +aztec "Hello World" --format svg > code.svg +aztec "Hello World" --format png --module-size 4 --output code.png +aztec --ec 33 --charset ISO-8859-1 "Héllo" --format svg --output code.svg +``` + +Supported flags: + +- `--format/-f`: `svg`, `png`, `terminal` (default: `terminal`) +- `--module-size/-m`: module size in pixels (default: `1`) +- `--ec`: error correction percent (default: `23`) +- `--charset`: text charset/ECI hint (default: `UTF-8`) +- `--output/-o`: output file path (required for `png`) + +## API Reference + +### `AztecCode` + +- `AztecCode(data, ec_percent=23, encoding=None, charset=None, size=None, compact=None)` +- `image(module_size=2, border=0)` +- `svg(module_size=1, border=1) -> str` +- `pdf(module_size=3, border=1) -> bytes` +- `save(path, module_size=2, border=0, format=None)` +- `print_out(border=0)` +- `print_fancy(border=0)` + +### `AztecRune` + +- `AztecRune(value)` where `value` is in `0..255` +- `image()`, `svg()`, `save(...)` + +### GS1 Payload Helper + +- `GS1Element(ai, data, variable_length=False)` +- `build_gs1_payload([...]) -> str` +- `GROUP_SEPARATOR` (`\x1d`) + +Example: + +```python +from aztec_py import AztecCode, GS1Element, build_gs1_payload + +payload = build_gs1_payload( + [ + GS1Element("01", "03453120000011"), + GS1Element("17", "120508"), + GS1Element("10", "ABCD1234", variable_length=True), + GS1Element("410", "9501101020917"), + ] +) +AztecCode(payload).save("gs1.png", module_size=4) +``` + +### Decode Utility + +```python +from aztec_py import AztecCode, decode + +code = AztecCode("test data") +decoded = decode(code.image()) +``` + +Requires `pip install "aztec-py[decode]"` and a Java runtime. + +## GS1 Recipes + +```python +from aztec_py import GS1Element, build_gs1_payload +``` + +1. GTIN + Expiry (fixed-length only) +```python +build_gs1_payload([ + GS1Element("01", "03453120000011"), + GS1Element("17", "120508"), +]) +``` +2. GTIN + Batch/Lot + Ship To (variable field adds separator) +```python +build_gs1_payload([ + GS1Element("01", "03453120000011"), + GS1Element("10", "ABCD1234", variable_length=True), + GS1Element("410", "9501101020917"), +]) +``` +3. GTIN + Serial +```python +build_gs1_payload([ + GS1Element("01", "03453120000011"), + GS1Element("21", "SERIAL0001", variable_length=True), +]) +``` +4. GTIN + Weight (kg) +```python +build_gs1_payload([ + GS1Element("01", "03453120000011"), + GS1Element("3103", "000750"), +]) +``` +5. GTIN + Expiry + Serial + Destination +```python +build_gs1_payload([ + GS1Element("01", "03453120000011"), + GS1Element("17", "120508"), + GS1Element("21", "XYZ12345", variable_length=True), + GS1Element("410", "9501101020917"), +]) +``` + +## Comparison + +| Library | Pure Python encode | SVG | CLI | Rune | PDF | Notes | +|---|---:|---:|---:|---:|---:|---| +| `aztec-py` | Yes | Yes | Yes | Yes | Yes | Active fork with bugfixes | +| `dlenski/aztec_code_generator` | Yes | PR pending | No | No | No | Upstream fork | +| `delimitry/aztec_code_generator` | Yes | No | No | No | No | Original lineage | +| `treepoem` | No (BWIPP/Ghostscript backend) | Via backend | No | Backend-dependent | Via backend | Wrapper-based | +| Aspose Barcode | No (proprietary) | Yes | N/A | Yes | Yes | Commercial SDK | + +## Lineage and Attribution + +- Originally written by Dmitry Alimov (delimitry); this fork's updates and bug fixes are maintained by greyllmmoder. +- Upstream fork source: https://github.com/dlenski/aztec_code_generator +- Original project: https://github.com/delimitry/aztec_code_generator +- License chain: MIT (`LICENSE` and `LICENSE.upstream`) +- SVG support based on upstream PR #6 by Zazzik1: + https://github.com/dlenski/aztec_code_generator/pull/6 ## Contributing -- Contribution guide: `CONTRIBUTING.md` -- Code of conduct: `CODE_OF_CONDUCT.md` -- Security policy: `SECURITY.md` -- PR template and issue templates are available under `.github/` +- Read contribution guidelines: `CONTRIBUTING.md` +- Security reporting process: `SECURITY.md` +- Production release checks: `PRODUCTION_CHECKLIST.md` +- To sync standard labels (after `gh auth login`): `./scripts/sync_labels.sh` diff --git a/aztec_code_generator.py b/aztec_code_generator.py new file mode 100755 index 0000000..27b6beb --- /dev/null +++ b/aztec_code_generator.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Backward-compatible import shim for the legacy module path.""" + +from aztec_py.core import * # noqa: F401,F403 +from aztec_py.core import main + + +if __name__ == '__main__': + import sys + + main(sys.argv) diff --git a/aztec_py/__init__.py b/aztec_py/__init__.py new file mode 100644 index 0000000..9068669 --- /dev/null +++ b/aztec_py/__init__.py @@ -0,0 +1,39 @@ +"""Public package API for aztec-py.""" + +from .core import ( + AztecCode, + Latch, + Misc, + Mode, + Shift, + encoding_to_eci, + find_optimal_sequence, + find_suitable_matrix_size, + get_data_codewords, + get_config_from_table, + optimal_sequence_to_bits, + reed_solomon, +) +from .decode import decode +from .gs1 import GROUP_SEPARATOR, GS1Element, build_gs1_payload +from .rune import AztecRune + +__all__ = [ + 'AztecCode', + 'Latch', + 'Misc', + 'Mode', + 'Shift', + 'encoding_to_eci', + 'find_optimal_sequence', + 'find_suitable_matrix_size', + 'get_data_codewords', + 'get_config_from_table', + 'optimal_sequence_to_bits', + 'reed_solomon', + 'AztecRune', + 'decode', + 'GROUP_SEPARATOR', + 'GS1Element', + 'build_gs1_payload', +] diff --git a/aztec_py/__main__.py b/aztec_py/__main__.py new file mode 100644 index 0000000..624dbb8 --- /dev/null +++ b/aztec_py/__main__.py @@ -0,0 +1,70 @@ +"""CLI entry point for ``aztec`` and ``python -m aztec_py``.""" + +from __future__ import annotations + +import argparse +import sys +from typing import Sequence + +from . import AztecCode + + +def build_parser() -> argparse.ArgumentParser: + """Build CLI argument parser.""" + parser = argparse.ArgumentParser(prog="aztec", description="Generate Aztec code symbols.") + parser.add_argument("data", help="Payload to encode.") + parser.add_argument( + "-f", + "--format", + choices=("svg", "png", "terminal"), + default="terminal", + help="Output format (default: terminal).", + ) + parser.add_argument( + "-m", + "--module-size", + type=int, + default=1, + help="Module size in pixels (default: 1).", + ) + parser.add_argument("--ec", type=int, default=23, help="Error correction percent (default: 23).") + parser.add_argument( + "--charset", + default="UTF-8", + help="Character set for string input (default: UTF-8).", + ) + parser.add_argument( + "-o", + "--output", + help="Output path. Required for png output; optional for svg.", + ) + return parser + + +def cli_main(argv: Sequence[str] | None = None) -> int: + """Run CLI and return process exit code.""" + parser = build_parser() + args = parser.parse_args(list(argv) if argv is not None else None) + + code = AztecCode(args.data, ec_percent=args.ec, charset=args.charset) + if args.format == "terminal": + code.print_fancy(border=2) + return 0 + + if args.format == "png": + if not args.output: + parser.error("--output is required when --format png is selected") + code.save(args.output, module_size=args.module_size, format="PNG") + return 0 + + svg = code.svg(module_size=args.module_size) + if args.output: + with open(args.output, "w", encoding="utf-8") as file_obj: + file_obj.write(svg) + else: + sys.stdout.write(svg) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(cli_main()) diff --git a/aztec_py/compat.py b/aztec_py/compat.py new file mode 100644 index 0000000..2d4db4a --- /dev/null +++ b/aztec_py/compat.py @@ -0,0 +1,152 @@ +"""Compatibility fixture loading utilities for production validation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import json +from typing import cast + + +@dataclass(frozen=True) +class CompatCase: + """Single compatibility validation case.""" + + case_id: str + payload: str | bytes + ec_percent: int + charset: str + decode_expected: bool + tags: tuple[str, ...] + + +def _as_dict(value: object, message: str) -> dict[str, object]: + if not isinstance(value, dict): + raise ValueError(message) + return cast(dict[str, object], value) + + +def _as_str(value: object, message: str) -> str: + if not isinstance(value, str): + raise ValueError(message) + return value + + +def _as_non_empty_str(value: object, message: str) -> str: + text = _as_str(value, message) + if not text: + raise ValueError(message) + return text + + +def _as_int(value: object, message: str) -> int: + if not isinstance(value, int): + raise ValueError(message) + return value + + +def _as_bool(value: object, message: str) -> bool: + if not isinstance(value, bool): + raise ValueError(message) + return value + + +def _as_tags(value: object, case_id: str) -> tuple[str, ...]: + if not isinstance(value, list): + raise ValueError(f"Case '{case_id}': tags must be a list") + tags: list[str] = [] + for tag in value: + if not isinstance(tag, str) or not tag: + raise ValueError(f"Case '{case_id}': each tag must be a non-empty string") + tags.append(tag) + return tuple(tags) + + +def _decode_payload(raw: object, case_id: str) -> str | bytes: + payload_obj = _as_dict(raw, f"Case '{case_id}': payload must be an object") + kind = _as_str(payload_obj.get("kind"), f"Case '{case_id}': payload.kind must be a string") + + if kind == "text": + return _as_str(payload_obj.get("value"), f"Case '{case_id}': text payload.value must be a string") + + if kind == "bytes_hex": + value = _as_str(payload_obj.get("value"), f"Case '{case_id}': bytes_hex payload.value must be a string") + if len(value) % 2 != 0: + raise ValueError(f"Case '{case_id}': bytes_hex payload must have even length") + try: + return bytes.fromhex(value) + except ValueError as exc: + raise ValueError(f"Case '{case_id}': bytes_hex payload is invalid") from exc + + if kind == "bytes_repeat": + byte_value = _as_str(payload_obj.get("byte"), f"Case '{case_id}': bytes_repeat byte must be a string") + count = _as_int(payload_obj.get("count"), f"Case '{case_id}': bytes_repeat count must be an integer") + if not 0 <= count <= 5000: + raise ValueError(f"Case '{case_id}': bytes_repeat count out of range") + try: + unit = bytes.fromhex(byte_value) + except ValueError as exc: + raise ValueError(f"Case '{case_id}': bytes_repeat byte must be valid hex") from exc + if len(unit) != 1: + raise ValueError(f"Case '{case_id}': bytes_repeat byte must represent exactly one byte") + return unit * count + + raise ValueError(f"Case '{case_id}': unsupported payload kind '{kind}'") + + +def load_compat_cases(path: str | Path) -> list[CompatCase]: + """Load compatibility cases from JSON fixtures. + + Args: + path: Path to JSON fixture file. + + Returns: + Parsed and validated compatibility cases. + + Raises: + ValueError: If fixture file contains invalid schema. + """ + fixture_path = Path(path) + with fixture_path.open("r", encoding="utf-8") as handle: + raw_data = json.load(handle) + + root = _as_dict(raw_data, "Fixture root must be an object") + raw_cases = root.get("cases") + if not isinstance(raw_cases, list): + raise ValueError("Fixture root must contain a 'cases' list") + + parsed_cases: list[CompatCase] = [] + seen: set[str] = set() + for index, raw_case in enumerate(raw_cases): + case_obj = _as_dict(raw_case, f"Case #{index}: must be an object") + + case_id = _as_non_empty_str(case_obj.get("id"), f"Case #{index}: 'id' must be a non-empty string") + if case_id in seen: + raise ValueError(f"Duplicate case id '{case_id}'") + seen.add(case_id) + + payload = _decode_payload(case_obj.get("payload"), case_id) + + ec_percent = _as_int(case_obj.get("ec_percent", 23), f"Case '{case_id}': ec_percent must be an integer") + if not 5 <= ec_percent <= 95: + raise ValueError(f"Case '{case_id}': ec_percent must be between 5 and 95") + + charset = _as_non_empty_str(case_obj.get("charset", "UTF-8"), f"Case '{case_id}': charset must be non-empty") + decode_expected = _as_bool( + case_obj.get("decode_expected", isinstance(payload, str)), + f"Case '{case_id}': decode_expected must be a boolean", + ) + tags = _as_tags(case_obj.get("tags", []), case_id) + + parsed_cases.append( + CompatCase( + case_id=case_id, + payload=payload, + ec_percent=ec_percent, + charset=charset, + decode_expected=decode_expected, + tags=tags, + ) + ) + + return parsed_cases diff --git a/aztec_py/core.py b/aztec_py/core.py new file mode 100755 index 0000000..722dc67 --- /dev/null +++ b/aztec_py/core.py @@ -0,0 +1,922 @@ +#!/usr/bin/env python3 +#-*- coding: utf-8 -*- +""" + aztec_code_generator + ~~~~~~~~~~~~~~~~~~~~ + + Aztec code generator. + + :copyright: (c) 2016-2018 by Dmitry Alimov. + :license: The MIT License (MIT), see LICENSE for more details. +""" + +import math +import numbers +import sys +import array +import codecs +from io import BytesIO +from os import PathLike +from collections import namedtuple +from enum import Enum +from typing import IO, Any, Optional, Union + +from aztec_py.renderers.image import image_from_matrix +from aztec_py.renderers.svg import svg_from_matrix + +try: + from StringIO import StringIO +except ImportError: + from io import StringIO + +Config = namedtuple('Config', ('layers', 'codewords', 'cw_bits', 'bits')) + +configs = { + (15, True): Config(layers=1, codewords=17, cw_bits=6, bits=102), + (19, False): Config(layers=1, codewords=21, cw_bits=6, bits=126), + (19, True): Config(layers=2, codewords=40, cw_bits=6, bits=240), + (23, False): Config(layers=2, codewords=48, cw_bits=6, bits=288), + (23, True): Config(layers=3, codewords=51, cw_bits=8, bits=408), + (27, False): Config(layers=3, codewords=60, cw_bits=8, bits=480), + (27, True): Config(layers=4, codewords=76, cw_bits=8, bits=608), + (31, False): Config(layers=4, codewords=88, cw_bits=8, bits=704), + (37, False): Config(layers=5, codewords=120, cw_bits=8, bits=960), + (41, False): Config(layers=6, codewords=156, cw_bits=8, bits=1248), + (45, False): Config(layers=7, codewords=196, cw_bits=8, bits=1568), + (49, False): Config(layers=8, codewords=240, cw_bits=8, bits=1920), + (53, False): Config(layers=9, codewords=230, cw_bits=10, bits=2300), + (57, False): Config(layers=10, codewords=272, cw_bits=10, bits=2720), + (61, False): Config(layers=11, codewords=316, cw_bits=10, bits=3160), + (67, False): Config(layers=12, codewords=364, cw_bits=10, bits=3640), + (71, False): Config(layers=13, codewords=416, cw_bits=10, bits=4160), + (75, False): Config(layers=14, codewords=470, cw_bits=10, bits=4700), + (79, False): Config(layers=15, codewords=528, cw_bits=10, bits=5280), + (83, False): Config(layers=16, codewords=588, cw_bits=10, bits=5880), + (87, False): Config(layers=17, codewords=652, cw_bits=10, bits=6520), + (91, False): Config(layers=18, codewords=720, cw_bits=10, bits=7200), + (95, False): Config(layers=19, codewords=790, cw_bits=10, bits=7900), + (101, False): Config(layers=20, codewords=864, cw_bits=10, bits=8640), + (105, False): Config(layers=21, codewords=940, cw_bits=10, bits=9400), + (109, False): Config(layers=22, codewords=1020, cw_bits=10, bits=10200), + (113, False): Config(layers=23, codewords=920, cw_bits=12, bits=11040), + (117, False): Config(layers=24, codewords=992, cw_bits=12, bits=11904), + (121, False): Config(layers=25, codewords=1066, cw_bits=12, bits=12792), + (125, False): Config(layers=26, codewords=1144, cw_bits=12, bits=13728), + (131, False): Config(layers=27, codewords=1224, cw_bits=12, bits=14688), + (135, False): Config(layers=28, codewords=1306, cw_bits=12, bits=15672), + (139, False): Config(layers=29, codewords=1392, cw_bits=12, bits=16704), + (143, False): Config(layers=30, codewords=1480, cw_bits=12, bits=17760), + (147, False): Config(layers=31, codewords=1570, cw_bits=12, bits=18840), + (151, False): Config(layers=32, codewords=1664, cw_bits=12, bits=19968), +} + +encoding_to_eci = { + 'cp437': 0, # also 2 + 'iso8859-1': 1, # (also 3) default interpretation, readers should assume if no ECI mark + 'iso8859-2': 4, + 'iso8859-3': 5, + 'iso8859-4': 6, + 'iso8859-5': 7, + 'iso8859-6': 8, + 'iso8859-7': 9, + 'iso8859-8': 10, + 'iso8859-9': 11, + 'iso8859-13': 15, + 'iso8859-14': 16, + 'iso8859-15': 17, + 'iso8859-16': 18, + 'shift_jis': 20, + 'cp1250': 21, + 'cp1251': 22, + 'cp1252': 23, + 'cp1256': 24, + 'utf-16-be': 25, # no BOM + 'utf-8': 26, + 'ascii': 27, # also 170 + 'big5': 28, + 'gb18030': 29, + 'euc_kr': 30, +} + +polynomials = { + 4: 19, + 6: 67, + 8: 301, + 10: 1033, + 12: 4201, +} + +Side = Enum('Side', ('left', 'right', 'bottom', 'top')) + +Mode = Enum('Mode', ('UPPER', 'LOWER', 'MIXED', 'PUNCT', 'DIGIT', 'BINARY')) +Latch = Enum('Latch', Mode.__members__) +Shift = Enum('Shift', Mode.__members__) +Misc = Enum('Misc', ('FLG', 'SIZE', 'RESUME')) + +code_chars = { + Mode.UPPER: [Shift.PUNCT] + list(b' ABCDEFGHIJKLMNOPQRSTUVWXYZ') + [Latch.LOWER, Latch.MIXED, Latch.DIGIT, Shift.BINARY], + Mode.LOWER: [Shift.PUNCT] + list(b' abcdefghijklmnopqrstuvwxyz') + [Shift.UPPER, Latch.MIXED, Latch.DIGIT, Shift.BINARY], + Mode.MIXED: [Shift.PUNCT] + list(b' \x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x1b\x1c\x1d\x1e\x1f@\\^_`|~\x7f') + [Latch.LOWER, Latch.UPPER, Latch.PUNCT, Shift.BINARY], + Mode.PUNCT: [Misc.FLG] + list(b'\r') + [b'\r\n', b'. ', b', ', b': '] + list(b'!"#$%&\'()*+,-./:;<=>?[]{}') + [Latch.UPPER], + Mode.DIGIT: [Shift.PUNCT] + list(b' 0123456789,.') + [Latch.UPPER, Shift.UPPER], +} + +punct_2_chars = [pc for pc in code_chars[Mode.PUNCT] if isinstance(pc, bytes)] + +E = 99999 # some big number + +latch_len = { + Mode.UPPER: { + Mode.UPPER: 0, Mode.LOWER: 5, Mode.MIXED: 5, Mode.PUNCT: 10, Mode.DIGIT: 5, Mode.BINARY: 10 + }, + Mode.LOWER: { + Mode.UPPER: 9, Mode.LOWER: 0, Mode.MIXED: 5, Mode.PUNCT: 10, Mode.DIGIT: 5, Mode.BINARY: 10 + }, + Mode.MIXED: { + Mode.UPPER: 5, Mode.LOWER: 5, Mode.MIXED: 0, Mode.PUNCT: 5, Mode.DIGIT: 10, Mode.BINARY: 10 + }, + Mode.PUNCT: { + Mode.UPPER: 5, Mode.LOWER: 10, Mode.MIXED: 10, Mode.PUNCT: 0, Mode.DIGIT: 10, Mode.BINARY: 15 + }, + Mode.DIGIT: { + Mode.UPPER: 4, Mode.LOWER: 9, Mode.MIXED: 9, Mode.PUNCT: 14, Mode.DIGIT: 0, Mode.BINARY: 14 + }, + Mode.BINARY: { + Mode.UPPER: 0, Mode.LOWER: 0, Mode.MIXED: 0, Mode.PUNCT: 0, Mode.DIGIT: 0, Mode.BINARY: 0 + }, +} + +shift_len = { + (Mode.UPPER, Mode.PUNCT): 5, + (Mode.LOWER, Mode.UPPER): 5, + (Mode.LOWER, Mode.PUNCT): 5, + (Mode.MIXED, Mode.PUNCT): 5, + (Mode.DIGIT, Mode.UPPER): 4, + (Mode.DIGIT, Mode.PUNCT): 4, +} + +char_size = { + Mode.UPPER: 5, Mode.LOWER: 5, Mode.MIXED: 5, Mode.PUNCT: 5, Mode.DIGIT: 4, Mode.BINARY: 8, +} + +abbr_modes = {m.name[0]:m for m in Mode} + + +def prod(x: int, y: int, log: dict[int, int], alog: dict[int, int], gf: int) -> int: + """Multiply two values in the configured Galois field.""" + if not x or not y: + return 0 + return alog[(log[x] + log[y]) % (gf - 1)] + + +def reed_solomon(wd: list[int], nd: int, nc: int, gf: int, pp: int) -> None: + """Calculate Reed-Solomon error correction codewords. + + Algorithm is based on Aztec Code bar code symbology specification from + GOST-R-ISO-MEK-24778-2010 (Russian) + Takes ``nd`` data codeword values in ``wd`` and adds on ``nc`` check + 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 + """ + # generate log and anti log tables + log = {0: 1 - gf} + alog = {0: 1} + for i in range(1, gf): + alog[i] = alog[i - 1] * 2 + if alog[i] >= gf: + alog[i] ^= pp + log[alog[i]] = i + # generate polynomial coeffs + c = {0: 1} + for i in range(1, nc + 1): + c[i] = 0 + for i in range(1, nc + 1): + c[i] = c[i - 1] + for j in range(i - 1, 0, -1): + c[j] = c[j - 1] ^ prod(c[j], alog[i], log, alog, gf) + c[0] = prod(c[0], alog[i], log, alog, gf) + # generate codewords + for i in range(nd, nd + nc): + wd[i] = 0 + for i in range(nd): + assert 0 <= wd[i] < gf + k = wd[nd] ^ wd[i] + for j in range(nc): + wd[nd + j] = prod(k, c[nc - j - 1], log, alog, gf) + if j < nc - 1: + wd[nd + j] ^= wd[nd + j + 1] + + +def find_optimal_sequence(data: Union[str, bytes], encoding: Optional[str] = None) -> list[Any]: + """Find the shortest mode/value sequence needed to encode the payload. + + TODO: add support of FLG(n) processing + + :param data: string or bytes to encode + :param encoding: see :py:class:`AztecCode` + :return: optimal sequence + """ + + # standardize encoding name, ensure that it's valid for ECI, and encode string to bytes + if encoding: + encoding = codecs.lookup(encoding).name + eci = encoding_to_eci[encoding] + else: + encoding = 'iso8859-1' + eci = None + if isinstance(data, str): + data = data.encode(encoding) + + back_to = {m: Mode.UPPER for m in Mode} + cur_len = {m: 0 if m==Mode.UPPER else E for m in Mode} + cur_seq = {m: [] for m in Mode} + prev_c = None + for c in data: + for x in Mode: + for y in Mode: + if cur_len[x] + latch_len[x][y] < cur_len[y]: + cur_len[y] = cur_len[x] + latch_len[x][y] + cur_seq[y] = cur_seq[x][:] + back_to[y] = y + if y == Mode.BINARY: + # for binary mode use B/S instead of B/L + if x in (Mode.PUNCT, Mode.DIGIT): + # if changing from punct or digit to binary mode use U/L as intermediate mode + # TODO: update for digit + back_to[y] = Mode.UPPER + cur_seq[y] += [Latch.UPPER, Shift.BINARY, Misc.SIZE] + else: + back_to[y] = x + cur_seq[y] += [Shift.BINARY, Misc.SIZE] + elif cur_seq[x]: + # if changing from punct or digit mode - use U/L as intermediate mode + # TODO: update for digit + if x == Mode.DIGIT and y == Mode.PUNCT: + cur_seq[y] += [Misc.RESUME, Latch.UPPER, Latch.MIXED, Latch.PUNCT] + elif x in (Mode.PUNCT, Mode.DIGIT) and y != Mode.UPPER: + cur_seq[y] += [Misc.RESUME, Latch.UPPER, Latch[y.name]] + elif x == Mode.LOWER and y == Mode.UPPER: + cur_seq[y] += [Latch.DIGIT, Latch.UPPER] + elif x in (Mode.UPPER, Mode.LOWER) and y == Mode.PUNCT: + cur_seq[y] += [Latch.MIXED, Latch[y.name]] + elif x == Mode.MIXED and y != Mode.UPPER: + if y == Mode.PUNCT: + cur_seq[y] += [Latch.PUNCT] + back_to[y] = Mode.PUNCT + else: + cur_seq[y] += [Latch.UPPER, Latch.DIGIT] + back_to[y] = Mode.DIGIT + continue + elif x == Mode.BINARY: + # TODO: review this + # Reviewed by jravallec + if y == back_to[x]: + # when return from binary to previous mode, skip mode change + cur_seq[y] += [Misc.RESUME] + elif y == Mode.UPPER: + if back_to[x] == Mode.LOWER: + cur_seq[y] += [Misc.RESUME, Latch.DIGIT, Latch.UPPER] + if back_to[x] == Mode.MIXED: + cur_seq[y] += [Misc.RESUME, Latch.UPPER] + elif y == Mode.LOWER: + cur_seq[y] += [Misc.RESUME, Latch.LOWER] + elif y == Mode.MIXED: + cur_seq[y] += [Misc.RESUME, Latch.MIXED] + elif y == Mode.PUNCT: + if back_to[x] == Mode.MIXED: + cur_seq[y] += [Misc.RESUME, Latch.PUNCT] + else: + cur_seq[y] += [Misc.RESUME, Latch.MIXED, Latch.PUNCT] + elif y == Mode.DIGIT: + if back_to[x] == Mode.MIXED: + cur_seq[y] += [Misc.RESUME, Latch.UPPER, Latch.DIGIT] + else: + cur_seq[y] += [Misc.RESUME, Latch.DIGIT] + else: + cur_seq[y] += [Misc.RESUME, Latch[y.name]] + else: + # if changing from punct or digit mode - use U/L as intermediate mode + # TODO: update for digit + if x in (Mode.PUNCT, Mode.DIGIT): + cur_seq[y] = [Latch.UPPER, Latch[y.name]] + elif x == Mode.LOWER and y == Mode.UPPER: + cur_seq[y] = [Latch.DIGIT, Latch.UPPER] + elif x in (Mode.BINARY, Mode.UPPER, Mode.LOWER) and y == Mode.PUNCT: + cur_seq[y] = [Latch.MIXED, Latch[y.name]] + else: + cur_seq[y] = [Latch[y.name]] + next_len = {m:E for m in Mode} + next_seq = {m:[] for m in Mode} + possible_modes = [m for m in Mode if m == Mode.BINARY or c in code_chars[m]] + for x in possible_modes: + # TODO: review this! + if back_to[x] == Mode.DIGIT and x == Mode.LOWER: + cur_seq[x] += [Latch.UPPER, Latch.LOWER] + cur_len[x] += latch_len[back_to[x]][x] + back_to[x] = Mode.LOWER + # add char to current sequence + if cur_len[x] + char_size[x] < next_len[x]: + next_len[x] = cur_len[x] + char_size[x] + next_seq[x] = cur_seq[x] + [c] + for y in Mode: + if (y, x) in shift_len and cur_len[y] + shift_len[(y, x)] + char_size[x] < next_len[y]: + next_len[y] = cur_len[y] + shift_len[y, x] + char_size[x] + next_seq[y] = cur_seq[y] + [Shift[x.name], c] + # TODO: review this!!! + if prev_c and bytes((prev_c, c)) in punct_2_chars: + for x in Mode: + # Will never StopIteration because we must have one S/L already since prev_c is PUNCT + last_mode = next(s.value for s in reversed(cur_seq[x]) if isinstance(s, Latch) or isinstance(s, Shift)) + if last_mode == Mode.PUNCT: + last_c = cur_seq[x][-1] + if isinstance(last_c, int) and bytes((last_c, c)) in punct_2_chars: + if x != Mode.MIXED: # we need to avoid this because it contains '\r', '\n' individually, but not combined + if cur_len[x] < next_len[x]: + next_len[x] = cur_len[x] + next_seq[x] = cur_seq[x][:-1] + [ bytes((last_c, c)) ] + if len(next_seq[Mode.BINARY]) - 2 == 32: + next_len[Mode.BINARY] += 11 + cur_len = next_len.copy() + cur_seq = next_seq.copy() + prev_c = c + # sort in ascending order and get shortest sequence + result_seq = [] + sorted_cur_len = sorted(cur_len, key=cur_len.__getitem__) + if sorted_cur_len: + min_length = sorted_cur_len[0] + result_seq = cur_seq[min_length] + # update binary sequences' sizes + sizes = {} + result_seq_len = len(result_seq) + reset_pos = result_seq_len - 1 + for i, c in enumerate(reversed(result_seq)): + if c == Misc.SIZE: + sizes[i] = reset_pos - (result_seq_len - i - 1) + reset_pos = result_seq_len - i + elif c == Misc.RESUME: + reset_pos = result_seq_len - i - 2 + for size_pos in sizes: + result_seq[len(result_seq) - size_pos - 1] = sizes[size_pos] + # remove 'resume' tokens + result_seq = [x for x in result_seq if x != Misc.RESUME] + # update binary sequences' extra sizes + updated_result_seq = [] + is_binary_length = False + for i, c in enumerate(result_seq): + if is_binary_length: + if c > 31: + updated_result_seq.append(0) + updated_result_seq.append(c - 31) + else: + updated_result_seq.append(c) + is_binary_length = False + else: + updated_result_seq.append(c) + + if c == Shift.BINARY: + is_binary_length = True + + if eci is not None: + updated_result_seq = [ Shift.PUNCT, Misc.FLG, len(str(eci)), eci ] + updated_result_seq + + return updated_result_seq + + +def optimal_sequence_to_bits(optimal_sequence: list[Any]) -> str: + """Convert an optimal sequence to a packed bit string. + + :param optimal_sequence: input optimal sequence + :return: string with bits + """ + out_bits = '' + mode = prev_mode = Mode.UPPER + shift = False + sequence = optimal_sequence[:] + while sequence: + # read one item from sequence + ch = sequence.pop(0) + index = code_chars[mode].index(ch) + out_bits += bin(index)[2:].zfill(char_size[mode]) + # resume previous mode for shift + if shift: + mode = prev_mode + shift = False + # get mode from sequence character + if isinstance(ch, Latch): + mode = ch.value + # handle FLG(n) + elif ch == Misc.FLG: + if not sequence: + raise Exception('Expected FLG(n) value') + flg_n = sequence.pop(0) + if not isinstance(flg_n, numbers.Number) or not 0 <= flg_n <= 7: + raise Exception('FLG(n) value must be a number from 0 to 7') + if flg_n == 7: + raise Exception('FLG(7) is reserved and currently illegal') + + out_bits += bin(flg_n)[2:].zfill(3) + if flg_n >= 1: + # ECI + if not sequence: + raise Exception('Expected FLG({}) to be followed by ECI code'.format(flg_n)) + eci_code = sequence.pop(0) + if not isinstance(eci_code, numbers.Number) or not 0 <= eci_code < (10**flg_n): + raise Exception('Expected FLG({}) ECI code to be a number from 0 to {}'.format(flg_n, (10**flg_n) - 1)) + out_digits = str(eci_code).zfill(flg_n).encode() + for ch in out_digits: + index = code_chars[Mode.DIGIT].index(ch) + out_bits += bin(index)[2:].zfill(char_size[Mode.DIGIT]) + # handle binary run + elif ch == Shift.BINARY: + if not sequence: + raise Exception('Expected binary sequence length') + # followed by a 5 bit length + seq_len = sequence.pop(0) + if not isinstance(seq_len, numbers.Number): + raise Exception('Binary sequence length must be a number') + out_bits += bin(seq_len)[2:].zfill(5) + # if length is zero - 11 additional length bits are used for length + if not seq_len: + seq_len = sequence.pop(0) + if not isinstance(seq_len, numbers.Number): + raise Exception('Binary sequence length must be a number') + out_bits += bin(seq_len)[2:].zfill(11) + seq_len += 31 + for binary_index in range(seq_len): + ch = sequence.pop(0) + out_bits += bin(ch)[2:].zfill(char_size[Mode.BINARY]) + # handle other shift + elif isinstance(ch, Shift): + mode, prev_mode = ch.value, mode + shift = True + return out_bits + + +def get_data_codewords(bits: str, codeword_size: int) -> list[int]: + """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 + """ + codewords = [] + sub_bits = '' + for bit in bits: + sub_bits += bit + # if first bits of sub sequence are zeros add 1 as a last bit + if len(sub_bits) == codeword_size - 1 and sub_bits.find('1') < 0: + sub_bits += '1' + # if first bits of sub sequence are ones add 0 as a last bit + if len(sub_bits) == codeword_size - 1 and sub_bits.find('0') < 0: + sub_bits += '0' + # convert bits to decimal int and add to result codewords + if len(sub_bits) >= codeword_size: + codewords.append(int(sub_bits, 2)) + sub_bits = '' + if sub_bits: + # update and add final bits + sub_bits = sub_bits.ljust(codeword_size, '1') + # change final bit to zero if all bits are ones + if sub_bits.find('0') < 0: + sub_bits = sub_bits[:-1] + '0' + codewords.append(int(sub_bits, 2)) + return codewords + + +def _required_capacity_bits(data_codewords_count: int, codeword_size: int, ec_percent: int) -> int: + """Calculate total symbol bits needed for stuffed data + EC overhead.""" + stuffed_data_bits = data_codewords_count * codeword_size + overhead_bits = 3 * codeword_size + return int(math.ceil((stuffed_data_bits + overhead_bits) * 100.0 / (100 - ec_percent))) + + +def get_config_from_table(size: int, compact: bool) -> Config: + """Get config with given size and compactness flag. + + :param size: matrix size + :param compact: compactness flag + :return: dict with config + """ + try: + return configs[(size, compact)] + except KeyError: + raise NotImplementedError('Failed to find config with size and compactness flag') + +def find_suitable_matrix_size( + data: Union[str, bytes], + ec_percent: int = 23, + encoding: Optional[str] = None, +) -> tuple[int, bool, list[Any]]: + """Find suitable matrix size. + Raise an exception if suitable size is not found + + :param data: string or bytes to encode + :param ec_percent: percentage of symbol capacity for error correction (default 23%) + :param encoding: see :py:class:`AztecCode` + :return: (size, compact) tuple + """ + optimal_sequence = find_optimal_sequence(data, encoding) + out_bits = optimal_sequence_to_bits(optimal_sequence) + for (size, compact) in sorted(configs.keys()): + config = get_config_from_table(size, compact) + bits = config.bits + data_cw_count = len(get_data_codewords(out_bits, config.cw_bits)) + required_bits_count = _required_capacity_bits(data_cw_count, config.cw_bits, ec_percent) + if required_bits_count < bits: + return size, compact, optimal_sequence + raise Exception('Data too big to fit in one Aztec code!') + +class AztecCode(object): + """Generate and render an Aztec Code symbol.""" + + def __init__( + self, + data: Union[str, bytes], + size: Optional[int] = None, + compact: Optional[bool] = None, + ec_percent: int = 23, + encoding: Optional[str] = None, + charset: Optional[str] = None, + ) -> None: + """Create Aztec code with given payload and settings. + + Args: + data: String or bytes to encode. + size: Fixed matrix size. If omitted, size is auto-selected. + compact: Compactness flag for fixed-size mode. + ec_percent: Error correction percentage, 5..95 inclusive. + encoding: Charset used for string payloads and ECI. + charset: Alias for ``encoding`` used by the modern API. + + Raises: + ValueError: If API arguments are invalid. + Exception: If payload does not fit in selected matrix size. + """ + if not data: + raise ValueError("data must not be empty") + if not 5 <= ec_percent <= 95: + raise ValueError(f"ec_percent must be between 5 and 95, got {ec_percent}") + if charset is not None: + if encoding is not None and encoding != charset: + raise ValueError("encoding and charset must match when both are provided") + encoding = charset + if isinstance(data, str) and encoding == "": + raise ValueError("charset must be specified when data is a string") + + self.data = data + self.encoding = encoding + self.sequence = None + self.ec_percent = ec_percent + if size is not None and compact is not None: + if (size, compact) in configs: + self.size, self.compact = size, compact + else: + raise Exception( + 'Given size and compact values (%s, %s) are not found in sizes table!' % (size, compact)) + else: + self.size, self.compact, self.sequence = find_suitable_matrix_size(self.data, ec_percent, encoding) + self.__create_matrix() + self.__encode_data() + + def __create_matrix(self) -> None: + """Create an empty Aztec matrix of the selected size.""" + self.matrix = [array.array('B', (0 for jj in range(self.size))) for ii in range(self.size)] + + def save( + self, + filename: Union[str, PathLike[str], IO[bytes], IO[str]], + module_size: int = 2, + border: int = 0, + format: Optional[str] = None, + ) -> None: + """Save symbol to disk in PNG/SVG/PDF formats. + + Args: + filename: Destination path or file object. + module_size: Module size in pixels. + border: Border width in modules. + format: Explicit image format override for Pillow. + """ + target_name = str(filename).lower() if isinstance(filename, (str, PathLike)) else "" + format_name = (format or "").lower() + + if target_name.endswith(".svg") or format_name == "svg": + payload = self.svg(module_size=module_size, border=border) + if hasattr(filename, "write"): + filename.write(payload) + else: + with open(filename, "w", encoding="utf-8") as file_obj: + file_obj.write(payload) + return + + if target_name.endswith(".pdf") or format_name == "pdf": + payload = self.pdf(module_size=module_size, border=border) + if hasattr(filename, "write"): + filename.write(payload) + else: + with open(filename, "wb") as file_obj: + file_obj.write(payload) + return + + self.image(module_size, border).save(filename, format=format) + + def image(self, module_size: int = 2, border: int = 0) -> Any: + """Create a PIL image for this symbol.""" + return image_from_matrix(self.matrix, module_size=module_size, border=border) + + def svg(self, module_size: int = 1, border: int = 1) -> str: + """Render symbol as SVG text.""" + return svg_from_matrix(self.matrix, module_size=module_size, border=border) + + def pdf(self, module_size: int = 3, border: int = 1) -> bytes: + """Render symbol into a single-page PDF and return bytes.""" + try: + from fpdf import FPDF + except ImportError as exc: + raise RuntimeError("PDF output requires optional dependency 'fpdf2'") from exc + + image = self.image(module_size=module_size, border=border) + png_data = BytesIO() + image.save(png_data, format="PNG") + png_data.seek(0) + + pdf = FPDF(unit="pt", format=(float(image.width), float(image.height))) + pdf.add_page() + pdf.image(png_data, x=0, y=0, w=image.width, h=image.height) + return bytes(pdf.output()) + + def print_out(self, border: int = 0) -> None: + """Print the Aztec matrix in ASCII.""" + print('\n'.join(' '*(2*border + self.size) for ii in range(border))) + for line in self.matrix: + print(' '*border + ''.join(('#' if x else ' ') for x in line) + ' '*border) + print('\n'.join(' '*(2*border + self.size) for ii in range(border))) + + def print_fancy(self, border: int = 0) -> None: + """Print the matrix using ANSI + Unicode block characters.""" + for y in range(-border, self.size+border, 2): + last_half_row = (y==self.size + border - 1) + ul = '\x1b[40;37;1m' + ('\u2580' if last_half_row else '\u2588')*border + for x in range(0, self.size): + a = self.matrix[y][x] if 0 <= y < self.size else None + b = self.matrix[y+1][x] if -1 <= y < self.size-1 else last_half_row + ul += ' ' if a and b else '\u2584' if a else '\u2580' if b else '\u2588' + ul += ('\u2580' if last_half_row else '\u2588')*border + '\x1b[0m' + print(ul) + + def __add_finder_pattern(self): + """ 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): + for y in range(-ring_radius, ring_radius): + self.matrix[center + y][center + x] = (max(abs(x), abs(y)) + 1) % 2 + + def __add_orientation_marks(self): + """ Add orientation marks to matrix """ + center = self.size // 2 + ring_radius = 5 if self.compact else 7 + # add orientation marks + # left-top + self.matrix[center - ring_radius][center - ring_radius] = 1 + self.matrix[center - ring_radius + 1][center - ring_radius] = 1 + self.matrix[center - ring_radius][center - ring_radius + 1] = 1 + # right-top + self.matrix[center - ring_radius + 0][center + ring_radius + 0] = 1 + self.matrix[center - ring_radius + 1][center + ring_radius + 0] = 1 + # right-down + self.matrix[center + ring_radius - 1][center + ring_radius + 0] = 1 + + def __add_reference_grid(self): + """ Add reference grid to matrix """ + if self.compact: + return + center = self.size // 2 + ring_radius = 5 if self.compact else 7 + for x in range(-center, center + 1): + for y in range(-center, center + 1): + # skip finder pattern + if -ring_radius <= x <= ring_radius and -ring_radius <= y <= ring_radius: + continue + # set pixel + if x % 16 == 0 or y % 16 == 0: + self.matrix[center + y][center + x] = (x + y + 1) % 2 + + def __get_mode_message(self, layers_count, data_cw_count): + """ Get mode message + + :param layers_count: number of layers + :param 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 + mode_word = '{0:02b}{1:06b}'.format(layers_count - 1, data_cw_count - 1) + # two 4 bits initial codewords with 5 Reed-Solomon check codewords + init_codewords = [int(mode_word[i:i + 4], 2) for i in range(0, 8, 4)] + total_cw_count = 7 + else: + # for full mode - 5 bits with layers count and 11 bits with data codewords count + mode_word = '{0:05b}{1:011b}'.format(layers_count - 1, data_cw_count - 1) + # four 4 bits initial codewords with 6 Reed-Solomon check codewords + init_codewords = [int(mode_word[i:i + 4], 2) for i in range(0, 16, 4)] + total_cw_count = 10 + # fill Reed-Solomon check codewords with zeros + init_cw_count = len(init_codewords) + codewords = (init_codewords + [0] * (total_cw_count - init_cw_count))[:total_cw_count] + # update Reed-Solomon check codewords using GF(16) + reed_solomon(codewords, init_cw_count, total_cw_count - init_cw_count, 16, polynomials[4]) + return codewords + + def __add_mode_info(self, data_cw_count): + """ Add mode info to matrix + + :param data_cw_count: number of data codewords. + """ + config = get_config_from_table(self.size, self.compact) + layers_count = config.layers + mode_data_values = self.__get_mode_message(layers_count, data_cw_count) + mode_data_bits = ''.join('{0:04b}'.format(v) for v in mode_data_values) + + center = self.size // 2 + ring_radius = 5 if self.compact else 7 + side_size = 7 if self.compact else 11 + bits_stream = StringIO(mode_data_bits) + x = 0 + y = 0 + index = 0 + while True: + # for full mode take a reference grid into account + if not self.compact: + if (index % side_size) == 5: + index += 1 + continue + # read one bit + bit = bits_stream.read(1) + if not bit: + break + if 0 <= index < side_size: + # top + x = index + 2 - ring_radius + y = -ring_radius + elif side_size <= index < side_size * 2: + # right + x = ring_radius + y = index % side_size + 2 - ring_radius + elif side_size * 2 <= index < side_size * 3: + # bottom + x = ring_radius - index % side_size - 2 + y = ring_radius + elif side_size * 3 <= index < side_size * 4: + # left + x = -ring_radius + y = ring_radius - index % side_size - 2 + # set pixel + self.matrix[center + y][center + x] = (bit == '1') + index += 1 + + def __add_data(self, data, encoding): + """ Add data to encode to the matrix + + :param data: data to encode + :param encoding: see :py:class:`AztecCode` + :return: number of data codewords + """ + if not self.sequence: + self.sequence = find_optimal_sequence(data, encoding) + out_bits = optimal_sequence_to_bits(self.sequence) + config = get_config_from_table(self.size, self.compact) + layers_count = config.layers + cw_count = config.codewords + cw_bits = config.cw_bits + bits = config.bits + + data_codewords = get_data_codewords(out_bits, cw_bits) + required_bits_count = _required_capacity_bits(len(data_codewords), cw_bits, self.ec_percent) + if required_bits_count > bits or len(data_codewords) > cw_count: + raise Exception('Data too big to fit in Aztec code with current size!') + + # add Reed-Solomon codewords to init data codewords + data_cw_count = len(data_codewords) + codewords = (data_codewords + [0] * (cw_count - data_cw_count))[:cw_count] + reed_solomon(codewords, data_cw_count, cw_count - data_cw_count, 2 ** cw_bits, polynomials[cw_bits]) + + center = self.size // 2 + ring_radius = 5 if self.compact else 7 + + num = 2 + side = Side.top + layer_index = 0 + pos_x = center - ring_radius + pos_y = center - ring_radius - 1 + full_bits = ''.join(bin(cw)[2:].zfill(cw_bits) for cw in codewords)[::-1] + for i in range(0, len(full_bits), 2): + num += 1 + max_num = ring_radius * 2 + layer_index * 4 + (4 if self.compact else 3) + bits_pair = [(bit == '1') for bit in full_bits[i:i + 2]] + if layer_index >= layers_count: + raise Exception('Maximum layer count for current size is exceeded!') + if side == Side.top: + # move right + dy0 = 1 if not self.compact and (center - pos_y) % 16 == 0 else 0 + dy1 = 2 if not self.compact and (center - pos_y + 1) % 16 == 0 else 1 + self.matrix[pos_y - dy0][pos_x] = bits_pair[0] + self.matrix[pos_y - dy1][pos_x] = bits_pair[1] + pos_x += 1 + if num > max_num: + num = 2 + side = Side.right + pos_x -= 1 + pos_y += 1 + # skip reference grid + if not self.compact and (center - pos_x) % 16 == 0: + pos_x += 1 + if not self.compact and (center - pos_y) % 16 == 0: + pos_y += 1 + elif side == Side.right: + # move down + dx0 = 1 if not self.compact and (center - pos_x) % 16 == 0 else 0 + dx1 = 2 if not self.compact and (center - pos_x + 1) % 16 == 0 else 1 + self.matrix[pos_y][pos_x - dx0] = bits_pair[1] + self.matrix[pos_y][pos_x - dx1] = bits_pair[0] + pos_y += 1 + if num > max_num: + num = 2 + side = Side.bottom + pos_x -= 2 + if not self.compact and (center - pos_x - 1) % 16 == 0: + pos_x -= 1 + pos_y -= 1 + # skip reference grid + if not self.compact and (center - pos_y) % 16 == 0: + pos_y += 1 + if not self.compact and (center - pos_x) % 16 == 0: + pos_x -= 1 + elif side == Side.bottom: + # move left + dy0 = 1 if not self.compact and (center - pos_y) % 16 == 0 else 0 + dy1 = 2 if not self.compact and (center - pos_y + 1) % 16 == 0 else 1 + self.matrix[pos_y - dy0][pos_x] = bits_pair[1] + self.matrix[pos_y - dy1][pos_x] = bits_pair[0] + pos_x -= 1 + if num > max_num: + num = 2 + side = Side.left + pos_x += 1 + pos_y -= 2 + if not self.compact and (center - pos_y - 1) % 16 == 0: + pos_y -= 1 + # skip reference grid + if not self.compact and (center - pos_x) % 16 == 0: + pos_x -= 1 + if not self.compact and (center - pos_y) % 16 == 0: + pos_y -= 1 + elif side == Side.left: + # move up + dx0 = 1 if not self.compact and (center - pos_x) % 16 == 0 else 0 + dx1 = 2 if not self.compact and (center - pos_x - 1) % 16 == 0 else 1 + self.matrix[pos_y][pos_x + dx1] = bits_pair[0] + self.matrix[pos_y][pos_x + dx0] = bits_pair[1] + pos_y -= 1 + if num > max_num: + num = 2 + side = Side.top + layer_index += 1 + # skip reference grid + if not self.compact and (center - pos_y) % 16 == 0: + pos_y -= 1 + return data_cw_count + + def __encode_data(self): + """ Encode data """ + self.__add_finder_pattern() + self.__add_orientation_marks() + self.__add_reference_grid() + data_cw_count = self.__add_data(self.data, self.encoding) + self.__add_mode_info(data_cw_count) + + +def main(argv: list[str]) -> int: + if len(argv) not in (2, 3): + print("usage: {} STRING_TO_ENCODE [IMAGE_FILE]".format(argv[0])) + print(" Generate a 2D Aztec barcode and print it, or save to a file.") + return 1 + data = argv[1] + aztec_code = AztecCode(data) + print('Aztec Code info: {0}x{0} {1}'.format(aztec_code.size, '(compact)' if aztec_code.compact else '')) + if len(sys.argv) == 3: + aztec_code.save(argv[2], module_size=5) + else: + aztec_code.print_fancy(border=2) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main(sys.argv)) diff --git a/aztec_py/decode.py b/aztec_py/decode.py new file mode 100644 index 0000000..d8da78e --- /dev/null +++ b/aztec_py/decode.py @@ -0,0 +1,34 @@ +"""Optional decode utility backed by python-zxing.""" + +from __future__ import annotations + +from typing import Any + + +def decode(source: Any) -> Any: + """Decode an Aztec symbol from an image path or PIL image. + + Args: + source: File path, file object, or PIL image supported by ``python-zxing``. + + Returns: + Decoded payload (`str` or `bytes` depending on the decoder/runtime). + + Raises: + RuntimeError: If optional decode dependencies are missing or decode fails. + """ + try: + import zxing # type: ignore[import-not-found] + except ImportError as exc: + raise RuntimeError( + "Decode support requires optional dependency 'zxing' and a Java runtime." + ) from exc + + reader = zxing.BarCodeReader() + try: + result = reader.decode(source) + except Exception as exc: # pragma: no cover - backend-specific exceptions + raise RuntimeError(f"Failed to decode Aztec symbol: {exc}") from exc + if result is None or result.raw is None: + raise RuntimeError("Decoder returned no payload.") + return result.raw diff --git a/aztec_py/error_correction.py b/aztec_py/error_correction.py new file mode 100644 index 0000000..c2a28fb --- /dev/null +++ b/aztec_py/error_correction.py @@ -0,0 +1,5 @@ +"""Error-correction exports.""" + +from .core import polynomials, prod, reed_solomon + +__all__ = ['polynomials', 'prod', 'reed_solomon'] diff --git a/aztec_py/gs1.py b/aztec_py/gs1.py new file mode 100644 index 0000000..0dfe657 --- /dev/null +++ b/aztec_py/gs1.py @@ -0,0 +1,65 @@ +"""GS1 payload helpers for Aztec integration workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Sequence + + +GROUP_SEPARATOR = "\x1d" + + +@dataclass(frozen=True) +class GS1Element: + """Single GS1 element string component. + + Attributes: + ai: Application Identifier digits (2 to 4 characters). + data: Field value for this AI. + variable_length: If true, inserts a group separator when another element follows. + """ + + ai: str + data: str + variable_length: bool = False + + +def _validate_element(element: GS1Element) -> None: + if not element.ai or not element.ai.isdigit(): + raise ValueError(f"Invalid AI '{element.ai}': AI must contain digits only") + if not 2 <= len(element.ai) <= 4: + raise ValueError(f"Invalid AI '{element.ai}': AI length must be between 2 and 4") + if not element.data: + raise ValueError(f"Invalid value for AI '{element.ai}': data must not be empty") + if GROUP_SEPARATOR in element.ai or GROUP_SEPARATOR in element.data: + raise ValueError("Element values must not include raw group separator characters") + + +def build_gs1_payload(elements: Sequence[GS1Element]) -> str: + """Build a GS1-style element string payload. + + This utility adds the ASCII group separator (GS, ``0x1D``) only between + variable-length fields and their subsequent element, reducing manual errors + in payload assembly. + + Args: + elements: Ordered GS1 elements. + + Returns: + Joined GS1 payload string. + + Raises: + ValueError: If elements are missing or contain invalid values. + """ + if not elements: + raise ValueError("elements must not be empty") + + parts: list[str] = [] + for index, element in enumerate(elements): + _validate_element(element) + parts.append(f"{element.ai}{element.data}") + has_next = index + 1 < len(elements) + if element.variable_length and has_next: + parts.append(GROUP_SEPARATOR) + + return "".join(parts) diff --git a/aztec_py/matrix.py b/aztec_py/matrix.py new file mode 100644 index 0000000..2cf57d4 --- /dev/null +++ b/aztec_py/matrix.py @@ -0,0 +1,5 @@ +"""Matrix configuration exports.""" + +from .core import Config, configs, get_config_from_table + +__all__ = ['Config', 'configs', 'get_config_from_table'] diff --git a/aztec_py/renderers/__init__.py b/aztec_py/renderers/__init__.py new file mode 100644 index 0000000..fa7c661 --- /dev/null +++ b/aztec_py/renderers/__init__.py @@ -0,0 +1,6 @@ +"""Renderer helpers.""" + +from .image import image_from_matrix +from .svg import SvgFactory, svg_from_matrix + +__all__ = ['image_from_matrix', 'svg_from_matrix', 'SvgFactory'] diff --git a/aztec_py/renderers/image.py b/aztec_py/renderers/image.py new file mode 100644 index 0000000..8678481 --- /dev/null +++ b/aztec_py/renderers/image.py @@ -0,0 +1,41 @@ +"""Pillow-based image renderer.""" + +from __future__ import annotations + +import sys +from collections.abc import Sequence +from typing import Any + +try: + from PIL import Image, ImageDraw +except ImportError: + Image = ImageDraw = None # type: ignore[assignment] + missing_pil = sys.exc_info() + + +def image_from_matrix( + matrix: Sequence[Sequence[object]], + module_size: int = 2, + border: int = 0, +) -> Any: + """Render matrix to a PIL monochrome image.""" + if ImageDraw is None: + exc = missing_pil[0](missing_pil[1]) + exc.__traceback__ = missing_pil[2] + raise exc + + size = len(matrix) + image = Image.new('1', ((size + 2 * border) * module_size, (size + 2 * border) * module_size), 1) + image_draw = ImageDraw.Draw(image) + for y in range(size): + for x in range(size): + image_draw.rectangle( + ( + (x + border) * module_size, + (y + border) * module_size, + (x + border + 1) * module_size, + (y + border + 1) * module_size, + ), + fill=not matrix[y][x], + ) + return image diff --git a/aztec_py/renderers/svg.py b/aztec_py/renderers/svg.py new file mode 100644 index 0000000..7c3cfa3 --- /dev/null +++ b/aztec_py/renderers/svg.py @@ -0,0 +1,82 @@ +"""SVG renderer adapted from upstream PR #6.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence + + +class SvgFactory: + """Build and save an SVG representation of an Aztec matrix.""" + + def __init__(self, data: str) -> None: + self.svg_str = data + + @staticmethod + def create_svg( + matrix: Sequence[Sequence[object]], + border: int = 1, + module_size: int = 1, + matching_fn: Callable[[object], bool] = bool, + ) -> "SvgFactory": + """Create SVG for a matrix. + + Args: + matrix: Two-dimensional matrix where truthy values are dark modules. + border: Border in modules. + module_size: Pixel size per module. + matching_fn: Predicate used to detect dark modules. + + Returns: + SvgFactory containing SVG content. + """ + path_data: list[str] = [] + for y, line in enumerate(matrix): + run_length = 0 + run_start: int | None = None + for x, cell in enumerate(line): + if matching_fn(cell): + run_length += 1 + if run_start is None: + run_start = x + next_is_dark = x + 1 < len(line) and matching_fn(line[x + 1]) + if run_start is not None and not next_is_dark: + x0 = (run_start + border) * module_size + y0 = (y + border) * module_size + width = run_length * module_size + path_data.append(f"M{x0} {y0} h{width}") + run_length = 0 + run_start = None + + size = (len(matrix[0]) + (2 * border)) * module_size + data = ( + '' + f'' + f'' + f'' + "" + ) + return SvgFactory(data) + + def save(self, filename: str) -> None: + """Save SVG output to disk.""" + with open(filename, "w", encoding="utf-8") as file_obj: + file_obj.write(self.svg_str) + + +def svg_from_matrix( + matrix: Sequence[Sequence[object]], + module_size: int = 1, + border: int = 1, +) -> str: + """Render matrix to SVG text. + + Args: + matrix: Two-dimensional matrix where truthy values are dark modules. + module_size: Pixel size per module. + border: Border in modules. + + Returns: + SVG payload as UTF-8 text. + """ + return SvgFactory.create_svg(matrix, border=border, module_size=module_size).svg_str diff --git a/aztec_py/rune.py b/aztec_py/rune.py new file mode 100644 index 0000000..47a6060 --- /dev/null +++ b/aztec_py/rune.py @@ -0,0 +1,113 @@ +"""Aztec Rune support.""" + +from __future__ import annotations + +from io import BytesIO +from os import PathLike +from typing import Any + +from aztec_py.renderers.image import image_from_matrix +from aztec_py.renderers.svg import svg_from_matrix + + +def _crc5(value: int) -> int: + """Compute a small checksum for rune payload placement.""" + poly = 0b100101 + reg = value << 5 + for bit in range(7, -1, -1): + if reg & (1 << (bit + 5)): + reg ^= poly << bit + return reg & 0b11111 + + +class AztecRune: + """Generate an 11x11 Aztec Rune for integer values 0..255.""" + + size = 11 + + def __init__(self, value: int) -> None: + if not 0 <= value <= 255: + raise ValueError(f"AztecRune value must be between 0 and 255, got {value}") + self.value = value + self.matrix = [[0 for _ in range(self.size)] for _ in range(self.size)] + self._build() + + def _build(self) -> None: + center = self.size // 2 + # Compact finder pattern rings. + for y in range(self.size): + for x in range(self.size): + ring = max(abs(x - center), abs(y - center)) + if ring in (0, 1, 3, 5): + self.matrix[y][x] = 1 + + # Orientation marks (same positions as compact Aztec markers where possible). + self.matrix[0][0] = 1 + self.matrix[1][0] = 1 + self.matrix[0][1] = 1 + self.matrix[0][10] = 1 + self.matrix[1][10] = 1 + self.matrix[9][10] = 1 + + payload = f"{self.value:08b}" + check = f"{_crc5(self.value):05b}" + bits = payload + check + payload[::-1] + check[::-1] + + perimeter: list[tuple[int, int]] = [] + perimeter.extend((x, 1) for x in range(1, 10)) + perimeter.extend((9, y) for y in range(2, 10)) + perimeter.extend((x, 9) for x in range(8, 0, -1)) + perimeter.extend((1, y) for y in range(8, 1, -1)) + + for bit, (x, y) in zip(bits, perimeter): + self.matrix[y][x] = 1 if bit == "1" else 0 + + def image(self, module_size: int = 2, border: int = 1) -> Any: + """Render the rune as a PIL image.""" + return image_from_matrix(self.matrix, module_size=module_size, border=border) + + def svg(self, module_size: int = 1, border: int = 1) -> str: + """Render the rune as SVG text.""" + return svg_from_matrix(self.matrix, module_size=module_size, border=border) + + def save( + self, + filename: Any, + module_size: int = 2, + border: int = 1, + format: str | None = None, + ) -> None: + """Save rune as PNG, SVG, or PDF based on extension/format.""" + target_name = str(filename).lower() if isinstance(filename, (str, PathLike)) else "" + format_name = (format or "").lower() + + if target_name.endswith(".svg") or format_name == "svg": + svg_payload = self.svg(module_size=module_size, border=border) + if isinstance(filename, (str, PathLike)): + with open(filename, "w", encoding="utf-8") as file_obj: + file_obj.write(svg_payload) + else: + filename.write(svg_payload) + return + + if target_name.endswith(".pdf") or format_name == "pdf": + try: + from fpdf import FPDF + except ImportError as exc: + raise RuntimeError("PDF output requires optional dependency 'fpdf2'") from exc + image = self.image(module_size=module_size, border=border) + png_data = BytesIO() + image.save(png_data, format="PNG") + png_data.seek(0) + pdf = FPDF(unit="pt", format=(float(image.width), float(image.height))) + pdf.add_page() + pdf.image(png_data, x=0, y=0, w=image.width, h=image.height) + pdf_payload = bytes(pdf.output()) + if isinstance(filename, (str, PathLike)): + with open(filename, "wb") as file_obj: + file_obj.write(pdf_payload) + else: + filename.write(pdf_payload) + return + + self.image(module_size=module_size, border=border).save(filename, format=format) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b0a6d23 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,92 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "aztec-py" +version = "0.11.0" +description = "Pure-Python Aztec Code 2D barcode generator - production-grade fork" +readme = "README.md" +requires-python = ">=3.9" +license = "MIT" +authors = [ + { name = "greyllmmoder" }, +] +keywords = ["aztec", "barcode", "2d-barcode", "qr", "iso-24778"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia :: Graphics", +] +dependencies = [] + +[project.optional-dependencies] +image = [ + "pillow>=8.0", +] +svg = [ + "lxml>=4.9", +] +pdf = [ + "fpdf2>=2.7", + "pillow>=8.0", +] +decode = [ + "zxing>=1.0.4", +] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "hypothesis>=6.0", + "mypy>=1.0", + "ruff>=0.5", + "pillow>=8.0", + "fpdf2>=2.7", +] + +[project.urls] +Homepage = "https://github.com/greyllmmoder/python-aztec" +"Bug Tracker" = "https://github.com/greyllmmoder/python-aztec/issues" +Source = "https://github.com/greyllmmoder/python-aztec" +Upstream = "https://github.com/dlenski/aztec_code_generator" + +[project.scripts] +aztec = "aztec_py.__main__:cli_main" + +[tool.setuptools] +py-modules = ["aztec_code_generator"] + +[tool.setuptools.packages.find] +include = ["aztec_py*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--cov=aztec_py --cov-report=term-missing --cov-fail-under=90" + +[tool.coverage.run] +source = ["aztec_py"] +branch = true + +[tool.mypy] +python_version = "3.9" +strict = true +files = ["aztec_py"] + +[[tool.mypy.overrides]] +module = ["aztec_py.core"] +ignore_errors = true + +[[tool.mypy.overrides]] +module = ["aztec_py.__main__"] +disallow_untyped_calls = false + +[tool.ruff] +target-version = "py39" +line-length = 100 diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..47fb2b9 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,4 @@ +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' +zxing>=0.13 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/scripts/decoder_matrix.py b/scripts/decoder_matrix.py new file mode 100644 index 0000000..b22a932 --- /dev/null +++ b/scripts/decoder_matrix.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Run compatibility fixtures across encode/decode matrix checks.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys +from tempfile import NamedTemporaryFile + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from aztec_py import AztecCode # noqa: E402 +from aztec_py.compat import CompatCase, load_compat_cases # noqa: E402 +from aztec_py.decode import decode as decode_symbol # noqa: E402 + + +def _preview(payload: str | bytes) -> str: + if isinstance(payload, bytes): + return f"bytes[{len(payload)}]" + preview = payload.replace("\x1d", "") + if len(preview) > 48: + preview = f"{preview[:45]}..." + return preview + + +def _decode_backend_unavailable(message: str) -> bool: + lower = message.lower() + return ( + "optional dependency 'zxing'" in lower + or "java runtime" in lower + or "java" in lower and "failed to decode" in lower + ) + + +def _matches_expected(decoded: object, expected: str | bytes) -> bool: + if decoded == expected: + return True + + if isinstance(expected, str) and isinstance(decoded, bytes): + try: + return decoded.decode("utf-8") == expected + except UnicodeDecodeError: + return False + + if isinstance(expected, bytes) and isinstance(decoded, str): + try: + return decoded.encode("iso-8859-1") == expected + except UnicodeEncodeError: + return False + + return False + + +def _evaluate_case(case: CompatCase, module_size: int, strict_decode: bool) -> tuple[bool, str, str, str]: + try: + with NamedTemporaryFile(suffix=".png") as image_file: + aztec = AztecCode(case.payload, ec_percent=case.ec_percent, charset=case.charset) + aztec.save(image_file.name, module_size=module_size) + + decode_state = "skip" + note = "decode not required" + if case.decode_expected: + try: + decoded = decode_symbol(image_file.name) + except RuntimeError as exc: + message = str(exc) + if _decode_backend_unavailable(message): + if strict_decode: + return False, "pass", "fail", f"decode backend unavailable: {message}" + return True, "pass", "skip", f"decode backend unavailable: {message}" + return False, "pass", "fail", message + + if _matches_expected(decoded, case.payload): + decode_state = "pass" + note = "decoded payload matches" + else: + return ( + False, + "pass", + "fail", + f"decode mismatch for payload type {type(case.payload).__name__}", + ) + + return True, "pass", decode_state, note + except Exception as exc: # pragma: no cover - guard for script use + return False, "fail", "skip", str(exc) + + +def _render_markdown(results: list[dict[str, str]]) -> str: + lines = [ + "# Decoder Compatibility Matrix", + "", + "| Case | Payload | Encode | Decode | Notes |", + "|---|---|---|---|---|", + ] + for row in results: + lines.append( + "| {case} | {payload} | {encode} | {decode} | {note} |".format( + case=row["case"], + payload=row["payload"], + encode=row["encode"], + decode=row["decode"], + note=row["note"].replace("|", "\\|"), + ) + ) + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run production compatibility fixtures.") + parser.add_argument( + "--fixtures", + type=Path, + default=Path("tests/compat/fixtures.json"), + help="Path to compatibility fixture file.", + ) + parser.add_argument( + "--module-size", + type=int, + default=5, + help="Module size used for generated PNG files.", + ) + parser.add_argument( + "--strict-decode", + action="store_true", + help="Fail when decode backend is unavailable for decode-expected cases.", + ) + parser.add_argument( + "--report", + type=Path, + default=Path("compat_matrix_report.md"), + help="Output path for markdown report.", + ) + args = parser.parse_args() + + cases = load_compat_cases(args.fixtures) + + success = True + rows: list[dict[str, str]] = [] + for case in cases: + case_ok, encode_state, decode_state, note = _evaluate_case( + case, + module_size=args.module_size, + strict_decode=args.strict_decode, + ) + success = success and case_ok + rows.append( + { + "case": case.case_id, + "payload": _preview(case.payload), + "encode": encode_state, + "decode": decode_state, + "note": note, + } + ) + + report = _render_markdown(rows) + args.report.write_text(report, encoding="utf-8") + print(report) + + return 0 if success else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/compat/fixtures.json b/tests/compat/fixtures.json new file mode 100644 index 0000000..196fd31 --- /dev/null +++ b/tests/compat/fixtures.json @@ -0,0 +1,86 @@ +{ + "version": 1, + "cases": [ + { + "id": "ascii_hello", + "payload": { + "kind": "text", + "value": "Hello World" + }, + "tags": ["smoke", "text"] + }, + { + "id": "latin1_explicit_charset", + "payload": { + "kind": "text", + "value": "Fran\u00e7ais" + }, + "charset": "ISO-8859-1", + "tags": ["text", "charset"] + }, + { + "id": "binary_small_bytes", + "payload": { + "kind": "bytes_hex", + "value": "00010203feff" + }, + "decode_expected": false, + "tags": ["binary", "smoke"] + }, + { + "id": "crlf_roundtrip_input", + "payload": { + "kind": "bytes_hex", + "value": "6c696e65310d0a6c696e65320d0a6c696e6533" + }, + "decode_expected": false, + "tags": ["regression", "crlf", "binary"] + }, + { + "id": "dense_ff_212", + "payload": { + "kind": "bytes_repeat", + "byte": "ff", + "count": 212 + }, + "ec_percent": 10, + "decode_expected": false, + "tags": ["regression", "dense", "ec"] + }, + { + "id": "dense_00_212", + "payload": { + "kind": "bytes_repeat", + "byte": "00", + "count": 212 + }, + "ec_percent": 10, + "decode_expected": false, + "tags": ["regression", "dense", "ec"] + }, + { + "id": "gs1_fixed_length", + "payload": { + "kind": "text", + "value": "010345312000001117120508" + }, + "tags": ["gs1", "text"] + }, + { + "id": "gs1_variable_with_group_separator", + "payload": { + "kind": "text", + "value": "01034531200000111712050810ABCD1234\u001d4109501101020917" + }, + "tags": ["gs1", "separator", "text"] + }, + { + "id": "long_text_paragraph", + "payload": { + "kind": "text", + "value": "Aztec production fixture paragraph used to verify medium-length payload generation remains stable across module changes." + }, + "tags": ["text", "length"] + } + ] +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..cc37cf8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +"""Shared pytest fixtures for aztec-py tests.""" diff --git a/tests/test_api_behaviour.py b/tests/test_api_behaviour.py new file mode 100644 index 0000000..3645c28 --- /dev/null +++ b/tests/test_api_behaviour.py @@ -0,0 +1,49 @@ +"""Additional API behavior tests for coverage and regressions.""" + +from __future__ import annotations + +from io import BytesIO, StringIO +from pathlib import Path + +import pytest + +from aztec_py import AztecCode +from aztec_py import core as core_module +from aztec_py.renderers import image as image_module + + +def test_save_svg_to_file_object() -> None: + code = AztecCode("hello") + stream = StringIO() + code.save(stream, format="svg") + assert " None: + pytest.importorskip("fpdf") + code = AztecCode("hello") + stream = BytesIO() + code.save(stream, format="pdf") + assert stream.getbuffer().nbytes > 64 + + +def test_image_renderer_missing_pillow(monkeypatch: pytest.MonkeyPatch) -> None: + missing = RuntimeError("Pillow missing") + monkeypatch.setattr(image_module, "ImageDraw", None) + monkeypatch.setattr(image_module, "missing_pil", (RuntimeError, missing, None), raising=False) + with pytest.raises(RuntimeError, match="Pillow missing"): + image_module.image_from_matrix([[1]]) + + +def test_core_main_usage_message(capsys: pytest.CaptureFixture[str]) -> None: + assert core_module.main(["aztec"]) == 1 + out = capsys.readouterr().out + assert "usage:" in out + + +def test_core_main_saves_png(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + target = tmp_path / "main.png" + monkeypatch.setattr(core_module.sys, "argv", ["aztec", "hello", str(target)]) + rc = core_module.main(["aztec", "hello", str(target)]) + assert rc == 0 + assert target.exists() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..ef3e540 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,43 @@ +"""CLI tests.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from aztec_py.__main__ import cli_main + + +def test_cli_svg_to_stdout(capsys: pytest.CaptureFixture[str]) -> None: + rc = cli_main(["Hello World", "--format", "svg"]) + assert rc == 0 + out = capsys.readouterr().out + assert out.startswith('') + + +def test_cli_png_requires_output() -> None: + with pytest.raises(SystemExit): + cli_main(["Hello World", "--format", "png"]) + + +def test_cli_png_writes_file(tmp_path: Path) -> None: + target = tmp_path / "code.png" + rc = cli_main(["Hello World", "--format", "png", "--output", str(target), "--module-size", "3"]) + assert rc == 0 + assert target.exists() + assert target.stat().st_size > 0 + + +def test_cli_terminal_output(capsys: pytest.CaptureFixture[str]) -> None: + rc = cli_main(["Hello World", "--format", "terminal"]) + assert rc == 0 + out = capsys.readouterr().out + assert out + + +def test_cli_svg_writes_file(tmp_path: Path) -> None: + target = tmp_path / "code.svg" + rc = cli_main(["Hello World", "--format", "svg", "--output", str(target)]) + assert rc == 0 + assert " list[CompatCase]: + return load_compat_cases(FIXTURE_PATH) + + +@pytest.mark.parametrize("case", _load_cases(), ids=lambda case: case.case_id) +def test_all_compat_fixtures_encode_to_matrix(case: CompatCase) -> None: + code = AztecCode(case.payload, ec_percent=case.ec_percent, charset=case.charset) + matrix = code.matrix + assert matrix + assert len(matrix) > 0 + assert all(len(row) == len(matrix) for row in matrix) + + +def test_compat_fixture_set_has_gs1_group_separator_case() -> None: + gs1_cases = [case for case in _load_cases() if "gs1" in case.tags] + assert gs1_cases, "expected GS1 fixtures" + assert any( + isinstance(case.payload, str) and "\x1d" in case.payload for case in gs1_cases + ), "expected at least one GS1 fixture with group separator" + + +def test_decoder_matrix_script_smoke(tmp_path: Path) -> None: + report_path = tmp_path / "compat_matrix.md" + result = subprocess.run( + [ + sys.executable, + "scripts/decoder_matrix.py", + "--fixtures", + str(FIXTURE_PATH), + "--report", + str(report_path), + ], + check=False, + cwd=Path(__file__).resolve().parents[1], + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert report_path.exists() + content = report_path.read_text(encoding="utf-8") + assert "Decoder Compatibility Matrix" in content + assert "ascii_hello" in content diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..5658cc8 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +#-*- coding: utf-8 -*- + +import unittest +from aztec_py import ( + reed_solomon, find_optimal_sequence, optimal_sequence_to_bits, get_data_codewords, encoding_to_eci, + Latch, Shift, Misc, + AztecCode, +) + +import codecs +from tempfile import NamedTemporaryFile + +try: + import zxing +except ImportError: + zxing = None + +def b(*items): + return [(ord(c) if len(c)==1 else c.encode()) if isinstance(c, str) else c for c in items] + +class Test(unittest.TestCase): + """ + Test aztec_code_generator module + """ + + def test_reed_solomon(self): + """ Test reed_solomon function """ + cw = [] + reed_solomon(cw, 0, 0, 0, 0) + self.assertEqual(cw, []) + cw = [0, 0] + [0, 0] + reed_solomon(cw, 2, 2, 16, 19) + self.assertEqual(cw, [0, 0, 0, 0]) + cw = [9, 50, 1, 41, 47, 2, 39, 37, 1, 27] + [0, 0, 0, 0, 0, 0, 0] + reed_solomon(cw, 10, 7, 64, 67) + self.assertEqual(cw, [9, 50, 1, 41, 47, 2, 39, 37, 1, 27, 38, 50, 8, 16, 10, 20, 40]) + cw = [0, 9] + [0, 0, 0, 0, 0] + reed_solomon(cw, 2, 5, 16, 19) + self.assertEqual(cw, [0, 9, 12, 2, 3, 1, 9]) + + def test_find_optimal_sequence_ascii_strings(self): + """ Test find_optimal_sequence function for ASCII strings """ + self.assertEqual(find_optimal_sequence(''), b()) + self.assertEqual(find_optimal_sequence('ABC'), b('A', 'B', 'C')) + self.assertEqual(find_optimal_sequence('abc'), b(Latch.LOWER, 'a', 'b', 'c')) + self.assertEqual(find_optimal_sequence('Wikipedia, the free encyclopedia'), b( + 'W', Latch.LOWER, 'i', 'k', 'i', 'p', 'e', 'd', 'i', 'a', Shift.PUNCT, ', ', 't', 'h', 'e', + ' ', 'f', 'r', 'e', 'e', ' ', 'e', 'n', 'c', 'y', 'c', 'l', 'o', 'p', 'e', 'd', 'i', 'a')) + self.assertEqual(find_optimal_sequence('Code 2D!'), b( + 'C', Latch.LOWER, 'o', 'd', 'e', Latch.DIGIT, ' ', '2', Shift.UPPER, 'D', Shift.PUNCT, '!')) + self.assertEqual(find_optimal_sequence('!#$%&?'), b(Latch.MIXED, Latch.PUNCT, '!', '#', '$', '%', '&', '?')) + + self.assertIn(find_optimal_sequence('. : '), ( + b(Shift.PUNCT, '. ', Shift.PUNCT, ': '), + b(Latch.MIXED, Latch.PUNCT, '. ', ': ') )) + self.assertEqual(find_optimal_sequence('\r\n\r\n\r\n'), b(Latch.MIXED, Latch.PUNCT, '\r\n', '\r\n', '\r\n')) + self.assertEqual(find_optimal_sequence('Code 2D!'), b( + 'C', Latch.LOWER, 'o', 'd', 'e', Latch.DIGIT, ' ', '2', Shift.UPPER, 'D', Shift.PUNCT, '!')) + self.assertEqual(find_optimal_sequence('test 1!test 2!'), b( + Latch.LOWER, 't', 'e', 's', 't', Latch.DIGIT, ' ', '1', Shift.PUNCT, '!', Latch.UPPER, + Latch.LOWER, 't', 'e', 's', 't', Latch.DIGIT, ' ', '2', Shift.PUNCT, '!')) + self.assertEqual(find_optimal_sequence('Abc-123X!Abc-123X!'), b( + 'A', Latch.LOWER, 'b', 'c', Latch.DIGIT, Shift.PUNCT, '-', '1', '2', '3', Latch.UPPER, 'X', Shift.PUNCT, '!', + 'A', Latch.LOWER, 'b', 'c', Latch.DIGIT, Shift.PUNCT, '-', '1', '2', '3', Shift.UPPER, 'X', Shift.PUNCT, '!')) + self.assertEqual(find_optimal_sequence('ABCabc1a2b3e'), b( + 'A', 'B', 'C', Latch.LOWER, 'a', 'b', 'c', Shift.BINARY, 5, '1', 'a', '2', 'b', '3', 'e')) + self.assertEqual(find_optimal_sequence('ABCabc1a2b3eBC'), b( + 'A', 'B', 'C', Latch.LOWER, 'a', 'b', 'c', Shift.BINARY, 6, '1', 'a', '2', 'b', '3', 'e', Latch.DIGIT, Latch.UPPER, 'B', 'C')) + self.assertEqual(find_optimal_sequence('abcABC'), b( + Latch.LOWER, 'a', 'b', 'c', Latch.DIGIT, Latch.UPPER, 'A', 'B', 'C')) + self.assertEqual(find_optimal_sequence('0a|5Tf.l'), b( + Shift.BINARY, 5, '0', 'a', '|', '5', 'T', Latch.LOWER, 'f', Shift.PUNCT, '.', 'l')) + self.assertEqual(find_optimal_sequence('*V1\x0c {Pa'), b( + Shift.PUNCT, '*', 'V', Shift.BINARY, 5, '1', '\x0c', ' ', '{', 'P', Latch.LOWER, 'a')) + self.assertEqual(find_optimal_sequence('~Fxlb"I4'), b( + Shift.BINARY, 7, '~', 'F', 'x', 'l', 'b', '"', 'I', Latch.DIGIT, '4')) + self.assertEqual(find_optimal_sequence('\\+=R?1'), b( + Latch.MIXED, '\\', Latch.PUNCT, '+', '=', Latch.UPPER, 'R', Latch.DIGIT, Shift.PUNCT, '?', '1')) + self.assertEqual(find_optimal_sequence('0123456789:;<=>'), b( + Latch.DIGIT, '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', Latch.UPPER, Latch.MIXED, Latch.PUNCT, ':', ';', '<', '=', '>')) + + def test_encodings_canonical(self): + for encoding in encoding_to_eci: + self.assertEqual(encoding, codecs.lookup(encoding).name) + + def _optimal_eci_sequence(self, charset): + eci = encoding_to_eci[charset] + ecis = str(eci) + return [ Shift.PUNCT, Misc.FLG, len(ecis), eci ] + + def test_find_optimal_sequence_non_ASCII_strings(self): + """ Test find_optimal_sequence function for non-ASCII strings""" + + # Implicit iso8559-1 without ECI: + self.assertEqual(find_optimal_sequence('Français'), b( + 'F', Latch.LOWER, 'r', 'a', 'n', Shift.BINARY, 1, 0xe7, 'a', 'i', 's')) + + # ECI: explicit iso8859-1, cp1252 (Windows-1252), and utf-8 + self.assertEqual(find_optimal_sequence('Français', 'iso8859-1'), self._optimal_eci_sequence('iso8859-1') + b( + 'F', Latch.LOWER, 'r', 'a', 'n', Shift.BINARY, 1, 0xe7, 'a', 'i', 's')) + self.assertEqual(find_optimal_sequence('€800', 'cp1252'), self._optimal_eci_sequence('cp1252') + b( + Shift.BINARY, 1, 0x80, Latch.DIGIT, '8', '0', '0')) + self.assertEqual(find_optimal_sequence('Français', 'utf-8'), self._optimal_eci_sequence('utf-8') + b( + 'F', Latch.LOWER, 'r', 'a', 'n', Shift.BINARY, 2, 0xc3, 0xa7, 'a', 'i', 's')) + + def test_find_optimal_sequence_bytes(self): + """ Test find_optimal_sequence function for byte strings """ + + self.assertEqual(find_optimal_sequence(b'a' + b'\xff' * 31 + b'A'), b( + Shift.BINARY, 0, 1, 'a') + [0xff] * 31 + b('A')) + self.assertEqual(find_optimal_sequence(b'abc' + b'\xff' * 32 + b'A'), b( + Latch.LOWER, 'a', 'b', 'c', Shift.BINARY, 0, 1) + [0xff] * 32 + b(Latch.DIGIT, Latch.UPPER, 'A')) + self.assertEqual(find_optimal_sequence(b'abc' + b'\xff' * 31 + b'@\\\\'), b( + Latch.LOWER, 'a', 'b', 'c', Shift.BINARY, 31) + [0xff] * 31 + b(Latch.MIXED, '@', '\\', '\\')) + self.assertEqual(find_optimal_sequence(b'!#$%&?\xff'), b( + Latch.MIXED, Latch.PUNCT, '!', '#', '$', '%', '&', '?', Latch.UPPER, Shift.BINARY, 1, '\xff')) + self.assertEqual(find_optimal_sequence(b'!#$%&\xff'), b(Shift.BINARY, 6, '!', '#', '$', '%', '&', '\xff')) + self.assertEqual(find_optimal_sequence(b'@\xff'), b(Shift.BINARY, 2, '@', '\xff')) + self.assertEqual(find_optimal_sequence(b'. @\xff'), b(Shift.PUNCT, '. ', Shift.BINARY, 2, '@', '\xff')) + + def test_find_optimal_sequence_CRLF_bug(self): + """ Demonstrate a known bug in find_optimal_sequence (https://github.com/dlenski/aztec_code_generator/pull/4) + + This is a much more minimal example of https://github.com/delimitry/aztec_code_generator/issues/7 + + The string '\t<\r\n': + SHOULD be sequenced as: Latch.MIXED '\t' Latch.PUNCT < '\r' '\n' + but is incorrectly sequenced as: Latch.MIXED '\t' Shift.PUNCT < '\r\n' + + ... which is impossible since no encoding of the 2 byte sequence b'\r\n' exists in MIXED mode. """ + + self.assertEqual(find_optimal_sequence(b'\t<\r\n'), b( + Latch.MIXED, '\t', Latch.PUNCT, '<', '\r\n' + )) + + def test_crlf_encoding(self): + """CRLF-containing payloads should encode without raising.""" + self.assertIsNotNone(AztecCode(b'hello\r\nworld')) + + def test_ec_worst_case_ff_bytes(self): + """0xFF-heavy payload should fit when size selection includes stuffing.""" + self.assertIsNotNone(AztecCode(b'\xff' * 212, ec_percent=10)) + + def test_ec_worst_case_null_bytes(self): + """0x00-heavy payload should fit when size selection includes stuffing.""" + self.assertIsNotNone(AztecCode(b'\x00' * 212, ec_percent=10)) + + @unittest.skipUnless(zxing, reason='Python module zxing cannot be imported; cannot test decoding.') + def test_crlf_roundtrip(self): + data = b"line1\r\nline2\r\nline3" + reader = zxing.BarCodeReader() + try: + self._encode_and_decode(reader, data) + except Exception as exc: + raise unittest.SkipTest(f"Decode backend unavailable in this runtime: {exc}") from exc + + def test_optimal_sequence_to_bits(self): + """ Test optimal_sequence_to_bits function """ + self.assertEqual(optimal_sequence_to_bits(b()), '') + self.assertEqual(optimal_sequence_to_bits(b(Shift.PUNCT)), '00000') + self.assertEqual(optimal_sequence_to_bits(b('A')), '00010') + self.assertEqual(optimal_sequence_to_bits(b(Shift.BINARY, 1, '\xff')), '111110000111111111') + self.assertEqual(optimal_sequence_to_bits(b(Shift.BINARY, 0, 1) + [0xff] * 32), '111110000000000000001' + '11111111'*32) + self.assertEqual(optimal_sequence_to_bits(b(Shift.PUNCT, Misc.FLG, 0, 'A')), '000000000000000010') + self.assertEqual(optimal_sequence_to_bits(b(Shift.PUNCT, Misc.FLG, 1, 3, 'A')), '0000000000001' + '0101' + '00010') # FLG(1) '3' + self.assertEqual(optimal_sequence_to_bits(b(Shift.PUNCT, Misc.FLG, 6, 3, 'A')), '0000000000110' + '0010'*5 + '0101' + '00010') # FLG(6) '000003' + + def test_get_data_codewords(self): + """ Test get_data_codewords function """ + self.assertEqual(get_data_codewords('000010', 6), [0b000010]) + self.assertEqual(get_data_codewords('111100', 6), [0b111100]) + self.assertEqual(get_data_codewords('111110', 6), [0b111110, 0b011111]) + self.assertEqual(get_data_codewords('000000', 6), [0b000001, 0b011111]) + self.assertEqual(get_data_codewords('111111', 6), [0b111110, 0b111110]) + self.assertEqual(get_data_codewords('111101111101', 6), [0b111101, 0b111101]) + + def _encode_and_decode(self, reader, data, *args, **kwargs): + with NamedTemporaryFile(suffix='.png') as f: + code = AztecCode(data, *args, **kwargs) + code.save(f, module_size=5) + try: + result = reader.decode(f.name, **(dict(encoding=None) if isinstance(data, bytes) else {})) + except Exception as exc: + raise unittest.SkipTest(f"Decode backend unavailable in this runtime: {exc}") from exc + assert result is not None + self.assertEqual(data, result.raw) + + @unittest.skipUnless(zxing, reason='Python module zxing cannot be imported; cannot test decoding.') + def test_barcode_readability(self): + r = zxing.BarCodeReader() + + # FIXME: ZXing command-line runner tries to coerce everything to UTF-8, at least on Linux, + # so we can only reliably encode and decode characters that are in the intersection of utf-8 + # and iso8559-1 (though with ZXing >=3.5, the iso8559-1 requirement is relaxed; see below). + # + # More discussion at: https://github.com/dlenski/python-zxing/issues/17#issuecomment-905728212 + # Proposed solution: https://github.com/dlenski/python-zxing/issues/19 + self._encode_and_decode(r, 'Wikipedia, the free encyclopedia', ec_percent=0) + self._encode_and_decode(r, 'Wow. Much error. Very correction. Amaze', ec_percent=95) + self._encode_and_decode(r, '¿Cuánto cuesta?') + + @unittest.skipUnless(zxing, reason='Python module zxing cannot be imported; cannot test decoding.') + def test_barcode_readability_eci(self): + r = zxing.BarCodeReader() + + # ZXing <=3.4.1 doesn't correctly decode ECI or FNC1 in Aztec (https://github.com/zxing/zxing/issues/1327), + # so we don't have a way to test readability of barcodes containing characters not in iso8559-1. + # ZXing 3.5.0 includes my contribution to decode Aztec codes with non-default charsets (https://github.com/zxing/zxing/pull/1328) + if r.zxing_version_info < (3, 5): + raise unittest.SkipTest("Running with ZXing v{}. In order to decode non-iso8859-1 charsets in Aztec Code, we need v3.5+".format(r.zxing_version)) + + self._encode_and_decode(r, 'The price is €4', encoding='utf-8') + self._encode_and_decode(r, 'אין לי מושג', encoding='iso8859-8') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/test_decode.py b/tests/test_decode.py new file mode 100644 index 0000000..10fed14 --- /dev/null +++ b/tests/test_decode.py @@ -0,0 +1,43 @@ +"""Decode utility tests.""" + +from __future__ import annotations + +import sys + +import pytest + +from aztec_py import decode + + +def test_decode_requires_optional_dependency(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delitem(sys.modules, "zxing", raising=False) + with pytest.raises(RuntimeError, match="optional dependency 'zxing'"): + decode("missing.png") + + +def test_decode_success_with_mocked_backend(monkeypatch: pytest.MonkeyPatch) -> None: + class Result: + raw = "ok" + + class Reader: + def decode(self, _source): + return Result() + + class ZXing: + BarCodeReader = Reader + + monkeypatch.setitem(sys.modules, "zxing", ZXing()) + assert decode("any.png") == "ok" + + +def test_decode_reports_empty_payload(monkeypatch: pytest.MonkeyPatch) -> None: + class Reader: + def decode(self, _source): + return None + + class ZXing: + BarCodeReader = Reader + + monkeypatch.setitem(sys.modules, "zxing", ZXing()) + with pytest.raises(RuntimeError, match="no payload"): + decode("any.png") diff --git a/tests/test_gs1.py b/tests/test_gs1.py new file mode 100644 index 0000000..fafa610 --- /dev/null +++ b/tests/test_gs1.py @@ -0,0 +1,45 @@ +"""GS1 helper tests.""" + +from __future__ import annotations + +import pytest + +from aztec_py import GROUP_SEPARATOR, GS1Element, build_gs1_payload + + +def test_build_gs1_payload_with_fixed_and_variable_fields() -> None: + payload = build_gs1_payload( + [ + GS1Element(ai="01", data="03453120000011"), + GS1Element(ai="17", data="120508"), + GS1Element(ai="10", data="ABCD1234", variable_length=True), + GS1Element(ai="410", data="9501101020917"), + ] + ) + assert payload == f"01034531200000111712050810ABCD1234{GROUP_SEPARATOR}4109501101020917" + + +def test_build_gs1_payload_omits_trailing_separator_for_last_variable_field() -> None: + payload = build_gs1_payload( + [ + GS1Element(ai="01", data="03453120000011"), + GS1Element(ai="10", data="LOT123", variable_length=True), + ] + ) + assert payload == "010345312000001110LOT123" + + +@pytest.mark.parametrize( + "elements", + [ + [], + [GS1Element(ai="A1", data="123")], + [GS1Element(ai="1", data="123")], + [GS1Element(ai="12345", data="123")], + [GS1Element(ai="10", data="")], + [GS1Element(ai="10", data="AB\x1dCD")], + ], +) +def test_build_gs1_payload_validates_inputs(elements: list[GS1Element]) -> None: + with pytest.raises(ValueError): + build_gs1_payload(elements) diff --git a/tests/test_modules.py b/tests/test_modules.py new file mode 100644 index 0000000..8cc8e24 --- /dev/null +++ b/tests/test_modules.py @@ -0,0 +1,15 @@ +"""Thin-module re-export tests.""" + +from aztec_py.error_correction import polynomials, reed_solomon +from aztec_py.matrix import get_config_from_table + + +def test_matrix_module_reexport() -> None: + cfg = get_config_from_table(15, True) + assert cfg.layers == 1 + + +def test_error_correction_module_reexport() -> None: + data = [0, 9] + [0, 0, 0, 0, 0] + reed_solomon(data, 2, 5, 16, polynomials[4]) + assert len(data) == 7 diff --git a/tests/test_property.py b/tests/test_property.py new file mode 100644 index 0000000..5809ea1 --- /dev/null +++ b/tests/test_property.py @@ -0,0 +1,21 @@ +"""Property-based tests for payload robustness.""" + +from __future__ import annotations + +from hypothesis import given, settings, strategies as st + +from aztec_py import AztecCode + + +@given(st.binary(min_size=1, max_size=500)) +@settings(max_examples=100) +def test_encode_arbitrary_bytes(data: bytes) -> None: + code = AztecCode(data) + assert code.size > 0 + + +@given(st.text(alphabet=st.characters(blacklist_categories=("Cs",)), min_size=1, max_size=300)) +@settings(max_examples=100) +def test_encode_arbitrary_text(text: str) -> None: + code = AztecCode(text, charset="utf-8") + assert code.size > 0 diff --git a/tests/test_renderers.py b/tests/test_renderers.py new file mode 100644 index 0000000..d9bdbc6 --- /dev/null +++ b/tests/test_renderers.py @@ -0,0 +1,42 @@ +"""Renderer-level tests (image/svg/pdf).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from aztec_py import AztecCode +from aztec_py.renderers import image_from_matrix, svg_from_matrix + + +def test_svg_renderer_emits_svg() -> None: + matrix = [[1, 0], [0, 1]] + svg = svg_from_matrix(matrix, module_size=2, border=1) + assert svg.startswith('') + assert " None: + matrix = [[1, 0], [0, 1]] + image = image_from_matrix(matrix, module_size=3, border=1) + assert image.size == (12, 12) + + +def test_save_svg_file(tmp_path: Path) -> None: + code = AztecCode("renderer-test") + target = tmp_path / "code.svg" + code.save(target, module_size=2) + payload = target.read_text(encoding="utf-8") + assert " None: + fpdf = pytest.importorskip("fpdf") + assert fpdf is not None + code = AztecCode("pdf-test") + target = tmp_path / "code.pdf" + code.save(target, module_size=2) + assert target.exists() + assert target.stat().st_size > 64 diff --git a/tests/test_rune.py b/tests/test_rune.py new file mode 100644 index 0000000..bae2ee3 --- /dev/null +++ b/tests/test_rune.py @@ -0,0 +1,53 @@ +"""Aztec Rune tests.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from aztec_py import AztecRune + + +def test_rune_bounds() -> None: + with pytest.raises(ValueError): + AztecRune(-1) + with pytest.raises(ValueError): + AztecRune(256) + + +def test_rune_matrix_shape() -> None: + rune = AztecRune(42) + assert len(rune.matrix) == 11 + assert all(len(row) == 11 for row in rune.matrix) + + +def test_rune_svg() -> None: + rune = AztecRune(42) + svg = rune.svg() + assert " None: + rune = AztecRune(42) + target = tmp_path / "rune.png" + rune.save(target, module_size=4) + assert target.exists() + assert target.stat().st_size > 0 + + +def test_rune_save_svg_file_object(tmp_path: Path) -> None: + rune = AztecRune(7) + target = tmp_path / "rune.svg" + with target.open("w", encoding="utf-8") as file_obj: + rune.save(file_obj, format="svg") + assert " None: + pytest.importorskip("fpdf") + rune = AztecRune(99) + target = tmp_path / "rune.pdf" + rune.save(target, format="pdf") + assert target.exists() + assert target.stat().st_size > 64 diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..fb80ddd --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,25 @@ +"""Validation tests for public AztecCode API.""" + +import pytest + +from aztec_py import AztecCode + + +def test_ec_percent_lower_bound() -> None: + with pytest.raises(ValueError, match="between 5 and 95"): + AztecCode("data", ec_percent=4) + + +def test_ec_percent_upper_bound() -> None: + with pytest.raises(ValueError, match="between 5 and 95"): + AztecCode("data", ec_percent=96) + + +def test_empty_data_rejected() -> None: + with pytest.raises(ValueError, match="data must not be empty"): + AztecCode("") + + +def test_charset_encoding_mismatch_rejected() -> None: + with pytest.raises(ValueError, match="encoding and charset must match"): + AztecCode("hello", encoding="utf-8", charset="iso-8859-1") From 1893a6ae3d10acf81418be3f076d83cc4c75457d Mon Sep 17 00:00:00 2001 From: greyllmmoder Date: Sun, 5 Apr 2026 13:03:42 +0530 Subject: [PATCH 2/2] fix(ci): use editable install for reliable coverage tracing --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b410d31..f789203 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install ".[dev,image,pdf]" + python -m pip install -e ".[dev,image,pdf]" - name: Run tests run: | @@ -43,7 +43,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install ".[dev,image,pdf]" + python -m pip install -e ".[dev,image,pdf]" - name: Lint run: |