""" 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 first test print comes out sideways, untick "Rotate for tape feed" and print again; it is the one setting worth checking on a new printer. 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' 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: [ 'image', 'name', 'dimensions', 'part_number', 'accent']) 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): """Build TapeOptions from request.form / request.args.""" 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 not in form: return default return str(form.get(key, '')).lower() in ('1', 'true', 'on', 'yes') fields = form.getlist('fields') if hasattr(form, 'getlist') else None 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 if fields else ['image', 'name', 'dimensions', 'part_number', 'accent'], rotate=flag('rotate', True), 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