diff --git a/app/services/tape_printer.py b/app/services/tape_printer.py index 0c10b32..eab0153 100644 --- a/app/services/tape_printer.py +++ b/app/services/tape_printer.py @@ -105,6 +105,10 @@ SIZE_MODES = [ DEFAULT_SIZE_MODE = 'auto' FIXED_MODES = ('auto', 'standard', 'double') +# The title may take this share of the row height before Text size is even +# applied. Generous on purpose: these are read at arm's length in a shed. +TITLE_MAX_SHARE = 0.55 + def label_height_mm(part_count, size_mode=DEFAULT_SIZE_MODE): """The physical height of a label holding this many part types.""" @@ -119,6 +123,43 @@ def label_height_mm(part_count, size_mode=DEFAULT_SIZE_MODE): return TWO_PART_HEIGHT_MM + EXTRA_PART_HEIGHT_MM * (part_count - 2) +def parse_group_spec(spec, labels): + """ + Turn "1,2|3" into [[label1, label2], [label3]]. + + This is how the page says "put these parts on one label" explicitly, + which matters for customs and MOC drawers where the parts have nothing + in common for auto-grouping to latch onto. Unknown or repeated ids are + ignored, and anything the spec never mentions keeps a label of its own, + so a stale spec can never silently drop a part. + """ + by_id = {label.id: label for label in labels} + groups = [] + used = set() + + for chunk in (spec or '').split('|'): + group = [] + for value in chunk.split(','): + if not value.strip().isdigit(): + continue + wanted = int(value) + if wanted in by_id and wanted not in used: + group.append(by_id[wanted]) + used.add(wanted) + if group: + groups.append(group) + + for label in labels: + if label.id not in used: + groups.append([label]) + return groups + + +def spec_from_groups(groups): + """The inverse: [[l1,l2],[l3]] -> '1,2|3'.""" + return '|'.join(','.join(str(l.id) for l in group) for group in groups) + + def group_by_drawer(labels, enabled=True): """ One physical label per drawer. @@ -285,7 +326,7 @@ def options_from_form(form, rotate_default=True, size_default=None, 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))), + font_scale=max(0.5, min(3.0, 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), @@ -358,6 +399,17 @@ def wrap_text(draw, text, font, available): return lines +def _title_size_for(draw, text, available, inner_h, padding, options): + """What size the title would come out at in this much room.""" + if not text or available <= 0: + return 0 + _font, _lines, _h, size = layout_title( + draw, text, available, inner_h - 2 * padding, + inner_h * TITLE_MAX_SHARE * options.font_scale, + inner_h * 0.13, bold=True) + return size + + def layout_title(draw, text, available, max_h, max_px, min_px, bold=True, max_lines=2): """ @@ -475,46 +527,71 @@ def render_fixed(labels, options, upload_folder): 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 + # Text beats artwork. The picture is a hint; the name is the label. + # Try the full-size image first and give room back to the type until + # the title reaches the size the Text size setting asked for - that + # is what makes turning Text size up actually do something on a + # label this small. + text_left = cursor_x + art = None + wanted_px = inner_h * 0.34 * options.font_scale + base_box = min(inner_h - 2 * padding, + int(width_px * options.image_percent / 100.0)) + + if 'image' in fields and options.image_percent and label.image: + for share in (1.0, 0.75, 0.5, 0.0): + candidate = _load_art(label, upload_folder, + int(base_box * share)) if share else None + used = (candidate.width + padding) if candidate else 0 + room = width_px - cursor_x - used - padding + probe_size = _title_size_for(draw, title, room, inner_h, + padding, options) if title else 0 + art = candidate + text_left = cursor_x + used + if not title or probe_size >= wanted_px or share == 0.0: + break + + if art is not None: + canvas.paste(art, (cursor_x, top + (inner_h - art.height) // 2), + art) + cursor_x = text_left + 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 + # The name comes first and takes what it needs; the meta line gets + # the leftover. Sizing the meta line first (and letting Text size + # inflate it) pushed the part number off the bottom of the label and + # squeezed the name smaller the higher you turned the setting. + room = inner_h - 2 * padding + reserve = min(room * 0.30, inner_h * 0.26) if meta_text else 0 title_font = title_lines = None title_h = 0 + title_px = 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. + # The floor stays put as Text size rises: the setting lifts the + # ceiling, and the fitter takes the largest size the whole name + # fits at. Raising the floor too meant a long name could not + # shrink far enough to wrap and got 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) + draw, title, available, room - reserve, + inner_h * TITLE_MAX_SHARE * options.font_scale, + inner_h * 0.13, bold=True) + + meta_font = meta_px = None + meta_h = 0 + leftover = room - title_h + if meta_text and leftover > mm_to_px(1.2): + meta_font, meta_px = fit_font( + draw, meta_text, available, + min(inner_h * 0.26 * options.font_scale, leftover / 1.15), + min(inner_h * 0.15, leftover / 1.15), bold=False) + meta_h = meta_px * 1.15 block_h = title_h + meta_h y = top + max(padding, (inner_h - block_h) / 2) @@ -679,26 +756,36 @@ def render_label(label, options, upload_folder): return render_group(group, options, upload_folder) -def plan_groups(labels, options): +def plan_groups(labels, options, group_spec=None): """ 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. + + An explicit group_spec from the page wins over auto-grouping - that is + how you combine parts by hand for a custom drawer. """ tape = get_tape(options.tape) - groups = group_by_drawer( - labels, enabled=options.group_drawers and options.is_fixed) + if group_spec: + groups = parse_group_spec(group_spec, labels) + else: + 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) + # Groups the user built by hand are never broken up: asking for these + # parts together outranks the size setting, so the label grows instead. + explicit = bool(group_spec) + plan = [] for group in groups: chunks = [group] - if options.is_fixed and per_label: + if options.is_fixed and per_label and not explicit: chunks = [group[i:i + per_label] for i in range(0, len(group), per_label)] or [[]] @@ -707,7 +794,10 @@ def plan_groups(labels, options): continue if options.is_fixed: width_mm = LABEL_WIDTH_MM - height_mm = label_height_mm(len(chunk), options.size_mode) + mode = options.size_mode + if explicit and per_label and len(chunk) > per_label: + mode = 'auto' + height_mm = label_height_mm(len(chunk), mode) fits = tape.printable_mm >= width_mm else: width_mm = height_mm = None @@ -747,9 +837,9 @@ def image_to_bytes(image, mime_type): return buffer.getvalue() -def render_batch_pdf(labels, options, upload_folder): +def render_batch_pdf(labels, options, upload_folder, group_spec=None): """Every label as one multi-page PDF, one tape label per page.""" - plan = plan_groups(labels, options) + plan = plan_groups(labels, options, group_spec) images = [] for item in plan: for _ in range(options.copies):