From 7d32ad8fd60c32a5a8913775644e28e6611e4380 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 12 Oct 2026 21:26:02 +1100 Subject: [PATCH] Add 313dpi continuous-tape rendering for the VC-500W --- app/services/tape_printer.py | 363 +++++++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 app/services/tape_printer.py diff --git a/app/services/tape_printer.py b/app/services/tape_printer.py new file mode 100644 index 0000000..794d105 --- /dev/null +++ b/app/services/tape_printer.py @@ -0,0 +1,363 @@ +""" +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