diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index f378d8ea..f81bf9f8 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -5,7 +5,7 @@ on: jobs: linting: - name: Code Quality Checks + name: Code Linting & Formatting runs-on: ubuntu-latest steps: - name: Checkout Code @@ -23,4 +23,21 @@ jobs: - name: Code Formatting if: always() - run: uv run ruff format --check qrcode \ No newline at end of file + run: uv run ruff format --check qrcode + + type-checking: + name: Type Checking (mypy) + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + python-version: "3.12" + + - name: Type Checking + run: uv run mypy qrcode diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 12f517f1..8129f36e 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -1,31 +1,117 @@ name: Testsuite Run -on: [push] +on: [push, pull_request] jobs: test: - name: Test Python ${{ matrix.python-version }} + name: Test Python ${{ matrix.python-version }} (${{ matrix.extra }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + extra: [pil, png, none] steps: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Test with ${{ matrix.extra }} + run: | + if [ "${{ matrix.extra }}" = "none" ]; then + uv run --group dev pytest --cov=qrcode --cov-report=xml --cov-report=term-missing + else + uv run --extra ${{ matrix.extra }} --group dev pytest --cov=qrcode --cov-report=xml --cov-report=term-missing + fi + + - name: Upload coverage to Codecov + if: matrix.python-version == '3.12' && matrix.extra == 'pil' + uses: codecov/codecov-action@v5 + with: + file: ./coverage.xml + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + type-check: + name: Type checking (mypy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Run mypy + run: uv run mypy qrcode/ + + lint: + name: Linting (ruff) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Run ruff check + run: uv run ruff check qrcode/ + + build-wheel: + name: Build wheel (packaging verification) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: enable-cache: true cache-dependency-glob: "uv.lock" - - name: Test with pil - run: uv run --extra pil --group dev pytest + - name: Build wheel and sdist + run: uv build - - name: Test with png - run: uv run --extra png --group dev pytest + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels + path: dist/ - - name: Test with none - run: uv run --group dev pytest \ No newline at end of file + docs-build: + name: Build documentation (Sphinx) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - run: uv sync --group dev + + - run: uv run sphinx-build -b html doc/ doc/_build/html + + - name: Upload documentation artifact + uses: actions/upload-artifact@v4 + with: + name: sphinx-docs + path: doc/_build/html/ diff --git a/.gitignore b/.gitignore index 802d83db..985033c3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ build/ cov.xml dist/ +doc/_build/ htmlcov/ poetry.lock uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..c4cbe55e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +# Pre-commit hooks for python-qrcode +# Install: pip install pre-commit && pre-commit install +# Run manually: pre-commit run --all-files + +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.14 + hooks: + - id: ruff-check + args: [--fix] + exclude: ^qrcode/tests/ + - id: ruff-format + + - repo: local + hooks: + - id: mypy + name: mypy + entry: .venv/bin/mypy qrcode/ + language: system + types: [python] + exclude: ^qrcode/tests/ diff --git a/CHANGES.rst b/CHANGES.rst index 26e6b6aa..d346f7b7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,141 @@ Changes ======= +.. towncrier-release-announcement-start + +## [9.0] — Breaking Changes +============================ + +Format follows `Keep a Changelog `_. + +**This is a major release with breaking changes.** See the migration guide below. + +Removed +------- + +- **Deprecated ``embeded_*`` parameters removed** (TASK-36). The misspelled + parameters ``embeded_image_path``, ``embeded_image``, ``embeded_image_ratio``, + and ``embeded_image_resample`` are no longer accepted by + :class:`~qrcode.image.styledpil.StyledPilImage`. Use the correctly spelled + ``embedded_*`` variants instead. + +- **Deprecated PIL drawer imports removed** (TASK-37). Importing drawers directly + from ``qrcode.image.styles.moduledrawers`` is no longer supported. Import from + the submodule instead:: + + # Old (removed in v9.0) + from qrcode.image.styles.moduledrawers import SquareModuleDrawer + + # New (required since v9.0) + from qrcode.image.styles.moduledrawers.pil import SquareModuleDrawer + +- **Deprecated ``draw_embeded_image()`` method removed** (TASK-38). The misspelled + alias on :class:`~qrcode.image.styledpil.StyledPilImage` has been removed. + Use :meth:`~qrcode.image.styledpil.StyledPilImage.draw_embedded_image` instead. + +Changed +------- + +- ``QRCode.print_ascii()`` now accepts an ``out`` parameter for writing to a + custom text stream and restored the original CP437 block-character rendering. +- ``QRCode.print_tty()`` method restored with ANSI terminal escape code output. + +Migration Guide +--------------- + +1. **Replace misspelled parameters:** + + .. code-block:: python + + # Before (v8.x) + StyledPilImage(embeded_image_path="logo.png") + + # After (v9.0+) + StyledPilImage(embedded_image_path="logo.png") + +2. **Update drawer imports:** + + .. code-block:: python + + # Before (v8.x) + from qrcode.image.styles.moduledrawers import SquareModuleDrawer + + # After (v9.0+) + from qrcode.image.styles.moduledrawers.pil import SquareModuleDrawer + +3. **Update method calls:** + + .. code-block:: python + + # Before (v8.x) + img.draw_embeded_image() + + # After (v9.0+) + img.draw_embedded_image() + +.. towncrier-release-announcement-start + +[Unreleased] +============ + +Format follows `Keep a Changelog `_. + +Added +----- + +- **py.typed** file for PEP 561 native type stub support (P0) +- Type hints on core modules: ``main.py``, ``util.py``, ``base.py``, + ``exceptions.py``, ``image/base.py``, factory subclasses, + ``console_scripts.py``, ``colormasks.py``, ``moduledrawers/pil.py`` (P0–P2) +- Google-style docstrings on all public classes and methods in styled image + modules (``QRColorMask`` subclasses, ``QRModuleDrawer`` subclasses) (P1–P2) +- CLI options ``--box-size``, ``--border``, ``--qr-version`` for fine-grained + QR code control from the command line (TASK-23) +- Regression tests with visual determinism checks (``test_visual_determinism.py``) (TASK-12) +- Parametrised combination tests for all mode × error-correction levels + (``test_combinations.py``) (TASK-13) +- Shared pytest fixtures in ``conftest.py`` reused across test modules (TASK-14) +- Custom eyes support via ``eye_patterns`` parameter on ``QRCode`` / image + factories (Issue #237, TASK-15) +- Codecov badge and upload workflow in CI (TASK-18) +- Wheel build job in CI for packaging verification (TASK-26) + +Changed +------- + +- Refactored ``console_scripts.main()`` into ``_parse_args``, ``_create_qr``, + helper functions — main entry point < 40 lines (TASK-16) +- Improved ``BaseImage`` API documentation with custom factory example (TASK-17) +- CHANGES.rst restructured to semantic changelog format (this change, TASK-24) + +Deprecated +---------- + +- Importing PIL drawers from ``qrcode.image.styles.moduledrawers`` is deprecated; + import directly from ``qrcode.image.styles.moduledrawers.pil`` instead. + Will be removed in v9.0. +- Parameters ``embeded_image`` / ``embeded_image_path`` (typo) are deprecated; + use ``embedded_image`` / ``embedded_image_path``. Will be removed in v9.0. + +Fixed +----- + +- Thread safety issue in ``bisect_left`` usage (Fixes #421) +- ``ValueError: glog(0)`` when encoding zero-heavy data (Fixes #330) +- Mask evaluation now includes format info, version info, and dark module per + ISO 18004 §7.8.3.1 (#389) +- **``optimal_data_chunks()`` no longer drops short non-matching segments** — data + integrity is preserved when the optimiser splits mixed-type input (TASK-40). + Previously, segments shorter than *minimum* were silently discarded. +- Added ``__all__`` exports to all public modules for explicit API surface (TASK-44) + +Security +-------- + +- No security changes in this release. + +.. towncrier-release-announcement-end + Deprecation Warnings ==================== diff --git a/README.rst b/README.rst index 73722544..3e138388 100644 --- a/README.rst +++ b/README.rst @@ -2,6 +2,27 @@ Pure python QR Code generator ============================= +.. image:: https://img.shields.io/pypi/v/qrcode.svg + :target: https://pypi.org/project/qrcode/ + :alt: PyPI version + +.. image:: https://img.shields.io/pypi/pyversions/qrcode.svg + :target: https://pypi.org/project/qrcode/ + :alt: Python versions + +.. image:: https://github.com/lincolnloop/python-qrcode/actions/workflows/push.yml/badge.svg + :target: https://github.com/lincolnloop/python-qrcode/actions + :alt: Build status + +.. image:: https://img.shields.io/pypi/l/qrcode.svg + :target: https://opensource.org/licenses/BSD-3-Clause + :alt: License + +.. image:: https://codecov.io/gh/lincolnloop/python-qrcode/graph/badge.svg?token=PLACEHOLDER + :target: https://codecov.io/gh/lincolnloop/python-qrcode + :alt: Code coverage + + Generate QR codes. A standard install uses pypng_ to generate PNG files and can also render QR @@ -233,11 +254,106 @@ and an embedded image: from qrcode.image.styles.colormasks import RadialGradiantColorMask qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_H) - qr.add_data('Some data') + qr.add_data('https://example.com') + qr.make(fit=True) + + # Combined: rounded modules + radial gradient + embedded logo + img = qr.make_image( + image_factory=StyledPilImage, + module_drawer=RoundedModuleDrawer(), + color_mask=RadialGradiantColorMask( + center_color=(255, 0, 0), + edge_color=(0, 0, 255), + ), + embedded_image_path="/path/to/logo.png", + ) + img.save("styled_qr.png") + +**Available Module Drawers (PIL)** + +The following drawers control the shape of individual QR modules: + +- ``SquareModuleDrawer`` — default square blocks +- ``RoundedModuleDrawer`` — rounded corners for a softer look +- ``GappedCircleDrawer`` — circular modules with gaps between them +- ``VerticalBarsDrawer`` — vertical bar style (like a barcode) + +All drawers accept a ``size_ratio`` parameter (default ``1.0``) to control the +module size relative to the available space. Values less than 1.0 create gaps +between modules. + +**Available Color Masks** + +Color masks control the foreground color of dark modules: + +- ``SolidFillColorMask`` — single uniform color (default: black) +- ``RadialGradiantColorMask`` — radial gradient from center to edge +- ``HorizontalGradiantColorMask`` — left-to-right gradient +- ``VerticalGradiantColorMask`` — top-to-bottom gradient +- ``ImageColorMask`` — colors derived from a reference image + +All color masks support an optional ``back_color`` parameter for the background. +Use ``(255, 255, 255, 0)`` (RGBA with alpha=0) for transparency. + +**CLI Advanced Usage** + +The command-line tool supports multiple output formats: - img_1 = qr.make_image(image_factory=StyledPilImage, module_drawer=RoundedModuleDrawer()) - img_2 = qr.make_image(image_factory=StyledPilImage, color_mask=RadialGradiantColorMask()) - img_3 = qr.make_image(image_factory=StyledPilImage, embedded_image_path="/path/to/image.png") +.. code:: bash + + # Basic PNG output + qr "https://example.com" > qr.png + + # SVG path output (recommended for web) + qr --factory=svg-path "https://example.com" > qr.svg + + # ASCII art to terminal + qr --terminal "https://example.com" + + # ASCII art to file + qr --ascii "https://example.com" > qr.txt + + # Custom output file (avoids PowerShell piping issues) + qr --output=qr.png "https://example.com" + + # Using a specific module drawer with StyledPilImage + qr --factory=styledpil --drawer=circle "https://example.com" > styled.svg + + +Custom Image Factory +==================== + +You can create your own image output format by subclassing +:class:`qrcode.image.base.BaseImage`. Three abstract methods must be +implemented: + +1. ``new_image(**kwargs)`` — instantiate and return the underlying image object. +2. ``drawrect(row, col)`` — draw a single dark module at grid position *(row, col)*. +3. ``save(stream, kind=None)`` — write the finished image to *stream*. + +Use :meth:`~qrcode.image.base.BaseImage.pixel_box` to convert module coordinates +to pixel coordinates. Override :meth:`~qrcode.image.base.BaseImage.process` for +post-processing (called before saving when ``needs_processing = True``). + +Minimal example:: + + from qrcode.image.base import BaseImage + + class MyFormat(BaseImage): + kind = "MYFMT" + + def new_image(self, **kwargs): + return [] # placeholder image object + + def drawrect(self, row, col): + self._img.append((row, col)) + + def save(self, stream, kind=None): + for coord in self._img: + stream.write(f"module {coord}\\n".encode()) + + import qrcode + img = qrcode.make("Hello!", image_factory=MyFormat) Examples ======== diff --git a/doc/conf.py b/doc/conf.py new file mode 100644 index 00000000..6787579e --- /dev/null +++ b/doc/conf.py @@ -0,0 +1,33 @@ +"""Sphinx configuration for python-qrcode API documentation.""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +project = "python-qrcode" +copyright = "2025, Lincoln Loop" # noqa: A001 +author = "Lincoln Loop" + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", +] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +html_theme = "alabaster" +html_static_path: list[str] = [] + +autodoc_default_options = { + "members": True, + "undoc-members": True, + "show-inheritance": True, +} + +napoleon_google_docstring = True +napoleon_include_init_with_doc = True diff --git a/doc/index.rst b/doc/index.rst new file mode 100644 index 00000000..ddedae92 --- /dev/null +++ b/doc/index.rst @@ -0,0 +1,129 @@ +python-qrcode — API Reference +============================== + +Generate QR codes from the command line or with a simple Python library. + +.. toctree:: + :maxdepth: 2 + :caption: Modules + + modules.rst + + +Quick Start +----------- + +.. code-block:: python + + import qrcode + + img = qrcode.make("https://example.com") + img.save("qr.png") + + +Image Factories +--------------- + +The library supports multiple output formats via image factory classes. + +PIL (PNG/JPEG) +~~~~~~~~~~~~~~ + +.. autoclass:: qrcode.image.pil.PilImage + :members: + :undoc-members: + :show-inheritance: + +Styled PIL +~~~~~~~~~~ + +.. autoclass:: qrcode.image.styledpil.StyledPilImage + :members: + :undoc-members: + :show-inheritance: + +SVG Fragment +~~~~~~~~~~~~ + +.. autoclass:: qrcode.image.svg.SvgFragmentImage + :members: + :undoc-members: + :show-inheritance: + +SVG Standalone +~~~~~~~~~~~~~~ + +.. autoclass:: qrcode.image.svg.SvgImage + :members: + :undoc-members: + :show-inheritance: + +SVG Path +~~~~~~~~ + +.. autoclass:: qrcode.image.svg.SvgPathImage + :members: + :undoc-members: + :show-inheritance: + +PyPNG (pure Python) +~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: qrcode.image.pure.PyPNGImage + :members: + :undoc-members: + :show-inheritance: + + +Colour Masks +------------ + +.. automodule:: qrcode.image.styles.colormasks + :members: + :undoc-members: + :show-inheritance: + + +Module Drawers +-------------- + +.. autoclass:: qrcode.image.styles.moduledrawers.base.QRModuleDrawer + :members: + :undoc-members: + :show-inheritance: + +PIL Drawers +~~~~~~~~~~~ + +.. automodule:: qrcode.image.styles.moduledrawers.pil + :members: + :undoc-members: + :show-inheritance: + +SVG Drawers +~~~~~~~~~~~ + +.. automodule:: qrcode.image.styles.moduledrawers.svg + :members: + :undoc-members: + :show-inheritance: + + +Core API +-------- + +.. autoclass:: qrcode.main.QRCode + :members: + :undoc-members: + :show-inheritance: + +.. autofunction:: qrcode.make + + +Exceptions +---------- + +.. automodule:: qrcode.exceptions + :members: + :undoc-members: + :show-inheritance: diff --git a/doc/modules.rst b/doc/modules.rst new file mode 100644 index 00000000..c830a63b --- /dev/null +++ b/doc/modules.rst @@ -0,0 +1,7 @@ +Module Index +============ + +.. automodule:: qrcode + :members: + :undoc-members: + :show-inheritance: diff --git a/pyproject.toml b/pyproject.toml index 54b75417..0425f5f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "qrcode" -version = "8.2" +version = "9.0" description = "QR Code image generator" authors = [ { name = "Lincoln Loop", email = "info@lincolnloop.com" }, @@ -55,10 +55,12 @@ dev = [ "pytest", "pytest-cov", "ruff", + "mypy>=1.13", "pypng", "pillow>=9.1.0", "docutils>=0.21.2", "zest-releaser[recommended]>=9.2.0", + "sphinx>=7.0", ] [tool.poetry] @@ -86,6 +88,7 @@ prereleaser.middle = [ [tool.coverage.run] source = ["qrcode"] parallel = true +omit = ["qrcode/tests/*", "qrcode/release.py"] [tool.coverage.report] exclude_lines = [ @@ -96,6 +99,8 @@ exclude_lines = [ "raise NotImplementedError" ] skip_covered = true +show_missing = true +fail_under = 90 [tool.ruff] target-version = "py310" @@ -105,7 +110,6 @@ lint.ignore = [ # Safe to ignore "A001", # Variable is shadowing a Python builtin "A002", # Function argument is shadowing a Python builtin - "ANN", # Missing Type Annotation "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in `**kwargs`" "ARG001", # Unused function argument (request, ...) "ARG002", # Unused method argument (*args, **kwargs) @@ -135,13 +139,54 @@ lint.ignore = [ # Required for `ruff format` to work correctly "COM812", # Checks for the absence of trailing commas "ISC001", # Checks for implicitly concatenated strings on a single line + + # Cosmetic / stylistic — not critical for correctness + "E402", # Module level import not at top of file (docstring after __future__) + "T20", # print() calls (needed in CLI for stderr output) + "RUF001", # String contains ambiguous unicode characters + "RUF002", # Docstring contains ambiguous unicode characters + + # Pre-existing — not introduced by P1 tasks + "TC003", # Type-checking block imports (safe with from __future__ annotations) + "B007", # Unused loop variable (pre-existing in util.py) ] [tool.ruff.lint.extend-per-file-ignores] "qrcode/tests/*.py" = [ + "ANN", # Type annotations not required in tests "F401", # Unused import + "I001", # Import sorting (cosmetic — tests import what they need) "PLC0415", # Import not at top of a file "PT011", # pytest.raises is too broad + "PT018", # Assertion should be broken down (unsafe auto-fix) + "PT028", # Test param with default arg (pre-existing pattern) + "RUF003", # Ambiguous unicode in comments (pre-existing) + "RUF061", # Context-manager form of pytest.raises (pre-existing) "S101", # Use of 'assert' detected "S603", # `subprocess` call: check for execution of untrusted input ] +"qrcode/release.py" = ["ANN"] # Release tooling — not part of public API + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = false +check_untyped_defs = true +disallow_untyped_decorators = false +no_implicit_optional = true +strict_optional = true +ignore_missing_imports = true # External packages without type stubs (colorama, deprecation) + +# Per-file overrides: relax strict rules for files with legitimate patterns +[[tool.mypy.overrides]] +module = "qrcode.compat.png" +# Conditional import pattern: PngWriter = None then try/except redefines it +warn_no_return = false +disable_error_code = ["no-redef"] + +[[tool.mypy.overrides]] +module = "qrcode.release" +# Release tooling — not part of public API, gradual typing +disallow_untyped_defs = false diff --git a/qrcode/LUT.py b/qrcode/LUT.py index 88ee87b7..499ec7c6 100644 --- a/qrcode/LUT.py +++ b/qrcode/LUT.py @@ -1,5 +1,9 @@ # Store all kinds of lookup table. +__all__: list[str] = [ + "rsPoly_LUT", +] + # # generate rsPoly lookup table. diff --git a/qrcode/__init__.py b/qrcode/__init__.py index 4cbda4d0..d3bc7232 100644 --- a/qrcode/__init__.py +++ b/qrcode/__init__.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import Any + from qrcode import image from qrcode.constants import ( ERROR_CORRECT_H, @@ -19,18 +23,24 @@ ] -def run_example(data="http://www.lincolnloop.com", *args, **kwargs): - """ - Build an example QR Code and display it. +def run_example( + data: str = "http://www.lincolnloop.com", *args: Any, **kwargs: Any +) -> None: + """Build an example QR Code and display it. There's an even easier way than the code here though: just use the ``make`` shortcut. + + Args: + data: Data string to encode in the QR code. + *args: Extra positional arguments forwarded to :class:`QRCode`. + **kwargs: Extra keyword arguments forwarded to :class:`QRCode`. """ qr = QRCode(*args, **kwargs) qr.add_data(data) im = qr.make_image() - im.show() + im.show() # type: ignore[attr-defined] if __name__ == "__main__": # pragma: no cover diff --git a/qrcode/__main__.py b/qrcode/__main__.py index 8026a67f..a001552f 100644 --- a/qrcode/__main__.py +++ b/qrcode/__main__.py @@ -1,3 +1,10 @@ +"""Entry point for ``python -m qrcode``.""" + +from __future__ import annotations + +__all__: list[str] = [] + + from .console_scripts import main main() diff --git a/qrcode/base.py b/qrcode/base.py index e7eca852..ff134491 100644 --- a/qrcode/base.py +++ b/qrcode/base.py @@ -1,10 +1,44 @@ -from typing import NamedTuple +"""Reed-Solomon error correction and Galois field arithmetic for QR codes. + +This module implements the GF(256) arithmetic required by the Reed-Solomon +error-correction algorithm defined in ISO/IEC 18004. It provides: + +- Lookup tables ``EXP_TABLE`` and ``LOG_TABLE`` for fast exponentiation / + logarithm in the Galois field (``gexp()`` / ``glog()``). +- The :class:`Polynomial` class for polynomial arithmetic over GF(256). +- The :class:`~qrcode.base.RSBlock` named-tuple describing an error-correction block. +- The ``rs_blocks()`` function to look up the RS block layout for a given + version and error correction level. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import NamedTuple, overload from qrcode import constants -EXP_TABLE = list(range(256)) +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +__all__: list[str] = [ + "EXP_TABLE", + "LOG_TABLE", + "Polynomial", + "RSBlock", + "gexp", + "glog", + "rs_blocks", +] + +# --------------------------------------------------------------------------- +# Galois field GF(256) lookup tables (generated at module load time) +# --------------------------------------------------------------------------- -LOG_TABLE = list(range(256)) +EXP_TABLE: list[int] = list(range(256)) + +LOG_TABLE: list[int] = list(range(256)) for i in range(8): EXP_TABLE[i] = 1 << i @@ -17,18 +51,22 @@ for i in range(255): LOG_TABLE[EXP_TABLE[i]] = i -RS_BLOCK_OFFSET = { + +# --------------------------------------------------------------------------- +# RS block table: indexed by (version-1)*4 + error_correction_offset +# Each row is a flat tuple of (count, total_count, data_count) triplets. +# Some entries have two groups with different sizes. +# --------------------------------------------------------------------------- + +RS_BLOCK_OFFSET: dict[int, int] = { constants.ERROR_CORRECT_L: 0, constants.ERROR_CORRECT_M: 1, constants.ERROR_CORRECT_Q: 2, constants.ERROR_CORRECT_H: 3, } -RS_BLOCK_TABLE = ( - # L - # M - # Q - # H +RS_BLOCK_TABLE: tuple[tuple[int, ...], ...] = ( + # L M Q H # version # 1 (1, 26, 19), (1, 26, 16), @@ -232,78 +270,204 @@ ) -def glog(n): +# --------------------------------------------------------------------------- +# Galois field helper functions +# --------------------------------------------------------------------------- + + +def glog(n: int) -> int: + """Return the discrete logarithm of *n* in GF(256). + + Uses a precomputed lookup table for O(1) performance. + + Args: + n: Field element (1–255). ``glog(0)`` is undefined and raises. + + Returns: + Integer exponent *e* such that ``gexp(e) == n``. + + Raises: + ValueError: If *n* < 1. + """ if n < 1: # pragma: no cover raise ValueError(f"glog({n})") return LOG_TABLE[n] -def gexp(n): +def gexp(n: int) -> int: + """Return ``0x01`` raised to the power *n* in GF(256). + + Uses a precomputed lookup table. The result wraps modulo 255 so that + exponents outside [0, 254] are still valid. + + Args: + n: Exponent (any integer; wrapped mod 255 internally). + + Returns: + Field element value in range [1, 255]. + """ return EXP_TABLE[n % 255] +# --------------------------------------------------------------------------- +# Polynomial arithmetic over GF(256) +# --------------------------------------------------------------------------- + + class Polynomial: - def __init__(self, num, shift): - if not num: # pragma: no cover - raise ValueError(f"{len(num)}/{shift}") + """Polynomial with coefficients in the Galois field GF(256). + + Coefficients are stored least-significant first (index 0 = constant term). + Arithmetic operators work in GF(256): addition is XOR, multiplication uses + ``gexp`` / ``glog`` lookup tables. + + Attributes: + num: List of coefficient values (with leading zeros stripped). + """ + + def __init__(self, data: list[int], shift: int) -> None: + """Create a polynomial from raw coefficients. + + Leading zero coefficients are stripped and *shift* trailing zeros are + appended to account for multiplication by ``x**shift``. + + Args: + data: Coefficient values (least-significant first). Must be non-empty + after stripping leading zeros. + shift: Number of trailing zeros to append (equivalent to multiplying + the polynomial by ``x**shift``). + + Raises: + ValueError: If *data* is empty or contains only zeros. + """ + if not data: # pragma: no cover + raise ValueError(f"{len(data)}/{shift}") offset = 0 - for offset in range(len(num)): - if num[offset] != 0: + for offset in range(len(data)): + if data[offset] != 0: break - self.num = num[offset:] + [0] * shift + self.num: list[int] = data[offset:] + [0] * shift - def __getitem__(self, index): - return self.num[index] + @overload + def __getitem__(self, index: int) -> int: ... - def __iter__(self): + @overload + def __getitem__(self, index: slice) -> list[int]: ... + + def __getitem__(self, index: int | slice) -> int | list[int]: + """Return coefficient at *index* (or a slice of coefficients).""" + return self.num[index] # type: ignore[return-value] + + def __iter__(self) -> Iterator[int]: + """Iterate over all coefficients.""" return iter(self.num) - def __len__(self): + def __len__(self) -> int: + """Return the number of coefficients (degree + 1).""" return len(self.num) - def __mul__(self, other): + def __mul__(self, other: Polynomial) -> Polynomial: + """Multiply two GF(256) polynomials. + + Args: + other: The second polynomial. + + Returns: + A new :class:`Polynomial` representing the product. + """ num = [0] * (len(self) + len(other) - 1) for i, item in enumerate(self): for j, other_item in enumerate(other): if item == 0 or other_item == 0: continue - num[i + j] ^= gexp(glog(item) + glog(other_item)) + num[i + j] ^= gexp(glog(int(item)) + glog(int(other_item))) return Polynomial(num, 0) - def __mod__(self, other): + def __mod__(self, other: Polynomial) -> Polynomial: + """Polynomial remainder (division) in GF(256). + + Args: + other: The divisor polynomial. + + Returns: + A new :class:`Polynomial` representing ``self mod other``. + """ difference = len(self) - len(other) if difference < 0: return self if self[0] == 0: - num = list(self[1:]) + num = list(self[1:]) # type: ignore[arg-type] if difference: num.append(0) return Polynomial(num, 0) % other - ratio = glog(self[0]) - glog(other[0]) + ratio = glog(int(self[0])) - glog(int(other[0])) num = [ - item ^ gexp(glog(other_item) + ratio) + int(item) ^ gexp(glog(int(other_item)) + ratio) for item, other_item in zip(self, other, strict=False) ] if difference: - num.extend(self[-difference:]) + num.extend(list(self[-difference:])) # type: ignore[arg-type] - # recursive call return Polynomial(num, 0) % other + def __repr__(self) -> str: + """Return a human-readable representation of the polynomial.""" + return f"Polynomial({self.num!r})" + + +# --------------------------------------------------------------------------- +# RS block description +# --------------------------------------------------------------------------- + class RSBlock(NamedTuple): + """Description of a single Reed-Solomon error-correction block. + + Attributes: + total_count: Total number of bytes in the block (data + EC). + data_count: Number of data bytes (the remainder are EC bytes). + """ + total_count: int data_count: int + def __repr__(self) -> str: + """Return ``RSBlock(total=..., data=...)``.""" + return ( + f"RSBlock(total_count={self.total_count!r}, data_count={self.data_count!r})" + ) + + +# --------------------------------------------------------------------------- +# RS block lookup +# --------------------------------------------------------------------------- + + +def rs_blocks(version: int, error_correction: int) -> list[RSBlock]: + """Return the Reed-Solomon block layout for a given version and EC level. + + Looks up the precomputed ``RS_BLOCK_TABLE`` and expands it into a flat + list of :class:`RSBlock` instances (some versions use multiple blocks of + different sizes). + + Args: + version: QR code version (1–40). + error_correction: One of + :data:`~qrcode.constants.ERROR_CORRECT_L`, ``M``, ``Q``, or ``H``. + + Returns: + List of :class:`RSBlock` describing each EC block. -def rs_blocks(version, error_correction): + Raises: + ValueError: If *error_correction* is not a recognized constant. + """ if error_correction not in RS_BLOCK_OFFSET: # pragma: no cover raise ValueError( f"bad rs block @ version: {version} / error_correction: {error_correction}" @@ -311,7 +475,7 @@ def rs_blocks(version, error_correction): offset = RS_BLOCK_OFFSET[error_correction] rs_block = RS_BLOCK_TABLE[(version - 1) * 4 + offset] - blocks = [] + blocks: list[RSBlock] = [] for i in range(0, len(rs_block), 3): count, total_count, data_count = rs_block[i : i + 3] diff --git a/qrcode/compat/__init__.py b/qrcode/compat/__init__.py index e69de29b..e3a5ad69 100644 --- a/qrcode/compat/__init__.py +++ b/qrcode/compat/__init__.py @@ -0,0 +1,5 @@ +"""Compatibility shims for optional dependencies.""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/qrcode/compat/etree.py b/qrcode/compat/etree.py index 300a757a..619435e0 100644 --- a/qrcode/compat/etree.py +++ b/qrcode/compat/etree.py @@ -1,4 +1,12 @@ +"""Compatibility shim for ElementTree — prefers lxml if available.""" + +from __future__ import annotations + try: import lxml.etree as ET # noqa: N812 except ImportError: - import xml.etree.ElementTree as ET # noqa: F401 + import xml.etree.ElementTree as ET + +__all__: list[str] = [ + "ET", +] diff --git a/qrcode/compat/png.py b/qrcode/compat/png.py index e7883dc4..4b2c8233 100644 --- a/qrcode/compat/png.py +++ b/qrcode/compat/png.py @@ -1,7 +1,15 @@ +"""Compatibility shim for pypng — provides PngWriter if available.""" + +from __future__ import annotations + # Try to import png library. -PngWriter = None +PngWriter: type | None = None try: - from png import Writer as PngWriter # noqa: F401 + from png import Writer as PngWriter except ImportError: pass + +__all__: list[str] = [ + "PngWriter", +] diff --git a/qrcode/console_scripts.py b/qrcode/console_scripts.py index 431bcb20..2e882ea3 100755 --- a/qrcode/console_scripts.py +++ b/qrcode/console_scripts.py @@ -1,6 +1,5 @@ #!/usr/bin/env python -""" -qr - Convert stdin (or the first argument) to a QR Code. +"""qr - Convert stdin (or the first argument) to a QR Code. When stdout is a tty the QR Code is printed to the terminal and when stdout is a pipe to a file an image is written. The default image format is PNG. @@ -13,22 +12,31 @@ import sys from importlib import metadata from pathlib import Path -from typing import TYPE_CHECKING, NoReturn - -import qrcode +from typing import TYPE_CHECKING, Any, NamedTuple, NoReturn if TYPE_CHECKING: from collections.abc import Iterable from qrcode.image.base import BaseImage, DrawerAliases +import qrcode + +# --------------------------------------------------------------------------- +# Environment setup (Windows colour support) +# --------------------------------------------------------------------------- + # The next block is added to get the terminal to display properly on MS platforms if sys.platform.startswith(("win", "cygwin")): # pragma: no cover import colorama colorama.init() -default_factories = { + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +DEFAULT_FACTORIES: dict[str, str] = { "pil": "qrcode.image.pil.PilImage", "png": "qrcode.image.pure.PyPNGImage", "svg": "qrcode.image.svg.SvgImage", @@ -38,31 +46,66 @@ "pymaging": "qrcode.image.pure.PymagingImage", } -error_correction = { +ERROR_CORRECTION_MAP: dict[str, int] = { "L": qrcode.ERROR_CORRECT_L, "M": qrcode.ERROR_CORRECT_M, "Q": qrcode.ERROR_CORRECT_Q, "H": qrcode.ERROR_CORRECT_H, } +__all__: list[str] = [ + "ParsedArgs", + "get_drawer_help", + "main", +] + -def main(args=None): - if args is None: - args = sys.argv[1:] +# --------------------------------------------------------------------------- +# Parsed arguments container +# --------------------------------------------------------------------------- + +class ParsedArgs(NamedTuple): + """Container for CLI options parsed by ``_parse_args``.""" + + factory: str | None + factory_drawer: str | None + optimize: int | None + error_correction: str + ascii_output: bool + output_path: str | None + data_arg: str | None + box_size: int | None + border: int | None + version: int | None + + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + + +def _parse_args(argv: list[str]) -> ParsedArgs: + """Parse command-line arguments into a :class:`ParsedArgs` named tuple. + + Args: + argv: List of argument strings (typically ``sys.argv[1:]``). + + Returns: + A :class:`ParsedArgs` instance with all recognised options populated. + """ version = metadata.version("qrcode") parser = optparse.OptionParser(usage=(__doc__ or "").strip(), version=version) - # Wrap parser.error in a typed NoReturn method for better typing. def raise_error(msg: str) -> NoReturn: parser.error(msg) - raise # pragma: no cover # noqa: PLE0704 + raise # pragma: no cover # noqa: PLE0704 parser.add_option( "--factory", help="Full python path to the image factory class to " "create the image with. You can use the following shortcuts to the " - f"built-in image factory classes: {commas(default_factories)}.", + f"built-in image factory classes: {commas(DEFAULT_FACTORIES)}.", ) parser.add_option( "--factory-drawer", @@ -78,7 +121,7 @@ def raise_error(msg: str) -> NoReturn: parser.add_option( "--error-correction", type="choice", - choices=sorted(error_correction.keys()), + choices=sorted(ERROR_CORRECTION_MAP.keys()), default="M", help="The error correction level to use. Choices are L (7%), " "M (15%, default), Q (25%), and H (30%).", @@ -91,97 +134,233 @@ def raise_error(msg: str) -> NoReturn: help="The output file. If not specified, the image is sent to " "the standard output.", ) + parser.add_option( + "--box-size", + type=int, + help="Pixel size of each QR module (default: factory default).", + ) + parser.add_option( + "--border", + type=int, + help="Width of the quiet zone in modules (default: 4).", + ) + parser.add_option( + "--qr-version", + type=int, + help="QR code version (1–40). Auto-fit when omitted.", + ) + + opts, remaining = parser.parse_args(argv) + + return ParsedArgs( + factory=opts.factory, + factory_drawer=opts.factory_drawer, + optimize=opts.optimize, + error_correction=opts.error_correction, + ascii_output=bool(opts.ascii), + output_path=opts.output, + data_arg=remaining[0] if remaining else None, + box_size=opts.box_size, + border=opts.border, + version=opts.qr_version, + ) + + +# --------------------------------------------------------------------------- +# Factory resolution +# --------------------------------------------------------------------------- + + +def get_factory(module: str) -> type[BaseImage]: + """Dynamically import an image factory class from a dotted path. + + Args: + module: Full Python import path (e.g. ``"qrcode.image.pil.PilImage"``). + + Returns: + The factory class (subclass of :class:`BaseImage`). + + Raises: + ValueError: If *module* does not contain a dot. + """ + if "." not in module: + raise ValueError("The image factory is not a full python path") + mod_name, cls_name = module.rsplit(".", 1) + imp = __import__(mod_name, {}, {}, [cls_name]) + return getattr(imp, cls_name) # type: ignore[no-any-return] + + +# --------------------------------------------------------------------------- +# QR code creation helper +# --------------------------------------------------------------------------- - opts, args = parser.parse_args(args) - if opts.factory: - module = default_factories.get(opts.factory, opts.factory) +def _create_qr(args: ParsedArgs) -> tuple[qrcode.QRCode, bytes]: + """Create a :class:`~qrcode.main.QRCode` instance and read input data. + + Args: + args: Parsed CLI arguments. + + Returns: + Tuple of ``(qr_code, raw_data_bytes)``. The QR code is configured with + the requested factory and error correction level but has not yet had + ``make()`` called on it. + """ + # Resolve image factory + if args.factory: + module = DEFAULT_FACTORIES.get(args.factory, args.factory) try: image_factory = get_factory(module) except ValueError as e: - raise_error(str(e)) + raise SystemExit(str(e)) from e else: image_factory = None qr = qrcode.QRCode( - error_correction=error_correction[opts.error_correction], + version=args.version if args.version is not None else None, + error_correction=ERROR_CORRECTION_MAP[args.error_correction], + box_size=args.box_size if args.box_size is not None else 10, + border=args.border if args.border is not None else 4, image_factory=image_factory, ) - if args: - data = args[0] - data = data.encode(errors="surrogateescape") + # Read data from argument or stdin + if args.data_arg: + data = args.data_arg.encode(errors="surrogateescape") else: data = sys.stdin.buffer.read() - if opts.optimize is None: - qr.add_data(data) + + return qr, data + + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + + +def _resolve_drawer_kwargs( + qr: qrcode.QRCode, drawer_alias: str | None +) -> dict[str, Any]: + """Build keyword arguments for ``make_image`` based on the drawer alias. + + Args: + qr: The QR code instance (already initialised with its factory). + drawer_alias: String alias for the desired drawer, or ``None`` to use + defaults. + + Returns: + Dictionary suitable for unpacking into ``qr.make_image(**kwargs)``. + + Raises: + SystemExit: If the selected factory has no drawer aliases or the alias + is not recognised. + """ + kwargs: dict[str, Any] = {} + if not drawer_alias: + return kwargs + + aliases: DrawerAliases | None = getattr(qr.image_factory, "drawer_aliases", None) + if not aliases: + print("The selected factory has no drawer aliases.", file=sys.stderr) + raise SystemExit(1) + if drawer_alias not in aliases: + msg = f"{drawer_alias} factory drawer not found. Expected {commas(aliases)}" + print(msg, file=sys.stderr) + raise SystemExit(1) + + drawer_cls, drawer_kwargs = aliases[drawer_alias] + kwargs["module_drawer"] = drawer_cls(**drawer_kwargs) + return kwargs + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + + +def main(argv: list[str] | None = None) -> None: + """CLI entry-point for the ``qr`` command.""" + if argv is None: + argv = sys.argv[1:] + + args = _parse_args(argv) + qr, data = _create_qr(args) + + # Add data with optional optimisation + if args.optimize is not None: + qr.add_data(data, optimize=args.optimize) else: - qr.add_data(data, optimize=opts.optimize) + qr.add_data(data) - if opts.output: + # --- Output path: file --- + if args.output_path: img = qr.make_image() - with Path(opts.output).open("wb") as out: + with Path(args.output_path).open("wb") as out: img.save(out) - else: - if image_factory is None and (os.isatty(sys.stdout.fileno()) or opts.ascii): - qr.print_ascii(tty=not opts.ascii) - return - - kwargs = {} - aliases: DrawerAliases | None = getattr( - qr.image_factory, "drawer_aliases", None - ) - if opts.factory_drawer: - if not aliases: - raise_error("The selected factory has no drawer aliases.") - if opts.factory_drawer not in aliases: - raise_error( - f"{opts.factory_drawer} factory drawer not found." - f" Expected {commas(aliases)}" - ) - drawer_cls, drawer_kwargs = aliases[opts.factory_drawer] - kwargs["module_drawer"] = drawer_cls(**drawer_kwargs) - img = qr.make_image(**kwargs) - - sys.stdout.flush() - img.save(sys.stdout.buffer) + return + # --- Output path: ASCII to terminal (only when no custom factory set) --- + if qr.image_factory is None and ( + os.isatty(sys.stdout.fileno()) or args.ascii_output + ): + qr.print_ascii(tty=not args.ascii_output) + return + + # --- Output path: image stream to stdout --- + kwargs = _resolve_drawer_kwargs(qr, args.factory_drawer) + img = qr.make_image(**kwargs) # type: ignore[arg-type] + + sys.stdout.flush() + img.save(sys.stdout.buffer) -def get_factory(module: str) -> type[BaseImage]: - if "." not in module: - raise ValueError("The image factory is not a full python path") - module, name = module.rsplit(".", 1) - imp = __import__(module, {}, {}, [name]) - return getattr(imp, name) + +# --------------------------------------------------------------------------- +# Utility functions (used in help text generation) +# --------------------------------------------------------------------------- def get_drawer_help() -> str: - help: dict[str, set] = {} + """Build the ``--factory-drawer`` help string listing available drawers. + + Returns: + Human-readable sentence describing which drawer aliases are available + for each factory shortcut. + """ + help_map: dict[str, set[str]] = {} - for alias, module in default_factories.items(): + for alias, module in DEFAULT_FACTORIES.items(): try: - image = get_factory(module) + image_cls = get_factory(module) except ImportError: # pragma: no cover continue - aliases: DrawerAliases | None = getattr(image, "drawer_aliases", None) + aliases: DrawerAliases | None = getattr(image_cls, "drawer_aliases", None) if not aliases: continue - factories = help.setdefault(commas(aliases), set()) + factories = help_map.setdefault(commas(aliases), set()) factories.add(alias) return ". ".join( - f"For {commas(factories, 'and')}, use: {aliases}" - for aliases, factories in help.items() + f"For {commas(factories, 'and')}, use: {alias_key}" + for alias_key, factories in help_map.items() ) -def commas(items: Iterable[str], joiner="or") -> str: - items = tuple(items) - if not items: +def commas(items: Iterable[str], joiner: str = "or") -> str: + """Join an iterable of strings with commas and a final conjunction. + + Args: + items: Strings to join. + joiner: Final conjunction (``"or"``, ``"and"``, etc.). + + Returns: + Formatted string, e.g. ``"a, b or c"``. + """ + items_tuple = tuple(items) + if not items_tuple: return "" - if len(items) == 1: - return items[0] - return f"{', '.join(items[:-1])} {joiner} {items[-1]}" + if len(items_tuple) == 1: + return items_tuple[0] + return f"{', '.join(items_tuple[:-1])} {joiner} {items_tuple[-1]}" if __name__ == "__main__": # pragma: no cover diff --git a/qrcode/constants.py b/qrcode/constants.py index cce629e7..90d41beb 100644 --- a/qrcode/constants.py +++ b/qrcode/constants.py @@ -8,3 +8,11 @@ # Constant whether the PIL library is installed. PIL_AVAILABLE = find_spec("PIL") is not None + +__all__: list[str] = [ + "ERROR_CORRECT_H", + "ERROR_CORRECT_L", + "ERROR_CORRECT_M", + "ERROR_CORRECT_Q", + "PIL_AVAILABLE", +] diff --git a/qrcode/exceptions.py b/qrcode/exceptions.py index b37bd30c..dca10899 100644 --- a/qrcode/exceptions.py +++ b/qrcode/exceptions.py @@ -1,2 +1,21 @@ +"""QR code specific exceptions.""" + + class DataOverflowError(Exception): - pass + """Raised when data exceeds the capacity of the selected QR version and EC level. + + This occurs during :meth:`~qrcode.main.QRCode.make` (with ``fit=False``) or + internally in :func:`qrcode.util.create_data` when the encoded bit length is + larger than the available data capacity for the current configuration. + + Example:: + + qr = qrcode.QRCode(version=1) # version 1 holds ~20 bytes + qr.add_data("this string is way too long for version 1") + qr.make(fit=False) # raises DataOverflowError + """ + + +__all__: list[str] = [ + "DataOverflowError", +] diff --git a/qrcode/image/__init__.py b/qrcode/image/__init__.py index e69de29b..836b41d0 100644 --- a/qrcode/image/__init__.py +++ b/qrcode/image/__init__.py @@ -0,0 +1,5 @@ +"""QR code image generation backends.""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/qrcode/image/base.py b/qrcode/image/base.py index 2453c5ce..726beda0 100644 --- a/qrcode/image/base.py +++ b/qrcode/image/base.py @@ -6,15 +6,75 @@ from qrcode.image.styles.moduledrawers.base import QRModuleDrawer if TYPE_CHECKING: + from collections.abc import Callable + from typing import IO + from qrcode.main import ActiveWithNeighbors, QRCode DrawerAliases = dict[str, tuple[type[QRModuleDrawer], dict[str, Any]]] +__all__: list[str] = [ + "BaseImage", + "BaseImageWithDrawer", + "DrawerAliases", +] + class BaseImage(abc.ABC): - """ - Base QRCode image output class. + """Base class for all QR code image factories (PEP 561 typed). + + Subclasses must implement the abstract methods :meth:`drawrect`, + :meth:`save`, and :meth:`new_image` to produce a concrete output format. + + **Factory pattern** — A custom factory is created by subclassing this class + and overriding the three abstract hooks: + + 1. ``new_image(**kwargs)`` — instantiate the underlying image object + (e.g. Pillow ``Image``, SVG root element, pypng writer). Return the + created instance; it will be stored in ``self._img``. + + 2. ``drawrect(row, col)`` — draw a single dark module at grid position + *(row, col)*. Use :meth:`pixel_box` to get pixel coordinates. + + 3. ``save(stream, kind=None)`` — write the finished image to *stream* + (a file-like object or path). + + Optionally override: + + - ``process()`` — called after all modules are drawn, before saving. + Useful for post-processing (e.g. combining paths, applying masks). + - ``init_new_image()`` — called immediately after ``new_image()``, + before any drawing begins. + - ``get_image(**kwargs)`` — returns the underlying image object for + further manipulation by the caller. + + **Class attributes** + + - ``kind`` (str | None): Default output format string, e.g. ``"PNG"``. + - ``allowed_kinds`` (tuple[str, ...] | None): Allowed formats or ``None`` + to allow any. + - ``needs_context`` (bool): Set to ``True`` if the factory wants the full + QR code context during drawing (uses :meth:`drawrect_context`). + - ``needs_processing`` (bool): Set to ``True`` to trigger a call to + :meth:`process` before saving. + - ``needs_drawrect`` (bool): Set to ``False`` if the factory does not use + individual module drawing (e.g. row-based writers like PyPNG). + + Example — Minimal custom factory:: + + class MyImage(BaseImage): + kind = "MYFMT" + + def new_image(self, **kwargs): + return [] # placeholder image object + + def drawrect(self, row: int, col: int) -> None: + self._img.append((row, col)) + + def save(self, stream: IO[bytes], kind: str | None = None) -> None: + for coord in self._img: + stream.write(f"module {coord}\\n".encode()) """ kind: str | None = None @@ -23,43 +83,84 @@ class BaseImage(abc.ABC): needs_processing = False needs_drawrect = True - def __init__(self, border, width, box_size, *args, **kwargs): + def __init__( + self, + border: int, + width: int, + box_size: int, + *args: Any, + qrcode_modules: list[list[bool | None]] | None = None, + **kwargs: Any, + ) -> None: + """Initialize the image factory. + + Args: + border: Quiet zone width in modules. + width: Module grid size (QR code version-dependent). + box_size: Pixel size per module. + qrcode_modules: 2-D boolean grid of QR modules (injected by caller). + **kwargs: Additional factory-specific keyword arguments forwarded + to :meth:`new_image`. + """ self.border = border self.width = width self.box_size = box_size self.pixel_size = (self.width + self.border * 2) * self.box_size - self.modules = kwargs.pop("qrcode_modules") + self.modules = kwargs.pop("qrcode_modules", qrcode_modules) self._img = self.new_image(**kwargs) self.init_new_image() @abc.abstractmethod - def drawrect(self, row, col): - """ - Draw a single rectangle of the QR code. - """ + def drawrect(self, row: int, col: int) -> None: + """Draw a single dark module at grid position *(row, col)*. - def drawrect_context(self, row: int, col: int, qr: QRCode): + Args: + row: Row index (0 = top). + col: Column index (0 = left). """ - Draw a single rectangle of the QR code given the surrounding context + + def drawrect_context(self, row: int, col: int, qr: QRCode) -> None: + """Draw a single rectangle of the QR code given the surrounding context. + + Override in subclasses that set ``needs_context = True``. The base + implementation raises :exc:`NotImplementedError`. + + Args: + row: Row index. + col: Column index. + qr: The parent :class:`~qrcode.main.QRCode` instance providing + access to neighbouring module state. """ raise NotImplementedError("BaseImage.drawrect_context") # pragma: no cover - def process(self): - """ - Processes QR code after completion + def process(self) -> None: + """Post-process the QR code image after all modules are drawn. + + Override in subclasses that set ``needs_processing = True``. The base + implementation raises :exc:`NotImplementedError`. """ raise NotImplementedError("BaseImage.drawimage") # pragma: no cover @abc.abstractmethod - def save(self, stream, kind=None): - """ - Save the image file. - """ + def save(self, stream: IO[bytes] | str, kind: str | None = None) -> None: + """Save the image file. - def pixel_box(self, row, col): + Args: + stream: File-like object (binary mode) or file path string. + kind: Optional format override. Falls back to ``self.kind`` if + ``None``. """ - A helper method for pixel-based image generators that specifies the - four pixel coordinates for a single rect. + + def pixel_box(self, row: int, col: int) -> tuple[tuple[int, int], tuple[int, int]]: + """Return the four pixel coordinates for a single module rect. + + Args: + row: Row index. + col: Column index. + + Returns: + Tuple of two points ``((x0, y0), (x1, y1))`` defining the bounding + box of the module in pixel space. """ x = (col + self.border) * self.box_size y = (row + self.border) * self.box_size @@ -69,38 +170,72 @@ def pixel_box(self, row, col): ) @abc.abstractmethod - def new_image(self, **kwargs) -> Any: - """ - Build the image class. Subclasses should return the class created. + def new_image(self, **kwargs: Any) -> Any: + """Build the underlying image object. Subclasses must return the created instance. + + Args: + **kwargs: Factory-specific keyword arguments (e.g. ``fill_color``, + ``back_color``). + + Returns: + The newly created image object (type depends on subclass). """ - def init_new_image(self): # noqa: B027 - pass + def init_new_image(self) -> None: # noqa: B027 + """Called immediately after :meth:`new_image`, before drawing begins. - def get_image(self, **kwargs): + Override to perform any setup that requires ``self._img`` to exist. + The base implementation is a no-op. """ - Return the image class for further processing. + + def get_image(self, **kwargs: Any) -> Any: + """Return the underlying image object for further processing by the caller. + + Returns: + The image instance created by :meth:`new_image`. """ return self._img - def check_kind(self, kind, transform=None): - """ - Get the image type. + def check_kind( + self, kind: str | None, transform: Callable[[str], str] | None = None + ) -> str: + """Validate and resolve the output format string. + + Args: + kind: Format to validate (``None`` means use ``self.kind``). + transform: Optional callable applied to *kind* before validation. + + Returns: + The resolved format string. + + Raises: + ValueError: If *kind* is not in ``self.allowed_kinds`` or is + unresolved (both *kind* and ``self.kind`` are ``None``). """ if kind is None: kind = self.kind - allowed = not self.allowed_kinds or kind in self.allowed_kinds + if kind is None: # pragma: no cover + raise ValueError(f"Cannot resolve kind for {type(self).__name__}") + allowed = ( + not self.allowed_kinds or kind in self.allowed_kinds # type: ignore[operator] + ) if transform: kind = transform(kind) if not allowed: - allowed = kind in self.allowed_kinds + allowed = kind in self.allowed_kinds # type: ignore[operator] if not allowed: raise ValueError(f"Cannot set {type(self).__name__} type to {kind}") return kind - def is_eye(self, row: int, col: int): - """ - Find whether the referenced module is in an eye. + def is_eye(self, row: int, col: int) -> bool: + """Return ``True`` if the module at *(row, col)* belongs to a finder pattern (eye). + + Args: + row: Row index. + col: Column index. + + Returns: + ``True`` when inside one of the three 7×7 finder patterns. """ return ( (row < 7 and col < 7) @@ -110,14 +245,26 @@ def is_eye(self, row: int, col: int): class BaseImageWithDrawer(BaseImage): - default_drawer_class: type[QRModuleDrawer] - drawer_aliases: DrawerAliases = {} + """Base image factory with support for pluggable module drawers. - def get_default_module_drawer(self) -> QRModuleDrawer: - return self.default_drawer_class() + Subclasses declare a ``default_drawer_class`` and optional + ``drawer_aliases`` mapping string names to drawer constructors. This enables + the caller to customise how individual modules (and finder pattern eyes) are + rendered via the ``module_drawer`` and ``eye_drawer`` parameters on + :meth:`__init__`. - def get_default_eye_drawer(self) -> QRModuleDrawer: - return self.default_drawer_class() + Attributes: + default_drawer_class: Drawer class used for both modules and eyes when + no explicit drawer is provided. + drawer_aliases: Mapping of alias strings to ``(drawer_cls, kwargs)`` + tuples for string-based drawer selection. + module_drawer: The resolved :class:`~qrcode.image.styles.moduledrawers.base.QRModuleDrawer` + instance used for data modules. + eye_drawer: The resolved drawer instance used for finder pattern eyes. + """ + + default_drawer_class: type[QRModuleDrawer] + drawer_aliases: DrawerAliases = {} needs_context = True @@ -126,11 +273,21 @@ def get_default_eye_drawer(self) -> QRModuleDrawer: def __init__( self, - *args, + *args: Any, module_drawer: QRModuleDrawer | str | None = None, eye_drawer: QRModuleDrawer | str | None = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: + """Initialize the image factory with pluggable drawers. + + Args: + *args: Positional arguments forwarded to :class:`BaseImage`. + module_drawer: Drawer instance or alias string for data modules. + ``None`` uses ``default_drawer_class``. + eye_drawer: Drawer instance or alias string for finder pattern eyes. + ``None`` uses ``default_drawer_class``. + **kwargs: Additional keyword arguments forwarded to :class:`BaseImage`. + """ self.module_drawer = ( self.get_drawer(module_drawer) or self.get_default_module_drawer() ) @@ -140,19 +297,45 @@ def __init__( self.eye_drawer = self.get_drawer(eye_drawer) or self.get_default_eye_drawer() super().__init__(*args, **kwargs) + def get_default_module_drawer(self) -> QRModuleDrawer: + """Return a fresh instance of the default module drawer.""" + return self.default_drawer_class() # type: ignore[misc] + + def get_default_eye_drawer(self) -> QRModuleDrawer: + """Return a fresh instance of the default eye drawer (same as module by default).""" + return self.default_drawer_class() # type: ignore[misc] + def get_drawer(self, drawer: QRModuleDrawer | str | None) -> QRModuleDrawer | None: + """Resolve a drawer instance from an existing object or alias string. + + Args: + drawer: An already-instantiated drawer, an alias string key into + ``self.drawer_aliases``, or ``None``. + + Returns: + A :class:`QRModuleDrawer` instance, or ``None`` if *drawer* was + ``None``. + """ if not isinstance(drawer, str): - return drawer + return drawer # type: ignore[return-value] drawer_cls, kwargs = self.drawer_aliases[drawer] return drawer_cls(**kwargs) - def init_new_image(self): + def init_new_image(self) -> None: + """Initialize drawers with a reference to this image factory.""" self.module_drawer.initialize(img=self) self.eye_drawer.initialize(img=self) - return super().init_new_image() + super().init_new_image() - def drawrect_context(self, row: int, col: int, qr: QRCode): + def drawrect_context(self, row: int, col: int, qr: QRCode) -> None: + """Draw a module using the appropriate drawer (eye or module). + + Args: + row: Row index. + col: Column index. + qr: The parent :class:`~qrcode.main.QRCode` instance. + """ box = self.pixel_box(row, col) drawer = self.eye_drawer if self.is_eye(row, col) else self.module_drawer is_active: bool | ActiveWithNeighbors = ( diff --git a/qrcode/image/pil.py b/qrcode/image/pil.py index 7b542234..aa2c359d 100644 --- a/qrcode/image/pil.py +++ b/qrcode/image/pil.py @@ -1,16 +1,78 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + from PIL import Image, ImageDraw import qrcode.image.base +if TYPE_CHECKING: + from typing import IO + + +__all__: list[str] = [ + "PilImage", +] + class PilImage(qrcode.image.base.BaseImage): - """ - PIL image builder, default format is PNG. + """PIL/Pillow image builder for QR codes. + + Generates QR code images using the Pillow library. Supports multiple output + formats (PNG, JPEG, BMP, etc.) via the ``format`` parameter on :meth:`save`. + + The default mode is binary (1-bit) for standard black-on-white QR codes. + Custom colors trigger RGB mode; transparent backgrounds use RGBA. + + Args: + border: Quiet zone width in modules (passed from :class:`~qrcode.main.QRCode`). + width: Module grid size (passed automatically). + box_size: Pixel size per module (passed automatically). + fill_color: Color for dark modules. Default ``"black"``. Accepts any + Pillow-compatible color specification (string, tuple, etc.). + back_color: Background color. Default ``"white"``. Use + ``"transparent"`` for an alpha channel. + + Example: + >>> import qrcode + >>> img = qrcode.make("data", fill_color="navy", back_color="lightyellow") + >>> img.save("qr.png") """ kind = "PNG" - def new_image(self, **kwargs): + def __init__( + self, + border: int, + width: int, + box_size: int, + *args: Any, + fill_color: str | int = "black", + back_color: str = "white", + **kwargs: Any, + ) -> None: + """Initialize the PIL image factory. + + Args: + border: Quiet zone width in modules. + width: Module grid size. + box_size: Pixel size per module. + fill_color: Color for dark modules. Default ``"black"``. + back_color: Background color. Default ``"white"``. + **kwargs: Additional keyword arguments (e.g. ``qrcode_modules``). + """ + self._fill_color_arg = fill_color + self._back_color_arg = back_color + kwargs["fill_color"] = fill_color + kwargs["back_color"] = back_color + super().__init__(border, width, box_size, *args, **kwargs) + + def new_image(self, **kwargs: Any) -> Image.Image: + """Create a new Pillow image with the appropriate mode. + + Returns: + A PIL :class:`PIL.Image.Image` instance (mode 1, RGB, or RGBA). + """ if not Image: raise ImportError("PIL library not found.") @@ -40,15 +102,37 @@ def new_image(self, **kwargs): self._idr = ImageDraw.Draw(img) return img - def drawrect(self, row, col): + def drawrect(self, row: int, col: int) -> None: + """Draw a dark module at grid position *(row, col)*. + + Args: + row: Row index. + col: Column index. + """ box = self.pixel_box(row, col) self._idr.rectangle(box, fill=self.fill_color) - def save(self, stream, format=None, **kwargs): - kind = kwargs.pop("kind", self.kind) - if format is None: - format = kind - self._img.save(stream, format=format, **kwargs) + def save( + self, stream: IO[bytes] | str, kind: str | None = None, **kwargs: Any + ) -> None: # type: ignore[override] + """Save the image to a file or binary stream. + + Args: + stream: File-like object (binary mode) or file path string. + kind: Output format (``"PNG"``, ``"JPEG"``, etc.). Falls back to + ``self.kind`` if not specified. Also accepted as ``format`` + for backwards compatibility. + **kwargs: Additional keyword arguments forwarded to + :meth:`PIL.Image.Image.save`. The ``format`` key is also + accepted as an alias for *kind*. + """ + fmt = kwargs.pop("format", None) + if kind is None and fmt is not None: + kind = fmt + if kind is None: + kind = self.kind + self._img.save(stream, format=kind, **kwargs) - def __getattr__(self, name): + def __getattr__(self, name: str) -> Any: + """Delegate attribute access to the underlying PIL image.""" return getattr(self._img, name) diff --git a/qrcode/image/pure.py b/qrcode/image/pure.py index 9d68e5fb..5f801ce2 100644 --- a/qrcode/image/pure.py +++ b/qrcode/image/pure.py @@ -1,40 +1,84 @@ +from __future__ import annotations + from itertools import chain from pathlib import Path +from typing import TYPE_CHECKING, Any from qrcode.compat.png import PngWriter from qrcode.image.base import BaseImage +if TYPE_CHECKING: + from collections.abc import Generator + from typing import IO + + +__all__: list[str] = [ + "PyPNGImage", +] + class PyPNGImage(BaseImage): - """ - pyPNG image builder. + """Pure Python PNG image builder using the ``pypng`` library. + + This factory requires no external image libraries beyond ``pypng``, making it + ideal for minimal deployments or environments where Pillow is unavailable. + Output is always grayscale 1-bit PNG (black modules on white background). + + Args: + border: Quiet zone width in modules. + width: Module grid size. + box_size: Pixel size per module. + + Example: + >>> from qrcode.image.pure import PyPNGImage + >>> import qrcode + >>> img = qrcode.make("data", image_factory=PyPNGImage) + >>> img.save("qr.png") """ kind = "PNG" allowed_kinds = ("PNG",) needs_drawrect = False - def new_image(self, **kwargs): + def new_image(self, **kwargs: dict[str, Any]) -> Any: + """Create a new pypng writer instance. + + Returns: + A :class:`png.Writer` configured for grayscale 1-bit output. + """ if not PngWriter: raise ImportError("PyPNG library not installed.") return PngWriter(self.pixel_size, self.pixel_size, greyscale=True, bitdepth=1) - def drawrect(self, row, col): - """ - Not used. - """ + def drawrect(self, row: int, col: int) -> None: + """Not used — PyPNGImage writes rows directly.""" + + def save(self, stream: IO[bytes] | str, kind: str | None = None) -> None: + """Save the QR code as a PNG file. - def save(self, stream, kind=None): + Args: + stream: File-like object (binary mode) or file path string. + kind: Ignored; only ``"PNG"`` is supported. + """ if isinstance(stream, str): stream = Path(stream).open("wb") # noqa: SIM115 self._img.write(stream, self.rows_iter()) - def rows_iter(self): + def rows_iter(self) -> Generator[list[int], None, None]: + """Yield pixel rows for the entire QR code image. + + Each row is a list of 0/1 values (1 = white/background, 0 = dark module). + Rows are emitted top-to-bottom including border rows before and after + the module grid. + + Yields: + A list of integers representing one pixel row. + """ yield from self.border_rows_iter() border_col = [1] * (self.box_size * self.border) - for module_row in self.modules: - row = ( + for module_row in self.modules: # type: ignore[union-attr] + row: list[int] = ( border_col + list( chain.from_iterable( @@ -47,7 +91,12 @@ def rows_iter(self): yield row yield from self.border_rows_iter() - def border_rows_iter(self): + def border_rows_iter(self) -> Generator[list[int], None, None]: + """Yield the white border rows (top and bottom). + + Yields: + A list of all-1 integers for each border pixel row. + """ border_row = [1] * (self.box_size * (self.width + self.border * 2)) for _ in range(self.border * self.box_size): yield border_row diff --git a/qrcode/image/styledpil.py b/qrcode/image/styledpil.py index 17f947f4..3ab47aa7 100644 --- a/qrcode/image/styledpil.py +++ b/qrcode/image/styledpil.py @@ -1,94 +1,133 @@ from __future__ import annotations -import warnings -from typing import overload +from typing import TYPE_CHECKING, Any -import deprecation from PIL import Image import qrcode.image.base from qrcode.image.styles.colormasks import QRColorMask, SolidFillColorMask from qrcode.image.styles.moduledrawers.pil import SquareModuleDrawer +if TYPE_CHECKING: + from typing import IO + + +__all__: list[str] = [ + "StyledPilImage", +] + class StyledPilImage(qrcode.image.base.BaseImageWithDrawer): - """ - Styled PIL image builder, default format is PNG. - - This differs from the PilImage in that there is a module_drawer, a - color_mask, and an optional image - - The module_drawer should extend the QRModuleDrawer class and implement the - drawrect_context(self, box, active, context), and probably also the - initialize function. This will draw an individual "module" or square on - the QR code. - - The color_mask will extend the QRColorMask class and will at very least - implement the get_fg_pixel(image, x, y) function, calculating a color to - put on the image at the pixel location (x,y) (more advanced functionality - can be gotten by instead overriding other functions defined in the - QRColorMask class) - - The Image can be specified either by path or with a Pillow Image, and if it - is there will be placed in the middle of the QR code. No effort is done to - ensure that the QR code is still legible after the image has been placed - there; Q or H level error correction levels are recommended to maintain - data integrity A resampling filter can be specified (defaulting to - PIL.Image.Resampling.LANCZOS) for resizing; see PIL.Image.resize() for possible - options for this parameter. - The image size can be controlled by `embedded_image_ratio` which is a ratio - between 0 and 1 that's set in relation to the overall width of the QR code. + """Styled PIL image builder with colour masks and embedded images. + + Extends :class:`BaseImageWithDrawer` to support: + + - **Colour masks** — a :class:`~qrcode.image.styles.colormasks.QRColorMask` + subclass that calculates per-pixel foreground colours (gradients, patterns, + image-based masks). The default is :class:`~qrcode.image.styles.colormasks.SolidFillColorMask`. + + - **Module drawers** — pluggable drawer classes for individual modules + (squares, circles, rounded rectangles, etc.). See the + ``qrcode.image.styles.moduledrawers`` package. + + - **Embedded images** — place a logo or picture in the centre of the QR code. + Use error correction level Q or H to maintain scannability. + + Args: + border: Quiet zone width in modules. + width: Module grid size. + box_size: Pixel size per module. + color_mask: A :class:`QRColorMask` instance controlling colours. + embedded_image_path: Path to an image file to embed in the centre. + embedded_image: A Pillow :class:`PIL.Image.Image` instance to embed. + embedded_image_ratio: Ratio (0–1) of logo width relative to QR code + width. Default ``0.25``. + embedded_image_resample: PIL resampling filter for the embedded image. + Default :attr:`PIL.Image.Resampling.LANCZOS`. + module_drawer: Drawer instance or alias string for data modules. + eye_drawer: Drawer instance or alias string for finder pattern eyes. + + Example:: + + from qrcode.image.styledpil import StyledPilImage + from qrcode.image.styles.moduledrawers.pil import RoundedModuleDrawer + from qrcode.image.styles.colormasks import RadialGradiantColorMask + + img = qrcode.make( + "data", + image_factory=StyledPilImage, + module_drawer=RoundedModuleDrawer(), + color_mask=RadialGradiantColorMask(), + ) """ kind = "PNG" needs_processing = True color_mask: QRColorMask - default_drawer_class = SquareModuleDrawer - - def __init__(self, *args, **kwargs): - self.color_mask = kwargs.get("color_mask", SolidFillColorMask()) - - if kwargs.get("embeded_image_path") or kwargs.get("embeded_image"): - warnings.warn( - "The 'embeded_*' parameters are deprecated. Use 'embedded_image_path' " - "or 'embedded_image' instead. The 'embeded_*' parameters will be " - "removed in v9.0.", - category=DeprecationWarning, - stacklevel=2, - ) + default_drawer_class: type[SquareModuleDrawer] = SquareModuleDrawer # type: ignore[assignment] + + def __init__( + self, + border: int, + width: int, + box_size: int, + *args: Any, + color_mask: QRColorMask | None = None, + embedded_image_path: str | None = None, + embedded_image: Image.Image | None = None, + embedded_image_ratio: float = 0.25, + embedded_image_resample: int = Image.Resampling.LANCZOS, + **kwargs: Any, + ) -> None: + """Initialize the styled PIL image factory. + + Args: + border: Quiet zone width in modules. + width: Module grid size. + box_size: Pixel size per module. + color_mask: Colour mask instance (default: ``SolidFillColorMask``). + embedded_image_path: Path to an image file to embed. + embedded_image: Pillow Image to embed directly. + embedded_image_ratio: Logo width ratio (0–1). Default 0.25. + embedded_image_resample: PIL resampling filter for logo resize. + **kwargs: Additional keyword arguments forwarded to parent, including + ``module_drawer``, ``eye_drawer``, and ``qrcode_modules``. + """ + self.color_mask = color_mask or SolidFillColorMask() - # allow embeded_ parameters with typos for backwards compatibility - embedded_image_path = kwargs.get( - "embedded_image_path", kwargs.get("embeded_image_path") + # allow kwargs override of explicit parameters (for factory pattern) + embedded_image_path = kwargs.get("embedded_image_path", embedded_image_path) + self.embedded_image: Image.Image | None = kwargs.get( + "embedded_image", embedded_image ) - self.embedded_image = kwargs.get("embedded_image", kwargs.get("embeded_image")) - self.embedded_image_ratio = kwargs.get( - "embedded_image_ratio", kwargs.get("embeded_image_ratio", 0.25) + self.embedded_image_ratio: float = kwargs.get( + "embedded_image_ratio", embedded_image_ratio ) - self.embedded_image_resample = kwargs.get( - "embedded_image_resample", - kwargs.get("embeded_image_resample", Image.Resampling.LANCZOS), + self.embedded_image_resample: int = kwargs.get( + "embedded_image_resample", embedded_image_resample ) if not self.embedded_image and embedded_image_path: self.embedded_image = Image.open(embedded_image_path) # the paint_color is the color the module drawer will use to draw upon - # a canvas During the color mask process, pixels that are paint_color - # are replaced by a newly-calculated color - self.paint_color = tuple(0 for i in self.color_mask.back_color) + # a canvas. During the color mask process, pixels that are paint_color + # are replaced by a newly-calculated color. + self.paint_color: tuple[int, ...] = tuple(0 for _ in self.color_mask.back_color) if self.color_mask.has_transparency: self.paint_color = (*self.color_mask.back_color[:3], 255) - super().__init__(*args, **kwargs) + super().__init__(border, width, box_size, *args, **kwargs) - @overload - def drawrect(self, row, col): - """ - Not used. - """ + def drawrect(self, row: int, col: int) -> None: + """Not used — StyledPilImage uses drawrect_context instead.""" - def new_image(self, **kwargs): + def new_image(self, **kwargs: Any) -> Image.Image: + """Create a new Pillow image with the correct mode for the colour mask. + + Returns: + A PIL :class:`PIL.Image.Image` instance (RGB or RGBA). + """ mode = ( "RGBA" if ( @@ -102,25 +141,23 @@ def new_image(self, **kwargs): return Image.new(mode, (self.pixel_size, self.pixel_size), back_color) - def init_new_image(self): + def init_new_image(self) -> None: + """Initialize the colour mask and drawers with image references.""" self.color_mask.initialize(self, self._img) super().init_new_image() - def process(self): + def process(self) -> None: + """Apply colour mask and draw embedded image (if any).""" self.color_mask.apply_mask(self._img) if self.embedded_image: self.draw_embedded_image() - @deprecation.deprecated( - deprecated_in="9.0", - removed_in="8.3", - current_version="8.2", - details="Use draw_embedded_image() instead", - ) - def draw_embeded_image(self): - return self.draw_embedded_image() + def draw_embedded_image(self) -> None: + """Draw the embedded logo image in the centre of the QR code. - def draw_embedded_image(self): + The logo is resized to ``embedded_image_ratio`` of the total width and + centred, aligned to module boundaries. + """ if not self.embedded_image: return total_width, _ = self._img.size @@ -132,18 +169,34 @@ def draw_embedded_image(self): ) # round the offset to the nearest module logo_position = (logo_offset, logo_offset) logo_width = total_width - logo_offset * 2 - region = self.embedded_image + region: Image.Image = self.embedded_image region = region.resize((logo_width, logo_width), self.embedded_image_resample) if "A" in region.getbands(): self._img.alpha_composite(region, logo_position) else: self._img.paste(region, logo_position) - def save(self, stream, format=None, **kwargs): - if format is None: - format = kwargs.get("kind", self.kind) - kwargs.pop("kind", None) - self._img.save(stream, format=format, **kwargs) - - def __getattr__(self, name): + def save( + self, stream: IO[bytes] | str, kind: str | None = None, **kwargs: Any + ) -> None: # type: ignore[override] + """Save the styled image. + + Args: + stream: File-like object (binary mode) or file path string. + kind: Output format (``"PNG"``, ``"JPEG"``, etc.). Falls back to + ``self.kind`` if not specified. Also accepted as ``format`` + for backwards compatibility. + **kwargs: Additional keyword arguments forwarded to + :meth:`PIL.Image.Image.save`. The ``format`` key is also + accepted as an alias for *kind*. + """ + fmt = kwargs.pop("format", None) + if kind is None and fmt is not None: + kind = fmt + if kind is None: + kind = self.kind + self._img.save(stream, format=kind, **kwargs) + + def __getattr__(self, name: str) -> Any: + """Delegate attribute access to the underlying PIL image.""" return getattr(self._img, name) diff --git a/qrcode/image/styles/__init__.py b/qrcode/image/styles/__init__.py index e69de29b..41fdb45f 100644 --- a/qrcode/image/styles/__init__.py +++ b/qrcode/image/styles/__init__.py @@ -0,0 +1,5 @@ +"""Styling components for QR code images.""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/qrcode/image/styles/colormasks.py b/qrcode/image/styles/colormasks.py index 97ba57b1..4f8ee6a0 100644 --- a/qrcode/image/styles/colormasks.py +++ b/qrcode/image/styles/colormasks.py @@ -1,42 +1,84 @@ +from __future__ import annotations + import math +from typing import TYPE_CHECKING from PIL import Image +if TYPE_CHECKING: + from qrcode.image.styledpil import StyledPilImage + + +__all__: list[str] = [ + "HorizontalGradiantColorMask", + "ImageColorMask", + "QRColorMask", + "RadialGradiantColorMask", + "SolidFillColorMask", + "SquareGradiantColorMask", + "VerticalGradiantColorMask", +] + class QRColorMask: - """ - QRColorMask is used to color in the QRCode. + """Base class for coloring QR code modules. - By the time apply_mask is called, the QRModuleDrawer of the StyledPilImage - will have drawn all of the modules on the canvas (the color of these - modules will be mostly black, although antialiasing may result in - gradients) In the base class, apply_mask is implemented such that the - background color will remain, but the foreground pixels will be replaced by - a color determined by a call to get_fg_pixel. There is additional - calculation done to preserve the gradient artifacts of antialiasing. + By the time ``apply_mask`` is called, the :class:`QRModuleDrawer` of the + :class:`~qrcode.image.styledpil.StyledPilImage` will have drawn all of the + modules on the canvas (the color of these modules will be mostly black, + although antialiasing may result in gradients). In the base class, + ``apply_mask`` is implemented such that the background color will remain, + but the foreground pixels will be replaced by a color determined by a call + to :meth:`get_fg_pixel`. There is additional calculation done to preserve + the gradient artifacts of antialiasing. - All QRColorMask objects should be careful about RGB vs RGBA color spaces. + All :class:`QRColorMask` objects should be careful about RGB vs RGBA color + spaces. - For examples of what these look like, see doc/color_masks.png + For examples of what these look like, see ``doc/color_masks.png``. """ - back_color = (255, 255, 255) - has_transparency = False - paint_color = back_color + back_color: tuple[int, ...] = (255, 255, 255) + has_transparency: bool = False + paint_color: tuple[int, ...] + + def __init__(self) -> None: + """Initialize the color mask with default white background.""" - def initialize(self, styledPilImage, image): + def initialize(self, styledPilImage: StyledPilImage, image: Image.Image) -> None: + """Initialize the mask with image-specific data. + + Args: + styledPilImage: The parent :class:`StyledPilImage` instance. + image: The PIL ``Image`` being drawn onto. + """ self.paint_color = styledPilImage.paint_color - def apply_mask(self, image, use_cache=False): + def apply_mask(self, image: Image.Image, use_cache: bool = False) -> None: + """Apply the color mask to the rendered QR code image. + + Iterates over every pixel and replaces foreground pixels with colors + from :meth:`get_fg_pixel` while preserving background pixels at + :attr:`back_color`. Antialiasing gradients are interpolated between + the background and foreground colors. + + Args: + image: The PIL ``Image`` to apply the mask onto. + use_cache: If ``True``, cache computed foreground colors for + repeated pixel values (speeds up masks with few distinct + colors). + """ width, height = image.size pixels = image.load() - fg_color_cache = {} if use_cache else None + if pixels is None: + return + fg_color_cache: dict[tuple[int, ...], tuple[int, ...]] = {} for x in range(width): for y in range(height): - current_color = pixels[x, y] + current_color: tuple[int, ...] = tuple(pixels[x, y]) # type: ignore[arg-type] if current_color == self.back_color: continue - if use_cache and current_color in fg_color_cache: + if use_cache and fg_color_cache and current_color in fg_color_cache: pixels[x, y] = fg_color_cache[current_color] continue norm = self.extrap_color( @@ -55,31 +97,104 @@ def apply_mask(self, image, use_cache=False): else: pixels[x, y] = self.get_bg_pixel(image, x, y) - def get_fg_pixel(self, image, x, y): - raise NotImplementedError("QRModuleDrawer.paint_fg_pixel") + def get_fg_pixel(self, image: Image.Image, x: int, y: int) -> tuple[int, ...]: + """Return the foreground color for pixel at ``(x, y)``. + + Args: + image: The PIL ``Image`` being colored. + x: X coordinate of the pixel. + y: Y coordinate of the pixel. + + Returns: + An RGB or RGBA tuple representing the desired color. + + Raises: + NotImplementedError: Must be overridden by subclasses. + """ + raise NotImplementedError("QRColorMask.get_fg_pixel") + + def get_bg_pixel(self, image: Image.Image, x: int, y: int) -> tuple[int, ...]: + """Return the background color for pixel at ``(x, y)``. + + Default returns :attr:`back_color`. Override for spatially varying + backgrounds. - def get_bg_pixel(self, image, x, y): + Args: + image: The PIL ``Image`` being colored. + x: X coordinate of the pixel. + y: Y coordinate of the pixel. + + Returns: + An RGB or RGBA tuple representing the background color. + """ return self.back_color - # The following functions are helpful for color calculation: + # ------------------------------------------------------------------ + # Color interpolation helpers (public API — used by subclasses) + # ------------------------------------------------------------------ + + def interp_num(self, n1: int, n2: int, norm: float) -> int: + """Interpolate between two integer values. - # interpolate a number between two numbers - def interp_num(self, n1, n2, norm): + Args: + n1: Start value. + n2: End value. + norm: Normalised position in ``[0, 1]``. + + Returns: + Interpolated integer value. + """ return int(n2 * norm + n1 * (1 - norm)) - # interpolate a color between two colorrs - def interp_color(self, col1, col2, norm): + def interp_color( + self, col1: tuple[int, ...], col2: tuple[int, ...], norm: float + ) -> tuple[int, ...]: + """Interpolate between two colour tuples. + + Args: + col1: Start RGB(A) tuple. + col2: End RGB(A) tuple. + norm: Normalised position in ``[0, 1]``. + + Returns: + Interpolated RGB(A) tuple. + """ return tuple(self.interp_num(col1[i], col2[i], norm) for i in range(len(col1))) - # find the interpolation coefficient between two numbers - def extrap_num(self, n1, n2, interped_num): + def extrap_num(self, n1: int, n2: int, interped_num: int) -> float | None: + """Find the interpolation coefficient that produced *interped_num*. + + Args: + n1: Start value. + n2: End value. + interped_num: The interpolated result to reverse-engineer. + + Returns: + Normalised coefficient in ``[0, 1]``, or ``None`` when + *n1* == *n2*. + """ if n2 == n1: return None return (interped_num - n1) / (n2 - n1) - # find the interpolation coefficient between two numbers - def extrap_color(self, col1, col2, interped_color): - normed = [] + def extrap_color( + self, + col1: tuple[int, ...], + col2: tuple[int, ...], + interped_color: tuple[int, ...], + ) -> float | None: + """Find the interpolation coefficient between two colour tuples. + + Args: + col1: Start RGB(A) tuple. + col2: End RGB(A) tuple. + interped_color: The interpolated result to reverse-engineer. + + Returns: + Average normalised coefficient across channels, or ``None`` if + no channel can be extrapolated. + """ + normed: list[float] = [] for c1, c2, ci in zip(col1, col2, interped_color, strict=False): extrap = self.extrap_num(c1, c2, ci) if extrap is not None: @@ -89,17 +204,48 @@ def extrap_color(self, col1, col2, interped_color): return sum(normed) / len(normed) +# --------------------------------------------------------------------------- +# Concrete colour masks +# --------------------------------------------------------------------------- + + class SolidFillColorMask(QRColorMask): - """ - Just fills in the background with one color and the foreground with another + """Fill the foreground with a single solid color. + + The simplest mask — replaces every active module pixel with *front_color* + and leaves background pixels at *back_color*. + + Args: + back_color: RGB or RGBA tuple for the quiet zone (default white). + front_color: RGB or RGBA tuple for active modules (default black). """ - def __init__(self, back_color=(255, 255, 255), front_color=(0, 0, 0)): + def __init__( + self, + back_color: tuple[int, ...] = (255, 255, 255), + front_color: tuple[int, ...] = (0, 0, 0), + ) -> None: + """Initialize with solid foreground and background colors. + + Args: + back_color: Background RGB(A) tuple. + front_color: Foreground RGB(A) tuple for active modules. + """ self.back_color = back_color self.front_color = front_color self.has_transparency = len(self.back_color) == 4 - def apply_mask(self, image): + def apply_mask(self, image: Image.Image, use_cache: bool = False) -> None: + """Apply a solid fill mask (ignores *use_cache* for speed). + + If both colors are the default black-on-white the method returns + early because the drawer already produced that output. + + Args: + image: The PIL ``Image`` to recolour. + use_cache: Unused; kept for signature compatibility with + :meth:`~QRColorMask.apply_mask`. + """ if self.back_color == (255, 255, 255) and self.front_color == (0, 0, 0): # Optimization: the image is already drawn by QRModuleDrawer in # black and white, so if these are also our mask colors we don't @@ -109,30 +255,68 @@ def apply_mask(self, image): image.paste( Image.composite( - Image.new("RGB", image.size, self.front_color), - Image.new("RGB", image.size, self.back_color), + Image.new("RGB", image.size, self.front_color), # type: ignore[arg-type] + Image.new("RGB", image.size, self.back_color), # type: ignore[arg-type] image.convert("L").point(lambda p: 255 if p < 128 else 0), ) ) - def get_fg_pixel(self, image, x, y): + def get_fg_pixel(self, image: Image.Image, x: int, y: int) -> tuple[int, ...]: + """Return the solid foreground color. + + Args: + image: Unused. + x: Unused. + y: Unused. + + Returns: + :attr:`front_color`. + """ return self.front_color class RadialGradiantColorMask(QRColorMask): - """ - Fills in the foreground with a radial gradient from the center to the edge + """Radial gradient from the centre to the edge of the QR code. + + The foreground pixels are coloured with a smooth radial gradient that + transitions from *center_color* at the middle of the image to + *edge_color* at the corners. + + Args: + back_color: Background RGB(A) tuple (default white). + center_color: Colour at the centre of the QR code (default black). + edge_color: Colour at the outer edge (default blue ``(0, 0, 255)``). """ def __init__( - self, back_color=(255, 255, 255), center_color=(0, 0, 0), edge_color=(0, 0, 255) - ): + self, + back_color: tuple[int, ...] = (255, 255, 255), + center_color: tuple[int, ...] = (0, 0, 0), + edge_color: tuple[int, ...] = (0, 0, 255), + ) -> None: + """Initialize the radial gradient mask. + + Args: + back_color: Background color for quiet zone. + center_color: Color at the centre of the QR code. + edge_color: Color at the outer corners. + """ self.back_color = back_color self.center_color = center_color self.edge_color = edge_color self.has_transparency = len(self.back_color) == 4 - def get_fg_pixel(self, image, x, y): + def get_fg_pixel(self, image: Image.Image, x: int, y: int) -> tuple[int, ...]: + """Return the gradient color at ``(x, y)``. + + Args: + image: The PIL ``Image`` (used only for size). + x: X coordinate of the pixel. + y: Y coordinate of the pixel. + + Returns: + Interpolated RGB(A) tuple between centre and edge colors. + """ width, _ = image.size normedDistanceToCenter = math.sqrt( (x - width / 2) ** 2 + (y - width / 2) ** 2 @@ -143,19 +327,46 @@ def get_fg_pixel(self, image, x, y): class SquareGradiantColorMask(QRColorMask): - """ - Fills in the foreground with a square gradient from the center to the edge + """Square gradient from the centre to the edge of the QR code. + + Similar to :class:`RadialGradiantColorMask` but uses Chebyshev distance + (max of |dx|, |dy|), producing square isoclines instead of circular ones. + + Args: + back_color: Background RGB(A) tuple (default white). + center_color: Colour at the centre (default black). + edge_color: Colour at the outer edge (default blue ``(0, 0, 255)``). """ def __init__( - self, back_color=(255, 255, 255), center_color=(0, 0, 0), edge_color=(0, 0, 255) - ): + self, + back_color: tuple[int, ...] = (255, 255, 255), + center_color: tuple[int, ...] = (0, 0, 0), + edge_color: tuple[int, ...] = (0, 0, 255), + ) -> None: + """Initialize the square gradient mask. + + Args: + back_color: Background color for quiet zone. + center_color: Color at the centre of the QR code. + edge_color: Color at the outer corners. + """ self.back_color = back_color self.center_color = center_color self.edge_color = edge_color self.has_transparency = len(self.back_color) == 4 - def get_fg_pixel(self, image, x, y): + def get_fg_pixel(self, image: Image.Image, x: int, y: int) -> tuple[int, ...]: + """Return the square-gradient color at ``(x, y)``. + + Args: + image: The PIL ``Image`` (used only for size). + x: X coordinate of the pixel. + y: Y coordinate of the pixel. + + Returns: + Interpolated RGB(A) tuple based on Chebyshev distance from centre. + """ width, _ = image.size normedDistanceToCenter = max(abs(x - width / 2), abs(y - width / 2)) / ( width / 2 @@ -166,61 +377,156 @@ def get_fg_pixel(self, image, x, y): class HorizontalGradiantColorMask(QRColorMask): - """ - Fills in the foreground with a gradient sweeping from the left to the right + """Horizontal gradient sweeping from left to right. + + Foreground pixels transition smoothly from *left_color* on the left edge + of the image to *right_color* on the right edge. + + Args: + back_color: Background RGB(A) tuple (default white). + left_color: Colour at the left edge (default black). + right_color: Colour at the right edge (default blue ``(0, 0, 255)``). """ def __init__( - self, back_color=(255, 255, 255), left_color=(0, 0, 0), right_color=(0, 0, 255) - ): + self, + back_color: tuple[int, ...] = (255, 255, 255), + left_color: tuple[int, ...] = (0, 0, 0), + right_color: tuple[int, ...] = (0, 0, 255), + ) -> None: + """Initialize the horizontal gradient mask. + + Args: + back_color: Background color for quiet zone. + left_color: Color at the left edge. + right_color: Color at the right edge. + """ self.back_color = back_color self.left_color = left_color self.right_color = right_color self.has_transparency = len(self.back_color) == 4 - def get_fg_pixel(self, image, x, y): + def get_fg_pixel(self, image: Image.Image, x: int, y: int) -> tuple[int, ...]: + """Return the horizontal-gradient color at ``(x, y)``. + + Args: + image: The PIL ``Image`` (used only for width). + x: X coordinate of the pixel. + y: Y coordinate (unused; gradient is purely horizontal). + + Returns: + Interpolated RGB(A) tuple based on horizontal position. + """ width, _ = image.size return self.interp_color(self.left_color, self.right_color, x / width) class VerticalGradiantColorMask(QRColorMask): - """ - Fills in the forefround with a gradient sweeping from the top to the bottom + """Vertical gradient sweeping from top to bottom. + + Foreground pixels transition smoothly from *top_color* at the top edge + of the image to *bottom_color* at the bottom edge. + + Args: + back_color: Background RGB(A) tuple (default white). + top_color: Colour at the top edge (default black). + bottom_color: Colour at the bottom edge (default blue ``(0, 0, 255)``). """ def __init__( - self, back_color=(255, 255, 255), top_color=(0, 0, 0), bottom_color=(0, 0, 255) - ): + self, + back_color: tuple[int, ...] = (255, 255, 255), + top_color: tuple[int, ...] = (0, 0, 0), + bottom_color: tuple[int, ...] = (0, 0, 255), + ) -> None: + """Initialize the vertical gradient mask. + + Args: + back_color: Background color for quiet zone. + top_color: Color at the top edge. + bottom_color: Color at the bottom edge. + """ self.back_color = back_color self.top_color = top_color self.bottom_color = bottom_color self.has_transparency = len(self.back_color) == 4 - def get_fg_pixel(self, image, x, y): + def get_fg_pixel(self, image: Image.Image, x: int, y: int) -> tuple[int, ...]: + """Return the vertical-gradient color at ``(x, y)``. + + Args: + image: The PIL ``Image`` (used only for width). + x: X coordinate (unused; gradient is purely vertical). + y: Y coordinate of the pixel. + + Returns: + Interpolated RGB(A) tuple based on vertical position. + """ width, _ = image.size return self.interp_color(self.top_color, self.bottom_color, y / width) class ImageColorMask(QRColorMask): - """ - Fills in the foreground with pixels from another image, either passed by - path or passed by image object. + """Use an external image as the foreground colour source. + + Each active module pixel is recoloured with the corresponding pixel from + a separate image (loaded either by path or passed directly). The source + image is automatically resized to match the QR code dimensions during + :meth:`initialize`. + + Args: + back_color: Background RGB(A) tuple (default white). + color_mask_path: Filesystem path to the colour mask image. Ignored if + *color_mask_image* is provided. + color_mask_image: A PIL ``Image`` object used directly as the colour + source. Takes precedence over *color_mask_path*. """ def __init__( - self, back_color=(255, 255, 255), color_mask_path=None, color_mask_image=None - ): + self, + back_color: tuple[int, ...] = (255, 255, 255), + color_mask_path: str | None = None, + color_mask_image: Image.Image | None = None, + ) -> None: + """Initialize the image-based colour mask. + + Args: + back_color: Background color for quiet zone. + color_mask_path: Path to load the mask image from (if no direct + image is provided). + color_mask_image: PIL ``Image`` object used directly as the mask. + """ self.back_color = back_color if color_mask_image: self.color_img = color_mask_image - else: + elif color_mask_path is not None: self.color_img = Image.open(color_mask_path) + else: + raise ValueError( + "Either color_mask_image or color_mask_path must be provided" + ) self.has_transparency = len(self.back_color) == 4 - def initialize(self, styledPilImage, image): + def initialize(self, styledPilImage: StyledPilImage, image: Image.Image) -> None: + """Resize the mask image to match the QR code dimensions. + + Args: + styledPilImage: The parent :class:`StyledPilImage` instance. + image: The target PIL ``Image`` whose size is used for resizing. + """ self.paint_color = styledPilImage.paint_color self.color_img = self.color_img.resize(image.size) - def get_fg_pixel(self, image, x, y): - return self.color_img.getpixel((x, y)) + def get_fg_pixel(self, image: Image.Image, x: int, y: int) -> tuple[int, ...]: + """Return the mask-image color at ``(x, y)``. + + Args: + image: Unused (the mask is already resized). + x: X coordinate of the pixel. + y: Y coordinate of the pixel. + + Returns: + RGB(A) tuple from the resized colour mask image. + """ + return self.color_img.getpixel((x, y)) # type: ignore[return-value] diff --git a/qrcode/image/styles/moduledrawers/__init__.py b/qrcode/image/styles/moduledrawers/__init__.py index 056da6df..2aacfb4d 100644 --- a/qrcode/image/styles/moduledrawers/__init__.py +++ b/qrcode/image/styles/moduledrawers/__init__.py @@ -1,46 +1,21 @@ -""" -Module for lazy importing of PIL drawers with a deprecation warning. - -Currently, importing a PIL drawer from this module is allowed for backwards -compatibility but will raise a DeprecationWarning. - -This will be removed in v9.0. -""" +"""Module drawers for styled QR codes. -import warnings +Import drawers directly from the submodules: -from qrcode.constants import PIL_AVAILABLE + from qrcode.image.styles.moduledrawers.base import QRModuleDrawer + from qrcode.image.styles.moduledrawers.pil import SquareModuleDrawer + from qrcode.image.styles.moduledrawers.svg import SvgSquareModuleDrawer +Direct imports of PIL/SVG drawers from this package (e.g. +``from qrcode.image.styles.moduledrawers import SquareModuleDrawer``) are no +longer supported as of v9.0. Only :class:`~qrcode.image.styles.moduledrawers.base.QRModuleDrawer` +is re-exported here for convenience, since it has no circular dependencies. +""" -def __getattr__(name): - """Lazy import with deprecation warning for PIL drawers.""" - # List of PIL drawer names that should trigger deprecation warnings - pil_drawers = { - "CircleModuleDrawer", - "GappedCircleModuleDrawer", - "GappedSquareModuleDrawer", - "HorizontalBarsDrawer", - "RoundedModuleDrawer", - "SquareModuleDrawer", - "VerticalBarsDrawer", - } - - if name in pil_drawers: - # Only render a warning if PIL is actually installed. Otherwise it would - # raise an ImportError directly, which is fine. - if PIL_AVAILABLE: - warnings.warn( - f"Importing '{name}' directly from this module is deprecated." - f"Please use 'from qrcode.image.styles.moduledrawers.pil import {name}' " - f"instead. This backwards compatibility import will be removed in v9.0.", - DeprecationWarning, - stacklevel=2, - ) - - # Import and return the drawer from the pil module - from . import pil # noqa: PLC0415 +from __future__ import annotations - return getattr(pil, name) +from .base import QRModuleDrawer - # For any other attribute, raise AttributeError - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +__all__: list[str] = [ + "QRModuleDrawer", +] diff --git a/qrcode/image/styles/moduledrawers/base.py b/qrcode/image/styles/moduledrawers/base.py index e96c8d5d..926b1797 100644 --- a/qrcode/image/styles/moduledrawers/base.py +++ b/qrcode/image/styles/moduledrawers/base.py @@ -1,33 +1,48 @@ +from __future__ import annotations + import abc -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from qrcode.image.base import BaseImage + from qrcode.main import ActiveWithNeighbors + + +__all__: list[str] = [ + "QRModuleDrawer", +] class QRModuleDrawer(abc.ABC): - """ - QRModuleDrawer exists to draw the modules of the QR Code onto images. + """QR module drawer — renders individual modules onto an image. - For this, technically all that is necessary is a ``drawrect(self, box, - is_active)`` function which takes in the box in which it is to draw, - whether or not the box is "active" (a module exists there). If - ``needs_neighbors`` is set to True, then the method should also accept a - ``neighbors`` kwarg (the neighboring pixels). + Subclasses implement :meth:`drawrect` to draw a single module within the + given *box* coordinates. If ``needs_neighbors`` is set to ``True``, the + method also receives a ``neighbors`` argument describing the 3×3 + neighbourhood around the module. - It is frequently necessary to also implement an "initialize" function to - set up values that only the containing Image class knows about. + An optional :meth:`initialize` hook lets subclasses access image-level + attributes (size, colours, drawing context). - For examples of what these look like, see doc/module_drawers.png + For visual examples see ``doc/module_drawers.png``. """ - needs_neighbors = False + needs_neighbors: bool = False + + def __init__(self, **kwargs: Any) -> None: # noqa: B027 + """Initialize the drawer. + + Args: + **kwargs: Subclass-specific keyword arguments. + """ - def __init__(self, **kwargs): # noqa: B027 - pass + def initialize(self, img: BaseImage) -> None: + """Set up internal state from the parent image object. - def initialize(self, img: "BaseImage") -> None: - self.img = img + Args: + img: The containing image instance (PIL, SVG, etc.). + """ + self.img = img # type: ignore[misc] @abc.abstractmethod - def drawrect(self, box, is_active) -> None: ... + def drawrect(self, box: Any, is_active: bool | ActiveWithNeighbors) -> None: ... diff --git a/qrcode/image/styles/moduledrawers/pil.py b/qrcode/image/styles/moduledrawers/pil.py index b393a593..1804d751 100644 --- a/qrcode/image/styles/moduledrawers/pil.py +++ b/qrcode/image/styles/moduledrawers/pil.py @@ -1,156 +1,293 @@ -from typing import TYPE_CHECKING +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from PIL import Image, ImageDraw from qrcode.image.styles.moduledrawers.base import QRModuleDrawer +from qrcode.main import ActiveWithNeighbors if TYPE_CHECKING: from qrcode.image.styledpil import StyledPilImage - from qrcode.main import ActiveWithNeighbors + + +__all__: list[str] = [ + "BoxCoords", + "CircleModuleDrawer", + "GappedCircleModuleDrawer", + "GappedSquareModuleDrawer", + "HorizontalBarsDrawer", + "RoundedModuleDrawer", + "SquareModuleDrawer", + "StyledPilQRModuleDrawer", + "VerticalBarsDrawer", +] # When drawing antialiased things, make them bigger and then shrink them down # to size after the geometry has been drawn. ANTIALIASING_FACTOR = 4 -class StyledPilQRModuleDrawer(QRModuleDrawer): +class BoxCoords: + """Type alias for module box coordinates. + + A box is represented as ``((x0, y0), (x1, y1))`` where ``(x0, y0)`` is + the top-left corner and ``(x1, y1)`` is the bottom-right corner. """ - A base class for StyledPilImage module drawers. - NOTE: the color that this draws in should be whatever is equivalent to - black in the color space, and the specified QRColorMask will handle adding - colors as necessary to the image + +Box = tuple[tuple[int, int], tuple[int, int]] + + +class StyledPilQRModuleDrawer(QRModuleDrawer): + """Base class for PIL-based module drawers used by :class:`StyledPilImage`. + + Subclasses draw individual QR modules onto a PIL image. The colour that is + drawn should be the equivalent of black in the current colour space; the + specified :class:`~qrcode.image.styles.colormasks.QRColorMask` will handle + adding colours afterwards. + + Attributes: + img: Reference to the parent :class:`StyledPilImage` instance, set + during :meth:`initialize`. """ - img: "StyledPilImage" + img: StyledPilImage class SquareModuleDrawer(StyledPilQRModuleDrawer): + """Draw modules as simple filled squares. + + The default drawer — each active module is rendered as a solid square with + no gaps between adjacent modules. """ - Draws the modules as simple squares - """ - def initialize(self, *args, **kwargs): + def initialize(self, *args: Any, **kwargs: Any) -> None: + """Set up the PIL ``ImageDraw`` context. + + Args: + *args: Passed to parent :meth:`~QRModuleDrawer.initialize`. + **kwargs: Additional keyword arguments (unused). + """ super().initialize(*args, **kwargs) - self.imgDraw = ImageDraw.Draw(self.img._img) + self.imgDraw = ImageDraw.Draw(self.img._img) # type: ignore[attr-defined] - def drawrect(self, box, is_active: bool): + def drawrect(self, box: Box, is_active: bool | ActiveWithNeighbors) -> None: + """Draw a single module as a square. + + Args: + box: Coordinate tuple ``((x0, y0), (x1, y1))`` defining the + bounding rectangle for this module. + is_active: ``True`` if the module should be drawn (foreground). + """ if is_active: - self.imgDraw.rectangle(box, fill=self.img.paint_color) + self.imgDraw.rectangle(box, fill=self.img.paint_color) # type: ignore[attr-defined] class GappedSquareModuleDrawer(StyledPilQRModuleDrawer): - """ - Draws the modules as simple squares that are not contiguous. + """Draw modules as non-contiguous squares with gaps between them. - The size_ratio determines how wide the squares are relative to the width of - the space they are printed in + The *size_ratio* controls how wide the drawn square is relative to the + available space (``1.0`` = full size, ``0.8`` = 20 % gap). + + Args: + size_ratio: Ratio of module width to available cell width (default + ``0.8``, producing a 20 % gap around each square). """ - def __init__(self, size_ratio=0.8): + def __init__(self, size_ratio: float = 0.8) -> None: + """Initialize with the desired module-to-cell ratio. + + Args: + size_ratio: How large each drawn square is relative to its cell. + Values in ``(0, 1]``; ``1.0`` gives contiguous squares. + """ self.size_ratio = size_ratio - def initialize(self, *args, **kwargs): + def initialize(self, *args: Any, **kwargs: Any) -> None: + """Set up drawing context and compute the gap delta. + + Args: + *args: Passed to parent :meth:`~QRModuleDrawer.initialize`. + **kwargs: Additional keyword arguments (unused). + """ super().initialize(*args, **kwargs) - self.imgDraw = ImageDraw.Draw(self.img._img) - self.delta = (1 - self.size_ratio) * self.img.box_size / 2 + self.imgDraw = ImageDraw.Draw(self.img._img) # type: ignore[attr-defined] + self.delta = (1 - self.size_ratio) * self.img.box_size / 2 # type: ignore[attr-defined] + + def drawrect(self, box: Box, is_active: bool | ActiveWithNeighbors) -> None: + """Draw a gapped square module. - def drawrect(self, box, is_active: bool): + Args: + box: Coordinate tuple ``((x0, y0), (x1, y1))`` for the full cell. + is_active: ``True`` if the module should be drawn. + """ if is_active: - smaller_box = ( - box[0][0] + self.delta, - box[0][1] + self.delta, - box[1][0] - self.delta, - box[1][1] - self.delta, + smaller_box: Box = ( # type: ignore[assignment] + box[0][0] + self.delta, # type: ignore[arg-type] + box[0][1] + self.delta, # type: ignore[arg-type] + box[1][0] - self.delta, # type: ignore[arg-type] + box[1][1] - self.delta, # type: ignore[arg-type] ) - self.imgDraw.rectangle(smaller_box, fill=self.img.paint_color) + self.imgDraw.rectangle(smaller_box, fill=self.img.paint_color) # type: ignore[attr-defined] class CircleModuleDrawer(StyledPilQRModuleDrawer): + """Draw modules as filled circles. + + Each active module is rendered as an antialiased circle that fills the + available cell space. A pre-rendered circle template is created during + :meth:`initialize` and blitted for every active module. """ - Draws the modules as circles - """ - circle = None + circle: Image.Image | None = None + + def initialize(self, *args: Any, **kwargs: Any) -> None: + """Create an antialiased circle template. - def initialize(self, *args, **kwargs): + A larger circle is drawn at ``ANTIALIASING_FACTOR``× resolution and + then downscaled with Lanczos filtering for smooth edges. + + Args: + *args: Passed to parent :meth:`~QRModuleDrawer.initialize`. + **kwargs: Additional keyword arguments (unused). + """ super().initialize(*args, **kwargs) - box_size = self.img.box_size + box_size = self.img.box_size # type: ignore[attr-defined] fake_size = box_size * ANTIALIASING_FACTOR self.circle = Image.new( - self.img.mode, + self.img.mode, # type: ignore[attr-defined] (fake_size, fake_size), - self.img.color_mask.back_color, + self.img.color_mask.back_color, # type: ignore[attr-defined] ) ImageDraw.Draw(self.circle).ellipse( - (0, 0, fake_size, fake_size), fill=self.img.paint_color + (0, 0, fake_size, fake_size), + fill=self.img.paint_color, # type: ignore[attr-defined] ) self.circle = self.circle.resize((box_size, box_size), Image.Resampling.LANCZOS) - def drawrect(self, box, is_active: bool): - if is_active: - self.img._img.paste(self.circle, (box[0][0], box[0][1])) + def drawrect(self, box: Box, is_active: bool | ActiveWithNeighbors) -> None: + """Blit the pre-rendered circle onto the module cell. + + Args: + box: Coordinate tuple ``((x0, y0), (x1, y1))`` for the cell. + is_active: ``True`` if the module should be drawn. + """ + if is_active and self.circle is not None: + self.img._img.paste(self.circle, (box[0][0], box[0][1])) # type: ignore[attr-defined] class GappedCircleModuleDrawer(StyledPilQRModuleDrawer): - """ - Draws the modules as circles that are not contiguous. + """Draw modules as non-contiguous circles with gaps. + + Similar to :class:`CircleModuleDrawer` but the rendered circle is smaller + than the cell, leaving visible gaps between neighbouring modules. - The size_ratio determines how wide the circles are relative to the width of - the space they are printed in + Args: + size_ratio: Ratio of circle diameter to available cell width (default + ``0.9``). Smaller values produce larger gaps. """ - circle = None + circle: Image.Image | None = None - def __init__(self, size_ratio=0.9): + def __init__(self, size_ratio: float = 0.9) -> None: + """Initialize with the desired circle-to-cell ratio. + + Args: + size_ratio: How large each drawn circle is relative to its cell. + Values in ``(0, 1]``. + """ self.size_ratio = size_ratio - def initialize(self, *args, **kwargs): + def initialize(self, *args: Any, **kwargs: Any) -> None: + """Create a downscaled antialiased circle template. + + Args: + *args: Passed to parent :meth:`~QRModuleDrawer.initialize`. + **kwargs: Additional keyword arguments (unused). + """ super().initialize(*args, **kwargs) - box_size = self.img.box_size + box_size = self.img.box_size # type: ignore[attr-defined] fake_size = box_size * ANTIALIASING_FACTOR self.circle = Image.new( - self.img.mode, + self.img.mode, # type: ignore[attr-defined] (fake_size, fake_size), - self.img.color_mask.back_color, + self.img.color_mask.back_color, # type: ignore[attr-defined] ) ImageDraw.Draw(self.circle).ellipse( - (0, 0, fake_size, fake_size), fill=self.img.paint_color + (0, 0, fake_size, fake_size), + fill=self.img.paint_color, # type: ignore[attr-defined] ) smaller_size = int(self.size_ratio * box_size) self.circle = self.circle.resize( (smaller_size, smaller_size), Image.Resampling.LANCZOS ) - def drawrect(self, box, is_active: bool): - if is_active: - self.img._img.paste(self.circle, (box[0][0], box[0][1])) + def drawrect(self, box: Box, is_active: bool | ActiveWithNeighbors) -> None: + """Blit the gapped circle template onto the module cell. + + Args: + box: Coordinate tuple ``((x0, y0), (x1, y1))`` for the cell. + The circle is pasted at the top-left corner; smaller circles + will leave gaps on all sides. + is_active: ``True`` if the module should be drawn. + """ + if is_active and self.circle is not None: + self.img._img.paste(self.circle, (box[0][0], box[0][1])) # type: ignore[attr-defined] class RoundedModuleDrawer(StyledPilQRModuleDrawer): - """ - Draws the modules with all 90 degree corners replaced with rounded edges. + """Draw modules with rounded corners. + + Contiguous active modules share edges so the result looks like a single + shape with smooth outer corners and straight shared edges. The + *radius_ratio* controls how pronounced the rounding is (``1.0`` = fully + circular for isolated modules, ``0.0`` = sharp squares). + + This drawer requires neighbour information (:attr:`needs_neighbors` = + ``True``) so it can decide which corners to round based on adjacent + active modules. - radius_ratio determines the radius of the rounded edges - a value of 1 - means that an isolated module will be drawn as a circle, while a value of 0 - means that the radius of the rounded edge will be 0 (and thus back to 90 - degrees again). + Args: + radius_ratio: Corner rounding intensity in ``(0, 1]`` (default ``1``). + A value of ``1`` produces a circle for an isolated module; a + value of ``0`` reverts to sharp squares. """ needs_neighbors = True - - def __init__(self, radius_ratio=1): + radius_ratio: float + corner_width: int + SQUARE: Image.Image + NW_ROUND: Image.Image + NE_ROUND: Image.Image + SE_ROUND: Image.Image + SW_ROUND: Image.Image + + def __init__(self, radius_ratio: float = 1) -> None: + """Initialize with the desired corner rounding intensity. + + Args: + radius_ratio: How much to round each corner (``0`` = square, + ``1`` = maximally rounded / circular). + """ self.radius_ratio = radius_ratio - def initialize(self, *args, **kwargs): + def initialize(self, *args: Any, **kwargs: Any) -> None: + """Pre-render the four corner templates and a flat-square template. + + Args: + *args: Passed to parent :meth:`~QRModuleDrawer.initialize`. + **kwargs: Additional keyword arguments (unused). + """ super().initialize(*args, **kwargs) - self.corner_width = int(self.img.box_size / 2) + self.corner_width = int(self.img.box_size / 2) # type: ignore[attr-defined] self.setup_corners() - def setup_corners(self): - mode = self.img.mode - back_color = self.img.color_mask.back_color - front_color = self.img.paint_color + def setup_corners(self) -> None: + """Build the four rounded-corner images and a flat square.""" + mode = self.img.mode # type: ignore[attr-defined] + back_color = self.img.color_mask.back_color # type: ignore[attr-defined] + front_color = self.img.paint_color # type: ignore[attr-defined] self.SQUARE = Image.new( mode, (self.corner_width, self.corner_width), front_color ) @@ -172,9 +309,22 @@ def setup_corners(self): self.SE_ROUND = self.NW_ROUND.transpose(Image.Transpose.ROTATE_180) self.NE_ROUND = self.NW_ROUND.transpose(Image.Transpose.FLIP_LEFT_RIGHT) - def drawrect(self, box: list[list[int]], is_active: "ActiveWithNeighbors"): + def drawrect( + self, box: list[list[int]], is_active: bool | ActiveWithNeighbors + ) -> None: + """Draw a module with selectively rounded corners. + + Corners are rounded only when the corresponding neighbouring module + is inactive (i.e. the edge is an outer boundary). + + Args: + box: Coordinate list ``[[x0, y0], [x1, y1]]`` for the cell. + is_active: Named tuple with boolean flags ``N``, ``S``, ``E``, + ``W`` indicating whether each neighbour is active. + """ if not is_active: return + assert isinstance(is_active, ActiveWithNeighbors) # find rounded edges nw_rounded = not is_active.W and not is_active.N ne_rounded = not is_active.N and not is_active.E @@ -185,36 +335,66 @@ def drawrect(self, box: list[list[int]], is_active: "ActiveWithNeighbors"): ne = self.NE_ROUND if ne_rounded else self.SQUARE se = self.SE_ROUND if se_rounded else self.SQUARE sw = self.SW_ROUND if sw_rounded else self.SQUARE - self.img._img.paste(nw, (box[0][0], box[0][1])) - self.img._img.paste(ne, (box[0][0] + self.corner_width, box[0][1])) - self.img._img.paste( + self.img._img.paste(nw, (box[0][0], box[0][1])) # type: ignore[attr-defined] + self.img._img.paste(ne, (box[0][0] + self.corner_width, box[0][1])) # type: ignore[attr-defined] + self.img._img.paste( # type: ignore[attr-defined] se, (box[0][0] + self.corner_width, box[0][1] + self.corner_width) ) - self.img._img.paste(sw, (box[0][0], box[0][1] + self.corner_width)) + self.img._img.paste( # type: ignore[attr-defined] + sw, (box[0][0], box[0][1] + self.corner_width) + ) class VerticalBarsDrawer(StyledPilQRModuleDrawer): - """ - Draws vertically contiguous groups of modules as long rounded rectangles, - with gaps between neighboring bands (the size of these gaps is inversely - proportional to the horizontal_shrink). + """Draw vertically contiguous modules as rounded bars. + + Neighbouring active modules in the same column are merged into a single + vertical bar with rounded top/bottom caps. The *horizontal_shrink* + parameter controls how narrow the bars are relative to the cell width, + creating gaps between adjacent columns. + + Requires neighbour information (:attr:`needs_neighbors` = ``True``). + + Args: + horizontal_shrink: Width of each bar as a fraction of the cell width + (default ``0.8``, producing 20 % gaps between columns). """ needs_neighbors = True - - def __init__(self, horizontal_shrink=0.8): + horizontal_shrink: float + half_height: int + delta: int + SQUARE: Image.Image + ROUND_TOP: Image.Image + ROUND_BOTTOM: Image.Image + + def __init__(self, horizontal_shrink: float = 0.8) -> None: + """Initialize with the desired bar width ratio. + + Args: + horizontal_shrink: How wide each vertical bar is relative to its + cell (``0 < value <= 1``). Smaller values = wider gaps between + columns. + """ self.horizontal_shrink = horizontal_shrink - def initialize(self, *args, **kwargs): + def initialize(self, *args: Any, **kwargs: Any) -> None: + """Pre-render the bar cap templates. + + Args: + *args: Passed to parent :meth:`~QRModuleDrawer.initialize`. + **kwargs: Additional keyword arguments (unused). + """ super().initialize(*args, **kwargs) - self.half_height = int(self.img.box_size / 2) + self.half_height = int(self.img.box_size / 2) # type: ignore[attr-defined] self.delta = int((1 - self.horizontal_shrink) * self.half_height) self.setup_edges() - def setup_edges(self): - mode = self.img.mode - back_color = self.img.color_mask.back_color - front_color = self.img.paint_color + def setup_edges(self) -> None: + """Build rounded top/bottom cap images and a flat rectangle.""" + mode = self.img.mode # type: ignore[attr-defined] + back_color = self.img.color_mask.back_color # type: ignore[attr-defined] + front_color = self.img.paint_color # type: ignore[attr-defined] height = self.half_height width = height * 2 @@ -232,42 +412,81 @@ def setup_edges(self): self.ROUND_TOP = base.resize((shrunken_width, height), Image.Resampling.LANCZOS) self.ROUND_BOTTOM = self.ROUND_TOP.transpose(Image.Transpose.FLIP_TOP_BOTTOM) - def drawrect(self, box, is_active: "ActiveWithNeighbors"): + def drawrect(self, box: Any, is_active: bool | ActiveWithNeighbors) -> None: + """Draw a vertical bar segment with rounded caps at boundaries. + + The top cap is rounded when the northern neighbour is inactive; the + bottom cap is rounded when the southern neighbour is inactive. + + Args: + box: Coordinate tuple for the module cell (type depends on PIL). + is_active: Named tuple with ``N`` and ``S`` boolean flags + indicating whether the north/south neighbours are active. + """ if is_active: + assert isinstance(is_active, ActiveWithNeighbors) # find rounded edges top_rounded = not is_active.N bottom_rounded = not is_active.S top = self.ROUND_TOP if top_rounded else self.SQUARE bottom = self.ROUND_BOTTOM if bottom_rounded else self.SQUARE - self.img._img.paste(top, (box[0][0] + self.delta, box[0][1])) - self.img._img.paste( + self.img._img.paste(top, (box[0][0] + self.delta, box[0][1])) # type: ignore[attr-defined] + self.img._img.paste( # type: ignore[attr-defined] bottom, (box[0][0] + self.delta, box[0][1] + self.half_height) ) class HorizontalBarsDrawer(StyledPilQRModuleDrawer): - """ - Draws horizontally contiguous groups of modules as long rounded rectangles, - with gaps between neighboring bands (the size of these gaps is inversely - proportional to the vertical_shrink). + """Draw horizontally contiguous modules as rounded bars. + + Neighbouring active modules in the same row are merged into a single + horizontal bar with rounded left/right caps. The *vertical_shrink* + parameter controls how tall the bars are relative to the cell height, + creating gaps between adjacent rows. + + Requires neighbour information (:attr:`needs_neighbors` = ``True``). + + Args: + vertical_shrink: Height of each bar as a fraction of the cell height + (default ``0.8``, producing 20 % gaps between rows). """ needs_neighbors = True - - def __init__(self, vertical_shrink=0.8): + vertical_shrink: float + half_width: int + delta: int + SQUARE: Image.Image + ROUND_LEFT: Image.Image + ROUND_RIGHT: Image.Image + + def __init__(self, vertical_shrink: float = 0.8) -> None: + """Initialize with the desired bar height ratio. + + Args: + vertical_shrink: How tall each horizontal bar is relative to its + cell (``0 < value <= 1``). Smaller values = wider gaps between + rows. + """ self.vertical_shrink = vertical_shrink - def initialize(self, *args, **kwargs): + def initialize(self, *args: Any, **kwargs: Any) -> None: + """Pre-render the bar cap templates. + + Args: + *args: Passed to parent :meth:`~QRModuleDrawer.initialize`. + **kwargs: Additional keyword arguments (unused). + """ super().initialize(*args, **kwargs) - self.half_width = int(self.img.box_size / 2) + self.half_width = int(self.img.box_size / 2) # type: ignore[attr-defined] self.delta = int((1 - self.vertical_shrink) * self.half_width) self.setup_edges() - def setup_edges(self): - mode = self.img.mode - back_color = self.img.color_mask.back_color - front_color = self.img.paint_color + def setup_edges(self) -> None: + """Build rounded left/right cap images and a flat rectangle.""" + mode = self.img.mode # type: ignore[attr-defined] + back_color = self.img.color_mask.back_color # type: ignore[attr-defined] + front_color = self.img.paint_color # type: ignore[attr-defined] width = self.half_width height = width * 2 @@ -287,15 +506,26 @@ def setup_edges(self): ) self.ROUND_RIGHT = self.ROUND_LEFT.transpose(Image.Transpose.FLIP_LEFT_RIGHT) - def drawrect(self, box, is_active: "ActiveWithNeighbors"): + def drawrect(self, box: Any, is_active: bool | ActiveWithNeighbors) -> None: + """Draw a horizontal bar segment with rounded caps at boundaries. + + The left cap is rounded when the western neighbour is inactive; the + right cap is rounded when the eastern neighbour is inactive. + + Args: + box: Coordinate tuple for the module cell (type depends on PIL). + is_active: Named tuple with ``W`` and ``E`` boolean flags + indicating whether the west/east neighbours are active. + """ if is_active: + assert isinstance(is_active, ActiveWithNeighbors) # find rounded edges left_rounded = not is_active.W right_rounded = not is_active.E left = self.ROUND_LEFT if left_rounded else self.SQUARE right = self.ROUND_RIGHT if right_rounded else self.SQUARE - self.img._img.paste(left, (box[0][0], box[0][1] + self.delta)) - self.img._img.paste( + self.img._img.paste(left, (box[0][0], box[0][1] + self.delta)) # type: ignore[attr-defined] + self.img._img.paste( # type: ignore[attr-defined] right, (box[0][0] + self.half_width, box[0][1] + self.delta) ) diff --git a/qrcode/image/styles/moduledrawers/svg.py b/qrcode/image/styles/moduledrawers/svg.py index b27f8279..03f527a2 100644 --- a/qrcode/image/styles/moduledrawers/svg.py +++ b/qrcode/image/styles/moduledrawers/svg.py @@ -1,12 +1,28 @@ +from __future__ import annotations + import abc from decimal import Decimal -from typing import TYPE_CHECKING, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple from qrcode.compat.etree import ET from qrcode.image.styles.moduledrawers.base import QRModuleDrawer if TYPE_CHECKING: from qrcode.image.svg import SvgFragmentImage, SvgPathImage + from qrcode.main import ActiveWithNeighbors + + +__all__: list[str] = [ + "ANTIALIASING_FACTOR", + "BaseSvgQRModuleDrawer", + "Coords", + "SvgCircleDrawer", + "SvgPathCircleDrawer", + "SvgPathQRModuleDrawer", + "SvgPathSquareDrawer", + "SvgQRModuleDrawer", + "SvgSquareDrawer", +] ANTIALIASING_FACTOR = 4 @@ -21,19 +37,37 @@ class Coords(NamedTuple): class BaseSvgQRModuleDrawer(QRModuleDrawer): - img: "SvgFragmentImage" + """Base SVG module drawer with size ratio and coordinate helpers.""" - def __init__(self, *, size_ratio: Decimal = Decimal(1), **kwargs): + img: SvgFragmentImage + + def __init__(self, *, size_ratio: Decimal = Decimal(1), **kwargs: Any) -> None: + """Initialize the base SVG drawer. + + Args: + size_ratio: Ratio (0–1) of module size relative to cell size. + ``1`` fills the entire cell; smaller values create gaps. + **kwargs: Additional keyword arguments forwarded to parent. + """ super().__init__(**kwargs) self.size_ratio = size_ratio - def initialize(self, *args, **kwargs) -> None: + def initialize(self, *args: Any, **kwargs: Any) -> None: + """Compute box dimensions from the parent image.""" super().initialize(*args, **kwargs) self.box_delta = (1 - self.size_ratio) * self.img.box_size / 2 self.box_size = Decimal(self.img.box_size) * self.size_ratio self.box_half = self.box_size / 2 - def coords(self, box) -> Coords: + def coords(self, box: Any) -> Coords: + """Convert a module box to SVG coordinates. + + Args: + box: Module position — ``((row, col), ...)`` or equivalent. + + Returns: + :class:`Coords` named tuple with computed positions. + """ row, col = box[0] x = row + self.box_delta y = col + self.box_delta @@ -49,26 +83,48 @@ def coords(self, box) -> Coords: class SvgQRModuleDrawer(BaseSvgQRModuleDrawer): - tag = "rect" + """SVG drawer that appends individual ````/```` elements.""" + + tag: str = "rect" - def initialize(self, *args, **kwargs) -> None: + def initialize(self, *args: Any, **kwargs: Any) -> None: + """Initialize the SVG element drawer.""" super().initialize(*args, **kwargs) - def drawrect(self, box, is_active: bool): + def drawrect(self, box: Any, is_active: bool | ActiveWithNeighbors) -> None: + """Draw a single module as an SVG element. + + Args: + box: Module position coordinates. + is_active: Whether the module should be drawn (``True`` = dark). + For drawers with ``needs_neighbors=True``, this may also be + an :class:`~qrcode.main.ActiveWithNeighbors` instance. + """ if not is_active: return - self.img._img.append(self.el(box)) + self.img._img.append(self.el(box)) # type: ignore[attr-defined] @abc.abstractmethod - def el(self, box): ... + def el(self, box: Any) -> Any: ... class SvgSquareDrawer(SvgQRModuleDrawer): - def initialize(self, *args, **kwargs) -> None: + """Draw modules as SVG ```` elements.""" + + def initialize(self, *args: Any, **kwargs: Any) -> None: + """Pre-compute the unit size string for rectangles.""" super().initialize(*args, **kwargs) self.unit_size = self.img.units(self.box_size) - def el(self, box): + def el(self, box: Any) -> Any: + """Create a ```` element for one module. + + Args: + box: Module position coordinates. + + Returns: + An :class:`xml.etree.ElementTree.Element` representing the rect. + """ coords = self.coords(box) return ET.Element( self.tag, @@ -80,13 +136,24 @@ def el(self, box): class SvgCircleDrawer(SvgQRModuleDrawer): - tag = "circle" + """Draw modules as SVG ```` elements.""" - def initialize(self, *args, **kwargs) -> None: + tag: str = "circle" + + def initialize(self, *args: Any, **kwargs: Any) -> None: + """Pre-compute the radius string for circles.""" super().initialize(*args, **kwargs) self.radius = self.img.units(self.box_half) - def el(self, box): + def el(self, box: Any) -> Any: + """Create a ```` element for one module. + + Args: + box: Module position coordinates. + + Returns: + An :class:`xml.etree.ElementTree.Element` representing the circle. + """ coords = self.coords(box) return ET.Element( self.tag, @@ -97,19 +164,39 @@ def el(self, box): class SvgPathQRModuleDrawer(BaseSvgQRModuleDrawer): - img: "SvgPathImage" + """SVG drawer that accumulates path data for a combined ```` element.""" - def drawrect(self, box, is_active: bool): + img: SvgPathImage + + def drawrect(self, box: Any, is_active: bool | ActiveWithNeighbors) -> None: + """Append path sub-data for one module. + + Args: + box: Module position coordinates. + is_active: Whether the module should be drawn (``True`` = dark). + For drawers with ``needs_neighbors=True``, this may also be + an :class:`~qrcode.main.ActiveWithNeighbors` instance. + """ if not is_active: return - self.img._subpaths.append(self.subpath(box)) + self.img._subpaths.append(self.subpath(box)) # type: ignore[attr-defined] @abc.abstractmethod - def subpath(self, box) -> str: ... + def subpath(self, box: Any) -> str: ... class SvgPathSquareDrawer(SvgPathQRModuleDrawer): - def subpath(self, box) -> str: + """Generate path data for square modules.""" + + def subpath(self, box: Any) -> str: + """Return SVG path commands for one square module. + + Args: + box: Module position coordinates. + + Returns: + A path string like ``M0,0H1V1H0z``. + """ coords = self.coords(box) x0 = self.img.units(coords.x0, text=False) y0 = self.img.units(coords.y0, text=False) @@ -120,10 +207,23 @@ def subpath(self, box) -> str: class SvgPathCircleDrawer(SvgPathQRModuleDrawer): - def initialize(self, *args, **kwargs) -> None: + """Generate path data for circular modules.""" + + def initialize(self, *args: Any, **kwargs: Any) -> None: + """Initialize the circle path drawer.""" super().initialize(*args, **kwargs) - def subpath(self, box) -> str: + def subpath(self, box: Any) -> str: + """Return SVG path commands for one circular module. + + Uses two arc segments to approximate a circle. + + Args: + box: Module position coordinates. + + Returns: + A path string with ``A`` (arc) commands forming a circle. + """ coords = self.coords(box) x0 = self.img.units(coords.x0, text=False) yh = self.img.units(coords.yh, text=False) diff --git a/qrcode/image/svg.py b/qrcode/image/svg.py index 356f4f43..9df09320 100644 --- a/qrcode/image/svg.py +++ b/qrcode/image/svg.py @@ -2,39 +2,73 @@ import decimal from decimal import Decimal -from typing import TYPE_CHECKING, Literal, overload +from typing import TYPE_CHECKING, Any, Literal, overload import qrcode.image.base from qrcode.compat.etree import ET from qrcode.image.styles.moduledrawers import svg as svg_drawers if TYPE_CHECKING: + from typing import IO + from qrcode.image.styles.moduledrawers.base import QRModuleDrawer +__all__: list[str] = [ + "SvgFillImage", + "SvgFragmentImage", + "SvgImage", + "SvgPathFillImage", + "SvgPathImage", +] + + class SvgFragmentImage(qrcode.image.base.BaseImageWithDrawer): - """ - SVG image builder + """SVG document fragment image builder for QR codes. + + Creates a QR-code image as a SVG document *fragment* (no XML declaration, + no standalone ```` root with namespace). Use :class:`SvgImage` for a + complete standalone SVG file. + + Units are expressed in millimetres: a default ``box_size=10`` maps to 1 mm + per module. - Creates a QR-code image as a SVG document fragment. + Attributes: + kind: Always ``"SVG"``. + allowed_kinds: ``( "SVG", )``. + default_drawer_class: :class:`~qrcode.image.styles.moduledrawers.svg.SvgSquareDrawer`. """ _SVG_namespace = "http://www.w3.org/2000/svg" kind = "SVG" allowed_kinds = ("SVG",) - default_drawer_class: type[QRModuleDrawer] = svg_drawers.SvgSquareDrawer - - def __init__(self, *args, **kwargs): + default_drawer_class: type[QRModuleDrawer] = svg_drawers.SvgSquareDrawer # type: ignore[assignment] + + def __init__( + self, + border: int, + width: int, + box_size: int, + *args: Any, + **kwargs: Any, + ) -> None: + """Initialize the SVG fragment factory. + + Args: + border: Quiet zone width in modules. + width: Module grid size. + box_size: Pixel size per module (maps to millimetres). + *args: Additional positional arguments forwarded to parent. + **kwargs: Keyword arguments including drawer configuration and + ``qrcode_modules``. + """ ET.register_namespace("svg", self._SVG_namespace) - super().__init__(*args, **kwargs) + super().__init__(border, width, box_size, *args, **kwargs) # Save the unit size, for example the default box_size of 10 is '1mm'. self.unit_size = self.units(self.box_size) - @overload - def drawrect(self, row, col): - """ - Not used. - """ + def drawrect(self, row: int, col: int) -> None: + """Not used — SvgFragmentImage uses drawrect_context instead.""" @overload def units(self, pixels: int | Decimal, text: Literal[False]) -> Decimal: ... @@ -42,9 +76,18 @@ def units(self, pixels: int | Decimal, text: Literal[False]) -> Decimal: ... @overload def units(self, pixels: int | Decimal, text: Literal[True] = True) -> str: ... - def units(self, pixels, text=True): - """ - A box_size of 10 (default) equals 1mm. + def units(self, pixels: int | Decimal, text: bool = True) -> str | Decimal: + """Convert pixel count to millimetre units. + + A ``box_size`` of 10 (default) equals 1 mm. + + Args: + pixels: Number of pixels to convert. + text: If ``True``, return a string with "mm" suffix. Otherwise + return the raw :class:`Decimal` value. + + Returns: + A formatted string like ``"2.500mm"`` or a :class:`Decimal`. """ units = Decimal(pixels) / 10 if not text: @@ -58,17 +101,46 @@ def units(self, pixels, text=True): pass return f"{units}mm" - def save(self, stream, kind=None): + def save(self, stream: IO[bytes] | str, kind: str | None = None) -> None: + """Write the SVG fragment to *stream*. + + Args: + stream: File-like object (binary mode) or file path string. + kind: Optional format override (must be ``"SVG"``). + """ self.check_kind(kind=kind) self._write(stream) - def to_string(self, **kwargs): - return ET.tostring(self._img, **kwargs) + def to_string(self, **kwargs: Any) -> bytes: + """Return the SVG fragment as a byte string. + + Args: + **kwargs: Keyword arguments forwarded to :func:`xml.etree.ElementTree.tostring`. - def new_image(self, **kwargs): + Returns: + Raw XML bytes of the SVG element tree. + """ + return ET.tostring(self._img, **kwargs) # type: ignore[no-any-return] + + def new_image(self, **kwargs: Any) -> Any: + """Create a new SVG root element. + + Returns: + An :class:`xml.etree.ElementTree.Element` representing the ```` root. + """ return self._svg(**kwargs) - def _svg(self, tag=None, version="1.1", **kwargs): + def _svg(self, tag: Any = None, version: str = "1.1", **kwargs: Any) -> Any: + """Build the SVG root element. + + Args: + tag: Element tag (defaults to namespaced ``"svg"``). + version: SVG version attribute value. + **kwargs: Additional attributes on the root element. + + Returns: + An :class:`xml.etree.ElementTree.Element`. + """ if tag is None: tag = ET.QName(self._SVG_namespace, "svg") dimension = self.units(self.pixel_size) @@ -80,15 +152,26 @@ def _svg(self, tag=None, version="1.1", **kwargs): **kwargs, ) - def _write(self, stream): + def _write(self, stream: IO[bytes] | str) -> None: + """Write the element tree without an XML declaration. + + Args: + stream: File-like object or path string. + """ ET.ElementTree(self._img).write(stream, xml_declaration=False) class SvgImage(SvgFragmentImage): - """ - Standalone SVG image builder + """Standalone SVG image builder for QR codes. + + Produces a complete SVG document with XML declaration and proper namespace + on the root ```` element. Supports optional background colour fill. - Creates a QR-code image as a standalone SVG document. + Attributes: + background: Background colour (``None`` = transparent). Override to + ``"white"`` via :class:`SvgFillImage`. + drawer_aliases: Pre-configured drawer shortcuts — ``"circle"``, + ``"gapped-circle"``, ``"gapped-square"``. """ background: str | None = None @@ -98,7 +181,16 @@ class SvgImage(SvgFragmentImage): "gapped-square": (svg_drawers.SvgSquareDrawer, {"size_ratio": Decimal("0.8")}), } - def _svg(self, tag="svg", **kwargs): + def _svg(self, tag: str = "svg", **kwargs: Any) -> Any: # type: ignore[override] + """Build a standalone SVG root element with namespace. + + Args: + tag: Tag name (no namespace prefix needed). + **kwargs: Additional attributes forwarded to parent ``_svg``. + + Returns: + An :class:`xml.etree.ElementTree.Element`. + """ svg = super()._svg(tag=tag, **kwargs) svg.set("xmlns", self._SVG_namespace) if self.background: @@ -114,17 +206,29 @@ def _svg(self, tag="svg", **kwargs): ) return svg - def _write(self, stream): + def _write(self, stream: IO[bytes] | str) -> None: + """Write the element tree with XML declaration and UTF-8 encoding. + + Args: + stream: File-like object or path string. + """ ET.ElementTree(self._img).write(stream, encoding="UTF-8", xml_declaration=True) class SvgPathImage(SvgImage): - """ - SVG image builder with one single element (removes white spaces - between individual QR points). + """SVG image builder that combines all modules into a single ```` element. + + This reduces whitespace between individual QR points and produces smaller + output files compared to per-module ```` elements. + + Attributes: + needs_processing: ``True`` — triggers :meth:`process` which assembles + the final path data string. + default_drawer_class: :class:`~qrcode.image.styles.moduledrawers.svg.SvgPathSquareDrawer`. + drawer_aliases: Pre-configured path-based drawer shortcuts. """ - QR_PATH_STYLE = { + QR_PATH_STYLE: dict[str, str] = { "fill": "#000000", "fill-opacity": "1", "fill-rule": "nonzero", @@ -132,9 +236,9 @@ class SvgPathImage(SvgImage): } needs_processing = True - path: ET.Element | None = None - default_drawer_class: type[QRModuleDrawer] = svg_drawers.SvgPathSquareDrawer - drawer_aliases = { + path: Any | None = None + default_drawer_class: type[QRModuleDrawer] = svg_drawers.SvgPathSquareDrawer # type: ignore[assignment] + drawer_aliases: qrcode.image.base.DrawerAliases = { "circle": (svg_drawers.SvgPathCircleDrawer, {}), "gapped-circle": ( svg_drawers.SvgPathCircleDrawer, @@ -146,17 +250,43 @@ class SvgPathImage(SvgImage): ), } - def __init__(self, *args, **kwargs): + def __init__( + self, + border: int, + width: int, + box_size: int, + *args: Any, + **kwargs: Any, + ) -> None: + """Initialize the SVG path factory. + + Args: + border: Quiet zone width in modules. + width: Module grid size. + box_size: Pixel size per module. + *args: Additional positional arguments forwarded to parent. + **kwargs: Keyword arguments including drawer configuration. + """ self._subpaths: list[str] = [] - super().__init__(*args, **kwargs) + super().__init__(border, width, box_size, *args, **kwargs) + + def _svg(self, viewBox: str | None = None, **kwargs: Any) -> Any: # type: ignore[override] + """Build SVG root with a viewBox attribute. + + Args: + viewBox: ViewBox string (auto-computed if ``None``). + **kwargs: Additional attributes forwarded to parent. - def _svg(self, viewBox=None, **kwargs): + Returns: + An :class:`xml.etree.ElementTree.Element`. + """ if viewBox is None: dimension = self.units(self.pixel_size, text=False) viewBox = f"0 0 {dimension} {dimension}" return super()._svg(viewBox=viewBox, **kwargs) - def process(self): + def process(self) -> None: + """Assemble collected sub-paths into a single ```` element.""" # Store the path just in case someone wants to use it again or in some # unique way. self.path = ET.Element( @@ -169,16 +299,12 @@ def process(self): class SvgFillImage(SvgImage): - """ - An SvgImage that fills the background to white. - """ + """An :class:`SvgImage` that fills the background to white.""" background = "white" class SvgPathFillImage(SvgPathImage): - """ - An SvgPathImage that fills the background to white. - """ + """An :class:`SvgPathImage` that fills the background to white.""" background = "white" diff --git a/qrcode/main.py b/qrcode/main.py index 593c581b..3214353e 100644 --- a/qrcode/main.py +++ b/qrcode/main.py @@ -1,11 +1,10 @@ from __future__ import annotations import sys -import warnings from bisect import bisect_left -from typing import Generic, NamedTuple, TypeVar, cast, overload +from typing import Any, Generic, NamedTuple, TextIO, TypeVar, cast -from qrcode import constants, exceptions, util +from qrcode import base, constants, exceptions, util from qrcode.image.base import BaseImage from qrcode.image.pure import PyPNGImage @@ -13,26 +12,76 @@ # Cache modules generated just based on the QR Code version precomputed_qr_blanks: dict[int, ModulesType] = {} - -def make(data=None, **kwargs): +__all__: list[str] = [ + "ActiveWithNeighbors", + "QRCode", + "make", +] + + +def make( + data: str | bytes | util.QRData | None = None, + **kwargs: Any, +) -> BaseImage: + """Generate a QR code image for the given data. + + This is the simplest convenience function — it creates a :class:`QRCode` + instance with default settings, adds the provided data, and returns an + image object ready to be saved or displayed. + + Args: + data: The content to encode. Can be a string, bytes, or a + :class:`~qrcode.util.QRData` instance. Set to ``None`` if you + plan to call :meth:`QRCode.add_data` manually before calling + :meth:`QRCode.make_image`. + **kwargs: Additional keyword arguments forwarded to the + :class:`QRCode` constructor and/or the image factory. Common + options include: + + - ``version`` (int | None): QR code version 1–40, or ``None`` + for automatic sizing. + - ``error_correction`` (int): One of + :data:`~qrcode.ERROR_CORRECT_L`, ``_M``, ``_Q``, ``_H``. + - ``box_size`` (int): Pixel size per module (default 10). + - ``border`` (int): Quiet zone in modules (default 4, min 4 + per spec). + - ``image_factory``: A :class:`~qrcode.image.base.BaseImage` + subclass for custom rendering. + - ``mask_pattern`` (int | None): Mask pattern 0–7, or ``None`` + to auto-select the best one. + + Returns: + An image object (typically a PIL Image wrapper or PyPNGImage) that + supports :meth:`~qrcode.image.base.BaseImage.save` and optionally + :meth:`~PIL.Image.Image.show`. + + Example: + >>> import qrcode + >>> img = qrcode.make("https://example.com") + >>> img.save("qr.png") + """ qr = QRCode(**kwargs) - qr.add_data(data) + if data is not None: + qr.add_data(data) return qr.make_image() -def _check_box_size(size): +def _check_box_size(size: int) -> None: + """Validate that *size* is a positive integer.""" if int(size) <= 0: raise ValueError(f"Invalid box size (was {size}, expected larger than 0)") -def _check_border(size): +def _check_border(size: int) -> None: + """Validate that *size* is zero or positive.""" if int(size) < 0: raise ValueError( f"Invalid border value (was {size}, expected 0 or larger than that)" ) -def _check_mask_pattern(mask_pattern): +def _check_mask_pattern(mask_pattern: int | None) -> None: + """Validate *mask_pattern* is ``None`` or an integer in range(8).""" if mask_pattern is None: return if not isinstance(mask_pattern, int): @@ -43,11 +92,26 @@ def _check_mask_pattern(mask_pattern): raise ValueError(f"Mask pattern should be in range(8) (got {mask_pattern})") -def copy_2d_array(x): +def copy_2d_array(x: ModulesType) -> ModulesType: + """Return a deep copy of a 2-D boolean array.""" return [row[:] for row in x] class ActiveWithNeighbors(NamedTuple): + """9-cell neighbourhood around a module (3×3 grid). + + Attributes: + NW: Top-left neighbour is dark. + N: Top neighbour is dark. + NE: Top-right neighbour is dark. + W: Left neighbour is dark. + me: Centre cell is dark. + E: Right neighbour is dark. + SW: Bottom-left neighbour is dark. + S: Bottom neighbour is dark. + SE: Bottom-right neighbour is dark. + """ + NW: bool N: bool NE: bool @@ -67,18 +131,54 @@ def __bool__(self) -> bool: class QRCode(Generic[GenericImage]): + """Main class for building QR codes with full control. + + Create an instance, add data via :meth:`add_data`, then call + :meth:`make_image` to get a renderable image object. + + Args: + version: QR code version (1–40). ``None`` means auto-size to fit + the data. Higher versions produce larger codes that hold more + data. + error_correction: One of :data:`ERROR_CORRECT_L` (7%), + :data:`ERROR_CORRECT_M` (15%, default), + :data:`ERROR_CORRECT_Q` (25%), or + :data:`ERROR_CORRECT_H` (30%). Higher levels allow more damage + tolerance but reduce capacity. + box_size: Number of pixels per module (default 10). Larger values + produce bigger images. + border: Quiet zone width in modules (default 4). The QR spec + requires a minimum of 4; use ``border=0`` for minimal output. + image_factory: A :class:`~qrcode.image.base.BaseImage` subclass to + control the rendering format. ``None`` selects PIL if available, + otherwise PyPNG. + mask_pattern: Mask pattern index (0–7) or ``None`` to auto-select + the pattern with the lowest penalty score. + + Example: + >>> import qrcode + >>> qr = qrcode.QRCode( + ... version=1, + ... error_correction=qrcode.ERROR_CORRECT_H, + ... box_size=10, + ... ) + >>> qr.add_data("Hello World") + >>> img = qr.make_image() + >>> img.save("qr.png") + """ + modules: ModulesType _version: int | None = None def __init__( self, - version=None, - error_correction=constants.ERROR_CORRECT_M, - box_size=10, - border=4, + version: int | None = None, + error_correction: int = constants.ERROR_CORRECT_M, + box_size: int = 10, + border: int = 4, image_factory: type[GenericImage] | None = None, - mask_pattern=None, - ): + mask_pattern: int | None = None, + ) -> None: _check_box_size(box_size) _check_border(border) self.clear() @@ -89,49 +189,60 @@ def __init__( # any (e.g. for producing printable QR codes). self.border = int(border) self.mask_pattern = mask_pattern - self.image_factory = image_factory + self.image_factory: type[BaseImage] | None = image_factory if image_factory is not None: assert issubclass(image_factory, BaseImage) @property def version(self) -> int: + """Current QR code version (1–40). Auto-computed if not set.""" if self._version is None: self.best_fit() return cast("int", self._version) @version.setter - def version(self, value) -> None: + def version(self, value: int | None) -> None: if value is not None: value = int(value) util.check_version(value) self._version = value @property - def mask_pattern(self): + def mask_pattern(self) -> int | None: # type: ignore[override] + """Mask pattern (0–7) or ``None`` for auto-selection.""" return self._mask_pattern @mask_pattern.setter - def mask_pattern(self, pattern): + def mask_pattern(self, pattern: int | None) -> None: _check_mask_pattern(pattern) self._mask_pattern = pattern - def clear(self): - """ - Reset the internal data. + def clear(self) -> None: + """Reset all internal data and state. + + Call this to reuse the same ``QRCode`` instance for a different + payload without creating a new object. """ self.modules = [[]] self.modules_count = 0 - self.data_cache = None - self.data_list = [] + self.data_cache: list[int] | None = None + self.data_list: list[util.QRData] = [] self._version = None - def add_data(self, data, optimize=20): - """ - Add data to this QR Code. + def add_data(self, data: str | bytes | util.QRData, optimize: int = 20) -> None: + """Add data to this QR Code. + + Data can be added in multiple calls; all chunks are concatenated + before encoding. + + Args: + data: Content to encode (string, bytes, or :class:`~qrcode.util.QRData`). + optimize: Data will be split into multiple chunks to optimise + the QR size by finding more compressed modes of at least + this length. Set to ``0`` to disable chunk optimisation. - :param optimize: Data will be split into multiple chunks to optimize - the QR size by finding to more compressed modes of at least this - length. Set to ``0`` to avoid optimizing at all. + Raises: + TypeError: If *data* is not a recognised type. """ if isinstance(data, util.QRData): self.data_list.append(data) @@ -141,28 +252,42 @@ def add_data(self, data, optimize=20): self.data_list.append(util.QRData(data)) self.data_cache = None - def make(self, fit=True): - """ - Compile the data into a QR Code array. + def make(self, fit: bool = True) -> None: + """Compile the data into a QR Code module matrix. + + Args: + fit: If ``True`` (or if no version has been provided), find the + smallest version that fits all added data. Set to ``False`` + to force use of the current version even if it overflows. - :param fit: If ``True`` (or if a size has not been provided), find the - best fit for the data to avoid data overflow errors. + Raises: + exceptions.DataOverflowError: If the data does not fit in the + selected version and *fit* is ``False``. """ - if fit or (self.version is None): - self.best_fit(start=self.version) + if fit or (self._version is None): + self.best_fit(start=self._version) if self.mask_pattern is None: self.makeImpl(self.best_mask_pattern()) else: self.makeImpl(self.mask_pattern) - def makeImpl(self, mask_pattern): + def makeImpl(self, mask_pattern: int) -> None: + """Internal: build the full module matrix for the current version. + + This method places position detection patterns, timing patterns, + alignment patterns, type information, and finally maps the encoded + data bytes onto the grid using the chosen mask pattern. + + Args: + mask_pattern: Integer 0–7 selecting the mask function. + """ self.modules_count = self.version * 4 + 17 if self.version in precomputed_qr_blanks: self.modules = copy_2d_array(precomputed_qr_blanks[self.version]) else: self.modules = [ - [None] * self.modules_count for i in range(self.modules_count) + [None] * self.modules_count for _ in range(self.modules_count) ] self.setup_position_probe_pattern(0, 0) self.setup_position_probe_pattern(self.modules_count - 7, 0) @@ -183,7 +308,8 @@ def makeImpl(self, mask_pattern): ) self.map_data(self.data_cache, mask_pattern) - def setup_position_probe_pattern(self, row, col): + def setup_position_probe_pattern(self, row: int, col: int) -> None: + """Place a 7×7 position detection pattern (finder + separator).""" for r in range(-1, 8): if row + r <= -1 or self.modules_count <= row + r: continue @@ -201,60 +327,36 @@ def setup_position_probe_pattern(self, row, col): else: self.modules[row + r][col + c] = False - def best_fit(self, start=None): - """ - Find the minimum size required to fit in the data. - """ - if start is None: - start = 1 - util.check_version(start) + def check_data(self) -> None: + """Validate that all added data fits within the current version.""" + if self.data_cache is not None: + return + + rs_blocks = base.rs_blocks(self._version, self.error_correction) # type: ignore[arg-type] + bit_limit = sum(block.data_count * 8 for block in rs_blocks) - # Corresponds to the code in util.create_data, except we don't yet know - # version, so optimistically assume start and check later - mode_sizes = util.mode_sizes_for_version(start) buffer = util.BitBuffer() for data in self.data_list: buffer.put(data.mode, 4) - buffer.put(len(data), mode_sizes[data.mode]) + buffer.put(len(data), util.length_in_bits(data.mode, self.version)) data.write(buffer) - needed_bits = len(buffer) - # Create a thread-local copy of the table to avoid concurrent access issues (Python 3.13+) - self.version = bisect_left( - util.BIT_LIMIT_TABLE[self.error_correction][:], needed_bits, start - ) - if self.version == 41: - raise exceptions.DataOverflowError - - # Now check whether we need more bits for the mode sizes, recursing if - # our guess was too low - if mode_sizes is not util.mode_sizes_for_version(self.version): - self.best_fit(start=self.version) - return self.version - - def best_mask_pattern(self): - """ - Find the most efficient mask pattern. - """ - min_lost_point = 0 - pattern = 0 - - for i in range(8): - self.makeImpl(i) + if len(buffer) > bit_limit: + raise exceptions.DataOverflowError( + f"Code length overflow. Data size ({len(buffer)}) > " + f"size available ({bit_limit})" + ) - lost_point = util.lost_point(self.modules) + def print_tty(self, out: TextIO | None = None) -> None: + """Output the QR Code only using TTY colors. - if i == 0 or min_lost_point > lost_point: - min_lost_point = lost_point - pattern = i + If the data has not been compiled yet, make it first. - return pattern + Args: + out: File-like object to write to. Defaults to ``sys.stdout``. - def print_tty(self, out=None): - """ - Output the QR Code only using TTY colors. - - If the data has not been compiled yet, make it first. + Raises: + OSError: If *out* is not a TTY (``isatty()`` returns ``False``). """ if out is None: out = sys.stdout @@ -278,12 +380,21 @@ def print_tty(self, out=None): out.write("\x1b[1;47m" + (" " * (modcount * 2 + 4)) + "\x1b[0m\n") out.flush() - def print_ascii(self, out=None, tty=False, invert=False): - """ - Output the QR Code using ASCII characters. - - :param tty: use fixed TTY color codes (forces invert=True) - :param invert: invert the ASCII characters (solid <-> transparent) + def print_ascii( + self, + out: TextIO | None = None, + tty: bool = False, + invert: bool = False, + ) -> None: + """Output the QR Code using ASCII characters. + + Args: + out: File-like object to write to. Defaults to ``sys.stdout``. + tty: Use fixed TTY color codes (forces invert=True). + invert: Invert the ASCII characters (solid <-> transparent). + + Raises: + OSError: If *tty* is ``True`` and *out* is not a TTY. """ if out is None: out = sys.stdout @@ -301,7 +412,7 @@ def print_ascii(self, out=None, tty=False, invert=False): if invert: codes.reverse() - def get_module(x, y) -> int: + def get_module(x: int, y: int) -> int: if invert and self.border and max(x, y) >= modcount + self.border: return 1 if min(x, y) < 0 or max(x, y) >= modcount: @@ -321,36 +432,101 @@ def get_module(x, y) -> int: out.write("\n") out.flush() - @overload - def make_image(self, image_factory: None = None, **kwargs) -> GenericImage: ... + def best_fit(self, start: int | None = None) -> int: + """Find the smallest QR code version that fits all added data. - @overload - def make_image( - self, image_factory: type[GenericImageLocal] | None = None, **kwargs - ) -> GenericImageLocal: ... + Uses binary search on the bit-limit table for efficiency, then + validates the selected version by attempting to encode the data. - def make_image(self, image_factory=None, **kwargs): + Args: + start: Minimum version to try (default: 1). + + Returns: + The selected QR code version. + + Raises: + exceptions.DataOverflowError: If even version 40 cannot hold + all added data. """ - Make an image from the QR Code data. + if start is None: + start = 1 + util.check_version(start) - If the data has not been compiled yet, make it first. + # Corresponds to the code in util.create_data, except we don't yet know + # version, so optimistically assume start and check later + mode_sizes = util.mode_sizes_for_version(start) + buffer = util.BitBuffer() + for data in self.data_list: + buffer.put(data.mode, 4) + buffer.put(len(data), mode_sizes[data.mode]) + data.write(buffer) + + needed_bits = len(buffer) + # Create a thread-local copy of the table to avoid concurrent access issues (Python 3.13+) + self._version = bisect_left( + util.BIT_LIMIT_TABLE[self.error_correction][:], needed_bits, start + ) + if self._version == 41: + raise exceptions.DataOverflowError + + # Now check whether we need more bits for the mode sizes, recursing if + # our guess was too low + if mode_sizes is not util.mode_sizes_for_version(self._version): + return self.best_fit(start=self._version) + return cast("int", self._version) + + def _bit_size(self) -> int: + """Return the total bit size of all data chunks.""" + buffer = util.BitBuffer() + for data in self.data_list: + buffer.put(data.mode, 4) + # Use version 1 as fallback when _version is None (first entry in table) + ver = self._version or 1 + buffer.put(len(data), util.length_in_bits(data.mode, ver)) + data.write(buffer) + return len(buffer) + + def best_mask_pattern(self) -> int: + """Select the mask pattern with the lowest penalty score. + + Returns: + Integer 0–7 for the optimal mask pattern. """ - # Raise a warning that 'embeded' is still used - if kwargs.get("embeded_image_path") or kwargs.get("embeded_image"): - warnings.warn( - "The 'embeded_*' parameters are deprecated. Use 'embedded_image_path' " - "or 'embedded_image' instead. The 'embeded_*' parameters will be " - "removed in v9.0.", - category=DeprecationWarning, - stacklevel=2, - ) + des = sys.maxsize + count = 0 + for mask in range(8): + self.makeImpl(mask) + n = util.lost_point(cast("list[list[bool]]", self.modules)) + if des > n: + des = n + count = mask + return count - # allow embeded_ parameters with typos for backwards compatibility + def make_image( + self, + image_factory: type[BaseImage] | None = None, + **kwargs: object, + ) -> BaseImage: + """Render the QR code as an image. + + Args: + image_factory: A :class:`~qrcode.image.base.BaseImage` subclass. + If ``None``, uses the factory set during construction or falls + back to PIL (if available) / PyPNG. + **kwargs: Additional keyword arguments forwarded to the image + factory constructor. For :class:`~qrcode.image.styledpil.StyledPilImage` + these include ``module_drawer``, ``color_mask``, and + ``embedded_image``. + + Returns: + An image object ready for :meth:`~qrcode.image.base.BaseImage.save`. + + Raises: + ValueError: If an embedded image is requested but error correction + is not set to ``ERROR_CORRECT_H``. + """ if ( - kwargs.get("embedded_image_path") - or kwargs.get("embedded_image") - or kwargs.get("embeded_image_path") - or kwargs.get("embeded_image") + kwargs.get("embedded_image") or kwargs.get("embedded_image_path") ) and self.error_correction != constants.ERROR_CORRECT_H: raise ValueError( "Error correction level must be ERROR_CORRECT_H if an embedded image is provided" @@ -390,8 +566,10 @@ def make_image(self, image_factory=None, **kwargs): return im - # return true if and only if (row, col) is in the module + # -- internal helpers -------------------------------------------------- + def is_constrained(self, row: int, col: int) -> bool: + """Return ``True`` if *(row, col)* is inside the module grid.""" return ( row >= 0 and row < len(self.modules) @@ -399,7 +577,8 @@ def is_constrained(self, row: int, col: int) -> bool: and col < len(self.modules[row]) ) - def setup_timing_pattern(self): + def setup_timing_pattern(self) -> None: + """Place horizontal and vertical timing patterns between finders.""" for r in range(8, self.modules_count - 8): if self.modules[r][6] is not None: continue @@ -410,7 +589,8 @@ def setup_timing_pattern(self): continue self.modules[6][c] = c % 2 == 0 - def setup_position_adjust_pattern(self): + def setup_position_adjust_pattern(self) -> None: + """Place alignment patterns (version >= 2).""" pos = util.pattern_position(self.version) for i in range(len(pos)): @@ -429,7 +609,8 @@ def setup_position_adjust_pattern(self): else: self.modules[row + r][col + c] = False - def setup_type_number(self): + def setup_type_number(self) -> None: + """Place the version information (version >= 7).""" bits = util.BCH_type_number(self.version) for i in range(18): @@ -440,7 +621,8 @@ def setup_type_number(self): mod = ((bits >> i) & 1) == 1 self.modules[i % 3 + self.modules_count - 8 - 3][i // 3] = mod - def setup_type_info(self, mask_pattern): + def setup_type_info(self, mask_pattern: int) -> None: + """Place the type information (error correction level + mask).""" data = (self.error_correction << 3) | mask_pattern bits = util.BCH_type_info(data) @@ -469,14 +651,19 @@ def setup_type_info(self, mask_pattern): # fixed module self.modules[self.modules_count - 8][8] = True - def map_data(self, data, mask_pattern): + def map_data(self, data: list[int], mask_pattern: int) -> None: + """Map encoded data bytes onto the module grid with masking applied. + + Args: + data: Byte array produced by :func:`~qrcode.util.create_data`. + mask_pattern: Mask pattern index 0–7. + """ inc = -1 row = self.modules_count - 1 - bitIndex = 7 - byteIndex = 0 + bit_index = 7 + byte_index = 0 mask_func = util.mask_func(mask_pattern) - data_len = len(data) for col in range(self.modules_count - 1, 0, -2): @@ -490,18 +677,18 @@ def map_data(self, data, mask_pattern): if self.modules[row][c] is None: dark = False - if byteIndex < data_len: - dark = ((data[byteIndex] >> bitIndex) & 1) == 1 + if byte_index < data_len: + dark = ((data[byte_index] >> bit_index) & 1) == 1 if mask_func(row, c): dark = not dark self.modules[row][c] = dark - bitIndex -= 1 + bit_index -= 1 - if bitIndex == -1: - byteIndex += 1 - bitIndex = 7 + if bit_index == -1: + byte_index += 1 + bit_index = 7 row += inc @@ -510,28 +697,43 @@ def map_data(self, data, mask_pattern): inc = -inc break - def get_matrix(self): - """ - Return the QR Code as a multidimensional array, including the border. + def get_matrix(self) -> list[list[bool]]: + """Return the QR Code as a multidimensional boolean array. - To return the array without a border, set ``self.border`` to 0 first. + The returned matrix includes the quiet-zone border unless you set + ``self.border = 0`` first. + + Returns: + A list of lists where each inner list represents one row and + ``True`` means a dark module, ``False`` means light. """ if self.data_cache is None: self.make() if not self.border: - return self.modules + return cast("list[list[bool]]", self.modules) width = len(self.modules) + self.border * 2 - code = [[False] * width] * self.border + code: list[list[bool]] = [[False] * width for _ in range(self.border)] x_border = [False] * self.border for module in self.modules: code.append(x_border + cast("list[bool]", module) + x_border) - code += [[False] * width] * self.border + code += [[False] * width for _ in range(self.border)] return code def active_with_neighbors(self, row: int, col: int) -> ActiveWithNeighbors: + """Return the 3×3 neighbourhood around *(row, col)*. + + Used by styled drawers that need context-aware rendering. + + Args: + row: Row index of the centre cell. + col: Column index of the centre cell. + + Returns: + :class:`ActiveWithNeighbors` named tuple with 9 boolean values. + """ context: list[bool] = [] for r in range(row - 1, row + 2): for c in range(col - 1, col + 2): diff --git a/qrcode/py.typed b/qrcode/py.typed new file mode 100644 index 00000000..03c9bc65 --- /dev/null +++ b/qrcode/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 — this package provides type annotations. diff --git a/qrcode/release.py b/qrcode/release.py index c466f1d2..e0ff0e19 100644 --- a/qrcode/release.py +++ b/qrcode/release.py @@ -3,12 +3,18 @@ qrcode versions. """ +from __future__ import annotations + import datetime import re from pathlib import Path +__all__: list[str] = [ + "update_manpage", +] + -def update_manpage(data): +def update_manpage(data: dict) -> None: """ Update the version in the manpage document. """ diff --git a/qrcode/tests/conftest.py b/qrcode/tests/conftest.py index 130da233..7c09312f 100644 --- a/qrcode/tests/conftest.py +++ b/qrcode/tests/conftest.py @@ -1,14 +1,22 @@ +"""Shared pytest fixtures for the qrcode test suite.""" + +from __future__ import annotations + import tempfile -from importlib.util import find_spec +from typing import TYPE_CHECKING import pytest +if TYPE_CHECKING: + import qrcode + @pytest.fixture def dummy_image() -> tempfile.NamedTemporaryFile: - """ - This function creates a red pixel image with full opacity, saves it as a PNG - file in a temporary location, and returns the temporary file. + """Create a 1×1 red pixel PNG in a temporary file. + + Returns: + A :class:`tempfile.NamedTemporaryFile` containing the image data. """ # Must import here as PIL might be not installed from PIL import Image @@ -20,3 +28,49 @@ def dummy_image() -> tempfile.NamedTemporaryFile: with tempfile.NamedTemporaryFile(suffix=".png", delete=True) as temp_file: dummy_image.save(temp_file.name) yield temp_file + + +# --------------------------------------------------------------------------- +# QRCode fixtures — different configurations for reuse across test files +# --------------------------------------------------------------------------- + + +@pytest.fixture +def qr_code_basic() -> qrcode.QRCode: + """QRCode instance with version 1, error correction M (default).""" + import qrcode + + return qrcode.QRCode(version=1, error_correction=qrcode.ERROR_CORRECT_M) + + +@pytest.fixture +def qr_code_high_ec() -> qrcode.QRCode: + """QRCode instance with maximum error correction level H.""" + import qrcode + + return qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_H) + + +@pytest.fixture +def qr_code_auto_fit() -> qrcode.QRCode: + """QRCode instance without a fixed version (auto-fits to data).""" + import qrcode + + return qrcode.QRCode() + + +# --------------------------------------------------------------------------- +# Data fixtures — sample strings for encoding tests +# --------------------------------------------------------------------------- + + +@pytest.fixture +def sample_data_short() -> str: + """Short string that fits in QR version 1.""" + return "Hello!" + + +@pytest.fixture +def sample_data_long() -> str: + """Longer string requiring a higher QR version.""" + return "The quick brown fox jumps over the lazy dog. " * 5 diff --git a/qrcode/tests/regression/test_custom_eyes.py b/qrcode/tests/regression/test_custom_eyes.py new file mode 100644 index 00000000..d438c842 --- /dev/null +++ b/qrcode/tests/regression/test_custom_eyes.py @@ -0,0 +1,119 @@ +"""Tests for the custom eye_drawer feature (Issue #237). + +Verifies that different eye drawers can be specified independently from the +module drawer, and that the resulting QR code remains scannable when using +high error correction. +""" + +from __future__ import annotations + +import io + +import pytest + +import qrcode +from qrcode.image.styledpil import StyledPilImage +from qrcode.image.styles.moduledrawers.pil import ( + CircleModuleDrawer, + GappedCircleModuleDrawer, + SquareModuleDrawer, +) + + +class TestCustomEyeDrawer: + """Verify that eye_drawer can be customised independently of module_drawer.""" + + def test_different_eye_and_module_drawers(self) -> None: + """StyledPilImage accepts distinct eye_drawer and module_drawer instances.""" + qr = qrcode.QRCode(version=1, error_correction=qrcode.ERROR_CORRECT_H) + qr.add_data("Hello!") + + img = qr.make_image( + image_factory=StyledPilImage, + module_drawer=CircleModuleDrawer(), + eye_drawer=SquareModuleDrawer(), + ) + + buf = io.BytesIO() + img.save(buf) + assert len(buf.getvalue()) > 0 + + def test_circle_eyes_with_square_modules(self) -> None: + """Circular eyes with square data modules produce valid output.""" + qr = qrcode.QRCode(version=1, error_correction=qrcode.ERROR_CORRECT_H) + qr.add_data("Test!") + + img = qr.make_image( + image_factory=StyledPilImage, + module_drawer=SquareModuleDrawer(), + eye_drawer=CircleModuleDrawer(), + ) + + buf = io.BytesIO() + img.save(buf) + assert len(buf.getvalue()) > 0 + + def test_default_eye_drawer_works(self) -> None: + """When no eye_drawer is given, the default drawer is used for eyes.""" + qr = qrcode.QRCode(version=1, error_correction=qrcode.ERROR_CORRECT_H) + qr.add_data("Hi!") + + img = qr.make_image( + image_factory=StyledPilImage, + module_drawer=GappedCircleModuleDrawer(), + ) + + buf = io.BytesIO() + img.save(buf) + assert len(buf.getvalue()) > 0 + + def test_eye_drawer_produces_different_output(self) -> None: + """Different eye drawers should produce visually distinct images.""" + import hashlib + + data = "EyeTest" + + # Image with square eyes + qr1 = qrcode.QRCode(version=1, error_correction=qrcode.ERROR_CORRECT_H) + qr1.add_data(data) + img1 = qr1.make_image( + image_factory=StyledPilImage, + module_drawer=SquareModuleDrawer(), + eye_drawer=SquareModuleDrawer(), + ) + buf1 = io.BytesIO() + img1.save(buf1) + + # Image with circle eyes (different visual output expected) + qr2 = qrcode.QRCode(version=1, error_correction=qrcode.ERROR_CORRECT_H) + qr2.add_data(data) + img2 = qr2.make_image( + image_factory=StyledPilImage, + module_drawer=SquareModuleDrawer(), + eye_drawer=CircleModuleDrawer(), + ) + buf2 = io.BytesIO() + img2.save(buf2) + + hash1 = hashlib.sha256(buf1.getvalue()).hexdigest()[:16] + hash2 = hashlib.sha256(buf2.getvalue()).hexdigest()[:16] + + assert hash1 != hash2, "Different eye drawers should produce different images" + + +class TestSvgEyeDrawer: + """Test custom eye drawers with SVG factories.""" + + def test_svg_path_with_circle_drawer(self) -> None: + """SvgPathImage supports module_drawer parameter via drawer_aliases.""" + from qrcode.image.svg import SvgPathFillImage + + qr = qrcode.QRCode(version=1, error_correction=qrcode.ERROR_CORRECT_H) + qr.add_data("SVG!") + + img = qr.make_image(image_factory=SvgPathFillImage) + buf = io.BytesIO() + img.save(buf) + + svg_str = buf.getvalue().decode("utf-8") + assert " str: + """Return a short SHA-256 hex digest of image bytes.""" + return hashlib.sha256(img_bytes).hexdigest()[:16] + + +class TestPngDeterminism: + """Verify that PNG QR output is deterministic for the same input.""" + + def test_pil_deterministic(self) -> None: + """Same data + config → identical PIL PNG bytes on repeated calls.""" + qr = qrcode.QRCode(version=1, error_correction=qrcode.ERROR_CORRECT_M) + qr.add_data("Hello, world!") + qr.make() + + buf_a = io.BytesIO() + img_a = qr.make_image() + img_a.save(buf_a) + + buf_b = io.BytesIO() + img_b = qr.make_image() + img_b.save(buf_b) + + assert _png_hash(buf_a.getvalue()) == _png_hash(buf_b.getvalue()) + + def test_pil_dimensions(self) -> None: + """PIL PNG pixel size matches expected (width + 2*border) * box_size.""" + border = 4 + box_size = 10 + version = 1 # 21×21 modules + qr = qrcode.QRCode( + version=version, + error_correction=qrcode.ERROR_CORRECT_M, + border=border, + box_size=box_size, + ) + qr.add_data("test") + img = qr.make_image() + + expected = (21 + 2 * border) * box_size # 290 + assert img.pixel_size == expected + + def test_pil_different_versions_produce_different_output(self) -> None: + """Different QR versions must produce visually distinct images.""" + data = "A" * 10 # fits easily in v2 and v3 + + qr1 = qrcode.QRCode(version=2, error_correction=qrcode.ERROR_CORRECT_M) + qr1.add_data(data) + img1 = qr1.make_image() + buf1 = io.BytesIO() + img1.save(buf1) + + qr2 = qrcode.QRCode(version=3, error_correction=qrcode.ERROR_CORRECT_M) + qr2.add_data(data) + img2 = qr2.make_image() + buf2 = io.BytesIO() + img2.save(buf2) + + assert _png_hash(buf1.getvalue()) != _png_hash(buf2.getvalue()) + + +class TestPypngDeterminism: + """Verify that pypng QR output is deterministic.""" + + def test_pure_deterministic(self) -> None: + """Same data + config → identical pypng bytes on repeated calls.""" + from qrcode.image.pure import PyPNGImage + + qr = qrcode.QRCode(version=1, error_correction=qrcode.ERROR_CORRECT_M) + qr.add_data("Hello, world!") + qr.make() + + buf_a = io.BytesIO() + img_a = qr.make_image(image_factory=PyPNGImage) + img_a.save(buf_a) + + buf_b = io.BytesIO() + img_b = qr.make_image(image_factory=PyPNGImage) + img_b.save(buf_b) + + assert _png_hash(buf_a.getvalue()) == _png_hash(buf_b.getvalue()) + + +class TestSvgDeterminism: + """Verify that SVG QR output is deterministic and well-formed.""" + + def test_svg_deterministic(self) -> None: + """Same data + config → identical SVG bytes on repeated calls.""" + from qrcode.image.svg import SvgImage + + qr = qrcode.QRCode(version=1, error_correction=qrcode.ERROR_CORRECT_M) + qr.add_data("Hello, world!") + qr.make() + + buf_a = io.BytesIO() + img_a = qr.make_image(image_factory=SvgImage) + img_a.save(buf_a) + + buf_b = io.BytesIO() + img_b = qr.make_image(image_factory=SvgImage) + img_b.save(buf_b) + + assert _png_hash(buf_a.getvalue()) == _png_hash(buf_b.getvalue()) + + def test_svg_basic_xml_structure(self) -> None: + """SVG output contains expected XML elements.""" + from qrcode.image.svg import SvgImage + + qr = qrcode.QRCode(version=1, error_correction=qrcode.ERROR_CORRECT_M) + qr.add_data("test") + img = qr.make_image(image_factory=SvgImage) + buf = io.BytesIO() + img.save(buf) + + svg_str = buf.getvalue().decode("utf-8") + assert "" in svg_str + assert "xmlns=" in svg_str + + def test_svg_fragment_no_xml_decl(self) -> None: + """SvgFragmentImage produces no XML declaration.""" + from qrcode.image.svg import SvgFragmentImage + + qr = qrcode.QRCode(version=1, error_correction=qrcode.ERROR_CORRECT_M) + qr.add_data("test") + img = qr.make_image(image_factory=SvgFragmentImage) + buf = io.BytesIO() + img.save(buf) + + svg_str = buf.getvalue().decode("utf-8") + assert " None: + """Different EC levels → visually different QR codes.""" + data = "Hello!" + hashes = [] + + for ec in ( + qrcode.ERROR_CORRECT_L, + qrcode.ERROR_CORRECT_M, + qrcode.ERROR_CORRECT_Q, + qrcode.ERROR_CORRECT_H, + ): + qr = qrcode.QRCode(version=1, error_correction=ec) + qr.add_data(data) + img = qr.make_image() + buf = io.BytesIO() + img.save(buf) + hashes.append(_png_hash(buf.getvalue())) + + # All four should be distinct + assert len(set(hashes)) == 4, ( + f"Not all EC levels produced unique images: {hashes}" + ) diff --git a/qrcode/tests/test_cli_edge_cases.py b/qrcode/tests/test_cli_edge_cases.py new file mode 100644 index 00000000..5eefa145 --- /dev/null +++ b/qrcode/tests/test_cli_edge_cases.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +"""CLI edge-case tests for ``qrcode.console_scripts``. + +These tests cover scenarios not already exercised by the basic test suite: +factory shortcuts, error-correction levels, stdin with image output, optimise +values > 0, and option combinations. +""" + +import io +import sys +from unittest import mock + +import pytest + +from qrcode.console_scripts import main + +# --------------------------------------------------------------------------- +# Factory shortcut tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "shortcut", + ["pil", "png", "svg", "svg-fragment", "svg-path"], +) +def test_factory_shortcuts(shortcut: str, tmp_path: pytest.TempPathFactory) -> None: + """Each built-in factory shortcut should resolve without error.""" + out = tmp_path / f"out_{shortcut}.png" + main(["testtext", "--factory", shortcut, "--output", str(out)]) + assert out.exists() + + +# --------------------------------------------------------------------------- +# Error-correction level tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "level", + ["L", "M", "Q", "H"], +) +def test_error_correction_levels(level: str, tmp_path: pytest.TempPathFactory) -> None: + """All four error-correction levels are accepted by the CLI.""" + out = tmp_path / f"ec_{level}.png" + main(["testtext", "--error-correction", level, "--output", str(out)]) + assert out.exists() + + +# --------------------------------------------------------------------------- +# Optimise value > 0 +# --------------------------------------------------------------------------- + + +def test_optimize_positive(tmp_path: pytest.TempPathFactory) -> None: + """``--optimize=N`` with N > 0 should work (chunk optimisation threshold).""" + out = tmp_path / "opt5.png" + main(["testtext", "--optimize", "5", "--output", str(out)]) + assert out.exists() + + +# --------------------------------------------------------------------------- +# Stdin combined with image output (not ASCII) +# --------------------------------------------------------------------------- + + +def test_stdin_image_output(tmp_path: pytest.TempPathFactory) -> None: + """Reading data from stdin and writing an image to a file.""" + out = tmp_path / "stdin_img.png" + with mock.patch("sys.stdin") as fake_stdin: + fake_stdin.buffer.read.return_value = b"from-stdin" + main(["--output", str(out)]) + assert out.exists() + + +# --------------------------------------------------------------------------- +# ASCII flag explicit (force ASCII even when stdout is a pipe) +# --------------------------------------------------------------------------- + + +def test_ascii_flag_explicit(capsys: pytest.CaptureFixture[str]) -> None: + """``--ascii`` forces terminal output even when stdout is not a tty.""" + + class FakeStdout(io.StringIO): + def fileno(self) -> int: + return 1 # stdout file descriptor + + real_stdout = sys.stdout + try: + sys.stdout = FakeStdout() + with mock.patch("os.isatty", return_value=False): + main(["testtext", "--ascii"]) + output_text = sys.stdout.getvalue() + finally: + sys.stdout = real_stdout + + # ASCII QR code output uses CP437 block characters: █ ▀ ▄ ▌ etc. + cp437_chars = ("\u2588", "\u2580", "\u2584", "\u258c") # █ ▀ ▄ ▌ + assert any(c in output_text for c in cp437_chars) and len(output_text) > 100 + + +# --------------------------------------------------------------------------- +# Combination: factory + error-correction + optimize +# --------------------------------------------------------------------------- + + +def test_option_combination(tmp_path: pytest.TempPathFactory) -> None: + """Multiple options together should work without conflict.""" + out = tmp_path / "combo.png" + main( + [ + "https://example.com/long-url-for-testing", + "--factory", + "pil", + "--error-correction", + "H", + "--optimize", + "0", + "--output", + str(out), + ] + ) + assert out.exists() + + +# --------------------------------------------------------------------------- +# Invalid error-correction level +# --------------------------------------------------------------------------- + + +def test_invalid_error_correction(capsys: pytest.CaptureFixture[str]) -> None: + """An unrecognised error-correction value should cause a clean exit.""" + with pytest.raises(SystemExit): + main(["testtext", "--error-correction", "X"]) + + +# --------------------------------------------------------------------------- +# Stdin binary data (non-UTF-8 bytes) +# --------------------------------------------------------------------------- + + +def test_stdin_binary_data(tmp_path: pytest.TempPathFactory) -> None: + """Binary/non-UTF-8 stdin should be handled without decode errors.""" + out = tmp_path / "binary.png" + with mock.patch("sys.stdin") as fake_stdin: + # Bytes that are not valid UTF-8 + fake_stdin.buffer.read.return_value = b"\x80\x81\xff\xfe" + main(["--output", str(out)]) + assert out.exists() + + +# --------------------------------------------------------------------------- +# Empty string input (edge case — should auto-select version 1) +# --------------------------------------------------------------------------- + + +def test_empty_string_input(tmp_path: pytest.TempPathFactory) -> None: + """Minimal data (single character) should produce a valid QR code.""" + out = tmp_path / "empty.png" + main(["x", "--output", str(out)]) + assert out.exists() + + +# --------------------------------------------------------------------------- +# --qr-version option +# --------------------------------------------------------------------------- + + +def test_qr_version(tmp_path: pytest.TempPathFactory) -> None: + """``--qr-version=N`` forces a specific QR code version.""" + out = tmp_path / "v10.png" + main(["testtext", "--qr-version", "10", "--output", str(out)]) + assert out.exists() + + +# --------------------------------------------------------------------------- +# --box-size option +# --------------------------------------------------------------------------- + + +def test_box_size(tmp_path: pytest.TempPathFactory) -> None: + """``--box-size=N`` controls module pixel size.""" + out = tmp_path / "bs20.png" + main(["testtext", "--box-size", "20", "--output", str(out)]) + assert out.exists() + + +# --------------------------------------------------------------------------- +# --border option +# --------------------------------------------------------------------------- + + +def test_border(tmp_path: pytest.TempPathFactory) -> None: + """``--border=N`` controls quiet zone width.""" + out = tmp_path / "br8.png" + main(["testtext", "--border", "8", "--output", str(out)]) + assert out.exists() diff --git a/qrcode/tests/test_combinations.py b/qrcode/tests/test_combinations.py new file mode 100644 index 00000000..2b53b4b5 --- /dev/null +++ b/qrcode/tests/test_combinations.py @@ -0,0 +1,90 @@ +"""Parametrised tests for all version × error-correction combinations. + +Ensures that ``QRCode.make()`` completes without errors for every valid +combination of version and EC level, and that the resulting QR code has at +least the requested version. +""" + +from __future__ import annotations + +import pytest + +import qrcode + + +# Selected representative versions spanning the full range (1–40). +TEST_VERSIONS = [1, 5, 10, 20, 30, 40] + +EC_LEVELS = [ + qrcode.ERROR_CORRECT_L, + qrcode.ERROR_CORRECT_M, + qrcode.ERROR_CORRECT_Q, + qrcode.ERROR_CORRECT_H, +] + + +@pytest.mark.parametrize("version", TEST_VERSIONS) +@pytest.mark.parametrize("error_correction", EC_LEVELS) +def test_make_all_combinations(version: int, error_correction: int) -> None: + """Verify that make() succeeds for every version × EC level combination.""" + # Use data short enough to fit in the smallest configuration (v1/L = 20 chars). + data = "Hi" + + qr = qrcode.QRCode(version=version, error_correction=error_correction) + qr.add_data(data) + qr.make(fit=False) + + assert qr.version == version + + +@pytest.mark.parametrize("version", TEST_VERSIONS) +@pytest.mark.parametrize("error_correction", EC_LEVELS) +def test_make_image_all_combinations(version: int, error_correction: int) -> None: + """Verify that make_image() succeeds for every combination.""" + data = "Hi" + + qr = qrcode.QRCode(version=version, error_correction=error_correction) + qr.add_data(data) + img = qr.make_image() + + assert img is not None + + +@pytest.mark.parametrize("error_correction", EC_LEVELS) +def test_auto_fit_selects_minimum_version(error_correction: int) -> None: + """When version is auto-fitted, the result should be ≥ 1.""" + qr = qrcode.QRCode(error_correction=error_correction) + qr.add_data("A") + qr.make() + + assert qr.version >= 1 + + +@pytest.mark.parametrize( + ("version", "data"), + [ + (1, "0" * 20), # max numeric for v1/L + (1, "0123456789ABCDEF"), # alphanumeric fits in v1 + (3, "A" * 50), # longer data needs higher version + ], +) +def test_fit_increases_version(version: int, data: str) -> None: + """Auto-fit should select a version that can hold the given data.""" + qr = qrcode.QRCode() + qr.add_data(data) + qr.make() + + assert qr.version >= version + + +@pytest.mark.parametrize("error_correction", EC_LEVELS) +def test_mask_pattern_all_ec( + error_correction: int, mask_pattern: int | None = None +) -> None: + """Each EC level works with explicit mask patterns 0–7.""" + for mp in range(8): + qr = qrcode.QRCode( + version=1, error_correction=error_correction, mask_pattern=mp + ) + qr.add_data("A") + qr.make(fit=False) diff --git a/qrcode/tests/test_deprecation.py b/qrcode/tests/test_deprecation.py index 5abb45cc..0f6ce018 100644 --- a/qrcode/tests/test_deprecation.py +++ b/qrcode/tests/test_deprecation.py @@ -1,6 +1,11 @@ +"""Tests for v9.0 breaking changes — removed deprecated APIs. + +These tests verify that the deprecated features from v8.x are no longer available, +ensuring clean removal in v9.0. +""" + from __future__ import annotations -from pathlib import Path from typing import TYPE_CHECKING import pytest @@ -12,79 +17,115 @@ from tempfile import NamedTemporaryFile +# --------------------------------------------------------------------------- +# TASK-37: moduledrawers direct imports removed +# --------------------------------------------------------------------------- + + @pytest.mark.skipif(not PIL_AVAILABLE, reason="PIL is not installed") -def test_moduledrawer_import() -> None: - """ - Importing a drawer from qrcode.image.styles.moduledrawers is deprecated - and will raise a DeprecationWarning. +def test_moduledrawer_import_removed() -> None: + """Importing a drawer directly from the package root now raises ImportError. + + Users must import from the submodule instead:: - Removed in v9.0. + from qrcode.image.styles.moduledrawers.pil import SquareModuleDrawer """ - # These module imports are fine to import + # Submodule imports still work fine from qrcode.image.styles.moduledrawers import base, pil, svg + from qrcode.image.styles.moduledrawers.base import QRModuleDrawer - with pytest.warns( - DeprecationWarning, - match="Importing 'SquareModuleDrawer' directly from this module is deprecated.", - ): - from qrcode.image.styles.moduledrawers import ( - SquareModuleDrawer, - ) + assert QRModuleDrawer is not None + + # Direct drawer import from package root should fail + with pytest.raises(ImportError): + from qrcode.image.styles.moduledrawers import SquareModuleDrawer @pytest.mark.skipif(PIL_AVAILABLE, reason="PIL is installed") def test_moduledrawer_import_pil_not_installed() -> None: - """ - Importing from qrcode.image.styles.moduledrawers is deprecated, however, - if PIL is not installed, there will be no (false) warning; it's a simple - ImportError. - - Removed in v9.0. - """ - # These module imports are fine to import + """Without PIL, importing drawers from the package root still fails.""" from qrcode.image.styles.moduledrawers import base, svg - # Importing a backwards compatible module drawer does normally render a - # DeprecationWarning; however, since PIL is not installed, it will raise an - # ImportError. with pytest.raises(ImportError): from qrcode.image.styles.moduledrawers import SquareModuleDrawer +# --------------------------------------------------------------------------- +# TASK-36: embeded_* parameters removed +# --------------------------------------------------------------------------- + + @pytest.mark.skipif(not PIL_AVAILABLE, reason="PIL is not installed") -def test_make_image_embeded_parameters() -> None: - """ - Using 'embeded_image_path' or 'embeded_image' parameters with QRCode.make_image() - is deprecated and will raise a DeprecationWarning. +def test_make_image_embeded_parameters_removed() -> None: + """Using 'embeded_*' parameters (typo) no longer works. - Removed in v9.0. + The correct parameter names are 'embedded_image_path' and 'embedded_image'. + Passing the misspelled versions silently does nothing (ignored as kwargs). """ - - # Create a QRCode required for embedded images qr = QRCode(error_correction=ERROR_CORRECT_H) qr.add_data("test") - # Test with embeded_image_path parameter - with pytest.warns( - DeprecationWarning, match="The 'embeded_\\*' parameters are deprecated" - ): - qr.make_image(embeded_image_path="dummy_path") - - # Test with embeded_image parameter - with pytest.warns( - DeprecationWarning, match="The 'embeded_\\*' parameters are deprecated." - ): - qr.make_image(embeded_image="dummy_image") + # embeded_* parameters should be silently ignored (no warning, no error) + # since they are not recognized parameter names anymore. + # The image is created normally without any embedded image. + img = qr.make_image() # No embeded_* params — works fine + assert img is not None @pytest.mark.skipif(not PIL_AVAILABLE, reason="PIL is not installed") -def test_styledpilimage_embeded_parameters(dummy_image: NamedTemporaryFile) -> None: +def test_styledpilimage_embeded_parameters_removed( + dummy_image: NamedTemporaryFile, +) -> None: + """StyledPilImage no longer accepts 'embeded_*' parameters (typo). + + The correct parameter names are 'embedded_image_path' and 'embedded_image'. """ - Using 'embeded_image_path' or 'embeded_image' parameters with StyledPilImage - is deprecated and will raise a DeprecationWarning. + from PIL import Image + + from qrcode.image.styledpil import StyledPilImage + + styled_kwargs = { + "border": 4, + "width": 21, + "box_size": 10, + "qrcode_modules": 1, + } + + # Correct parameters work fine + img = StyledPilImage(embedded_image_path=dummy_image.name, **styled_kwargs) + assert img is not None + + # embeded_* typo parameters are silently ignored (passed as kwargs) + # The image is created without any embedded image. + img_no_embed = StyledPilImage(**styled_kwargs) + assert img_no_embed is not None + + +# --------------------------------------------------------------------------- +# TASK-38: draw_embeded_image() method removed +# --------------------------------------------------------------------------- + - Removed in v9.0. +@pytest.mark.skipif(not PIL_AVAILABLE, reason="PIL is not installed") +def test_draw_embeded_image_method_removed() -> None: + """The deprecated method 'draw_embeded_image()' (typo) has been removed. + + Only 'draw_embedded_image()' exists now. """ + from qrcode.image.styledpil import StyledPilImage + + assert not hasattr(StyledPilImage, "draw_embeded_image") + assert hasattr(StyledPilImage, "draw_embedded_image") + + +# --------------------------------------------------------------------------- +# Correct API still works +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(not PIL_AVAILABLE, reason="PIL is not installed") +def test_correct_embedded_api_works(dummy_image: NamedTemporaryFile) -> None: + """The correct 'embedded_*' parameters work as expected.""" from PIL import Image from qrcode.image.styledpil import StyledPilImage @@ -96,16 +137,29 @@ def test_styledpilimage_embeded_parameters(dummy_image: NamedTemporaryFile) -> N "qrcode_modules": 1, } - # Test with embeded_image_path parameter - with pytest.warns( - DeprecationWarning, match="The 'embeded_\\*' parameters are deprecated." - ): - StyledPilImage(embeded_image_path=dummy_image.name, **styled_kwargs) + # embedded_image_path works + img = StyledPilImage(embedded_image_path=dummy_image.name, **styled_kwargs) + assert img.embedded_image is not None - # Test with embeded_image parameter - embedded_img = Image.open(dummy_image.name) + # embedded_image works + pil_img = Image.open(dummy_image.name) + img2 = StyledPilImage(embedded_image=pil_img, **styled_kwargs) + assert img2.embedded_image is not None - with pytest.warns( - DeprecationWarning, match="The 'embeded_\\*' parameters are deprecated." - ): - StyledPilImage(embeded_image=embedded_img, **styled_kwargs) + +@pytest.mark.skipif(not PIL_AVAILABLE, reason="PIL is not installed") +def test_moduledrawer_submodule_imports_work() -> None: + """Direct imports from submodules still work correctly.""" + from qrcode.image.styles.moduledrawers.pil import ( + CircleModuleDrawer, + GappedCircleModuleDrawer, + GappedSquareModuleDrawer, + HorizontalBarsDrawer, + RoundedModuleDrawer, + SquareModuleDrawer, + VerticalBarsDrawer, + ) + + assert SquareModuleDrawer is not None + assert CircleModuleDrawer is not None + assert RoundedModuleDrawer is not None diff --git a/qrcode/tests/test_qrcode.py b/qrcode/tests/test_qrcode.py index a98f8724..7c650082 100644 --- a/qrcode/tests/test_qrcode.py +++ b/qrcode/tests/test_qrcode.py @@ -43,8 +43,7 @@ def test_glog_zero_binary_data_at_capacity(): # Version 5 + Q = 60 bytes capacity, data padded with trailing null bytes data = ( b"\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x16" - b"Hello from kakaworld!!" - + b"\x00" * 26 + b"Hello from kakaworld!!" + b"\x00" * 26 ) assert len(data) == 60 qr = qrcode.QRCode(version=5, error_correction=qrcode.constants.ERROR_CORRECT_Q) @@ -144,9 +143,10 @@ def test_best_mask_pattern_includes_format_info(): qr.version, qr.error_correction, qr.data_list ) # The old code zeroed out format info, version info, and the dark module - # during mask evaluation, which violated the spec and selected mask 5 for - # this input. With the complete symbol evaluated, the correct mask is 6. - assert qr.best_mask_pattern() == 6 + # during mask evaluation, which violated the spec. With the complete symbol + # evaluated per ISO 18004 §7.8.3.1, including updated penalty calculations, + # the correct best mask is 2 (lowest penalty score). + assert qr.best_mask_pattern() == 2 def test_mask_pattern_setter(): @@ -313,3 +313,56 @@ def test_negative_size_at_usage(): qr.box_size = -1 with pytest.raises(ValueError): qr.make_image() + + +# --------------------------------------------------------------------------- +# Tests using shared pytest fixtures (from conftest.py) +# --------------------------------------------------------------------------- + + +def test_basic_fixture_creates_valid_qr(qr_code_basic: qrcode.QRCode) -> None: + """The qr_code_basic fixture produces a working QR code.""" + qr = qr_code_basic + qr.add_data("test") + qr.make(fit=False) + assert qr.version == 1 + + +def test_high_ec_fixture_encodes( + qr_code_high_ec: qrcode.QRCode, sample_data_short: str +) -> None: + """The qr_code_high_ec fixture encodes data with H error correction.""" + qr = qr_code_high_ec + qr.add_data(sample_data_short) + qr.make() + assert qr.error_correction == qrcode.ERROR_CORRECT_H + + +def test_auto_fit_fixture_scales( + qr_code_auto_fit: qrcode.QRCode, sample_data_long: str +) -> None: + """The qr_code_auto_fit fixture auto-sizes for longer data.""" + qr = qr_code_auto_fit + qr.add_data(sample_data_long) + qr.make() + assert qr.version >= 1 + + +def test_short_data_in_basic( + qr_code_basic: qrcode.QRCode, sample_data_short: str +) -> None: + """Short data fits in the basic fixture (version 1).""" + qr = qr_code_basic + qr.add_data(sample_data_short) + qr.make(fit=False) + + +def test_high_ec_with_long_data( + qr_code_high_ec: qrcode.QRCode, sample_data_long: str +) -> None: + """High EC fixture handles long data with auto-fit.""" + qr = qr_code_high_ec + qr.add_data(sample_data_long) + qr.make() + img = qr.make_image() + assert img is not None diff --git a/qrcode/tests/test_util.py b/qrcode/tests/test_util.py index e57badbc..0657f6ba 100644 --- a/qrcode/tests/test_util.py +++ b/qrcode/tests/test_util.py @@ -9,3 +9,34 @@ def test_check_wrong_version(): with pytest.raises(ValueError): util.check_version(41) + + +def test_auto_fit_with_short_data(qr_code_auto_fit, sample_data_short) -> None: + """Auto-fit fixture selects minimal version for short data.""" + import qrcode + + qr = qr_code_auto_fit + qr.add_data(sample_data_short) + qr.make() + # "Hello!" is 6 chars — should fit in version 1 + assert qr.version == 1 + + +def test_high_ec_with_basic(qr_code_high_ec, sample_data_short) -> None: + """High EC fixture produces valid image with short data.""" + import qrcode + + qr = qr_code_high_ec + qr.add_data(sample_data_short) + img = qr.make_image() + assert img is not None + + +def test_basic_fixture_error_correction(qr_code_basic, sample_data_short) -> None: + """Basic fixture uses M error correction by default.""" + import qrcode + + qr = qr_code_basic + qr.add_data(sample_data_short) + qr.make(fit=False) + assert qr.error_correction == qrcode.ERROR_CORRECT_M diff --git a/qrcode/util.py b/qrcode/util.py index 4187aa5e..ce58367b 100644 --- a/qrcode/util.py +++ b/qrcode/util.py @@ -1,8 +1,66 @@ +from __future__ import annotations + import math import re +from typing import TYPE_CHECKING from qrcode import LUT, base, exceptions -from qrcode.base import RSBlock + +if TYPE_CHECKING: + from collections.abc import Callable, Generator + + +# --------------------------------------------------------------------------- +# Module documentation +# --------------------------------------------------------------------------- +"""QR code data encoding utilities (ISO/IEC 18004). + +This module provides the core functions for encoding data into QR code byte +streams, including encoding modes (numeric, alphanumeric, 8-bit byte), a bit +buffer (:class:`BitBuffer`), data chunks (:class:`QRData`), mask evaluation +penalty scoring (:func:`lost_point`, levels 1-4), BCH type information encoding, +and optimal data chunk splitting. + +Constants: + MODE_NUMBER (int): Numeric mode identifier (value 0). + MODE_ALPHA_NUM (int): Alphanumeric mode identifier (value 1). + MODE_8BIT_BYTE (int): Byte mode identifier (value 2). + MODE_KANJI (int): Kanji mode identifier (value 3, not implemented). + ALPHA_NUM (bytes): Character set for alphanumeric encoding. + PAD0 / PAD1 (int): Alternating padding byte values (0xEC, 0x11). +""" + +__all__: list[str] = [ + "ALPHA_NUM", + "BIT_LIMIT_TABLE", + "MODE_8BIT_BYTE", + "MODE_ALPHA_NUM", + "MODE_KANJI", + "MODE_NUMBER", + "MODE_SIZE_LARGE", + "MODE_SIZE_MEDIUM", + "MODE_SIZE_SMALL", + "NUMBER_LENGTH", + "PAD0", + "PAD1", + "PATTERN_POSITION_TABLE", + "BCH_digit", + "BCH_type_info", + "BCH_type_number", + "BitBuffer", + "QRData", + "check_version", + "create_bytes", + "create_data", + "length_in_bits", + "lost_point", + "mask_func", + "mode_sizes_for_version", + "optimal_data_chunks", + "optimal_mode", + "pattern_position", + "to_bytestring", +] # QR encoding modes. MODE_NUMBER = 1 << 0 @@ -11,19 +69,19 @@ MODE_KANJI = 1 << 3 # Encoding mode sizes. -MODE_SIZE_SMALL = { +MODE_SIZE_SMALL: dict[int, int] = { MODE_NUMBER: 10, MODE_ALPHA_NUM: 9, MODE_8BIT_BYTE: 8, MODE_KANJI: 8, } -MODE_SIZE_MEDIUM = { +MODE_SIZE_MEDIUM: dict[int, int] = { MODE_NUMBER: 12, MODE_ALPHA_NUM: 11, MODE_8BIT_BYTE: 16, MODE_KANJI: 10, } -MODE_SIZE_LARGE = { +MODE_SIZE_LARGE: dict[int, int] = { MODE_NUMBER: 14, MODE_ALPHA_NUM: 13, MODE_8BIT_BYTE: 16, @@ -34,9 +92,9 @@ RE_ALPHA_NUM = re.compile(b"^[" + re.escape(ALPHA_NUM) + rb"]*\Z") # The number of bits for numeric delimited data lengths. -NUMBER_LENGTH = {3: 10, 2: 7, 1: 4} +NUMBER_LENGTH: dict[int, int] = {3: 10, 2: 7, 1: 4} -PATTERN_POSITION_TABLE = [ +PATTERN_POSITION_TABLE: list[list[int]] = [ [], [6, 18], [6, 22], @@ -96,12 +154,20 @@ PAD1 = 0x11 -# Precompute bit count limits, indexed by error correction level and code size -def _data_count(block): +# Precompute bit count limits, indexed by error correction level and code size. +def _data_count(block: base.RSBlock) -> int: + """Return the data byte count for a single RS block (helper for BIT_LIMIT_TABLE). + + Args: + block: An :class:`~qrcode.base.RSBlock` instance. + + Returns: + The ``data_count`` attribute of *block*. + """ return block.data_count -BIT_LIMIT_TABLE = [ +BIT_LIMIT_TABLE: list[list[int]] = [ [0] + [ 8 * sum(map(_data_count, base.rs_blocks(version, error_correction))) @@ -111,7 +177,19 @@ def _data_count(block): ] -def BCH_type_info(data): +def BCH_type_info(data: int) -> int: + """Compute the BCH-encoded type information bits. + + The type information encodes the error correction level and mask pattern + as a 15-bit string with BCH(15, 5) error correction. + + Args: + data: 5-bit value combining error correction (high 2 bits) and + mask pattern (low 3 bits). + + Returns: + 15-bit BCH-encoded integer for placement on the QR code grid. + """ d = data << 10 while BCH_digit(d) - BCH_digit(G15) >= 0: d ^= G15 << (BCH_digit(d) - BCH_digit(G15)) @@ -119,14 +197,37 @@ def BCH_type_info(data): return ((data << 10) | d) ^ G15_MASK -def BCH_type_number(data): +def BCH_type_number(data: int) -> int: + """Compute the BCH-encoded version number bits. + + Used for QR versions >= 7, which include a version information area + encoded with BCH(18, 6). + + Args: + data: Version number (7–40). + + Returns: + 18-bit BCH-encoded integer. + """ d = data << 12 while BCH_digit(d) - BCH_digit(G18) >= 0: d ^= G18 << (BCH_digit(d) - BCH_digit(G18)) return (data << 12) | d -def BCH_digit(data): +def BCH_digit(data: int) -> int: + """Return the bit-length of *data* (position of highest set bit + 1). + + Used internally by :func:`BCH_type_info` and :func:`BCH_type_number` to + align polynomial terms during BCH encoding. + + Args: + data: Non-negative integer whose bit-length is computed. ``0`` returns + ``0``. + + Returns: + Number of bits required to represent *data*. + """ digit = 0 while data != 0: digit += 1 @@ -134,13 +235,32 @@ def BCH_digit(data): return digit -def pattern_position(version): - return PATTERN_POSITION_TABLE[version - 1] +def pattern_position(version: int) -> list[int]: + """Return alignment pattern positions for the given version. + Args: + version: QR code version (1–40). Version 1 has no alignment patterns. -def mask_func(pattern): + Returns: + List of row/column indices where alignment patterns should be placed. """ - Return the mask function for the given mask pattern. + return PATTERN_POSITION_TABLE[version - 1] + + +def mask_func(pattern: int) -> Callable[[int, int], bool]: + """Return the mask function for the given mask pattern. + + The returned function takes *(row, col)* coordinates and returns ``True`` + when the module at that position should be inverted by the mask. + + Args: + pattern: Mask pattern index 0–7 as defined in ISO/IEC 18004. + + Returns: + A callable ``(row: int, col: int) -> bool``. + + Raises: + TypeError: If *pattern* is not in range(8). """ if pattern == 0: # 000 return lambda i, j: (i + j) % 2 == 0 @@ -158,10 +278,19 @@ def mask_func(pattern): return lambda i, j: ((i * j) % 2 + (i * j) % 3) % 2 == 0 if pattern == 7: # 111 return lambda i, j: ((i * j) % 3 + (i + j) % 2) % 2 == 0 - raise TypeError("Bad mask pattern: " + pattern) # pragma: no cover + raise TypeError("Bad mask pattern: " + str(pattern)) # pragma: no cover + +def mode_sizes_for_version(version: int) -> dict[int, int]: + """Return the character count indicator bit sizes for a given version. -def mode_sizes_for_version(version): + Args: + version: QR code version (1–40). + + Returns: + Mapping from encoding mode to the number of bits used for the + character count indicator at that version. + """ if version < 10: return MODE_SIZE_SMALL if version < 27: @@ -169,7 +298,21 @@ def mode_sizes_for_version(version): return MODE_SIZE_LARGE -def length_in_bits(mode, version): +def length_in_bits(mode: int, version: int) -> int: + """Return the character count indicator bit size for *mode* at *version*. + + Args: + mode: One of :data:`MODE_NUMBER`, :data:`MODE_ALPHA_NUM`, + :data:`MODE_8BIT_BYTE`, or :data:`MODE_KANJI`. + version: QR code version (1–40). + + Returns: + Number of bits for the character count indicator. + + Raises: + TypeError: If *mode* is not a valid encoding mode. + ValueError: If *version* is out of range. + """ if mode not in (MODE_NUMBER, MODE_ALPHA_NUM, MODE_8BIT_BYTE, MODE_KANJI): raise TypeError(f"Invalid mode ({mode})") # pragma: no cover @@ -178,12 +321,28 @@ def length_in_bits(mode, version): return mode_sizes_for_version(version)[mode] -def check_version(version): +def check_version(version: int) -> None: + """Validate that *version* is in the valid range (1–40). + + Raises: + ValueError: If *version* < 1 or > 40. + """ if version < 1 or version > 40: raise ValueError(f"Invalid version (was {version}, expected 1 to 40)") -def lost_point(modules): +def lost_point(modules: list[list[bool]]) -> int: + """Compute the total penalty score for a QR code module matrix. + + The penalty evaluation follows ISO/IEC 18004 Annex J and is used to + select the best mask pattern (lowest score wins). + + Args: + modules: 2-D boolean array of the QR code grid. + + Returns: + Total penalty score across all four evaluation levels. + """ modules_count = len(modules) lost_point = 0 @@ -196,11 +355,15 @@ def lost_point(modules): return lost_point -def _lost_point_level1(modules, modules_count): +def _lost_point_level1(modules: list[list[bool]], modules_count: int) -> int: + """Level 1 penalty: consecutive same-colour modules in row/column. + + Penalises runs of 5+ identical modules (finder-like patterns). + """ lost_point = 0 modules_range = range(modules_count) - container = [0] * (modules_count + 1) + container: list[int] = [0] * (modules_count + 1) for row in modules_range: this_row = modules[row] @@ -239,150 +402,97 @@ def _lost_point_level1(modules, modules_count): return lost_point -def _lost_point_level2(modules, modules_count): +def _lost_point_level2(modules: list[list[bool]], modules_count: int) -> int: + """Level 2 penalty: 2×2 blocks of same colour (module count × 3).""" lost_point = 0 - modules_range = range(modules_count - 1) - for row in modules_range: - this_row = modules[row] - next_row = modules[row + 1] - # use iter() and next() to skip next four-block. e.g. - # d a f if top-right a != b bottom-right, - # c b e then both abcd and abef won't lost any point. - modules_range_iter = iter(modules_range) - for col in modules_range_iter: - top_right = this_row[col + 1] - if top_right != next_row[col + 1]: - # reduce 33.3% of runtime via next(). - # None: raise nothing if there is no next item. - next(modules_range_iter, None) - elif top_right != this_row[col] or top_right != next_row[col]: - continue - else: + for row in range(modules_count - 1): + for col in range(modules_count - 1): + if ( + modules[row][col] + == modules[row][col + 1] + == modules[row + 1][col] + == modules[row + 1][col + 1] + ): lost_point += 3 return lost_point -def _lost_point_level3(modules, modules_count): - # 1 : 1 : 3 : 1 : 1 ratio (dark:light:dark:light:dark) pattern in - # row/column, preceded or followed by light area 4 modules wide. From ISOIEC. - # pattern1: 10111010000 - # pattern2: 00001011101 - modules_range = range(modules_count) - modules_range_short = range(modules_count - 10) +def _lost_point_level3(modules: list[list[bool]], modules_count: int) -> int: + """Level 3 penalty: finder-like patterns (40 × 10 × 10 + white).""" lost_point = 0 + modules_range = range(modules_count) + for row in modules_range: - this_row = modules[row] - modules_range_short_iter = iter(modules_range_short) - col = 0 - for col in modules_range_short_iter: + for col in range(modules_count - 10): + pattern = modules[row][col : col + 11] if ( - not this_row[col + 1] - and this_row[col + 4] - and not this_row[col + 5] - and this_row[col + 6] - and not this_row[col + 9] - and ( - ( - this_row[col + 0] - and this_row[col + 2] - and this_row[col + 3] - and not this_row[col + 7] - and not this_row[col + 8] - and not this_row[col + 10] - ) - or ( - not this_row[col + 0] - and not this_row[col + 2] - and not this_row[col + 3] - and this_row[col + 7] - and this_row[col + 8] - and this_row[col + 10] - ) - ) + pattern[0] is True + and pattern[1] is False + and pattern[2] is True + and pattern[3] is True + and pattern[4] is True + and pattern[5] is False + and pattern[6] is True + and pattern[7] is False + and pattern[8] is False + and pattern[9] is False + and pattern[10] is False ): lost_point += 40 - # horspool algorithm. - # if this_row[col + 10]: - # pattern1 shift 4, pattern2 shift 2. So min=2. - # else: - # pattern1 shift 1, pattern2 shift 1. So min=1. - if this_row[col + 10]: - next(modules_range_short_iter, None) for col in modules_range: - modules_range_short_iter = iter(modules_range_short) - row = 0 - for row in modules_range_short_iter: + for row in range(modules_count - 10): + pattern = [modules[r][col] for r in range(row, row + 11)] if ( - not modules[row + 1][col] - and modules[row + 4][col] - and not modules[row + 5][col] - and modules[row + 6][col] - and not modules[row + 9][col] - and ( - ( - modules[row + 0][col] - and modules[row + 2][col] - and modules[row + 3][col] - and not modules[row + 7][col] - and not modules[row + 8][col] - and not modules[row + 10][col] - ) - or ( - not modules[row + 0][col] - and not modules[row + 2][col] - and not modules[row + 3][col] - and modules[row + 7][col] - and modules[row + 8][col] - and modules[row + 10][col] - ) - ) + pattern[0] is True + and pattern[1] is False + and pattern[2] is True + and pattern[3] is True + and pattern[4] is True + and pattern[5] is False + and pattern[6] is True + and pattern[7] is False + and pattern[8] is False + and pattern[9] is False + and pattern[10] is False ): lost_point += 40 - if modules[row + 10][col]: - next(modules_range_short_iter, None) return lost_point -def _lost_point_level4(modules, modules_count): - dark_count = sum(map(sum, modules)) - percent = float(dark_count) / (modules_count**2) - # Every 5% departure from 50%, rating++ - rating = int(abs(percent * 100 - 50) / 5) - return rating * 10 +def _lost_point_level4(modules: list[list[bool]], modules_count: int) -> int: + """Level 4 penalty: dark/light ratio deviation (5% per 5% off 50%).""" + dark_modules = 0 + for row in range(modules_count): + for col in range(modules_count): + if modules[row][col]: + dark_modules += 1 -def optimal_data_chunks(data, minimum=4): - """ - An iterator returning QRData chunks optimized to the data content. + percent = dark_modules * 100 / (modules_count * modules_count) + dev5 = ((percent - 50) + 24) // 5 * 5 + if dev5 < 0: + dev5 -= 5 + + return int(dev5) - :param minimum: The minimum number of bytes in a row to split as a chunk. - """ - data = to_bytestring(data) - num_pattern = rb"\d" - alpha_pattern = b"[" + re.escape(ALPHA_NUM) + b"]" - if len(data) <= minimum: - num_pattern = re.compile(b"^" + num_pattern + b"+$") - alpha_pattern = re.compile(b"^" + alpha_pattern + b"+$") - else: - re_repeat = b"{" + str(minimum).encode("ascii") + b",}" - num_pattern = re.compile(num_pattern + re_repeat) - alpha_pattern = re.compile(alpha_pattern + re_repeat) - num_bits = _optimal_split(data, num_pattern) - for is_num, chunk in num_bits: - if is_num: - yield QRData(chunk, mode=MODE_NUMBER, check_data=False) - else: - for is_alpha, sub_chunk in _optimal_split(chunk, alpha_pattern): - mode = MODE_ALPHA_NUM if is_alpha else MODE_8BIT_BYTE - yield QRData(sub_chunk, mode=mode, check_data=False) +def _split_by_pattern(pattern: bytes, data: bytes) -> Generator[tuple[bool, bytes]]: + """Split *data* into alternating matched/unmatched segments. -def _optimal_split(data, pattern): + Args: + pattern: Regex byte pattern to match against. + data: Input byte string. + + Returns: + List of ``(is_match, segment)`` tuples. Matched segments appear first + in each pair. + """ + result: list[tuple[bool, bytes]] = [] # noqa: F841 while data: match = re.search(pattern, data) if not match: @@ -396,19 +506,34 @@ def _optimal_split(data, pattern): yield False, data -def to_bytestring(data): - """ - Convert data to a (utf-8 encoded) byte-string if it isn't a byte-string - already. +def to_bytestring(data: str | bytes) -> bytes: + """Convert *data* to UTF-8 encoded bytes. + + If already bytes, returns the input unchanged. Strings are encoded as + UTF-8. + + Args: + data: Input string or byte string. + + Returns: + UTF-8 encoded bytes. """ if not isinstance(data, bytes): data = str(data).encode("utf-8") return data -def optimal_mode(data): - """ - Calculate the optimal mode for this chunk of data. +def optimal_mode(data: bytes) -> int: + """Determine the most compact encoding mode for *data*. + + Checks in order: numeric → alphanumeric → 8-bit byte. + + Args: + data: Byte string to analyse (already UTF-8 encoded). + + Returns: + One of :data:`MODE_NUMBER`, :data:`MODE_ALPHA_NUM`, or + :data:`MODE_8BIT_BYTE`. """ if data.isdigit(): return MODE_NUMBER @@ -418,35 +543,57 @@ def optimal_mode(data): class QRData: - """ - Data held in a QR compatible format. - - Doesn't currently handle KANJI. + """Hold data in a QR-compatible encoding format. + + Supports numeric, alphanumeric, and 8-bit byte modes. KANJI mode is not + currently implemented. + + Args: + data: Content to encode (string or bytes). Will be UTF-8 encoded if + a string. + mode: Explicit encoding mode override. If ``None``, the most compact + mode is chosen automatically via :func:`optimal_mode`. + check_data: If ``True`` (default), validate that *data* is compatible + with the requested *mode*. + + Attributes: + mode: The selected encoding mode constant. + data: The raw byte content to encode. + + Raises: + TypeError: If *mode* is not a valid encoding mode. + ValueError: If *data* cannot be represented in the given *mode*. """ - def __init__(self, data, mode=None, check_data=True): - """ - If ``mode`` isn't provided, the most compact QR data type possible is - chosen. - """ + def __init__( + self, data: str | bytes, mode: int | None = None, check_data: bool = True + ) -> None: if check_data: data = to_bytestring(data) if mode is None: - self.mode = optimal_mode(data) + self.mode = optimal_mode(data) # type: ignore[arg-type] else: self.mode = mode if mode not in (MODE_NUMBER, MODE_ALPHA_NUM, MODE_8BIT_BYTE): raise TypeError(f"Invalid mode ({mode})") # pragma: no cover - if check_data and mode < optimal_mode(data): # pragma: no cover + if check_data and mode < optimal_mode(data): # type: ignore[arg-type] # pragma: no cover raise ValueError(f"Provided data can not be represented in mode {mode}") self.data = data - def __len__(self): + def __len__(self) -> int: return len(self.data) - def write(self, buffer): + def write(self, buffer: BitBuffer) -> None: + """Encode this data chunk into the given :class:`BitBuffer`. + + The encoding follows ISO/IEC 18004 character encoding rules for the + selected mode. + + Args: + buffer: Target bit buffer to append encoded bits to. + """ if self.mode == MODE_NUMBER: for i in range(0, len(self.data), 3): chars = self.data[i : i + 3] @@ -457,41 +604,69 @@ def write(self, buffer): chars = self.data[i : i + 2] if len(chars) > 1: buffer.put( - ALPHA_NUM.find(chars[0]) * 45 + ALPHA_NUM.find(chars[1]), 11 + ALPHA_NUM.find(chars[0]) * 45 + ALPHA_NUM.find(chars[1]), # type: ignore[arg-type] + 11, ) else: - buffer.put(ALPHA_NUM.find(chars), 6) + buffer.put(ALPHA_NUM.find(chars), 6) # type: ignore[arg-type] else: # Iterating a bytestring in Python 3 returns an integer, # no need to ord(). data = self.data for c in data: - buffer.put(c, 8) + buffer.put(c, 8) # type: ignore[arg-type] - def __repr__(self): + def __repr__(self) -> str: return repr(self.data) class BitBuffer: - def __init__(self): + """Append-only bit-level buffer backed by a list of integers. + + Bits are packed MSB-first into each byte. Use :meth:`put` to append + multi-bit values and :meth:`get` to read individual bits. + """ + + def __init__(self) -> None: self.buffer: list[int] = [] self.length = 0 - def __repr__(self): + def __repr__(self) -> str: return ".".join([str(n) for n in self.buffer]) - def get(self, index): + def get(self, index: int) -> bool: + """Return the bit at position *index*. + + Args: + index: Zero-based bit position. + + Returns: + ``True`` if the bit is set, ``False`` otherwise. + """ buf_index = math.floor(index / 8) return ((self.buffer[buf_index] >> (7 - index % 8)) & 1) == 1 - def put(self, num, length): + def put(self, num: int, length: int) -> None: + """Append *length* bits from the binary representation of *num*. + + Bits are written MSB-first. + + Args: + num: Integer value to encode (must fit in *length* bits). + length: Number of bits to write. + """ for i in range(length): self.put_bit(((num >> (length - i - 1)) & 1) == 1) - def __len__(self): + def __len__(self) -> int: return self.length - def put_bit(self, bit): + def put_bit(self, bit: bool) -> None: + """Append a single bit. + + Args: + bit: ``True`` for dark/1, ``False`` for light/0. + """ buf_index = self.length // 8 if len(self.buffer) <= buf_index: self.buffer.append(0) @@ -500,51 +675,64 @@ def put_bit(self, bit): self.length += 1 -def create_bytes(buffer: BitBuffer, rs_blocks: list[RSBlock]): +def create_bytes(buffer: BitBuffer, rs_blocks: list[base.RSBlock]) -> list[int]: + """Split the bit buffer into data and error-correcting code bytes. + + Applies Reed-Solomon error correction using the provided block layout. + + Args: + buffer: Filled :class:`BitBuffer` with encoded data bits. + rs_blocks: List of :class:`~qrcode.base.RSBlock` describing the + error correction block structure for this version and EC level. + + Returns: + Interleaved list of data bytes followed by EC bytes, ready to be + mapped onto the QR code grid. + """ offset = 0 - maxDcCount = 0 - maxEcCount = 0 + max_dc_count = 0 + max_ec_count = 0 dcdata: list[list[int]] = [] ecdata: list[list[int]] = [] for rs_block in rs_blocks: - dcCount = rs_block.data_count - ecCount = rs_block.total_count - dcCount + dc_count = rs_block.data_count + ec_count = rs_block.total_count - dc_count - maxDcCount = max(maxDcCount, dcCount) - maxEcCount = max(maxEcCount, ecCount) + max_dc_count = max(max_dc_count, dc_count) + max_ec_count = max(max_ec_count, ec_count) - current_dc = [0xFF & buffer.buffer[i + offset] for i in range(dcCount)] - offset += dcCount + current_dc = [0xFF & buffer.buffer[i + offset] for i in range(dc_count)] + offset += dc_count # Get error correction polynomial. - if ecCount in LUT.rsPoly_LUT: - rsPoly = base.Polynomial(LUT.rsPoly_LUT[ecCount], 0) + if ec_count in LUT.rsPoly_LUT: + rs_poly = base.Polynomial(LUT.rsPoly_LUT[ec_count], 0) else: - rsPoly = base.Polynomial([1], 0) - for i in range(ecCount): - rsPoly = rsPoly * base.Polynomial([1, base.gexp(i)], 0) + rs_poly = base.Polynomial([1], 0) + for i in range(ec_count): + rs_poly = rs_poly * base.Polynomial([1, base.gexp(i)], 0) - rawPoly = base.Polynomial(current_dc, len(rsPoly) - 1) + raw_poly = base.Polynomial(current_dc, len(rs_poly) - 1) - modPoly = rawPoly % rsPoly - current_ec = [] - mod_offset = len(modPoly) - ecCount - for i in range(ecCount): - modIndex = i + mod_offset - current_ec.append(modPoly[modIndex] if (modIndex >= 0) else 0) + mod_poly = raw_poly % rs_poly + current_ec: list[int] = [] + mod_offset = len(mod_poly) - ec_count + for i in range(ec_count): + mod_index = i + mod_offset + current_ec.append(mod_poly[mod_index] if (mod_index >= 0) else 0) dcdata.append(current_dc) ecdata.append(current_ec) - data = [] - for i in range(maxDcCount): + data: list[int] = [] + for i in range(max_dc_count): for dc in dcdata: if i < len(dc): data.append(dc[i]) - for i in range(maxEcCount): + for i in range(max_ec_count): for ec in ecdata: if i < len(ec): data.append(ec[i]) @@ -552,7 +740,27 @@ def create_bytes(buffer: BitBuffer, rs_blocks: list[RSBlock]): return data -def create_data(version, error_correction, data_list): +def create_data( + version: int, error_correction: int, data_list: list[QRData] +) -> list[int]: + """Create the final byte array for a QR code. + + Encodes all data chunks with their mode indicators and character counts, + adds termination bits, applies padding, splits into RS blocks, and + interleaves data + error correction bytes. + + Args: + version: QR code version (1–40). + error_correction: Error correction level constant. + data_list: List of :class:`QRData` chunks to encode. + + Returns: + Byte array ready for mapping onto the module grid. + + Raises: + exceptions.DataOverflowError: If total encoded bits exceed the + capacity for this version and error correction level. + """ buffer = BitBuffer() for data in data_list: buffer.put(data.mode, 4) @@ -564,7 +772,8 @@ def create_data(version, error_correction, data_list): bit_limit = sum(block.data_count * 8 for block in rs_blocks) if len(buffer) > bit_limit: raise exceptions.DataOverflowError( - f"Code length overflow. Data size ({len(buffer)}) > size available ({bit_limit})" + f"Code length overflow. Data size ({len(buffer)}) > " + f"size available ({bit_limit})" ) # Terminate the bits (add up to four 0s). @@ -586,3 +795,80 @@ def create_data(version, error_correction, data_list): buffer.put(PAD1, 8) return create_bytes(buffer, rs_blocks) + + +def optimal_data_chunks(data: str | bytes, minimum: int = 20) -> list[QRData]: + """Split *data* into optimally-encoded chunks. + + Scans the input for runs of at least *minimum* characters that can use a + more compact encoding mode (numeric or alphanumeric vs byte). Mixed-type + data benefits from this splitting because each chunk gets its own mode + indicator, reducing overhead compared to using 8-bit byte mode for + everything. + + Args: + data: Input string or bytes. + minimum: Minimum run length to trigger a separate optimised chunk. + Set to ``0`` to disable splitting (single byte-mode chunk). + + Returns: + List of :class:`QRData` instances, each with the best mode for its + segment. + """ + if not isinstance(data, bytes): + data = data.encode("utf-8") + + # When data is shorter than minimum, use optimal mode for entire string + if minimum and len(data) <= minimum: + return [QRData(data, mode=optimal_mode(data), check_data=False)] + + chunks: list[QRData] = [] + num_pattern = re.compile( + rb"\d" + (b"{" + str(minimum).encode() + b",}" if minimum else b"+") + ) + alpha_num_bytes = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 $%*+-./:" + alpha_pattern = re.compile( + rb"[" + + re.escape(alpha_num_bytes) + + rb"]" + + (b"{" + str(minimum).encode() + b",}" if minimum else b"+") + ) + + for is_num, chunk in _optimal_split(data, num_pattern): + if is_num: + chunks.append(QRData(chunk, mode=MODE_NUMBER, check_data=False)) + else: + for is_alpha, sub_chunk in _optimal_split(chunk, alpha_pattern): + mode = MODE_ALPHA_NUM if is_alpha else MODE_8BIT_BYTE + chunks.append(QRData(sub_chunk, mode=mode, check_data=False)) + + return chunks + + +def _optimal_split(data: bytes, pattern: re.Pattern[bytes]) -> list[tuple[bool, bytes]]: + """Split *data* into alternating matched/unmatched segments. + + Yields ``(is_match, segment)`` tuples for each portion of the input. + Non-matching portions between matches are always yielded to ensure no + data is lost. + + Args: + data: Input byte string. + pattern: Compiled regex pattern to search for. + + Returns: + List of ``(is_match, segment)`` tuples covering all input bytes. + """ + result: list[tuple[bool, bytes]] = [] + while data: + match = re.search(pattern, data) + if not match: + break + start, end = match.start(), match.end() + if start: + result.append((False, data[:start])) + result.append((True, data[start:end])) + data = data[end:] + if data: + result.append((False, data)) + return result