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".
348 lines
13 KiB
Python
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
|