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 ``| `` 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 `` |
`` 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("
")
+ 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("|