Add SVG support to datamatrix (#12875)

* Extract Data Matrix rendering helpers

* Add SVG Data Matrix output

* Verify vector Data Matrix output in PDFs

---------

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
Petr Ledvina
2026-09-18 18:14:50 +10:00
committed by GitHub
co-authored by Oliver
parent 98f4a8203b
commit f76d79baf1
3 changed files with 335 additions and 35 deletions
+7 -2
View File
@@ -129,7 +129,12 @@ which produces the following output:
!!! info "ppf.datamatrix"
Data Matrix codes are generated using the [ppf.datamatrix](https://pypi.org/project/ppf-datamatrix/) library.
[Data Matrix Codes](https://en.wikipedia.org/wiki/Data_Matrix) provide an alternative to QR codes for encoding data in a two-dimensional matrix. To render a Data Matrix code, use the `datamatrix` template tag:
[Data Matrix Codes](https://en.wikipedia.org/wiki/Data_Matrix) provide an alternative to QR codes for encoding data in a two-dimensional matrix. To render a Data Matrix code, use the `datamatrix` template tag.
Data Matrix codes are rendered as PNG images by default. Use `fmt="SVG"` to
generate a vector image, avoiding intermediate raster scaling when a report
is resized or converted to printer output. Final module alignment still depends
on the printed dimensions and the printer's rasterization.
::: report.templatetags.barcode.datamatrix
options:
@@ -161,7 +166,7 @@ which produces the following output:
{% block content %}
<img class='qr' src='{% datamatrix "Foo Bar" back_color="yellow" %}'>
<img class='qr' src='{% datamatrix "Foo Bar" back_color="yellow" fmt="SVG" %}'>
{% endblock content %}
{% endraw %}
@@ -1,5 +1,8 @@
"""Template tags for rendering various barcodes."""
import base64
from collections.abc import Sequence
from django import template
from django.core.exceptions import ValidationError
from django.utils.safestring import mark_safe
@@ -30,6 +33,104 @@ def image_data(img, fmt='PNG') -> str:
return report.helpers.encode_image_base64(img, fmt)
def _render_2d_barcode_svg(
matrix: Sequence[Sequence[bool]],
foreground: tuple[int, int, int],
background: tuple[int, int, int],
width: int,
height: int,
scale: float,
border: int,
) -> str:
"""Render a two-dimensional barcode matrix as SVG image data."""
foreground_hex = '#{:02x}{:02x}{:02x}'.format(*foreground)
background_hex = '#{:02x}{:02x}{:02x}'.format(*background)
path = ' '.join(
f'M{x + border} {y + border}h1v1h-1z'
for y, row in enumerate(matrix)
for x, value in enumerate(row)
if value
)
svg = (
f'<svg xmlns="http://www.w3.org/2000/svg" '
f'width="{int(width * scale)}" height="{int(height * scale)}" '
f'viewBox="0 0 {width} {height}" shape-rendering="crispEdges">'
f'<rect width="{width}" height="{height}" fill="{background_hex}"/>'
f'<path d="{path}" fill="{foreground_hex}"/>'
'</svg>'
)
data = base64.b64encode(svg.encode()).decode()
return f'data:image/svg+xml;charset=utf-8;base64,{data}'
def _render_2d_barcode_raster(
matrix: Sequence[Sequence[bool]],
foreground: tuple[int, int, int],
background: tuple[int, int, int],
width: int,
height: int,
scale: float,
border: int,
fmt: str,
) -> str:
"""Render a two-dimensional barcode matrix as raster image data."""
img = Image.new('RGB', (width, height), color=background)
for y, row in enumerate(matrix):
for x, value in enumerate(row):
if value:
img.putpixel((x + border, y + border), foreground)
img = img.resize(
(int(width * scale), int(height * scale)), Image.Resampling.NEAREST
)
return image_data(img, fmt=fmt)
def _render_2d_barcode(
matrix: Sequence[Sequence[bool]],
fill_color: str = 'black',
back_color: str = 'white',
scale: float = 1.0,
border: int = 1,
fmt: str = 'PNG',
) -> str:
"""Render a two-dimensional barcode matrix in the requested format."""
try:
border = int(border)
except Exception:
border = 1
border = max(0, border)
try:
foreground = ImageColor.getcolor(fill_color, 'RGB')
except Exception:
foreground = ImageColor.getcolor('black', 'RGB')
try:
background = ImageColor.getcolor(back_color, 'RGB')
except Exception:
background = ImageColor.getcolor('white', 'RGB')
height = len(matrix) + 2 * border
width = len(matrix[0]) + 2 * border
if str(fmt).upper() == 'SVG':
return _render_2d_barcode_svg(
matrix, foreground, background, width, height, scale, border
)
return _render_2d_barcode_raster(
matrix, foreground, background, width, height, scale, border, fmt
)
@register.simple_tag()
def clean_barcode(data):
"""Return a 'cleaned' string for encoding into a barcode / qrcode.
@@ -154,7 +255,7 @@ def datamatrix(
back_color: Background color (default = 'white')
scale: Scaling factor (default = 1)
border: Border width (default = 1)
fmt: Generated image format (default = 'PNG')
fmt: Generated image format (default = 'PNG'; use 'SVG' for vector output)
Returns:
image (str): base64 encoded image data
@@ -170,36 +271,11 @@ def datamatrix(
dm = DataMatrix(data, rect=rectangular)
try:
border = int(border)
except Exception:
border = 1
border = max(0, border)
try:
fg = ImageColor.getcolor(fill_color, 'RGB')
except Exception:
fg = ImageColor.getcolor('black', 'RGB')
try:
bg = ImageColor.getcolor(back_color, 'RGB')
except Exception:
bg = ImageColor.getcolor('white', 'RGB')
height = len(dm.matrix) + 2 * border
width = len(dm.matrix[0]) + 2 * border
# Generate raw image from the matrix
img = Image.new('RGB', (width, height), color=bg)
for y, row in enumerate(dm.matrix):
for x, value in enumerate(row):
if value:
img.putpixel((x + border, y + border), fg)
img = img.resize(
(int(width * scale), int(height * scale)), Image.Resampling.NEAREST
return _render_2d_barcode(
dm.matrix,
fill_color=fill_color,
back_color=back_color,
scale=scale,
border=border,
fmt=fmt,
)
return image_data(img, fmt=fmt)
+219
View File
@@ -1,11 +1,17 @@
"""Test for custom report tags."""
import base64
import hashlib
import io
import re
from decimal import Decimal
from xml.etree import ElementTree
from zoneinfo import ZoneInfo
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.template import Context, Template
from django.test import TestCase, override_settings
from django.utils import timezone
from django.utils.safestring import SafeString
@@ -861,6 +867,65 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
class BarcodeTagTest(TestCase):
"""Unit tests for the barcode template tags."""
def test_datamatrix_raster_geometry(self):
"""Raster output preserves modules, borders, colors, and scaling."""
# Pixel digests captured before extracting the raster renderer.
for rectangular, border, scale, fmt, size, digest in [
(
False,
0,
1,
'PNG',
(16, 16),
'df39de36e5926c66d156d7984876a5c6a1493211b2cc324548317da363589da2',
),
(
True,
3,
2,
'PNG',
(76, 28),
'ae64b12a2a1e30aa886aeb5abfd61eec9499da6101eee9b8a0476de98cfd551c',
),
(
False,
-2,
1,
'BMP',
(16, 16),
'df39de36e5926c66d156d7984876a5c6a1493211b2cc324548317da363589da2',
),
(
True,
'abc',
2.5,
'PNG',
(85, 25),
'58006f73ca9b70eec4ed7090ed0f28455a5bb93507fdf60320d1e62a7f1e259b',
),
]:
with self.subTest(
rectangular=rectangular, border=border, scale=scale, fmt=fmt
):
data = barcode_tags.datamatrix(
'hello world',
rectangular=rectangular,
border=border,
scale=scale,
fill_color='red',
back_color='blue',
fmt=fmt,
)
prefix, payload = data.split(',', 1)
self.assertEqual(
prefix, f'data:image/{fmt.lower()};charset=utf-8;base64'
)
with Image.open(io.BytesIO(base64.b64decode(payload))) as image:
self.assertEqual(image.size, size)
self.assertEqual(
hashlib.sha256(image.tobytes()).hexdigest(), digest
)
def test_barcode(self):
"""Test the barcode generation tag."""
barcode = barcode_tags.barcode('12345')
@@ -936,3 +1001,157 @@ class BarcodeTagTest(TestCase):
datamatrix,
'data:image/png;charset=utf-8;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAIAAADZrBkAAAAAlElEQVR4nJ1TQQ7AIAgri///cncw6wroEseBgEFbCgZJnNsFICKOPAAIjeSM5T11IznK5f5WRMgnkhP9JfCcTC/MxFZ5hxLOgqrn3o/z/OqtsNpdSL31Iu9W4Dq8Sulu+q5Nuqa3XYOdnuidlICPpXhZVBruyzAKSZehT+yNlzvZQcq6JiW7Ni592swf/43kdlDfdgMk1eOtR7kWpAAAAABJRU5ErkJggg==',
)
def test_2d_barcode_svg_renderer(self):
"""Test vector rendering of a two-dimensional barcode matrix."""
svg_data = barcode_tags._render_2d_barcode(
[[True, False], [False, True]],
fill_color='red',
back_color='blue',
scale=2,
border=1,
fmt='SVG',
)
prefix, payload = svg_data.split(',', 1)
self.assertEqual(prefix, 'data:image/svg+xml;charset=utf-8;base64')
root = ElementTree.fromstring(base64.b64decode(payload))
namespace = '{http://www.w3.org/2000/svg}'
self.assertEqual(root.attrib['width'], '8')
self.assertEqual(root.attrib['height'], '8')
self.assertEqual(root.attrib['viewBox'], '0 0 4 4')
self.assertEqual(root.attrib['shape-rendering'], 'crispEdges')
background = root.find(f'{namespace}rect')
path = root.find(f'{namespace}path')
self.assertIsNotNone(background)
self.assertIsNotNone(path)
self.assertEqual(background.attrib['fill'], '#0000ff')
self.assertEqual(path.attrib['fill'], '#ff0000')
self.assertEqual(path.attrib['d'], 'M1 1h1v1h-1z M2 2h1v1h-1z')
def test_datamatrix_svg(self):
"""Test SVG output from the Data Matrix template tag."""
datamatrix = barcode_tags.datamatrix(
'hello world',
rectangular=True,
border='abc',
fill_color='aaaaaaa',
back_color='aaaaaaa',
fmt='svg',
)
prefix, payload = datamatrix.split(',', 1)
self.assertEqual(prefix, 'data:image/svg+xml;charset=utf-8;base64')
root = ElementTree.fromstring(base64.b64decode(payload))
namespace = '{http://www.w3.org/2000/svg}'
_, _, width, height = root.attrib['viewBox'].split()
self.assertNotEqual(width, height)
background = root.find(f'{namespace}rect')
path = root.find(f'{namespace}path')
self.assertIsNotNone(background)
self.assertIsNotNone(path)
self.assertEqual(background.attrib['fill'], '#ffffff')
self.assertEqual(path.attrib['fill'], '#000000')
def test_datamatrix_svg_geometry(self):
"""SVG modules retain the encoded matrix geometry at different scales."""
from ppf.datamatrix.datamatrix import DataMatrix
namespace = '{http://www.w3.org/2000/svg}'
for rectangular, border, scale in [
(False, 0, 1),
(True, 3, 2.5),
(False, -2, 2),
]:
with self.subTest(rectangular=rectangular, border=border, scale=scale):
matrix = DataMatrix('hello world', rect=rectangular).matrix
padding = max(0, border)
width = len(matrix[0]) + 2 * padding
height = len(matrix) + 2 * padding
data = barcode_tags.datamatrix(
'hello world',
rectangular=rectangular,
border=border,
scale=scale,
fmt='SVG',
)
root = ElementTree.fromstring(base64.b64decode(data.split(',', 1)[1]))
self.assertEqual(root.attrib['viewBox'], f'0 0 {width} {height}')
self.assertEqual(root.attrib['width'], str(int(width * scale)))
self.assertEqual(root.attrib['height'], str(int(height * scale)))
background = root.find(f'{namespace}rect')
self.assertEqual(background.attrib['width'], str(width))
self.assertEqual(background.attrib['height'], str(height))
path = root.find(f'{namespace}path').attrib['d']
modules = re.findall(r'M(\d+) (\d+)h1v1h-1z', path)
self.assertEqual(len(modules), sum(map(sum, matrix)))
self.assertEqual(
{(int(x), int(y)) for x, y in modules},
{
(x + padding, y + padding)
for y, row in enumerate(matrix)
for x, dark in enumerate(row)
if dark
},
)
def test_datamatrix_svg_template(self):
"""The template tag produces embeddable SVG and rejects empty data."""
template = Template(
'{% load barcode %}<img src="{% datamatrix data fmt="SVG" %}"/>'
)
html = template.render(Context({'data': 'hello world'}))
image = ElementTree.fromstring(html)
self.assertEqual(
image.attrib['src'], barcode_tags.datamatrix('hello world', fmt='SVG')
)
for data in ['', ' ']:
with self.subTest(data=data), self.assertRaises(ValidationError):
template.render(Context({'data': data}))
def test_datamatrix_svg_pdf(self):
"""PDF output contains painted vector modules, not a missing image."""
from ppf.datamatrix.datamatrix import DataMatrix
from pypdf import PdfReader
from weasyprint import HTML
template = Template(
'{% load barcode %}'
'<img src="{% datamatrix data fmt="SVG" fill_color="red" %}">'
)
html = template.render(Context({'data': 'hello world'}))
with self.assertNoLogs('weasyprint', level='WARNING'):
pdf = HTML(string=html).write_pdf()
reader = PdfReader(io.BytesIO(pdf))
self.assertEqual(len(reader.pages), 1)
page = reader.pages[0]
self.assertEqual(len(page.images), 0)
# Count closed module outlines painted in the selected foreground color.
# A valid PDF header also occurs when WeasyPrint omits a broken image.
color = None
closed_paths = 0
painted_modules = 0
for operands, operator in page.get_contents().operations:
if operator == b'rg':
color = tuple(operands)
elif operator == b'h':
closed_paths += 1
elif operator in (b'f', b'f*'):
if color == (1, 0, 0):
painted_modules += closed_paths
closed_paths = 0
elif operator == b'n':
closed_paths = 0
matrix = DataMatrix('hello world').matrix
self.assertEqual(painted_modules, sum(map(sum, matrix)))