Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ``<table>`` element:

.. code:: python

img = qr.make_image(alt="Scan to pay", attrib={"class": "qr-code"})


Styled Image
------------

Expand Down
1 change: 1 addition & 0 deletions qrcode/console_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
120 changes: 120 additions & 0 deletions qrcode/image/html.py
Original file line number Diff line number Diff line change
@@ -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 ``<tr>``
and runs of same-coloured modules are merged into a single ``<td>`` 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 ``<tr>`` 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"<table{self._table_attrs()}>"]
for row, row_count in self.merged_rows_iter():
parts.append(f'<tr style="height:{row_count * self.box_size}px">')
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'<td{colspan} style="{style}"></td>')
parts.append("</tr>\n")
parts.append("</table>")
return "".join(parts)
149 changes: 149 additions & 0 deletions qrcode/tests/test_qrcode_html.py
Original file line number Diff line number Diff line change
@@ -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("<table")
assert html.endswith("</table>")
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("<tr") <= qr.modules_count + 2


def test_alt_and_attrib():
html = make_html(alt="Scan me", attrib={"class": "qr-code"})
assert 'role="img"' in html
assert 'aria-label="Scan me"' in html
assert 'class="qr-code"' in html


def test_escaped_attributes():
html = make_html(alt='"><script>')
assert "<script>" not in html
assert 'aria-label="&quot;&gt;&lt;script&gt;"' in html
10 changes: 10 additions & 0 deletions qrcode/tests/test_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ def test_factory():
main(["testtext", "--factory", "svg"])


def test_html_factory():
main(["testtext", "--factory", "html"])


def test_html_factory_output(tmp_path):
output = tmp_path / "test.html"
main(["testtext", "--factory", "html", "--output", str(output)])
assert output.read_text(encoding="utf-8").startswith("<table")


def test_bad_factory():
with pytest.raises(SystemExit):
main(["testtext", "--factory", "nope"])
Expand Down