Label sheet printer (#5883)

* Add skeleton for new label sheet plugin

* Add custom printing options serializer

* Render individual label outputs to HTML

* Extract page size and column size

* Check label dimensions before printing

* Split labels into multiple pages / sheets

* Render out multiple labels onto a single sheet

* Cleanup base label template

- Allow @page style to *not* be generated
- Pass through as optional context variable
- Check that it still works for single label printing (default behaviour unchanged)
- Prevents multiple @page styles from being generated on label sheet output

* Fix stylesheets for part labels

* Cleanup stock location labels

* Cleanup more label templates

* Check if label can actually fit on page

* Generate output to PDF and return correct response

* Update panel.md

* Fix unit tests

* More unit test fixes
This commit is contained in:
Oliver 2023-11-09 09:00:23 +11:00 committed by GitHub
parent e674ca7437
commit a0b1ba62a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 380 additions and 35 deletions

View File

@ -221,6 +221,10 @@ class LabelPrintMixin(LabelFilterMixin):
# Label template
label = self.get_object()
# Check the label dimensions
if label.width <= 0 or label.height <= 0:
raise ValidationError('Label has invalid dimensions')
# if the plugin returns a serializer, validate the data
if serializer := plugin.get_printing_options_serializer(request, data=request.data):
serializer.is_valid(raise_exception=True)

View File

@ -191,10 +191,38 @@ class LabelTemplate(MetadataMixin, models.Model):
return template_string.render(context)
def context(self, request):
"""Provides context data to the template."""
def generate_page_style(self, **kwargs):
"""Generate @page style for the label template.
This is inserted at the top of the style block for a given label
"""
width = kwargs.get('width', self.width)
height = kwargs.get('height', self.height)
margin = kwargs.get('margin', 0)
return f"""
@page {{
size: {width}mm {height}mm;
margin: {margin}mm;
}}
"""
def context(self, request, **kwargs):
"""Provides context data to the template.
Arguments:
request: The HTTP request object
kwargs: Additional keyword arguments
"""
context = self.get_context_data(request)
# By default, each label is supplied with '@page' data
# However, it can be excluded, e.g. when rendering a label sheet
if kwargs.get('insert_page_style', True):
context['page_style'] = self.generate_page_style()
# Add "basic" context data which gets passed to every label
context['base_url'] = get_base_url(request=request)
context['date'] = datetime.datetime.now().date()
@ -213,18 +241,31 @@ class LabelTemplate(MetadataMixin, models.Model):
return context
def render_as_string(self, request, **kwargs):
"""Render the label to a HTML string.
def render_as_string(self, request, target_object=None, **kwargs):
"""Render the label to a HTML string"""
Useful for debug mode (viewing generated code)
"""
return render_to_string(self.template_name, self.context(request), request)
if target_object:
self.object_to_print = target_object
def render(self, request, **kwargs):
context = self.context(request, **kwargs)
return render_to_string(
self.template_name,
context,
request
)
def render(self, request, target_object=None, **kwargs):
"""Render the label template to a PDF file.
Uses django-weasyprint plugin to render HTML template
"""
if target_object:
self.object_to_print = target_object
context = self.context(request, **kwargs)
wp = WeasyprintLabelMixin(
request,
self.template_name,
@ -235,7 +276,7 @@ class LabelTemplate(MetadataMixin, models.Model):
)
return wp.render_to_response(
self.context(request),
context,
**kwargs
)

View File

@ -16,9 +16,9 @@ Refer to the documentation for a full list of available template variables.
}
.qr {
position: absolute;
height: 28mm;
width: 28mm;
position: relative;
top: 0mm;
right: 0mm;
float: right;

View File

@ -4,15 +4,18 @@
<head>
<style>
@page {
{% localize off %}
size: {{ width }}mm {{ height }}mm;
{% endlocalize %}
{% block margin %}
margin: 0mm;
{% endblock margin %}
}
{% block page_style %}
{% if page_style %}
/* @page styling */
{% localize off %}
{{ page_style }}
{% endlocalize %}
{% endif %}
{% endblock page_style %}
{% block body_style %}
/* body styling */
body {
font-family: Arial, Helvetica, sans-serif;
margin: 0mm;
@ -21,20 +24,27 @@
page-break-before: always;
page-break-after: always;
}
{% endblock body_style %}
img {
display: inline-block;
image-rendering: pixelated;
}
/* Global content wrapper div which takes up entire page area */
.content {
width: 100%;
{% localize off %}
width: {{ width }}mm;
height: {{ height }}mm;
{% endlocalize %}
break-after: always;
position: relative;
left: 0mm;
top: 0mm;
}
{% block style %}
/* User-defined styles can go here */
/* User-defined styles can go here, and override any styles defined above */
{% endblock style %}
</style>

View File

@ -5,7 +5,7 @@
{% block style %}
.qr {
position: fixed;
position: absolute;
left: 0mm;
top: 0mm;
{% localize off %}
@ -16,7 +16,7 @@
.part {
font-family: Arial, Helvetica, sans-serif;
display: inline;
display: flex;
position: absolute;
{% localize off %}
left: {{ height }}mm;

View File

@ -5,7 +5,7 @@
{% block style %}
.qr {
position: fixed;
position: absolute;
left: 0mm;
top: 0mm;
{% localize off %}
@ -16,7 +16,7 @@
.part {
font-family: Arial, Helvetica, sans-serif;
display: inline;
display: flex;
position: absolute;
{% localize off %}
left: {{ height }}mm;

View File

@ -5,7 +5,7 @@
{% block style %}
.qr {
position: fixed;
position: absolute;
left: 0mm;
top: 0mm;
{% localize off %}

View File

@ -5,7 +5,7 @@
{% block style %}
.qr {
position: fixed;
position: absolute;
left: 0mm;
top: 0mm;
{% localize off %}
@ -17,6 +17,5 @@
{% endblock style %}
{% block content %}
<img class='qr' alt="{% trans 'QR Code' %}" src='{% qrcode qr_data %}'>
{% endblock content %}

View File

@ -5,7 +5,7 @@
{% block style %}
.qr {
position: fixed;
position: absolute;
left: 0mm;
top: 0mm;
{% localize off %}
@ -16,7 +16,7 @@
.loc {
font-family: Arial, Helvetica, sans-serif;
display: inline;
display: flex;
position: absolute;
{% localize off %}
left: {{ height }}mm;

View File

@ -78,11 +78,11 @@ class LabelMixinTests(InvenTreeAPITestCase):
"""Test that the sample printing plugin is installed."""
# Get all label plugins
plugins = registry.with_mixin('labels')
self.assertEqual(len(plugins), 2)
self.assertEqual(len(plugins), 3)
# But, it is not 'active'
plugins = registry.with_mixin('labels', active=True)
self.assertEqual(len(plugins), 1)
self.assertEqual(len(plugins), 2)
def test_api(self):
"""Test that we can filter the API endpoint by mixin."""
@ -124,9 +124,12 @@ class LabelMixinTests(InvenTreeAPITestCase):
}
)
self.assertEqual(len(response.data), 2)
data = response.data[1]
self.assertEqual(data['key'], 'samplelabelprinter')
self.assertEqual(len(response.data), 3)
labels = [item['key'] for item in response.data]
self.assertIn('samplelabelprinter', labels)
self.assertIn('inventreelabelsheet', labels)
def test_printing_process(self):
"""Test that a label can be printed."""

View File

@ -90,4 +90,5 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin):
if debug:
return self.render_to_html(label, request, **kwargs)
return self.render_to_pdf(label, request, **kwargs)

View File

@ -0,0 +1,267 @@
"""Label printing plugin which supports printing multiple labels on a single page"""
import logging
import math
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.http import JsonResponse
from django.utils.translation import gettext_lazy as _
import weasyprint
from rest_framework import serializers
import report.helpers
from label.models import LabelOutput, LabelTemplate
from plugin import InvenTreePlugin
from plugin.mixins import LabelPrintingMixin, SettingsMixin
logger = logging.getLogger('inventree')
class LabelPrintingOptionsSerializer(serializers.Serializer):
"""Custom printing options for the label sheet plugin"""
page_size = serializers.ChoiceField(
choices=report.helpers.report_page_size_options(),
default='A4',
label=_('Page Size'),
help_text=_('Page size for the label sheet')
)
border = serializers.BooleanField(
default=False,
label=_('Border'),
help_text=_('Print a border around each label')
)
landscape = serializers.BooleanField(
default=False,
label=_('Landscape'),
help_text=_('Print the label sheet in landscape mode')
)
class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin):
"""Builtin plugin for label printing.
This plugin arrays multiple labels onto a single larger sheet,
and returns the resulting PDF file.
"""
NAME = "InvenTreeLabelSheet"
TITLE = _("InvenTree Label Sheet Printer")
DESCRIPTION = _("Arrays multiple labels onto a single sheet")
VERSION = "1.0.0"
AUTHOR = _("InvenTree contributors")
BLOCKING_PRINT = True
SETTINGS = {}
PrintingOptionsSerializer = LabelPrintingOptionsSerializer
def print_labels(self, label: LabelTemplate, items: list, request, **kwargs):
"""Handle printing of the provided labels"""
printing_options = kwargs['printing_options']
# Extract page size for the label sheet
page_size_code = printing_options.get('page_size', 'A4')
landscape = printing_options.get('landscape', False)
border = printing_options.get('border', False)
# Extract size of page
page_size = report.helpers.page_size(page_size_code)
page_width, page_height = page_size
if landscape:
page_width, page_height = page_height, page_width
# Calculate number of rows and columns
n_cols = math.floor(page_width / label.width)
n_rows = math.floor(page_height / label.height)
n_cells = n_cols * n_rows
if n_cells == 0:
raise ValidationError(_("Label is too large for page size"))
n_labels = len(items)
# Data to pass through to each page
document_data = {
"border": border,
"landscape": landscape,
"page_width": page_width,
"page_height": page_height,
"label_width": label.width,
"label_height": label.height,
"n_labels": n_labels,
"n_pages": math.ceil(n_labels / n_cells),
"n_cols": n_cols,
"n_rows": n_rows,
}
pages = []
idx = 0
while idx < n_labels:
if page := self.print_page(label, items[idx:idx + n_cells], request, **document_data):
pages.append(page)
idx += n_cells
if len(pages) == 0:
raise ValidationError(_("No labels were generated"))
# Render to a single HTML document
html_data = self.wrap_pages(pages, **document_data)
# Render HTML to PDF
html = weasyprint.HTML(string=html_data)
document = html.render().write_pdf()
output_file = ContentFile(document, 'labels.pdf')
output = LabelOutput.objects.create(
label=output_file,
user=request.user
)
return JsonResponse({
'file': output.label.url,
'success': True,
'message': f'{len(items)} labels generated'
})
def print_page(self, label: LabelTemplate, items: list, request, **kwargs):
"""Generate a single page of labels:
For a single page, generate a simple table grid of labels.
Styling of the table is handled by the higher level label template
Arguments:
label: The LabelTemplate object to use for printing
items: The list of database items to print (e.g. StockItem instances)
request: The HTTP request object which triggered this print job
Kwargs:
n_cols: Number of columns
n_rows: Number of rows
"""
n_cols = kwargs['n_cols']
n_rows = kwargs['n_rows']
# Generate a table of labels
html = """<table class='label-sheet-table'>"""
for row in range(n_rows):
html += "<tr class='label-sheet-row'>"
for col in range(n_cols):
html += f"<td class='label-sheet-cell label-sheet-row-{row} label-sheet-col-{col}'>"
# Cell index
idx = row * n_cols + col
if idx < len(items):
try:
# Render the individual label template
# Note that we disable @page styling for this
cell = label.render_as_string(
request,
target_object=items[idx],
insert_page_style=False
)
html += cell
except Exception as exc:
logger.exception("Error rendering label: %s", str(exc))
html += """
<div class='label-sheet-cell-error'></div>
"""
html += "</td>"
html += "</tr>"
html += "</table>"
return html
def wrap_pages(self, pages, **kwargs):
"""Wrap the generated pages into a single document"""
border = kwargs['border']
page_width = kwargs['page_width']
page_height = kwargs['page_height']
label_width = kwargs['label_width']
label_height = kwargs['label_height']
n_rows = kwargs['n_rows']
n_cols = kwargs['n_cols']
inner = ''.join(pages)
# Generate styles for individual cells (on each page)
cell_styles = []
for row in range(n_rows):
cell_styles.append(f"""
.label-sheet-row-{row} {{
top: {row * label_height}mm;
}}
""")
for col in range(n_cols):
cell_styles.append(f"""
.label-sheet-col-{col} {{
left: {col * label_width}mm;
}}
""")
cell_styles = "\n".join(cell_styles)
return f"""
<head>
<style>
@page {{
size: {page_width}mm {page_height}mm;
margin: 0mm;
padding: 0mm;
}}
.label-sheet-table {{
page-break-after: always;
table-layout: fixed;
width: {page_width}mm;
border-spacing: 0mm 0mm;
}}
.label-sheet-cell-error {{
background-color: #F00;
}}
.label-sheet-cell {{
border: {"1px solid #000;" if border else "0mm;"}
width: {label_width}mm;
height: {label_height}mm;
padding: 0mm;
position: absolute;
}}
{cell_styles}
body {{
margin: 0mm !important;
}}
</style>
</head>
<body>
{inner}
</body>
</html>
"""

View File

@ -17,6 +17,26 @@ def report_page_size_options():
]
def page_sizes():
"""Returns a dict of page sizes for PDF reports."""
return {
'A4': (210, 297),
'A3': (297, 420),
'Legal': (215.9, 355.6),
'Letter': (215.9, 279.4),
}
def page_size(page_code):
"""Return the page size associated with a particular page code"""
if page_code in page_sizes():
return page_sizes()[page_code]
# Default to A4
logger.warning("Unknown page size code '%s' - defaulting to A4", page_code)
return page_sizes()['A4']
def report_page_size_default():
"""Returns the default page size for PDF reports."""
from common.models import InvenTreeSetting

View File

@ -12,6 +12,7 @@ Most pages in the web interface support multiple panels, which are selected via
{% include 'img.html' %}
{% endwith %}
Each plugin which implements this mixin can return zero or more custom panels for a particular page. The plugin can decide (at runtime) which panels it wishes to render. This determination can be made based on the page routing, the item being viewed, the particular user, or other considerations.
### Panel Content
@ -311,4 +312,3 @@ import json
The URL and the called function have no parameter names any longer. All data is in the
request message and can be extracted from this using json.loads. If more data is needed
just add it to the json container. No further changes are needed. It's really simple :-)