From d86a71de678222fc4930b0dd2f65dea4a25d26f2 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 12 Oct 2026 22:42:01 +1100 Subject: [PATCH] Fixed 40 x 15mm drawer labels, 40 x 20mm for a shared drawer Drawer labels are now a set physical size rather than however long the text happens to be: one part type in the drawer 40 x 15 mm two part types 40 x 20 mm They lie across the tape, so the 40mm spans the width and each label only costs its height in tape length - 1.5m per 100 standard labels instead of 4m. That needs 50mm tape, which is now the default; a narrower tape is detected and warned about rather than silently clipping. Two part types in one drawer are matched on the location field and combined onto a single taller label. Nothing is ever dropped: in "always 40 x 15" a shared drawer simply yields two labels, and a third part type grows the label by 5mm rather than vanishing. Part names are wrapped to two lines and the type auto-sized to fit, so "Plate, Modified with Clip" prints in full instead of truncating. "Fit the tape width" keeps the previous content-sized behaviour. --- app/services/tape_printer.py | 540 ++++++++++++++++++++++++++++++----- 1 file changed, 472 insertions(+), 68 deletions(-) diff --git a/app/services/tape_printer.py b/app/services/tape_printer.py index d008051..0c10b32 100644 --- a/app/services/tape_printer.py +++ b/app/services/tape_printer.py @@ -12,11 +12,21 @@ VC-500W facts this is built around: * 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 prints come out sideways, -untick "Rotate for tape feed"; to make that permanent, turn off the default -orientation in Label Settings. +THE HOUSE LABEL SIZE +-------------------- +Drawer labels are a fixed physical size, not "however long the text is": + + one part in the drawer 40 x 15 mm + two parts in the drawer 40 x 20 mm + +They are laid ACROSS the tape - the 40mm spans the tape width, so each +label only eats 15mm (or 20mm) of tape length. That needs 50mm tape, and it +is about 2.7x cheaper than running the 40mm along the feed: 100 standard +labels come to 1.5m of tape instead of 4m. Cut across the tape between +labels and you get a 40 x 15mm sticker. + +"Fit tape width" keeps the older behaviour - the label is as long as its +content needs and fills the tape width - for anyone who wants it. Created by: Jess Rogerson (yelling commands at Claude.AI) """ @@ -40,6 +50,10 @@ def mm_to_px(mm, dpi=DPI): return max(1, int(round(mm / 25.4 * dpi))) +def px_to_mm(px, dpi=DPI): + return px / dpi * 25.4 + + @dataclass class TapePreset: key: str @@ -59,29 +73,80 @@ class TapePreset: TAPE_PRESETS = { p.key: p for p in [ TapePreset('cz9', 'CZ tape 9mm', 9.0, 8.0, - 'Tiny. One line, part number only.'), + 'Too narrow for the 40mm house label.'), TapePreset('cz12', 'CZ tape 12mm', 12.0, 11.0, - 'One bold line, or two small ones.'), + 'Too narrow for the 40mm house label.'), TapePreset('cz19', 'CZ tape 19mm', 19.0, 17.5, - 'Two comfortable lines. Good for bags.'), + 'Too narrow for the 40mm house label.'), TapePreset('cz25', 'CZ tape 25mm', 25.0, 23.5, - 'The default roll. Image plus two lines.'), + 'Too narrow for the 40mm house label.'), TapePreset('cz50', 'CZ tape 50mm', 50.0, 48.0, - 'Wide. Big artwork, drawer-front labels.'), + 'The one to use. Fits the 40mm label across the tape.'), ] } -DEFAULT_TAPE = 'cz25' +DEFAULT_TAPE = 'cz50' -# What a label shows when you have not said otherwise. Defined once: the -# dataclass default and the form parser both read it, so they cannot drift. -DEFAULT_TAPE_FIELDS = ['image', 'name', 'dimensions', 'part_number', 'accent'] +# ---------------------------------------------------------- label sizing -# Hidden marker every options form submits. Its presence is how we tell -# "submitted with the box unticked" from "arrived here with no options at -# all" - an unticked checkbox sends nothing, so without this they look -# identical and a default-on box can never be turned off. -FORM_MARKER = '_opts' +LABEL_WIDTH_MM = 40.0 +ONE_PART_HEIGHT_MM = 15.0 +TWO_PART_HEIGHT_MM = 20.0 +# Three or more part types in one drawer is not a size you asked for, so +# rather than silently dropping parts the label grows by this much each. +EXTRA_PART_HEIGHT_MM = 5.0 + +SIZE_MODES = [ + ('auto', '40 x 15mm, or 40 x 20mm for a shared drawer'), + ('standard', 'Always 40 x 15mm'), + ('double', 'Always 40 x 20mm'), + ('fit', 'Fit the tape width (length follows the content)'), +] +DEFAULT_SIZE_MODE = 'auto' +FIXED_MODES = ('auto', 'standard', 'double') + + +def label_height_mm(part_count, size_mode=DEFAULT_SIZE_MODE): + """The physical height of a label holding this many part types.""" + if size_mode == 'standard': + return ONE_PART_HEIGHT_MM + if size_mode == 'double': + return TWO_PART_HEIGHT_MM + if part_count <= 1: + return ONE_PART_HEIGHT_MM + if part_count == 2: + return TWO_PART_HEIGHT_MM + return TWO_PART_HEIGHT_MM + EXTRA_PART_HEIGHT_MM * (part_count - 2) + + +def group_by_drawer(labels, enabled=True): + """ + One physical label per drawer. + + Labels sharing a location are the same drawer, so they go on one taller + label. A label with no location can't be grouped - it gets its own. + Order is preserved: a group sits where its first member was. + """ + if not enabled: + return [[label] for label in labels] + + groups = [] + by_location = {} + for label in labels: + key = (label.location or '').strip().lower() + if not key: + groups.append([label]) + continue + if key in by_location: + by_location[key].append(label) + else: + group = [label] + by_location[key] = group + groups.append(group) + return groups + + +# ------------------------------------------------------------------ fonts FONT_CANDIDATES = { False: [ # regular @@ -121,14 +186,30 @@ def load_font(size_px, bold=False): return font +# ---------------------------------------------------------------- options + +DEFAULT_TAPE_FIELDS = ['image', 'name', 'dimensions', 'part_number', 'accent'] + +# Hidden marker every options form submits. Its presence is how we tell +# "submitted with the box unticked" from "arrived here with no options at +# all" - an unticked checkbox sends nothing, so without this they look +# identical and a default-on box can never be turned off. +FORM_MARKER = '_opts' + + @dataclass class TapeOptions: tape: str = DEFAULT_TAPE + size_mode: str = DEFAULT_SIZE_MODE + # Combine labels that share a drawer onto one taller label. + group_drawers: bool = True fields: List[str] = field( default_factory=lambda: list(DEFAULT_TAPE_FIELDS)) + # Fixed sizes always lie across the tape - that is the point of them. + # This only applies to 'fit' mode. rotate: bool = True copies: int = 1 - # Fixed length in mm, or None to size each label to its content. + # 'fit' mode only: a fixed length in mm instead of sizing to content. fixed_length_mm: Optional[float] = None padding_mm: float = 1.5 font_scale: float = 1.0 @@ -136,12 +217,22 @@ class TapeOptions: align: str = 'left' # left | center outline: bool = False # hairline box, for checking alignment + @property + def is_fixed(self): + return self.size_mode in FIXED_MODES + def get_tape(key): return TAPE_PRESETS.get(key or DEFAULT_TAPE, TAPE_PRESETS[DEFAULT_TAPE]) -def options_from_form(form, rotate_default=True): +def tape_fits_label(tape): + """Can the 40mm label lie across this tape?""" + return get_tape(tape).printable_mm >= LABEL_WIDTH_MM + + +def options_from_form(form, rotate_default=True, size_default=None, + tape_default=None): """ Build TapeOptions from request.form / request.args. @@ -152,8 +243,9 @@ def options_from_form(form, rotate_default=True): field the options form always sends: when it is present we know every checkbox was on screen, so absent means the user unticked it. - rotate_default applies only when the form was NOT submitted (a fresh - arrival), so the saved site-wide preference is honoured there. + The *_default arguments apply only when the form was NOT submitted (a + fresh arrival), so saved site-wide preferences are honoured there + without ever overriding an explicit choice on the page. """ submitted = FORM_MARKER in form @@ -179,8 +271,15 @@ def options_from_form(form, rotate_default=True): except ValueError: fixed = None + valid_modes = {key for key, _ in SIZE_MODES} + size_mode = form.get('size_mode') or size_default or DEFAULT_SIZE_MODE + if size_mode not in valid_modes: + size_mode = DEFAULT_SIZE_MODE + return TapeOptions( - tape=form.get('tape') or DEFAULT_TAPE, + tape=form.get('tape') or tape_default or DEFAULT_TAPE, + size_mode=size_mode, + group_drawers=flag('group_drawers', True), fields=fields, rotate=flag('rotate', rotate_default), copies=max(1, min(99, number('copies', 1, int))), @@ -193,6 +292,8 @@ def options_from_form(form, rotate_default=True): ) +# ------------------------------------------------------------ text pieces + def _label_lines(label, fields): """The text that goes on the tape, as (text, weight) pairs.""" lines = [] @@ -232,11 +333,217 @@ def _text_width(draw, text, font): 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). +def _fit_text(draw, text, font, available): + """Trim with an ellipsis until it fits.""" + 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' + return shown - Height is the printable tape width; width is the label length. + +def wrap_text(draw, text, font, available): + """Greedy word wrap. A word longer than the line gets its own line.""" + words = (text or '').split() + if not words: + return [] + lines = [words[0]] + for word in words[1:]: + trial = lines[-1] + ' ' + word + if _text_width(draw, trial, font) <= available: + lines[-1] = trial + else: + lines.append(word) + return lines + + +def layout_title(draw, text, available, max_h, max_px, min_px, + bold=True, max_lines=2): + """ + Largest type at which the title fits the box, wrapping if that helps. + + "Plate, Modified with Clip" will not fit one line of a 40mm label at a + readable size, but it fits two comfortably. Prefer wrapping over + shrinking, and shrinking over truncating - the whole name is the point + of the label. + """ + size = int(max_px) + floor = max(6, int(min_px)) + best = None + while size >= floor: + font = load_font(size, bold=bold) + lines = wrap_text(draw, text, font, available) + if lines and len(lines) <= max_lines: + widest = max(_text_width(draw, line, font) for line in lines) + height = len(lines) * size * 1.12 + if widest <= available and height <= max_h: + return font, lines, height, size + if best is None: + best = (font, lines, height, size) + size -= 1 + font = load_font(floor, bold=bold) + return font, [text], floor * 1.12, floor + + +def fit_font(draw, text, available, max_px, min_px, bold=False): + """ + The largest font size at which `text` still fits in `available`. + + A 40mm label has very little room once the artwork is on it, and part + names vary from "Tile" to "Plate, Modified with Clip". Shrinking the + type keeps the whole name readable; truncating it to "Plate, Mod..." + does not. Below min_px it stops shrinking and the caller ellipsises. + """ + size = int(max_px) + floor = max(6, int(min_px)) + while size > floor: + font = load_font(size, bold=bold) + if _text_width(draw, text, font) <= available: + return font, size + size -= 1 + return load_font(floor, bold=bold), floor + + +def _load_art(label, upload_folder, box_px): + if not label.image: + return None + path = os.path.join(upload_folder, label.image.replace('/', os.sep)) + if not os.path.exists(path): + return None + try: + art = Image.open(path).convert('RGBA') + art.thumbnail((box_px, box_px), Image.LANCZOS) + return art + except Exception as exc: + logger.warning('Could not load part image %s: %s', path, exc) + return None + + +# -------------------------------------------------------- fixed-size draw + +def render_fixed(labels, options, upload_folder): + """ + One drawer label at the house size, drawn flat and reading normally: + 40mm wide, 15mm tall for one part, 20mm for two, taller beyond that. + + `labels` is every part type in that drawer - one row each. + """ + # Whatever is handed in gets drawn. Deciding how many parts belong on + # one label is plan_groups' job - doing it here too once meant a part + # was quietly dropped instead of getting a label of its own. + labels = list(labels) or [] + rows = max(1, len(labels)) + + width_px = mm_to_px(LABEL_WIDTH_MM) + height_mm = label_height_mm(len(labels), options.size_mode) + height_px = mm_to_px(height_mm) + + canvas = Image.new('RGB', (width_px, height_px), 'white') + draw = ImageDraw.Draw(canvas) + + if options.outline: + draw.rectangle([0, 0, width_px - 1, height_px - 1], + outline=(190, 190, 190), width=max(1, mm_to_px(0.2))) + + fields = set(options.fields) + # Deliberately tighter than the fit-tape padding: 1.5mm top and bottom + # would eat a third of a two-part label's 10mm row. + padding = mm_to_px(min(options.padding_mm, 0.8)) + row_h = height_px / rows + + for index, label in enumerate(labels): + top = int(index * row_h) + bottom = int((index + 1) * row_h) + inner_h = bottom - top + + # A hairline between parts so the two halves read as separate. + if index: + draw.line([(padding, top), (width_px - padding, top)], + fill=(205, 205, 205), width=max(1, mm_to_px(0.15))) + + cursor_x = padding + if 'accent' in fields and label.category: + accent_w = mm_to_px(1.2) + 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([cursor_x, top + padding, + cursor_x + accent_w, bottom - padding], fill=rgb) + cursor_x += accent_w + padding + + if 'image' in fields and options.image_percent: + # Cap the artwork by label width as well as row height, or a + # square image eats a third of a 40mm label. + box = min(inner_h - 2 * padding, + int(width_px * options.image_percent / 100.0)) + art = _load_art(label, upload_folder, box) + if art is not None: + canvas.paste(art, (cursor_x, top + (inner_h - art.height) // 2), + art) + cursor_x += art.width + padding + + lines = _label_lines(label, fields) + title = next((t for t, w in lines if w == 'title'), None) + rest = [t for t, w in lines if w != 'title'] + meta_text = ' \u00b7 '.join(rest) if rest else None + + available = width_px - cursor_x - padding + + # The meta line is short and fixed, so size it first and give the + # title whatever vertical room is left. + meta_font = meta_px = None + meta_h = 0 + if meta_text: + meta_font, meta_px = fit_font( + draw, meta_text, available, + inner_h * 0.26 * options.font_scale, + inner_h * 0.15 * options.font_scale, bold=False) + meta_h = meta_px * 1.15 + + title_font = title_lines = None + title_h = 0 + if title: + # The floor has to be low enough to reach a size where a long + # name wraps to two lines; stopping at 20% of the row meant + # "Plate, Modified with Clip" was truncated instead. + title_font, title_lines, title_h, title_px = layout_title( + draw, title, available, + inner_h - 2 * padding - meta_h, + inner_h * 0.44 * options.font_scale, + inner_h * 0.13 * options.font_scale, bold=True) + + block_h = title_h + meta_h + y = top + max(padding, (inner_h - block_h) / 2) + + if title_lines: + for line in title_lines: + shown = _fit_text(draw, line, title_font, available) + x = cursor_x + if options.align == 'center': + x += max(0, (available - _text_width(draw, shown, title_font)) / 2) + draw.text((x, y), shown, font=title_font, fill=(0, 0, 0)) + y += title_px * 1.12 + + if meta_text: + shown = _fit_text(draw, meta_text, meta_font, available) + x = cursor_x + if options.align == 'center': + x += max(0, (available - _text_width(draw, shown, meta_font)) / 2) + draw.text((x, y), shown, font=meta_font, fill=(70, 70, 70)) + + return canvas + + +# ------------------------------------------------------- fit-tape drawing + +def render_fit(label, options, upload_folder): + """ + The older behaviour: the label fills the tape width and runs as long as + its content needs. Drawn flat, reading left to right. """ tape = get_tape(options.tape) height = tape.printable_px @@ -245,7 +552,6 @@ def render_label(label, options, upload_folder): 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 @@ -260,24 +566,15 @@ def render_label(label, options, upload_folder): '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 + if 'image' in fields and options.image_percent: + art = _load_art(label, upload_folder, height - 2 * padding) + if art is not None: + art_width = art.width + padding 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: @@ -312,30 +609,129 @@ def render_label(label, options, upload_folder): 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' + shown = _fit_text(draw, text, font, available) 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 +# --------------------------------------------------------- tape composing + +def compose_for_tape(flat, options): + """ + Place a flat label onto the tape raster. + + Convention: the returned image's WIDTH is the across-tape dimension and + its HEIGHT is how much tape length the label consumes. + + Fixed sizes always lie across the tape - the 40mm spans the tape width, + which is the whole point of them - so the flat image goes on as-is, + centred, and each label only costs 15mm of tape. In 'fit' mode the + rotate option still chooses between reading along the tape or across it. + + The raster is the full printable tape width even though the label is + 40mm: AirPrint scales a job to the media width, so sending a 40mm-wide + image would be stretched to 48 and print oversize. Full width at 1:1 + keeps the printed label exactly 40mm. + """ + tape = get_tape(options.tape) + tape_px = tape.printable_px + + if options.is_fixed: + placed = flat + # Too narrow a tape would clip the label; scale to fit and let the + # caller warn, rather than printing something cut off. + if placed.width > tape_px: + ratio = tape_px / placed.width + placed = placed.resize( + (tape_px, max(1, int(placed.height * ratio))), Image.LANCZOS) + canvas = Image.new('RGB', (tape_px, placed.height), 'white') + canvas.paste(placed, ((tape_px - placed.width) // 2, 0)) + return canvas + + # 'fit' mode: rotate so the label reads along the tape as it feeds. + return flat.rotate(90, expand=True) if options.rotate else flat + + +def render_group(labels, options, upload_folder): + """A finished tape raster for one drawer (one or more part types).""" + labels = list(labels) + if options.is_fixed: + flat = render_fixed(labels, options, upload_folder) + else: + flat = render_fit(labels[0], options, upload_folder) + return compose_for_tape(flat, options) + + +def render_label(label, options, upload_folder): + """Single-label convenience wrapper (previews, one-off prints).""" + group = label if isinstance(label, (list, tuple)) else [label] + return render_group(group, options, upload_folder) + + +def plan_groups(labels, options): + """ + How the selection turns into physical labels. + + Returns a list of dicts: the parts on each label, its size in mm, and + whether it will fit the loaded tape. + """ + tape = get_tape(options.tape) + groups = group_by_drawer( + labels, enabled=options.group_drawers and options.is_fixed) + + # How many part types one label may carry. 'standard' is a one-part + # label, so a drawer with two parts gets two labels rather than losing + # one; 'double' spills a third part onto a second label. + per_label = {'standard': 1, 'double': 2}.get(options.size_mode) + + plan = [] + for group in groups: + chunks = [group] + if options.is_fixed and per_label: + chunks = [group[i:i + per_label] + for i in range(0, len(group), per_label)] or [[]] + + for chunk in chunks: + if not chunk: + continue + if options.is_fixed: + width_mm = LABEL_WIDTH_MM + height_mm = label_height_mm(len(chunk), options.size_mode) + fits = tape.printable_mm >= width_mm + else: + width_mm = height_mm = None + fits = True + plan.append({ + 'labels': chunk, + 'width_mm': width_mm, + 'height_mm': height_mm, + 'fits': fits, + 'drawer': (chunk[0].location or '').strip(), + }) + return plan + + +def tape_used_mm(plan, copies=1): + """Total tape length a print run will consume, in mm.""" + total = 0.0 + for item in plan: + total += (item['height_mm'] or 0) * copies + return total + + +# --------------------------------------------------------------- outputs + def image_to_bytes(image, mime_type): """Serialise a rendered label in whatever format the printer accepts.""" buffer = io.BytesIO() @@ -352,11 +748,12 @@ def image_to_bytes(image, mime_type): def render_batch_pdf(labels, options, upload_folder): - """All selected labels as one multi-page PDF, one tape label per page.""" + """Every label as one multi-page PDF, one tape label per page.""" + plan = plan_groups(labels, options) images = [] - for label in labels: + for item in plan: for _ in range(options.copies): - images.append(render_label(label, options, upload_folder) + images.append(render_group(item['labels'], options, upload_folder) .convert('RGB')) if not images: return None @@ -367,23 +764,30 @@ def render_batch_pdf(labels, options, upload_folder): return buffer -def preview_png(label, options, upload_folder, scale=2.0): +def preview_png(labels, options, upload_folder, scale=11.0): """ - A screen-sized preview. Rendered unrotated however the print option is - set, because a sideways preview is useless to look at. + A screen preview of one physical label, shown flat so it is readable. + + Scale is px per mm on screen, so a 40 x 15mm label previews at roughly + 440 x 165 - close enough to actual size on a typical monitor to judge. """ - saved = options.rotate - options.rotate = False - try: - image = render_label(label, options, upload_folder) - finally: - options.rotate = saved + group = labels if isinstance(labels, (list, tuple)) else [labels] + + if options.is_fixed: + flat = render_fixed(group, options, upload_folder) + else: + saved = options.rotate + options.rotate = False + try: + flat = render_fit(group[0], options, upload_folder) + finally: + options.rotate = saved + + target_w = max(1, int(px_to_mm(flat.width) * scale)) + target_h = max(1, int(px_to_mm(flat.height) * scale)) + preview = flat.resize((target_w, target_h), Image.LANCZOS) - 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') + preview.save(buffer, 'PNG') buffer.seek(0) return buffer