Files
lego-instructions-manager/app/services/tape_printer.py
T
jessikittyandClaude Opus 5 fbfd66d889 Hand-built label groups, and text that grows when you ask it to
parse_group_spec / spec_from_groups let the page combine any parts onto
one label, and plan_groups never breaks a hand-made group up.

Text size now goes to 3x and means it: the name is sized first, the
image shrinks out of its way, and the floor no longer rises with the
setting - which is what truncated long names at large sizes. The meta
line takes the leftover room instead of pushing itself off the label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vunz4VLxAYDBnU5tLciLDC
2026-10-13 10:30:10 +11:00

884 lines
31 KiB
Python

"""
Continuous-tape label rendering for the Brother VC-500W.
Sheet labels (Avery etc.) are laid out in label_render.py as an HTML page.
A tape printer is a different animal: one label at a time, fixed width, and
the length is however long the content needs to be. So these are rendered as
images at the printer's native resolution and sent to it over IPP.
VC-500W facts this is built around:
* 313 dpi, full colour, ZINK (no ink)
* CZ tape in 9, 12, 19, 25 and 50mm widths
* up to about 17 inches (431mm) per label
* AirPrint, so it speaks IPP on port 631
THE HOUSE LABEL SIZE
--------------------
Drawer labels are a fixed physical size, not "however long the text is":
one part in the drawer 40 x 15 mm
two parts in the drawer 40 x 20 mm
They are laid ACROSS the tape - the 40mm spans the tape width, so each
label only eats 15mm (or 20mm) of tape length. That needs 50mm tape, and it
is about 2.7x cheaper than running the 40mm along the feed: 100 standard
labels come to 1.5m of tape instead of 4m. Cut across the tape between
labels and you get a 40 x 15mm sticker.
"Fit tape width" keeps the older behaviour - the label is as long as its
content needs and fills the tape width - for anyone who wants it.
Created by: Jess Rogerson (yelling commands at Claude.AI)
"""
import io
import logging
import os
from dataclasses import dataclass, field
from typing import List, Optional
from PIL import Image, ImageDraw, ImageFont
logger = logging.getLogger(__name__)
DPI = 313
MAX_LENGTH_MM = 430.0
MIN_LENGTH_MM = 12.0
def mm_to_px(mm, dpi=DPI):
return max(1, int(round(mm / 25.4 * dpi)))
def px_to_mm(px, dpi=DPI):
return px / dpi * 25.4
@dataclass
class TapePreset:
key: str
name: str
tape_mm: float
printable_mm: float
note: str = ''
@property
def printable_px(self):
return mm_to_px(self.printable_mm)
# Brother CZ tape widths. printable_mm leaves a hair of margin so a slightly
# misaligned cassette doesn't clip the text; raise it toward tape_mm from
# the settings page if you want true edge to edge.
TAPE_PRESETS = {
p.key: p for p in [
TapePreset('cz9', 'CZ tape 9mm', 9.0, 8.0,
'Too narrow for the 40mm house label.'),
TapePreset('cz12', 'CZ tape 12mm', 12.0, 11.0,
'Too narrow for the 40mm house label.'),
TapePreset('cz19', 'CZ tape 19mm', 19.0, 17.5,
'Too narrow for the 40mm house label.'),
TapePreset('cz25', 'CZ tape 25mm', 25.0, 23.5,
'Too narrow for the 40mm house label.'),
TapePreset('cz50', 'CZ tape 50mm', 50.0, 48.0,
'The one to use. Fits the 40mm label across the tape.'),
]
}
DEFAULT_TAPE = 'cz50'
# ---------------------------------------------------------- label sizing
LABEL_WIDTH_MM = 40.0
ONE_PART_HEIGHT_MM = 15.0
TWO_PART_HEIGHT_MM = 20.0
# Three or more part types in one drawer is not a size you asked for, so
# rather than silently dropping parts the label grows by this much each.
EXTRA_PART_HEIGHT_MM = 5.0
SIZE_MODES = [
('auto', '40 x 15mm, or 40 x 20mm for a shared drawer'),
('standard', 'Always 40 x 15mm'),
('double', 'Always 40 x 20mm'),
('fit', 'Fit the tape width (length follows the content)'),
]
DEFAULT_SIZE_MODE = 'auto'
FIXED_MODES = ('auto', 'standard', 'double')
# The title may take this share of the row height before Text size is even
# applied. Generous on purpose: these are read at arm's length in a shed.
TITLE_MAX_SHARE = 0.55
def label_height_mm(part_count, size_mode=DEFAULT_SIZE_MODE):
"""The physical height of a label holding this many part types."""
if size_mode == 'standard':
return ONE_PART_HEIGHT_MM
if size_mode == 'double':
return TWO_PART_HEIGHT_MM
if part_count <= 1:
return ONE_PART_HEIGHT_MM
if part_count == 2:
return TWO_PART_HEIGHT_MM
return TWO_PART_HEIGHT_MM + EXTRA_PART_HEIGHT_MM * (part_count - 2)
def parse_group_spec(spec, labels):
"""
Turn "1,2|3" into [[label1, label2], [label3]].
This is how the page says "put these parts on one label" explicitly,
which matters for customs and MOC drawers where the parts have nothing
in common for auto-grouping to latch onto. Unknown or repeated ids are
ignored, and anything the spec never mentions keeps a label of its own,
so a stale spec can never silently drop a part.
"""
by_id = {label.id: label for label in labels}
groups = []
used = set()
for chunk in (spec or '').split('|'):
group = []
for value in chunk.split(','):
if not value.strip().isdigit():
continue
wanted = int(value)
if wanted in by_id and wanted not in used:
group.append(by_id[wanted])
used.add(wanted)
if group:
groups.append(group)
for label in labels:
if label.id not in used:
groups.append([label])
return groups
def spec_from_groups(groups):
"""The inverse: [[l1,l2],[l3]] -> '1,2|3'."""
return '|'.join(','.join(str(l.id) for l in group) for group in groups)
def group_by_drawer(labels, enabled=True):
"""
One physical label per drawer.
Labels sharing a location are the same drawer, so they go on one taller
label. A label with no location can't be grouped - it gets its own.
Order is preserved: a group sits where its first member was.
"""
if not enabled:
return [[label] for label in labels]
groups = []
by_location = {}
for label in labels:
key = (label.location or '').strip().lower()
if not key:
groups.append([label])
continue
if key in by_location:
by_location[key].append(label)
else:
group = [label]
by_location[key] = group
groups.append(group)
return groups
# ------------------------------------------------------------------ fonts
FONT_CANDIDATES = {
False: [ # regular
'C:\\Windows\\Fonts\\segoeui.ttf',
'C:\\Windows\\Fonts\\arial.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
'/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf',
'/Library/Fonts/Arial.ttf',
],
True: [ # bold
'C:\\Windows\\Fonts\\segoeuib.ttf',
'C:\\Windows\\Fonts\\arialbd.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
'/Library/Fonts/Arial Bold.ttf',
],
}
_font_cache = {}
def load_font(size_px, bold=False):
"""Best available sans font at this pixel size. Never raises."""
key = (int(size_px), bool(bold))
if key in _font_cache:
return _font_cache[key]
for path in FONT_CANDIDATES[bool(bold)]:
if os.path.exists(path):
try:
font = ImageFont.truetype(path, int(size_px))
_font_cache[key] = font
return font
except Exception:
continue
# Pillow's built-in bitmap font: ugly at 313dpi but never fails.
font = ImageFont.load_default()
_font_cache[key] = font
return font
# ---------------------------------------------------------------- options
DEFAULT_TAPE_FIELDS = ['image', 'name', 'dimensions', 'part_number', 'accent']
# Hidden marker every options form submits. Its presence is how we tell
# "submitted with the box unticked" from "arrived here with no options at
# all" - an unticked checkbox sends nothing, so without this they look
# identical and a default-on box can never be turned off.
FORM_MARKER = '_opts'
@dataclass
class TapeOptions:
tape: str = DEFAULT_TAPE
size_mode: str = DEFAULT_SIZE_MODE
# Combine labels that share a drawer onto one taller label.
group_drawers: bool = True
fields: List[str] = field(
default_factory=lambda: list(DEFAULT_TAPE_FIELDS))
# Fixed sizes always lie across the tape - that is the point of them.
# This only applies to 'fit' mode.
rotate: bool = True
copies: int = 1
# 'fit' mode only: a fixed length in mm instead of sizing to content.
fixed_length_mm: Optional[float] = None
padding_mm: float = 1.5
font_scale: float = 1.0
image_percent: int = 30
align: str = 'left' # left | center
outline: bool = False # hairline box, for checking alignment
@property
def is_fixed(self):
return self.size_mode in FIXED_MODES
def get_tape(key):
return TAPE_PRESETS.get(key or DEFAULT_TAPE, TAPE_PRESETS[DEFAULT_TAPE])
def tape_fits_label(tape):
"""Can the 40mm label lie across this tape?"""
return get_tape(tape).printable_mm >= LABEL_WIDTH_MM
def options_from_form(form, rotate_default=True, size_default=None,
tape_default=None):
"""
Build TapeOptions from request.form / request.args.
An unticked checkbox is simply absent from a form submission - the
browser sends nothing for it. So "rotate unticked" and "rotate never
mentioned" arrive here looking exactly the same, and a box that
defaults to on could never be turned off. FORM_MARKER is a hidden
field the options form always sends: when it is present we know every
checkbox was on screen, so absent means the user unticked it.
The *_default arguments apply only when the form was NOT submitted (a
fresh arrival), so saved site-wide preferences are honoured there
without ever overriding an explicit choice on the page.
"""
submitted = FORM_MARKER in form
def number(key, default, cast=float):
try:
return cast(form.get(key, default))
except (TypeError, ValueError):
return default
def flag(key, default=False):
if key in form:
return str(form.get(key, '')).lower() in ('1', 'true', 'on', 'yes')
# Submitted without it: the user unticked it. Not submitted: default.
return False if submitted else default
fields = list(form.getlist('fields')) if hasattr(form, 'getlist') else []
if not fields and not submitted:
fields = list(DEFAULT_TAPE_FIELDS)
length = (form.get('fixed_length_mm') or '').strip() if hasattr(form, 'get') else ''
try:
fixed = float(length) if length else None
except ValueError:
fixed = None
valid_modes = {key for key, _ in SIZE_MODES}
size_mode = form.get('size_mode') or size_default or DEFAULT_SIZE_MODE
if size_mode not in valid_modes:
size_mode = DEFAULT_SIZE_MODE
return TapeOptions(
tape=form.get('tape') or tape_default or DEFAULT_TAPE,
size_mode=size_mode,
group_drawers=flag('group_drawers', True),
fields=fields,
rotate=flag('rotate', rotate_default),
copies=max(1, min(99, number('copies', 1, int))),
fixed_length_mm=fixed,
padding_mm=max(0.0, min(10.0, number('padding_mm', 1.5))),
font_scale=max(0.5, min(3.0, number('font_scale', 1.0))),
image_percent=max(0, min(60, number('image_percent', 30, int))),
align=form.get('align') or 'left',
outline=flag('outline', False),
)
# ------------------------------------------------------------ text pieces
def _label_lines(label, fields):
"""The text that goes on the tape, as (text, weight) pairs."""
lines = []
if 'name' in fields:
title = (label.display_title if 'dimensions' in fields
else label.name)
if title:
lines.append((title, 'title'))
elif 'dimensions' in fields and label.dimensions:
lines.append((label.dimensions, 'title'))
if 'colour' in fields and label.colour_name:
lines.append((label.colour_name, 'sub'))
if 'category' in fields and label.category:
lines.append((label.category.full_name, 'sub'))
meta = []
if 'part_number' in fields and label.part_number:
meta.append(label.part_number)
if 'alt_numbers' in fields and label.alt_part_numbers:
meta.append(label.alt_part_numbers)
if 'category_code' in fields and label.category and label.category.display_code:
meta.append(label.category.display_code)
if meta:
lines.append((' \u00b7 '.join(meta), 'meta'))
if 'location' in fields and label.location:
lines.append((label.location, 'meta'))
return lines
def _text_width(draw, text, font):
try:
box = draw.textbbox((0, 0), text, font=font)
return box[2] - box[0]
except Exception:
return draw.textlength(text, font=font)
def _fit_text(draw, text, font, available):
"""Trim with an ellipsis until it fits."""
shown = text
while _text_width(draw, shown, font) > available and len(shown) > 2:
shown = shown[:-2]
if shown != text and len(shown) > 1:
shown = shown[:-1] + '\u2026'
return shown
def wrap_text(draw, text, font, available):
"""Greedy word wrap. A word longer than the line gets its own line."""
words = (text or '').split()
if not words:
return []
lines = [words[0]]
for word in words[1:]:
trial = lines[-1] + ' ' + word
if _text_width(draw, trial, font) <= available:
lines[-1] = trial
else:
lines.append(word)
return lines
def _title_size_for(draw, text, available, inner_h, padding, options):
"""What size the title would come out at in this much room."""
if not text or available <= 0:
return 0
_font, _lines, _h, size = layout_title(
draw, text, available, inner_h - 2 * padding,
inner_h * TITLE_MAX_SHARE * options.font_scale,
inner_h * 0.13, bold=True)
return size
def layout_title(draw, text, available, max_h, max_px, min_px,
bold=True, max_lines=2):
"""
Largest type at which the title fits the box, wrapping if that helps.
"Plate, Modified with Clip" will not fit one line of a 40mm label at a
readable size, but it fits two comfortably. Prefer wrapping over
shrinking, and shrinking over truncating - the whole name is the point
of the label.
"""
size = int(max_px)
floor = max(6, int(min_px))
best = None
while size >= floor:
font = load_font(size, bold=bold)
lines = wrap_text(draw, text, font, available)
if lines and len(lines) <= max_lines:
widest = max(_text_width(draw, line, font) for line in lines)
height = len(lines) * size * 1.12
if widest <= available and height <= max_h:
return font, lines, height, size
if best is None:
best = (font, lines, height, size)
size -= 1
font = load_font(floor, bold=bold)
return font, [text], floor * 1.12, floor
def fit_font(draw, text, available, max_px, min_px, bold=False):
"""
The largest font size at which `text` still fits in `available`.
A 40mm label has very little room once the artwork is on it, and part
names vary from "Tile" to "Plate, Modified with Clip". Shrinking the
type keeps the whole name readable; truncating it to "Plate, Mod..."
does not. Below min_px it stops shrinking and the caller ellipsises.
"""
size = int(max_px)
floor = max(6, int(min_px))
while size > floor:
font = load_font(size, bold=bold)
if _text_width(draw, text, font) <= available:
return font, size
size -= 1
return load_font(floor, bold=bold), floor
def _load_art(label, upload_folder, box_px):
if not label.image:
return None
path = os.path.join(upload_folder, label.image.replace('/', os.sep))
if not os.path.exists(path):
return None
try:
art = Image.open(path).convert('RGBA')
art.thumbnail((box_px, box_px), Image.LANCZOS)
return art
except Exception as exc:
logger.warning('Could not load part image %s: %s', path, exc)
return None
# -------------------------------------------------------- fixed-size draw
def render_fixed(labels, options, upload_folder):
"""
One drawer label at the house size, drawn flat and reading normally:
40mm wide, 15mm tall for one part, 20mm for two, taller beyond that.
`labels` is every part type in that drawer - one row each.
"""
# Whatever is handed in gets drawn. Deciding how many parts belong on
# one label is plan_groups' job - doing it here too once meant a part
# was quietly dropped instead of getting a label of its own.
labels = list(labels) or []
rows = max(1, len(labels))
width_px = mm_to_px(LABEL_WIDTH_MM)
height_mm = label_height_mm(len(labels), options.size_mode)
height_px = mm_to_px(height_mm)
canvas = Image.new('RGB', (width_px, height_px), 'white')
draw = ImageDraw.Draw(canvas)
if options.outline:
draw.rectangle([0, 0, width_px - 1, height_px - 1],
outline=(190, 190, 190), width=max(1, mm_to_px(0.2)))
fields = set(options.fields)
# Deliberately tighter than the fit-tape padding: 1.5mm top and bottom
# would eat a third of a two-part label's 10mm row.
padding = mm_to_px(min(options.padding_mm, 0.8))
row_h = height_px / rows
for index, label in enumerate(labels):
top = int(index * row_h)
bottom = int((index + 1) * row_h)
inner_h = bottom - top
# A hairline between parts so the two halves read as separate.
if index:
draw.line([(padding, top), (width_px - padding, top)],
fill=(205, 205, 205), width=max(1, mm_to_px(0.15)))
cursor_x = padding
if 'accent' in fields and label.category:
accent_w = mm_to_px(1.2)
colour = label.category.accent_colour()
try:
rgb = tuple(int(colour.lstrip('#')[i:i + 2], 16)
for i in (0, 2, 4))
except Exception:
rgb = (209, 16, 19)
draw.rectangle([cursor_x, top + padding,
cursor_x + accent_w, bottom - padding], fill=rgb)
cursor_x += accent_w + padding
lines = _label_lines(label, fields)
title = next((t for t, w in lines if w == 'title'), None)
rest = [t for t, w in lines if w != 'title']
meta_text = ' \u00b7 '.join(rest) if rest else None
# Text beats artwork. The picture is a hint; the name is the label.
# Try the full-size image first and give room back to the type until
# the title reaches the size the Text size setting asked for - that
# is what makes turning Text size up actually do something on a
# label this small.
text_left = cursor_x
art = None
wanted_px = inner_h * 0.34 * options.font_scale
base_box = min(inner_h - 2 * padding,
int(width_px * options.image_percent / 100.0))
if 'image' in fields and options.image_percent and label.image:
for share in (1.0, 0.75, 0.5, 0.0):
candidate = _load_art(label, upload_folder,
int(base_box * share)) if share else None
used = (candidate.width + padding) if candidate else 0
room = width_px - cursor_x - used - padding
probe_size = _title_size_for(draw, title, room, inner_h,
padding, options) if title else 0
art = candidate
text_left = cursor_x + used
if not title or probe_size >= wanted_px or share == 0.0:
break
if art is not None:
canvas.paste(art, (cursor_x, top + (inner_h - art.height) // 2),
art)
cursor_x = text_left
available = width_px - cursor_x - padding
# The name comes first and takes what it needs; the meta line gets
# the leftover. Sizing the meta line first (and letting Text size
# inflate it) pushed the part number off the bottom of the label and
# squeezed the name smaller the higher you turned the setting.
room = inner_h - 2 * padding
reserve = min(room * 0.30, inner_h * 0.26) if meta_text else 0
title_font = title_lines = None
title_h = 0
title_px = 0
if title:
# The floor stays put as Text size rises: the setting lifts the
# ceiling, and the fitter takes the largest size the whole name
# fits at. Raising the floor too meant a long name could not
# shrink far enough to wrap and got truncated instead.
title_font, title_lines, title_h, title_px = layout_title(
draw, title, available, room - reserve,
inner_h * TITLE_MAX_SHARE * options.font_scale,
inner_h * 0.13, bold=True)
meta_font = meta_px = None
meta_h = 0
leftover = room - title_h
if meta_text and leftover > mm_to_px(1.2):
meta_font, meta_px = fit_font(
draw, meta_text, available,
min(inner_h * 0.26 * options.font_scale, leftover / 1.15),
min(inner_h * 0.15, leftover / 1.15), bold=False)
meta_h = meta_px * 1.15
block_h = title_h + meta_h
y = top + max(padding, (inner_h - block_h) / 2)
if title_lines:
for line in title_lines:
shown = _fit_text(draw, line, title_font, available)
x = cursor_x
if options.align == 'center':
x += max(0, (available - _text_width(draw, shown, title_font)) / 2)
draw.text((x, y), shown, font=title_font, fill=(0, 0, 0))
y += title_px * 1.12
if meta_text:
shown = _fit_text(draw, meta_text, meta_font, available)
x = cursor_x
if options.align == 'center':
x += max(0, (available - _text_width(draw, shown, meta_font)) / 2)
draw.text((x, y), shown, font=meta_font, fill=(70, 70, 70))
return canvas
# ------------------------------------------------------- fit-tape drawing
def render_fit(label, options, upload_folder):
"""
The older behaviour: the label fills the tape width and runs as long as
its content needs. Drawn flat, reading left to right.
"""
tape = get_tape(options.tape)
height = tape.printable_px
padding = mm_to_px(options.padding_mm)
fields = set(options.fields)
lines = _label_lines(label, fields)
base = height * 0.34 * options.font_scale
if len(lines) > 2:
base *= 0.78
sizes = {
'title': max(8, int(base)),
'sub': max(7, int(base * 0.72)),
'meta': max(6, int(base * 0.62)),
}
fonts = {
'title': load_font(sizes['title'], bold=True),
'sub': load_font(sizes['sub'], bold=False),
'meta': load_font(sizes['meta'], bold=False),
}
art = None
art_width = 0
if 'image' in fields and options.image_percent:
art = _load_art(label, upload_folder, height - 2 * padding)
if art is not None:
art_width = art.width + padding
accent_width = mm_to_px(1.2) if ('accent' in fields and label.category) else 0
probe = ImageDraw.Draw(Image.new('RGB', (10, 10)))
text_width = 0
for text, weight in lines:
text_width = max(text_width, _text_width(probe, text, fonts[weight]))
natural = accent_width + padding + art_width + text_width + 2 * padding
if options.fixed_length_mm:
width = mm_to_px(min(MAX_LENGTH_MM, max(MIN_LENGTH_MM,
options.fixed_length_mm)))
else:
width = max(mm_to_px(MIN_LENGTH_MM),
min(mm_to_px(MAX_LENGTH_MM), int(natural)))
canvas = Image.new('RGB', (width, height), 'white')
draw = ImageDraw.Draw(canvas)
if options.outline:
draw.rectangle([0, 0, width - 1, height - 1], outline=(190, 190, 190),
width=max(1, mm_to_px(0.2)))
cursor_x = 0
if accent_width:
colour = label.category.accent_colour()
try:
rgb = tuple(int(colour.lstrip('#')[i:i + 2], 16) for i in (0, 2, 4))
except Exception:
rgb = (209, 16, 19)
draw.rectangle([0, 0, accent_width, height], fill=rgb)
cursor_x = accent_width + padding
if art is not None:
canvas.paste(art, (cursor_x, (height - art.height) // 2), art)
cursor_x += art.width + padding
total_text = sum(sizes[w] * 1.22 for _, w in lines)
y = max(padding, (height - total_text) / 2)
available = width - cursor_x - padding
for text, weight in lines:
font = fonts[weight]
shown = _fit_text(draw, text, font, available)
x = cursor_x
if options.align == 'center':
x = cursor_x + max(0, (available - _text_width(draw, shown, font)) / 2)
draw.text((x, y), shown, font=font, fill=(0, 0, 0))
y += sizes[weight] * 1.22
return canvas
# --------------------------------------------------------- tape composing
def compose_for_tape(flat, options):
"""
Place a flat label onto the tape raster.
Convention: the returned image's WIDTH is the across-tape dimension and
its HEIGHT is how much tape length the label consumes.
Fixed sizes always lie across the tape - the 40mm spans the tape width,
which is the whole point of them - so the flat image goes on as-is,
centred, and each label only costs 15mm of tape. In 'fit' mode the
rotate option still chooses between reading along the tape or across it.
The raster is the full printable tape width even though the label is
40mm: AirPrint scales a job to the media width, so sending a 40mm-wide
image would be stretched to 48 and print oversize. Full width at 1:1
keeps the printed label exactly 40mm.
"""
tape = get_tape(options.tape)
tape_px = tape.printable_px
if options.is_fixed:
placed = flat
# Too narrow a tape would clip the label; scale to fit and let the
# caller warn, rather than printing something cut off.
if placed.width > tape_px:
ratio = tape_px / placed.width
placed = placed.resize(
(tape_px, max(1, int(placed.height * ratio))), Image.LANCZOS)
canvas = Image.new('RGB', (tape_px, placed.height), 'white')
canvas.paste(placed, ((tape_px - placed.width) // 2, 0))
return canvas
# 'fit' mode: rotate so the label reads along the tape as it feeds.
return flat.rotate(90, expand=True) if options.rotate else flat
def render_group(labels, options, upload_folder):
"""A finished tape raster for one drawer (one or more part types)."""
labels = list(labels)
if options.is_fixed:
flat = render_fixed(labels, options, upload_folder)
else:
flat = render_fit(labels[0], options, upload_folder)
return compose_for_tape(flat, options)
def render_label(label, options, upload_folder):
"""Single-label convenience wrapper (previews, one-off prints)."""
group = label if isinstance(label, (list, tuple)) else [label]
return render_group(group, options, upload_folder)
def plan_groups(labels, options, group_spec=None):
"""
How the selection turns into physical labels.
Returns a list of dicts: the parts on each label, its size in mm, and
whether it will fit the loaded tape.
An explicit group_spec from the page wins over auto-grouping - that is
how you combine parts by hand for a custom drawer.
"""
tape = get_tape(options.tape)
if group_spec:
groups = parse_group_spec(group_spec, labels)
else:
groups = group_by_drawer(
labels, enabled=options.group_drawers and options.is_fixed)
# How many part types one label may carry. 'standard' is a one-part
# label, so a drawer with two parts gets two labels rather than losing
# one; 'double' spills a third part onto a second label.
per_label = {'standard': 1, 'double': 2}.get(options.size_mode)
# Groups the user built by hand are never broken up: asking for these
# parts together outranks the size setting, so the label grows instead.
explicit = bool(group_spec)
plan = []
for group in groups:
chunks = [group]
if options.is_fixed and per_label and not explicit:
chunks = [group[i:i + per_label]
for i in range(0, len(group), per_label)] or [[]]
for chunk in chunks:
if not chunk:
continue
if options.is_fixed:
width_mm = LABEL_WIDTH_MM
mode = options.size_mode
if explicit and per_label and len(chunk) > per_label:
mode = 'auto'
height_mm = label_height_mm(len(chunk), mode)
fits = tape.printable_mm >= width_mm
else:
width_mm = height_mm = None
fits = True
plan.append({
'labels': chunk,
'width_mm': width_mm,
'height_mm': height_mm,
'fits': fits,
'drawer': (chunk[0].location or '').strip(),
})
return plan
def tape_used_mm(plan, copies=1):
"""Total tape length a print run will consume, in mm."""
total = 0.0
for item in plan:
total += (item['height_mm'] or 0) * copies
return total
# --------------------------------------------------------------- outputs
def image_to_bytes(image, mime_type):
"""Serialise a rendered label in whatever format the printer accepts."""
buffer = io.BytesIO()
if mime_type == 'image/jpeg':
image.convert('RGB').save(buffer, 'JPEG', quality=95, dpi=(DPI, DPI))
elif mime_type == 'image/png':
image.save(buffer, 'PNG', dpi=(DPI, DPI))
elif mime_type == 'application/pdf':
image.convert('RGB').save(buffer, 'PDF', resolution=DPI)
else:
raise ValueError(f'Cannot produce {mime_type}')
buffer.seek(0)
return buffer.getvalue()
def render_batch_pdf(labels, options, upload_folder, group_spec=None):
"""Every label as one multi-page PDF, one tape label per page."""
plan = plan_groups(labels, options, group_spec)
images = []
for item in plan:
for _ in range(options.copies):
images.append(render_group(item['labels'], options, upload_folder)
.convert('RGB'))
if not images:
return None
buffer = io.BytesIO()
images[0].save(buffer, 'PDF', resolution=DPI, save_all=True,
append_images=images[1:])
buffer.seek(0)
return buffer
def preview_png(labels, options, upload_folder, scale=11.0):
"""
A screen preview of one physical label, shown flat so it is readable.
Scale is px per mm on screen, so a 40 x 15mm label previews at roughly
440 x 165 - close enough to actual size on a typical monitor to judge.
"""
group = labels if isinstance(labels, (list, tuple)) else [labels]
if options.is_fixed:
flat = render_fixed(group, options, upload_folder)
else:
saved = options.rotate
options.rotate = False
try:
flat = render_fit(group[0], options, upload_folder)
finally:
options.rotate = saved
target_w = max(1, int(px_to_mm(flat.width) * scale))
target_h = max(1, int(px_to_mm(flat.height) * scale))
preview = flat.resize((target_w, target_h), Image.LANCZOS)
buffer = io.BytesIO()
preview.save(buffer, 'PNG')
buffer.seek(0)
return buffer