diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f789203
--- /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 -e ".[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 -e ".[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.
+[](https://github.com/greyllmmoder/python-aztec/actions/workflows/ci.yml)
+[](https://pypi.org/project/aztec-py/)
+[](https://github.com/greyllmmoder/python-aztec)
+[](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'"
+ )
+ 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 "