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.
This commit is contained in:
1 parent
ba11a52909
commit
d86a71de67
1 file changed
+472
-68
+472
-68
@@ -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
|
||||
Reference in new issue
Block a user