mirror of
https://github.com/inventree/InvenTree.git
synced 2026-09-27 14:16:02 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user