From f76d79baf100e997a0c259157a3657b0b8595c36 Mon Sep 17 00:00:00 2001 From: Petr Ledvina Date: Fri, 18 Sep 2026 10:14:50 +0200 Subject: [PATCH] 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 --- docs/docs/report/barcodes.md | 9 +- .../InvenTree/report/templatetags/barcode.py | 142 +++++++++--- src/backend/InvenTree/report/test_tags.py | 219 ++++++++++++++++++ 3 files changed, 335 insertions(+), 35 deletions(-) diff --git a/docs/docs/report/barcodes.md b/docs/docs/report/barcodes.md index 07187509e6..8108cc17fe 100644 --- a/docs/docs/report/barcodes.md +++ b/docs/docs/report/barcodes.md @@ -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 %} - + {% endblock content %} {% endraw %} diff --git a/src/backend/InvenTree/report/templatetags/barcode.py b/src/backend/InvenTree/report/templatetags/barcode.py index bc1abee247..aad489df10 100644 --- a/src/backend/InvenTree/report/templatetags/barcode.py +++ b/src/backend/InvenTree/report/templatetags/barcode.py @@ -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'' + f'' + f'' + '' + ) + + 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) diff --git a/src/backend/InvenTree/report/test_tags.py b/src/backend/InvenTree/report/test_tags.py index f2762a8660..fecd7fec7f 100644 --- a/src/backend/InvenTree/report/test_tags.py +++ b/src/backend/InvenTree/report/test_tags.py @@ -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 %}' + ) + 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 %}' + '' + ) + 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)))