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:
jessikitty committed 2026-10-12 22:42:01 +11:00
1 parent ba11a52909
commit d86a71de67
1 file changed
+472 -68
+472 -68
View File
@@ -12,11 +12,21 @@ VC-500W facts this is built around:
* up to about 17 inches (431mm) per label * up to about 17 inches (431mm) per label
* AirPrint, so it speaks IPP on port 631 * AirPrint, so it speaks IPP on port 631
ORIENTATION: the tape feeds lengthwise, so the raster sent to the printer is THE HOUSE LABEL SIZE
normally rotated 90 degrees - the image is tape-width across and --------------------
label-length tall. That is the default. If your prints come out sideways, Drawer labels are a fixed physical size, not "however long the text is":
untick "Rotate for tape feed"; to make that permanent, turn off the default
orientation in Label Settings. 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) 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))) return max(1, int(round(mm / 25.4 * dpi)))
def px_to_mm(px, dpi=DPI):
return px / dpi * 25.4
@dataclass @dataclass
class TapePreset: class TapePreset:
key: str key: str
@@ -59,29 +73,80 @@ class TapePreset:
TAPE_PRESETS = { TAPE_PRESETS = {
p.key: p for p in [ p.key: p for p in [
TapePreset('cz9', 'CZ tape 9mm', 9.0, 8.0, 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, 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, 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, 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, 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 # ---------------------------------------------------------- label sizing
# dataclass default and the form parser both read it, so they cannot drift.
DEFAULT_TAPE_FIELDS = ['image', 'name', 'dimensions', 'part_number', 'accent']
# Hidden marker every options form submits. Its presence is how we tell LABEL_WIDTH_MM = 40.0
# "submitted with the box unticked" from "arrived here with no options at ONE_PART_HEIGHT_MM = 15.0
# all" - an unticked checkbox sends nothing, so without this they look TWO_PART_HEIGHT_MM = 20.0
# identical and a default-on box can never be turned off. # Three or more part types in one drawer is not a size you asked for, so
FORM_MARKER = '_opts' # 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 = { FONT_CANDIDATES = {
False: [ # regular False: [ # regular
@@ -121,14 +186,30 @@ def load_font(size_px, bold=False):
return font 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 @dataclass
class TapeOptions: class TapeOptions:
tape: str = DEFAULT_TAPE 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( fields: List[str] = field(
default_factory=lambda: list(DEFAULT_TAPE_FIELDS)) 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 rotate: bool = True
copies: int = 1 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 fixed_length_mm: Optional[float] = None
padding_mm: float = 1.5 padding_mm: float = 1.5
font_scale: float = 1.0 font_scale: float = 1.0
@@ -136,12 +217,22 @@ class TapeOptions:
align: str = 'left' # left | center align: str = 'left' # left | center
outline: bool = False # hairline box, for checking alignment outline: bool = False # hairline box, for checking alignment
@property
def is_fixed(self):
return self.size_mode in FIXED_MODES
def get_tape(key): def get_tape(key):
return TAPE_PRESETS.get(key or DEFAULT_TAPE, TAPE_PRESETS[DEFAULT_TAPE]) 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. 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 field the options form always sends: when it is present we know every
checkbox was on screen, so absent means the user unticked it. checkbox was on screen, so absent means the user unticked it.
rotate_default applies only when the form was NOT submitted (a fresh The *_default arguments apply only when the form was NOT submitted (a
arrival), so the saved site-wide preference is honoured there. fresh arrival), so saved site-wide preferences are honoured there
without ever overriding an explicit choice on the page.
""" """
submitted = FORM_MARKER in form submitted = FORM_MARKER in form
@@ -179,8 +271,15 @@ def options_from_form(form, rotate_default=True):
except ValueError: except ValueError:
fixed = None 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( 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, fields=fields,
rotate=flag('rotate', rotate_default), rotate=flag('rotate', rotate_default),
copies=max(1, min(99, number('copies', 1, int))), 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): def _label_lines(label, fields):
"""The text that goes on the tape, as (text, weight) pairs.""" """The text that goes on the tape, as (text, weight) pairs."""
lines = [] lines = []
@@ -232,11 +333,217 @@ def _text_width(draw, text, font):
return draw.textlength(text, font=font) return draw.textlength(text, font=font)
def render_label(label, options, upload_folder): def _fit_text(draw, text, font, available):
""" """Trim with an ellipsis until it fits."""
Render one PartLabel as a PIL image, unrotated (reading left to right). 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) tape = get_tape(options.tape)
height = tape.printable_px height = tape.printable_px
@@ -245,7 +552,6 @@ def render_label(label, options, upload_folder):
fields = set(options.fields) fields = set(options.fields)
lines = _label_lines(label, 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 base = height * 0.34 * options.font_scale
if len(lines) > 2: if len(lines) > 2:
base *= 0.78 base *= 0.78
@@ -260,24 +566,15 @@ def render_label(label, options, upload_folder):
'meta': load_font(sizes['meta'], bold=False), 'meta': load_font(sizes['meta'], bold=False),
} }
# Artwork
art = None art = None
art_width = 0 art_width = 0
if 'image' in fields and label.image and options.image_percent: if 'image' in fields and options.image_percent:
path = os.path.join(upload_folder, label.image.replace('/', os.sep)) art = _load_art(label, upload_folder, height - 2 * padding)
if os.path.exists(path): if art is not None:
try: art_width = art.width + padding
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
accent_width = mm_to_px(1.2) if ('accent' in fields and label.category) else 0 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))) probe = ImageDraw.Draw(Image.new('RGB', (10, 10)))
text_width = 0 text_width = 0
for text, weight in lines: 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) canvas.paste(art, (cursor_x, (height - art.height) // 2), art)
cursor_x += art.width + padding cursor_x += art.width + padding
# Vertically centre the text block
total_text = sum(sizes[w] * 1.22 for _, w in lines) total_text = sum(sizes[w] * 1.22 for _, w in lines)
y = max(padding, (height - total_text) / 2) y = max(padding, (height - total_text) / 2)
available = width - cursor_x - padding available = width - cursor_x - padding
for text, weight in lines: for text, weight in lines:
font = fonts[weight] font = fonts[weight]
shown = text shown = _fit_text(draw, text, font, available)
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'
x = cursor_x x = cursor_x
if options.align == 'center': if options.align == 'center':
x = cursor_x + max(0, (available - _text_width(draw, shown, font)) / 2) x = cursor_x + max(0, (available - _text_width(draw, shown, font)) / 2)
draw.text((x, y), shown, font=font, fill=(0, 0, 0)) draw.text((x, y), shown, font=font, fill=(0, 0, 0))
y += sizes[weight] * 1.22 y += sizes[weight] * 1.22
if options.rotate:
canvas = canvas.rotate(90, expand=True)
return canvas 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): def image_to_bytes(image, mime_type):
"""Serialise a rendered label in whatever format the printer accepts.""" """Serialise a rendered label in whatever format the printer accepts."""
buffer = io.BytesIO() buffer = io.BytesIO()
@@ -352,11 +748,12 @@ def image_to_bytes(image, mime_type):
def render_batch_pdf(labels, options, upload_folder): 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 = [] images = []
for label in labels: for item in plan:
for _ in range(options.copies): for _ in range(options.copies):
images.append(render_label(label, options, upload_folder) images.append(render_group(item['labels'], options, upload_folder)
.convert('RGB')) .convert('RGB'))
if not images: if not images:
return None return None
@@ -367,23 +764,30 @@ def render_batch_pdf(labels, options, upload_folder):
return buffer 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 A screen preview of one physical label, shown flat so it is readable.
set, because a sideways preview is useless to look at.
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 group = labels if isinstance(labels, (list, tuple)) else [labels]
options.rotate = False
try: if options.is_fixed:
image = render_label(label, options, upload_folder) flat = render_fixed(group, options, upload_folder)
finally: else:
options.rotate = saved 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() buffer = io.BytesIO()
image.save(buffer, 'PNG') preview.save(buffer, 'PNG')
buffer.seek(0) buffer.seek(0)
return buffer return buffer