Coverage for src/backend/InvenTree/plugin/builtin/labels/label_sheet.py: 25%
108 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""Label printing plugin which supports printing multiple labels on a single page."""
3import math
5from django.core.exceptions import ValidationError
6from django.core.files.base import ContentFile
7from django.utils.translation import gettext_lazy as _
9import structlog
10import weasyprint
11from rest_framework import serializers
13import report.helpers
14from common.models import DataOutput
15from InvenTree.helpers import str2bool
16from plugin import InvenTreePlugin
17from plugin.mixins import LabelPrintingMixin, SettingsMixin
18from report.fetcher import InvenTreeURLFetcher
19from report.models import LabelTemplate
21logger = structlog.get_logger('inventree')
24class LabelPrintingOptionsSerializer(serializers.Serializer):
25 """Custom printing options for the label sheet plugin."""
27 page_size = serializers.ChoiceField(
28 choices=report.helpers.report_page_size_options(),
29 default='A4',
30 label=_('Page Size'),
31 help_text=_('Page size for the label sheet'),
32 )
34 skip = serializers.IntegerField(
35 default=0,
36 label=_('Skip Labels'),
37 help_text=_('Skip this number of labels when printing label sheets'),
38 min_value=0,
39 max_value=500,
40 )
42 border = serializers.BooleanField(
43 default=False,
44 label=_('Border'),
45 help_text=_('Print a border around each label'),
46 )
48 landscape = serializers.BooleanField(
49 default=False,
50 label=_('Landscape'),
51 help_text=_('Print the label sheet in landscape mode'),
52 )
54 margin = serializers.IntegerField(
55 default=10,
56 label=_('Page Margin'),
57 help_text=_('Margin around the page in mm'),
58 min_value=0,
59 )
62class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlugin):
63 """Builtin plugin for label printing.
65 This plugin arrays multiple labels onto a single larger sheet,
66 and returns the resulting PDF file.
67 """
69 NAME = 'InvenTreeLabelSheet'
70 TITLE = _('InvenTree Label Sheet Printer')
71 DESCRIPTION = _('Arrays multiple labels onto a single sheet')
72 VERSION = '1.0.1'
73 AUTHOR = _('InvenTree contributors')
75 BLOCKING_PRINT = True
77 SETTINGS = {
78 'DEBUG': {
79 'name': _('Debug mode'),
80 'description': _('Enable debug mode - returns raw HTML instead of PDF'),
81 'validator': bool,
82 'default': False,
83 }
84 }
86 PrintingOptionsSerializer = LabelPrintingOptionsSerializer
88 def print_labels(
89 self, label: LabelTemplate, output: DataOutput, items: list, request, **kwargs
90 ):
91 """Handle printing of the provided labels.
93 Note that we override the entire print_labels method for this plugin.
94 """
95 printing_options = kwargs['printing_options']
97 # Extract page size for the label sheet
98 page_size_code = printing_options.get('page_size', 'A4')
99 landscape = printing_options.get('landscape', False)
100 border = printing_options.get('border', False)
101 skip = int(printing_options.get('skip', 0))
103 # Get margin from printing options
104 margin = printing_options.get('margin', 10)
106 # Extract size of page
107 page_size = report.helpers.page_size(page_size_code)
108 page_width, page_height = page_size
110 if landscape:
111 page_width, page_height = page_height, page_width
113 # Calculate available space after margins
114 available_width = page_width - (2 * margin)
115 available_height = page_height - (2 * margin)
117 # Calculate number of rows and columns based on available space
118 n_cols = math.floor(available_width / label.width)
119 n_rows = math.floor(available_height / label.height)
120 n_cells = n_cols * n_rows
122 if n_cells == 0:
123 raise ValidationError(_('Label is too large for page size'))
125 # Prepend the required number of skipped null labels
126 items = [None] * skip + list(items)
128 n_labels = len(items)
130 # Data to pass through to each page
131 document_data = {
132 'border': border,
133 'landscape': landscape,
134 'page_width': page_width,
135 'page_height': page_height,
136 'label_width': label.width,
137 'label_height': label.height,
138 'n_labels': n_labels,
139 'n_pages': math.ceil(n_labels / n_cells),
140 'n_cols': n_cols,
141 'n_rows': n_rows,
142 'margin': margin,
143 }
145 pages = []
147 idx = 0
149 while idx < n_labels:
150 if page := self.print_page(
151 label, items[idx : idx + n_cells], request, **document_data
152 ):
153 pages.append(page)
155 idx += n_cells
157 # Update printing progress
158 output.progress += 1
159 output.save()
161 if len(pages) == 0:
162 raise ValidationError(_('No labels were generated'))
164 # Render to a single HTML document
165 html_data = self.wrap_pages(pages, **document_data)
167 if str2bool(self.get_setting('DEBUG')):
168 # In debug mode return with the raw HTML
169 generated_file = ContentFile(html_data, 'labels.html')
170 else:
171 # Render HTML to PDF
172 html = weasyprint.HTML(string=html_data, url_fetcher=InvenTreeURLFetcher())
173 document = html.render().write_pdf()
174 generated_file = ContentFile(document, 'labels.pdf')
176 output.mark_complete(progress=n_labels, output=generated_file)
178 def print_page(self, label: LabelTemplate, items: list, request, **kwargs):
179 """Generate a single page of labels.
181 For a single page, generate a simple table grid of labels.
182 Styling of the table is handled by the higher level label template
184 Arguments:
185 label: The LabelTemplate object to use for printing
186 items: The list of database items to print (e.g. StockItem instances)
187 request: The HTTP request object which triggered this print job
189 Kwargs:
190 n_cols: Number of columns
191 n_rows: Number of rows
192 """
193 n_cols = kwargs['n_cols']
194 n_rows = kwargs['n_rows']
196 # Generate a table of labels
197 html = """<table class='label-sheet-table'>"""
199 for row in range(n_rows):
200 html += "<tr class='label-sheet-row'>"
202 for col in range(n_cols):
203 # Cell index
204 idx = row * n_cols + col
206 if idx >= len(items):
207 break
209 html += f"<td class='label-sheet-cell label-sheet-row-{row} label-sheet-col-{col}'>"
211 # If the label is empty (skipped), render an empty cell
212 if items[idx] is None:
213 html += """<div class='label-sheet-cell-skip'></div>"""
214 else:
215 try:
216 # Render the individual label template
217 # Note that we disable @page styling for this
218 cell = label.render_as_string(
219 items[idx], request, insert_page_style=False
220 )
221 html += cell
222 except Exception as exc:
223 logger.exception('Error rendering label: %s', exc)
224 html += """
225 <div class='label-sheet-cell-error'></div>
226 """
228 html += '</td>'
230 html += '</tr>'
232 html += '</table>'
234 return html
236 def wrap_pages(self, pages, **kwargs):
237 """Wrap the generated pages into a single document."""
238 border = kwargs['border']
240 page_width = kwargs['page_width']
241 page_height = kwargs['page_height']
243 label_width = kwargs['label_width']
244 label_height = kwargs['label_height']
246 n_rows = kwargs['n_rows']
247 n_cols = kwargs['n_cols']
249 margin = kwargs.get('margin', 0)
251 inner = ''.join(pages)
253 # Generate styles for individual cells (on each page)
254 cell_styles = []
256 for row in range(n_rows):
257 cell_styles.append(
258 f"""
259 .label-sheet-row-{row} {{
260 top: {row * label_height}mm;
261 }}
262 """
263 )
265 for col in range(n_cols):
266 cell_styles.append(
267 f"""
268 .label-sheet-col-{col} {{
269 left: {col * label_width}mm;
270 }}
271 """
272 )
274 cell_styles = '\n'.join(cell_styles)
276 return f"""
277 <head>
278 <style>
279 @page {{
280 size: {page_width}mm {page_height}mm;
281 margin: {margin}mm;
282 padding: 0mm;
283 }}
285 .label-sheet-table {{
286 page-break-after: always;
287 table-layout: fixed;
288 width: {page_width}mm;
289 border-spacing: 0mm 0mm;
290 }}
292 .label-sheet-cell-error {{
293 background-color: #F00;
294 }}
296 .label-sheet-cell {{
297 border: {'1px solid #000;' if border else '0mm;'}
298 width: {label_width}mm;
299 height: {label_height}mm;
300 padding: 0mm;
301 position: absolute;
302 }}
304 {cell_styles}
306 body {{
307 margin: 0mm !important;
308 }}
309 </style>
310 </head>
311 <body>
312 {inner}
313 </body>
314 </html>
315 """