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
+466
-62
+466
-62
@@ -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 = Image.open(path).convert('RGBA')
|
|
||||||
box = height - 2 * padding
|
|
||||||
art.thumbnail((box, box), Image.LANCZOS)
|
|
||||||
art_width = art.width + padding
|
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.
|
||||||
"""
|
"""
|
||||||
|
group = labels if isinstance(labels, (list, tuple)) else [labels]
|
||||||
|
|
||||||
|
if options.is_fixed:
|
||||||
|
flat = render_fixed(group, options, upload_folder)
|
||||||
|
else:
|
||||||
saved = options.rotate
|
saved = options.rotate
|
||||||
options.rotate = False
|
options.rotate = False
|
||||||
try:
|
try:
|
||||||
image = render_label(label, options, upload_folder)
|
flat = render_fit(group[0], options, upload_folder)
|
||||||
finally:
|
finally:
|
||||||
options.rotate = saved
|
options.rotate = saved
|
||||||
|
|
||||||
target_height = int(get_tape(options.tape).printable_mm * scale * 4)
|
target_w = max(1, int(px_to_mm(flat.width) * scale))
|
||||||
ratio = target_height / image.height
|
target_h = max(1, int(px_to_mm(flat.height) * scale))
|
||||||
image = image.resize((max(1, int(image.width * ratio)), target_height),
|
preview = flat.resize((target_w, target_h), Image.LANCZOS)
|
||||||
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
|
||||||
Reference in new issue
Block a user