Skip to content

Add an HTML image factory - #432

Open
vsengar26 wants to merge 1 commit into
lincolnloop:mainfrom
vsengar26:html-image-factory
Open

Add an HTML image factory#432
vsengar26 wants to merge 1 commit into
lincolnloop:mainfrom
vsengar26:html-image-factory

Conversation

@vsengar26

@vsengar26 vsengar26 commented Aug 5, 2026

Copy link
Copy Markdown

Adds qrcode.image.html.HtmlImage, which renders a QR code as an HTML fragment, plus a --factory=html shortcut for the qr script.

import qrcode
from qrcode.image.html import HtmlImage

img = qrcode.make("Some data here", image_factory=HtmlImage)
html = img.to_string()
qr --factory=html "Some text" > test.html

Why

Every current factory produces an image, which then has to reach the page as a file or an image source. There are a few common situations where that isn't possible, or isn't wanted:

There is no image pipeline. Inlining markup into a template means no PNG written to disk, no file hosted and served, no attachment referenced by Content-ID, and no separate /qr.png view to build, route and secure. It also needs no imaging dependency at all — the output is a string built with the standard library, which matters in slim containers and serverless builds where Pillow or a native PNG library is unwelcome. For short-lived codes such as TOTP enrolment or single-use tickets, it also avoids the code existing as a fetchable URL that can end up in logs or caches.

Sanitisers and content policies. Plenty of pipelines strip <img>, refuse data: URIs, or restrict image hosts through CSP. Markup survives in places an image source does not — the most familiar example being email, where external images are blocked by default in most clients until the recipient chooses to load them, so a legitimate QR code shows as a placeholder on first read.

Styling and theming. As part of the DOM the code inherits the surrounding CSS: recolour it, resize it, give it a dark-mode variant, and it stays crisp at any zoom, all without re-rendering.

The SVG factory is the closest fit, but inline SVG is not rendered by many email clients and is commonly stripped by HTML sanitisers, so it does not cover these cases.

How

The code is drawn as a table of cells. Two things keep that from being enormous:

  • runs of same-coloured modules within a row are merged into one <td> with colspan
  • identical adjacent rows are merged into a single, taller <tr> — which mostly collapses the quiet zone

For a default version-4 code that is 589 cells across 35 rows, rather than 1681 cells across 41 rows.

Only inline styles and the legacy table attributes are used, since that is the subset of HTML that renders consistently in the constrained environments above. Trimmed sample output:

<table role="img" aria-label="QR Code" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;border-spacing:0;table-layout:fixed;width:200px;height:200px;background-color:#ffffff;font-size:0;line-height:0"><tr style="height:16px"><td colspan="25" style="width:200px"></td></tr>
<tr style="height:8px"><td colspan="2" style="width:16px"></td><td colspan="7" style="width:56px;background-color:#000000"></td><td colspan="3" style="width:24px"></td><td colspan="3" style="width:24px;background-color:#000000"></td><td style="width:8px"></td><td colspan="7" style="width:56px;background-color:#000000"></td><td colspan="2" style="width:16px"></td></tr>
...
</table>

box_size and border behave as they do for the other factories, setting the module size in pixels and the width of the quiet zone. make_image() additionally accepts:

argument default
fill_color #000000 colour of the dark modules
back_color #ffffff colour of the background and quiet zone
alt QR Code used as the table's aria-label
attrib {} extra attributes for the <table> element

to_string() returns the markup. save() accepts a path, a binary stream or a text stream. Attribute values are escaped.

Testing

test_qrcode_html.py parses the generated markup back into a matrix and asserts it matches QRCode.get_matrix(), over border 0/1/4 and box_size 1/7, which covers the two merging steps. The rest covers to_string(), the three save() targets, check_kind, colours, alt/attrib and escaping; test_script.py covers the CLI shortcut.

Beyond the suite, I rendered eight variants in headless Chromium — default, box_size 2 and 4, border 0 and 8, custom colours, error-correction H, and a version-12 code — screenshotted each and decoded the screenshots with zxing. All scanned back to the original data, as did the fragment written by the qr script.

Notes

  • The markup runs to roughly 23–28 KB for a typical code. That is the trade-off for not being an image; box_size changes the rendered size but not the markup size.
  • Setting an explicit background colour on the table and on the dark cells is deliberate: it also keeps clients that auto-invert unstyled backgrounds in dark mode from inverting the code, which would leave it unreadable to many scanners.
  • No changelog entry, since the history suggests those are added separately after merge — happy to add one if you'd prefer.

Adds an `html` image factory that renders the code as an HTML fragment,
for embedding in a document rather than referencing an image file. This
covers contexts where an <img> is not an option: email clients block
external images by default and support neither inline SVG nor data: URIs,
and some sanitizers and CSP policies strip image sources.

The markup is a table of cells. Runs of same-coloured modules within a
row are merged with colspan, and identical adjacent rows are merged into
a single taller row, which keeps the output to about a third of the cells
of a naive one-td-per-module grid. Only inline styles and the legacy
table attributes are used, as that is the subset of HTML that email
clients render reliably.

Colours, the aria-label and extra table attributes can be set through
make_image(); box_size and border behave as they do for the other
factories.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant