diff --git a/README.rst b/README.rst index 73722544..8971b8b3 100644 --- a/README.rst +++ b/README.rst @@ -98,8 +98,8 @@ The ``border`` parameter controls how many boxes thick the border should be Other image factories ===================== -You can encode as SVG, or use a new pure Python image processor to encode to -PNG images. +You can encode as SVG or HTML, or use a new pure Python image processor to +encode to PNG images. The Python examples below use the ``make`` shortcut. The same ``image_factory`` keyword argument is a valid option for the ``QRCode`` class for more advanced @@ -188,6 +188,53 @@ Or in Python: img = qrcode.make('Some data here', image_factory=PyPNGImage) +HTML +---- + +You can render the QR code as an HTML fragment, to embed in a document instead +of referencing an image. The code is drawn as a table of cells, which is the +only markup that email clients render reliably: inline SVG and ``data:`` URIs +are not supported by Outlook or Gmail. + +From your command line:: + + qr --factory=html "Some text" > test.html + +Or in Python: + +.. code:: python + + import qrcode + from qrcode.image.html import HtmlImage + + img = qrcode.make('Some data here', image_factory=HtmlImage) + img.save('some_file.html') + +Use ``to_string()`` to get the markup, for example to pass it to a template: + +.. code:: python + + import qrcode + from qrcode.image.html import HtmlImage + + qr = qrcode.QRCode(box_size=4, image_factory=HtmlImage) + qr.add_data('Some data') + + img = qr.make_image(fill_color="#375f23", back_color="#ffc3eb") + html = img.to_string() + +The ``box_size`` parameter sets the size of each module in pixels and +``border`` sets the width of the quiet zone, as with the other image factories. + +``make_image()`` also accepts an ``alt`` argument, used as the ``aria-label`` +of the table (it defaults to ``"QR Code"``), and an ``attrib`` dictionary of +extra attributes for the ```` element: + +.. code:: python + + img = qr.make_image(alt="Scan to pay", attrib={"class": "qr-code"}) + + Styled Image ------------ diff --git a/qrcode/console_scripts.py b/qrcode/console_scripts.py index 431bcb20..d0c2e781 100755 --- a/qrcode/console_scripts.py +++ b/qrcode/console_scripts.py @@ -31,6 +31,7 @@ default_factories = { "pil": "qrcode.image.pil.PilImage", "png": "qrcode.image.pure.PyPNGImage", + "html": "qrcode.image.html.HtmlImage", "svg": "qrcode.image.svg.SvgImage", "svg-fragment": "qrcode.image.svg.SvgFragmentImage", "svg-path": "qrcode.image.svg.SvgPathImage", diff --git a/qrcode/image/html.py b/qrcode/image/html.py new file mode 100644 index 00000000..c12135ad --- /dev/null +++ b/qrcode/image/html.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from html import escape +from itertools import groupby +from pathlib import Path + +from qrcode.image.base import BaseImage + + +class HtmlImage(BaseImage): + """ + HTML image builder. + + Renders the QR code as an HTML fragment, so it can be embedded in a + document (or an email) without referencing an external image. + + The markup is a table of cells: each row of the QR code becomes a ```` + and runs of same-coloured modules are merged into a single ```` per module. + """ + for row, group in groupby(self.rows_iter()): + yield row, len(list(group)) + + def _table_attrs(self): + attrs = { + "role": "img", + "aria-label": self.alt, + # Legacy attributes, for email clients that ignore the styles. + "cellpadding": "0", + "cellspacing": "0", + "border": "0", + "style": ( + "border-collapse:collapse;border-spacing:0;table-layout:fixed;" + f"width:{self.pixel_size}px;height:{self.pixel_size}px;" + f"background-color:{self.back_color};font-size:0;line-height:0" + ), + } + attrs.update(self.attrib) + return "".join( + f' {name}="{escape(str(value), quote=True)}"' + for name, value in attrs.items() + ) + + def _html(self): + parts = [f""] + for row, row_count in self.merged_rows_iter(): + parts.append(f'') + for dark, group in groupby(row): + col_count = len(list(group)) + colspan = f' colspan="{col_count}"' if col_count > 1 else "" + style = f"width:{col_count * self.box_size}px" + if dark: + style += f";background-color:{self.fill_color}" + parts.append(f'') + parts.append("\n") + parts.append("
`` using + ``colspan``. Only inline styles and legacy table attributes are used, since + that is the subset of HTML that email clients render reliably. + """ + + kind = "HTML" + allowed_kinds = ("HTML",) + needs_drawrect = False + + def new_image( + self, + fill_color="#000000", + back_color="#ffffff", + alt="QR Code", + attrib=None, + ): + self.fill_color = fill_color + self.back_color = back_color + self.alt = alt + self.attrib = attrib or {} + return self._html() + + def drawrect(self, row, col): + """ + Not used. + """ + + def to_string(self): + """ + Return the QR code as an HTML fragment. + """ + return self._img + + def save(self, stream, kind=None): + self.check_kind(kind=kind) + html = self.to_string() + if isinstance(stream, (str, Path)): + Path(stream).write_text(html, encoding="utf-8") + return + try: + stream.write(html.encode("utf-8")) + except TypeError: + # A text stream was given rather than a binary one. + stream.write(html) + + def rows_iter(self): + """ + Yield each row of modules, including the border (quiet zone). + """ + width = self.width + self.border * 2 + blank_row = [False] * width + x_border = [False] * self.border + for _ in range(self.border): + yield blank_row + for module_row in self.modules: + yield x_border + [bool(module) for module in module_row] + x_border + for _ in range(self.border): + yield blank_row + + def merged_rows_iter(self): + """ + Yield ``(row, count)`` pairs, collapsing identical adjacent rows. + + Identical rows render the same as a single, taller row, which keeps the + border from adding a ``
") + return "".join(parts) diff --git a/qrcode/tests/test_qrcode_html.py b/qrcode/tests/test_qrcode_html.py new file mode 100644 index 00000000..1ac3a372 --- /dev/null +++ b/qrcode/tests/test_qrcode_html.py @@ -0,0 +1,149 @@ +import io +import re +from html.parser import HTMLParser + +import pytest + +import qrcode +from qrcode.image.html import HtmlImage +from qrcode.tests.consts import UNICODE_TEXT + + +class TableParser(HTMLParser): + """ + Rebuild the QR code matrix from the generated markup. + """ + + def __init__(self, box_size, fill_color="#000000"): + super().__init__() + self.box_size = box_size + self.fill_color = fill_color + self.matrix = [] + self.tables = 0 + self.row = None + self.row_count = 0 + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag == "table": + self.tables += 1 + elif tag == "tr": + self.row = [] + self.row_count = self.px(attrs["style"], "height") // self.box_size + elif tag == "td": + colspan = int(attrs.get("colspan", 1)) + assert self.px(attrs["style"], "width") == colspan * self.box_size + dark = f"background-color:{self.fill_color}" in attrs["style"] + self.row.extend([dark] * colspan) + + def handle_endtag(self, tag): + if tag == "tr": + self.matrix.extend([self.row] * self.row_count) + self.row = None + + @staticmethod + def px(style, prop): + return int(re.search(rf"(?:^|;){prop}:(\d+)px", style).group(1)) + + +def parse(html, box_size=10, fill_color="#000000"): + parser = TableParser(box_size, fill_color) + parser.feed(html) + return parser + + +def make_html(**kwargs): + qr = qrcode.QRCode() + qr.add_data(UNICODE_TEXT) + return qr.make_image(image_factory=HtmlImage, **kwargs).to_string() + + +def test_render_html(): + qr = qrcode.QRCode() + qr.add_data(UNICODE_TEXT) + img = qr.make_image(image_factory=HtmlImage) + img.save(io.BytesIO()) + + +def test_html_string(): + qr = qrcode.QRCode() + qr.add_data(UNICODE_TEXT) + img = qr.make_image(image_factory=HtmlImage) + html = img.to_string() + assert html.startswith("") + assert img.get_image() == html + + +def test_save_binary_stream(): + img = qrcode.make(UNICODE_TEXT, image_factory=HtmlImage) + stream = io.BytesIO() + img.save(stream) + assert stream.getvalue().decode() == img.to_string() + + +def test_save_text_stream(): + img = qrcode.make(UNICODE_TEXT, image_factory=HtmlImage) + stream = io.StringIO() + img.save(stream) + assert stream.getvalue() == img.to_string() + + +def test_save_path(tmp_path): + img = qrcode.make(UNICODE_TEXT, image_factory=HtmlImage) + path = tmp_path / "test.html" + img.save(str(path)) + assert path.read_text(encoding="utf-8") == img.to_string() + + +def test_wrong_kind(): + img = qrcode.make(UNICODE_TEXT, image_factory=HtmlImage) + with pytest.raises(ValueError): + img.save(io.BytesIO(), kind="PNG") + + +@pytest.mark.parametrize("border", [0, 1, 4]) +@pytest.mark.parametrize("box_size", [1, 7]) +def test_matches_matrix(border, box_size): + qr = qrcode.QRCode(border=border, box_size=box_size) + qr.add_data(UNICODE_TEXT) + img = qr.make_image(image_factory=HtmlImage) + parser = parse(img.to_string(), box_size=box_size) + assert parser.tables == 1 + assert parser.matrix == qr.get_matrix() + + +def test_size(): + qr = qrcode.QRCode(border=4, box_size=5) + qr.add_data(UNICODE_TEXT) + img = qr.make_image(image_factory=HtmlImage) + size = (qr.modules_count + 8) * 5 + assert f"width:{size}px;height:{size}px" in img.to_string() + + +def test_colors(): + html = make_html(fill_color="#375f23", back_color="#ffc3eb") + assert "background-color:#ffc3eb" in html + assert "background-color:#375f23" in html + assert parse(html, fill_color="#375f23").matrix + + +def test_merged_border_rows(): + qr = qrcode.QRCode(border=4) + qr.add_data(UNICODE_TEXT) + img = qr.make_image(image_factory=HtmlImage) + # The border rows above and below the code collapse into a single row each. + assert img.to_string().count("