""" 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'] @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.""" def number(key, default, cast=float): try: return cast(form.get(key, default)) except (TypeError, ValueError): return default fields = form.getlist('fields') if hasattr(form, 'getlist') else None return LabelOptions( preset=form.get('preset') or DEFAULT_PRESET, fields=fields if fields else list(DEFAULT_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(' \u00b7 '.join(m for m in meta if m), type_sizes['meta']) pdf.save() buffer.seek(0) return buffer