diff --git a/app/services/label_render.py b/app/services/label_render.py new file mode 100644 index 0000000..22d2f31 --- /dev/null +++ b/app/services/label_render.py @@ -0,0 +1,332 @@ +""" +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