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''
+ )
+
+ 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)))