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:
1 parent
fb59530f0e
commit
fbfd66d889
1 file changed
+124
-34
+124
-34
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user