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 ``