Files
lego-instructions-manager/app/services/tape_printer.py
T
jessikitty 4a1bfc4acd Fix: unticking "Rotate for tape feed" had no effect
An unticked checkbox is absent from a form submission entirely, so
"rotate unticked" and "rotate never mentioned" arrived at the parser
looking identical - and rotate defaults to on, so it snapped straight
back every time. Add a hidden marker the options form always sends, so
absent-but-submitted can be read as unticked.

Same bug silently reset the field list when every field was unticked;
that is fixed by the same marker. rotate_default lets a saved site-wide
preference apply on a fresh arrival without overriding an explicit
untick on the page.
2026-10-12 22:08:51 +11:00

390 lines
13 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
ORIENTATION: the tape feeds lengthwise, so the raster sent to the printer is
normally rotated 90 degrees - the image is tape-width across and
label-length tall. That is the default. If your prints come out sideways,
untick "Rotate for tape feed"; to make that permanent, turn off the default
orientation in Label Settings.
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)))
@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,
'Tiny. One line, part number only.'),
TapePreset('cz12', 'CZ tape 12mm', 12.0, 11.0,
'One bold line, or two small ones.'),
TapePreset('cz19', 'CZ tape 19mm', 19.0, 17.5,
'Two comfortable lines. Good for bags.'),
TapePreset('cz25', 'CZ tape 25mm', 25.0, 23.5,
'The default roll. Image plus two lines.'),
TapePreset('cz50', 'CZ tape 50mm', 50.0, 48.0,
'Wide. Big artwork, drawer-front labels.'),
]
}
DEFAULT_TAPE = 'cz25'
# What a label shows when you have not said otherwise. Defined once: the
# dataclass default and the form parser both read it, so they cannot drift.
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'
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
@dataclass
class TapeOptions:
tape: str = DEFAULT_TAPE
fields: List[str] = field(
default_factory=lambda: list(DEFAULT_TAPE_FIELDS))
rotate: bool = True
copies: int = 1
# Fixed length in mm, or None to size each label to its 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
def get_tape(key):
return TAPE_PRESETS.get(key or DEFAULT_TAPE, TAPE_PRESETS[DEFAULT_TAPE])
def options_from_form(form, rotate_default=True):
"""
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.
rotate_default applies only when the form was NOT submitted (a fresh
arrival), so the saved site-wide preference is honoured there.
"""
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
return TapeOptions(
tape=form.get('tape') or DEFAULT_TAPE,
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(2.5, 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),
)
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 render_label(label, options, upload_folder):
"""
Render one PartLabel as a PIL image, unrotated (reading left to right).
Height is the printable tape width; width is the label length.
"""
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)
# Type sizes scale off tape width so 9mm doesn't get 25mm's typography.
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),
}
# Artwork
art = None
art_width = 0
if 'image' in fields and label.image and options.image_percent:
path = os.path.join(upload_folder, label.image.replace('/', os.sep))
if os.path.exists(path):
try:
art = Image.open(path).convert('RGBA')
box = height - 2 * padding
art.thumbnail((box, box), Image.LANCZOS)
art_width = art.width + padding
except Exception as exc:
logger.warning('Could not load part image %s: %s', path, exc)
art = None
accent_width = mm_to_px(1.2) if ('accent' in fields and label.category) else 0
# Measure to find the natural length
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
# Vertically centre the text block
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 = 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'
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
if options.rotate:
canvas = canvas.rotate(90, expand=True)
return canvas
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):
"""All selected labels as one multi-page PDF, one tape label per page."""
images = []
for label in labels:
for _ in range(options.copies):
images.append(render_label(label, 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(label, options, upload_folder, scale=2.0):
"""
A screen-sized preview. Rendered unrotated however the print option is
set, because a sideways preview is useless to look at.
"""
saved = options.rotate
options.rotate = False
try:
image = render_label(label, options, upload_folder)
finally:
options.rotate = saved
target_height = int(get_tape(options.tape).printable_mm * scale * 4)
ratio = target_height / image.height
image = image.resize((max(1, int(image.width * ratio)), target_height),
Image.LANCZOS)
buffer = io.BytesIO()
image.save(buffer, 'PNG')
buffer.seek(0)
return buffer