Files
lego-instructions-manager/app/services/label_render.py
T
jessikitty 5319870a7c Fix: unticking every sheet field silently reset to the defaults
Same absent-checkbox bug as the tape page, caught alongside it. The
hidden form marker lets an empty field list be honoured as a real
choice instead of being treated as "nothing was submitted".
2026-10-12 22:09:41 +11:00

348 lines
13 KiB
Python

"""
Label sheet geometry.
Everything here is in millimetres and the printable page is laid out with
absolutely positioned boxes, because that is the only way label sheets line
up reliably across browsers and printers.
The primary output is an HTML page you print from the browser (Ctrl+P,
margins "None", scale 100%). A PDF export is offered as well when ReportLab
happens to be installed - it is listed as an optional dependency because it
has historically been slow to publish wheels for brand new Python versions,
and LIM should not stop working over a nice-to-have.
Created by: Jess Rogerson (yelling commands at Claude.AI)
"""
from dataclasses import dataclass, field, asdict
from typing import Dict, List, Optional
PAGE_SIZES = {
'a4': {'name': 'A4', 'width': 210.0, 'height': 297.0},
'letter': {'name': 'US Letter', 'width': 215.9, 'height': 279.4},
}
@dataclass
class SheetPreset:
key: str
name: str
page: str
label_width: float
label_height: float
columns: int
rows: int
margin_top: float
margin_left: float
gap_x: float = 0.0
gap_y: float = 0.0
corner_radius: float = 1.5
note: str = ''
@property
def per_page(self) -> int:
return self.columns * self.rows
@property
def page_size(self) -> Dict:
return PAGE_SIZES.get(self.page, PAGE_SIZES['a4'])
def as_dict(self) -> Dict:
data = asdict(self)
data['per_page'] = self.per_page
data['page_size'] = self.page_size
return data
# Nominal figures from the manufacturers' own sheet specs. Print the
# alignment test page once per printer and nudge margin_top / margin_left in
# the print options if your printer is a few tenths out - most are.
SHEET_PRESETS: Dict[str, SheetPreset] = {
p.key: p for p in [
SheetPreset('avery_l7651', 'Avery L7651 - 38.1 x 21.2mm (65/sheet)',
'a4', 38.1, 21.2, 5, 13, 10.7, 4.75, 2.5, 0.0, 1.0,
'Small. Good for bags and shallow drawers.'),
SheetPreset('avery_l7160', 'Avery L7160 - 63.5 x 38.1mm (21/sheet)',
'a4', 63.5, 38.1, 3, 7, 15.0, 7.25, 2.5, 0.0, 1.5,
'The all-rounder. Fits an image plus two lines of text.'),
SheetPreset('avery_l7163', 'Avery L7163 - 99.1 x 38.1mm (14/sheet)',
'a4', 99.1, 38.1, 2, 7, 15.0, 5.0, 2.5, 0.0, 1.5,
'Wide. Room for long part names and a location code.'),
SheetPreset('avery_l7159', 'Avery L7159 - 63.5 x 33.9mm (24/sheet)',
'a4', 63.5, 33.9, 3, 8, 13.0, 7.25, 2.5, 0.0, 1.5, ''),
SheetPreset('avery_l7169', 'Avery L7169 - 99.1 x 67.7mm (8/sheet)',
'a4', 99.1, 67.7, 2, 4, 13.0, 5.0, 2.5, 0.0, 2.0,
'Large. Big artwork for deep drawers.'),
SheetPreset('avery_5160', 'Avery 5160 US - 66.7 x 25.4mm (30/sheet)',
'letter', 66.7, 25.4, 3, 10, 12.7, 4.76, 3.18, 0.0, 1.0, ''),
SheetPreset('a4_grid_40x15', 'Plain A4 - 40 x 15mm grid (cut yourself)',
'a4', 40.0, 15.0, 5, 18, 10.0, 5.0, 2.0, 1.0, 0.0,
'For plain sticker sheets or paper plus tape.'),
SheetPreset('a4_grid_70x25', 'Plain A4 - 70 x 25mm grid (cut yourself)',
'a4', 70.0, 25.0, 2, 10, 15.0, 15.0, 10.0, 2.0, 0.0, ''),
]
}
DEFAULT_PRESET = 'avery_l7160'
# What can appear on a label. Order here is the order on the page.
FIELD_OPTIONS = [
('image', 'Part image'),
('name', 'Part name'),
('dimensions', 'Dimensions'),
('part_number', 'Part number'),
('alt_numbers', 'Alternate part numbers'),
('category', 'Category'),
('category_code', 'Category code'),
('location', 'Drawer / location'),
('colour', 'Colour name'),
('accent', 'Category colour stripe'),
]
DEFAULT_FIELDS = ['image', 'name', 'dimensions', 'part_number', 'accent']
# Hidden marker the options form always submits, so an unticked checkbox
# (which the browser omits entirely) can be told apart from a fresh arrival
# with no options at all. See tape_printer.FORM_MARKER.
FORM_MARKER = '_opts'
@dataclass
class LabelOptions:
preset: str = DEFAULT_PRESET
fields: List[str] = field(default_factory=lambda: list(DEFAULT_FIELDS))
# Per-printer alignment nudge, millimetres, can be negative.
offset_x: float = 0.0
offset_y: float = 0.0
# Skip this many label positions on the first sheet, so part-used sheets
# are not wasted.
skip: int = 0
copies: int = 1
# Draw a hairline border round each label. Handy for cutting, and for
# checking alignment before you waste a sheet of Averys.
outline: bool = False
font_scale: float = 1.0
image_percent: int = 34 # share of label width given to the artwork
def get_preset(key: Optional[str]) -> SheetPreset:
return SHEET_PRESETS.get(key or DEFAULT_PRESET,
SHEET_PRESETS[DEFAULT_PRESET])
def options_from_form(form) -> LabelOptions:
"""
Build LabelOptions from request.form or request.args.
FORM_MARKER tells a real submission from a fresh arrival, so unticking
every field is honoured instead of silently snapping back to the
defaults. Same reasoning as tape_printer.options_from_form.
"""
submitted = FORM_MARKER in form
def number(key, default, cast=float):
try:
return cast(form.get(key, default))
except (TypeError, ValueError):
return default
fields = list(form.getlist('fields')) if hasattr(form, 'getlist') else []
if not fields and not submitted:
fields = list(DEFAULT_FIELDS)
return LabelOptions(
preset=form.get('preset') or DEFAULT_PRESET,
fields=fields,
offset_x=number('offset_x', 0.0),
offset_y=number('offset_y', 0.0),
skip=max(0, number('skip', 0, int)),
copies=max(1, min(99, number('copies', 1, int))),
outline=str(form.get('outline', '')).lower() in ('1', 'true', 'on', 'yes'),
font_scale=max(0.5, min(2.0, number('font_scale', 1.0))),
image_percent=max(0, min(60, number('image_percent', 34, int))),
)
def build_pages(labels, options: LabelOptions) -> Dict:
"""
Turn a list of PartLabel rows into positioned boxes, page by page.
Returns a dict the print template renders directly - no layout logic in
Jinja, which keeps the two in step.
"""
preset = get_preset(options.preset)
# Expand copies, then pad the front with blanks for a part-used sheet.
queue = []
for _ in range(options.skip):
queue.append(None)
for label in labels:
for _ in range(options.copies):
queue.append(label)
per_page = preset.per_page
pages = []
for start in range(0, max(len(queue), 1), per_page):
chunk = queue[start:start + per_page]
cells = []
for index, label in enumerate(chunk):
if label is None:
continue
row, column = divmod(index, preset.columns)
cells.append({
'label': label,
'left': round(preset.margin_left + options.offset_x
+ column * (preset.label_width + preset.gap_x), 3),
'top': round(preset.margin_top + options.offset_y
+ row * (preset.label_height + preset.gap_y), 3),
})
pages.append(cells)
# Font sizing scales off the label height so a 21mm label does not get
# the same type as a 68mm one.
base = max(5.0, min(13.0, preset.label_height * 0.26)) * options.font_scale
typography = {
'title': round(base, 2),
'sub': round(base * 0.72, 2),
'meta': round(base * 0.62, 2),
}
return {
'preset': preset,
'pages': pages,
'typography': typography,
'options': options,
'total_labels': sum(len(page) for page in pages),
'fields': set(options.fields),
}
def reportlab_available() -> bool:
try:
import reportlab # noqa: F401
return True
except Exception:
return False
def render_pdf(layout, upload_folder):
"""
Optional PDF export. Returns a BytesIO, or None when ReportLab is not
installed. Only locally cached images are drawn - a PDF should not be
reaching out to the internet while it renders.
"""
if not reportlab_available():
return None
import io
import os
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas as pdf_canvas
preset = layout['preset']
options = layout['options']
fields = layout['fields']
type_sizes = layout['typography']
page = preset.page_size
page_width, page_height = page['width'] * mm, page['height'] * mm
buffer = io.BytesIO()
pdf = pdf_canvas.Canvas(buffer, pagesize=(page_width, page_height))
pdf.setTitle('LEGO parts drawer labels')
def mm_x(value):
return value * mm
def mm_y(value):
"""PDF origin is bottom-left; our geometry is top-left."""
return page_height - value * mm
for page_index, cells in enumerate(layout['pages']):
if page_index:
pdf.showPage()
for cell in cells:
label = cell['label']
left, top = cell['left'], cell['top']
width, height = preset.label_width, preset.label_height
if options.outline:
pdf.setLineWidth(0.25)
pdf.setStrokeColorRGB(0.8, 0.8, 0.8)
pdf.rect(mm_x(left), mm_y(top + height),
mm_x(width), mm_x(height))
text_left = left + 1.5
if 'accent' in fields and label.category:
accent = label.category.accent_colour().lstrip('#')
try:
red, green, blue = (int(accent[i:i + 2], 16) / 255
for i in (0, 2, 4))
except Exception:
red, green, blue = (0.82, 0.06, 0.07)
pdf.setFillColorRGB(red, green, blue)
pdf.rect(mm_x(left), mm_y(top + height), mm_x(1.6),
mm_x(height), stroke=0, fill=1)
text_left += 1.6
image_width = 0.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):
image_width = width * options.image_percent / 100.0
try:
reader = ImageReader(path)
pdf.drawImage(reader, mm_x(text_left),
mm_y(top + height - 1.0),
width=mm_x(image_width - 1.0),
height=mm_x(height - 2.0),
preserveAspectRatio=True, anchor='c',
mask='auto')
except Exception:
image_width = 0.0
cursor = top + type_sizes['title'] * 0.38 + 1.2
body_left = text_left + image_width + 1.0
pdf.setFillColorRGB(0, 0, 0)
def write(text, size, bold=False, gap=1.25):
nonlocal cursor
if not text or cursor > top + height:
return
pdf.setFont('Helvetica-Bold' if bold else 'Helvetica', size)
available = (width - (body_left - left) - 1.5) * mm
while (pdf.stringWidth(text, pdf._fontname, size) > available
and len(text) > 4):
text = text[:-2]
pdf.drawString(mm_x(body_left), mm_y(cursor), text)
cursor += size * 0.3528 + gap
if 'name' in fields:
title = (label.display_title if 'dimensions' in fields
else label.name)
write(title, type_sizes['title'], bold=True)
elif 'dimensions' in fields:
write(label.dimensions, type_sizes['title'], bold=True)
if 'colour' in fields:
write(label.colour_name, type_sizes['sub'])
if 'category' in fields and label.category:
write(label.category.full_name, type_sizes['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:
meta.append(label.category.display_code)
if 'location' in fields and label.location:
meta.append(label.location)
if meta:
write(' · '.join(m for m in meta if m), type_sizes['meta'])
pdf.save()
buffer.seek(0)
return buffer