Hand-built label groups, and text that grows when you ask it to

parse_group_spec / spec_from_groups let the page combine any parts onto
one label, and plan_groups never breaks a hand-made group up.

Text size now goes to 3x and means it: the name is sized first, the
image shrinks out of its way, and the floor no longer rises with the
setting - which is what truncated long names at large sizes. The meta
line takes the leftover room instead of pushing itself off the label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vunz4VLxAYDBnU5tLciLDC
This commit is contained in:
jessikittyandClaude Opus 5 committed 2026-10-13 10:30:10 +11:00
1 parent fb59530f0e
commit fbfd66d889
1 file changed
+124 -34
+124 -34
View File
@@ -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,14 +756,20 @@ 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)
if group_spec:
groups = parse_group_spec(group_spec, labels)
else:
groups = group_by_drawer(
labels, enabled=options.group_drawers and options.is_fixed)
@@ -695,10 +778,14 @@ def plan_groups(labels, options):
# 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):