This is why printed labels were illegible. Pillow uses a built-in bitmap font when it cannot load a TrueType one, and that font ignores the size it is asked for - at 300dpi it draws letters under a millimetre tall, which no Text size setting could fix. python:3.12-slim ships no fonts at all. load_font now tries LABEL_FONT, a wider list of system paths, a scan of the font directories, matplotlib's bundled DejaVu, and finally Pillow's scalable default. font_report() says which one won, so the pages can warn instead of quietly printing micro-text. render_fixed also takes a dpi, so the Dymo path draws at 300 rather than being resampled from 313. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vunz4VLxAYDBnU5tLciLDC
1022 lines
37 KiB
Python
1022 lines
37 KiB
Python
"""
|
|
Continuous-tape label rendering for the Brother VC-500W.
|
|
|
|
Sheet labels (Avery etc.) are laid out in label_render.py as an HTML page.
|
|
A tape printer is a different animal: one label at a time, fixed width, and
|
|
the length is however long the content needs to be. So these are rendered as
|
|
images at the printer's native resolution and sent to it over IPP.
|
|
|
|
VC-500W facts this is built around:
|
|
* 313 dpi, full colour, ZINK (no ink)
|
|
* CZ tape in 9, 12, 19, 25 and 50mm widths
|
|
* up to about 17 inches (431mm) per label
|
|
* AirPrint, so it speaks IPP on port 631
|
|
|
|
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)
|
|
"""
|
|
|
|
import io
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from typing import List, Optional
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DPI = 313
|
|
MAX_LENGTH_MM = 430.0
|
|
MIN_LENGTH_MM = 12.0
|
|
|
|
|
|
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
|
|
name: str
|
|
tape_mm: float
|
|
printable_mm: float
|
|
note: str = ''
|
|
|
|
@property
|
|
def printable_px(self):
|
|
return mm_to_px(self.printable_mm)
|
|
|
|
|
|
# Brother CZ tape widths. printable_mm leaves a hair of margin so a slightly
|
|
# misaligned cassette doesn't clip the text; raise it toward tape_mm from
|
|
# the settings page if you want true edge to edge.
|
|
TAPE_PRESETS = {
|
|
p.key: p for p in [
|
|
TapePreset('cz9', 'CZ tape 9mm', 9.0, 8.0,
|
|
'Too narrow for the 40mm house label.'),
|
|
TapePreset('cz12', 'CZ tape 12mm', 12.0, 11.0,
|
|
'Too narrow for the 40mm house label.'),
|
|
TapePreset('cz19', 'CZ tape 19mm', 19.0, 17.5,
|
|
'Too narrow for the 40mm house label.'),
|
|
TapePreset('cz25', 'CZ tape 25mm', 25.0, 23.5,
|
|
'Too narrow for the 40mm house label.'),
|
|
TapePreset('cz50', 'CZ tape 50mm', 50.0, 48.0,
|
|
'The one to use. Fits the 40mm label across the tape.'),
|
|
]
|
|
}
|
|
|
|
DEFAULT_TAPE = 'cz50'
|
|
|
|
# ---------------------------------------------------------- label sizing
|
|
|
|
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')
|
|
|
|
# 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."""
|
|
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 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.
|
|
|
|
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
|
|
|
|
# Finding a real font matters more than anything else on these labels.
|
|
# Pillow falls back to a 10-pixel bitmap font when it can't load a TrueType
|
|
# one, and that font ignores the size you ask for: at 300dpi it prints
|
|
# letters under a millimetre tall. A slim container image ships no fonts at
|
|
# all, so "my labels are illegible" is almost always this.
|
|
FONT_CANDIDATES = {
|
|
False: [ # regular
|
|
'C:\\Windows\\Fonts\\segoeui.ttf',
|
|
'C:\\Windows\\Fonts\\arial.ttf',
|
|
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
|
'/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf',
|
|
'/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf',
|
|
'/usr/share/fonts/TTF/DejaVuSans.ttf',
|
|
'/usr/share/fonts/dejavu/DejaVuSans.ttf',
|
|
'/usr/share/fonts/liberation-sans/LiberationSans-Regular.ttf',
|
|
'/usr/share/fonts/truetype/freefont/FreeSans.ttf',
|
|
'/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf',
|
|
'/Library/Fonts/Arial.ttf',
|
|
'/System/Library/Fonts/Helvetica.ttc',
|
|
'/System/Library/Fonts/Supplemental/Arial.ttf',
|
|
],
|
|
True: [ # bold
|
|
'C:\\Windows\\Fonts\\segoeuib.ttf',
|
|
'C:\\Windows\\Fonts\\arialbd.ttf',
|
|
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
|
|
'/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
|
|
'/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf',
|
|
'/usr/share/fonts/TTF/DejaVuSans-Bold.ttf',
|
|
'/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf',
|
|
'/usr/share/fonts/liberation-sans/LiberationSans-Bold.ttf',
|
|
'/usr/share/fonts/truetype/freefont/FreeSansBold.ttf',
|
|
'/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf',
|
|
'/Library/Fonts/Arial Bold.ttf',
|
|
'/System/Library/Fonts/Supplemental/Arial Bold.ttf',
|
|
],
|
|
}
|
|
|
|
# Anything with these in the filename will do if the named paths miss.
|
|
FONT_HINTS = {
|
|
False: ('dejavusans', 'liberationsans', 'arial', 'helvetica', 'notosans',
|
|
'roboto', 'freesans', 'opensans'),
|
|
True: ('dejavusans-bold', 'liberationsans-bold', 'arialbd', 'arial bold',
|
|
'notosans-bold', 'roboto-bold', 'freesansbold'),
|
|
}
|
|
|
|
FONT_DIRS = [
|
|
'/usr/share/fonts', '/usr/local/share/fonts', '/usr/share/texmf/fonts',
|
|
os.path.expanduser('~/.fonts'), os.path.expanduser('~/.local/share/fonts'),
|
|
'C:\\Windows\\Fonts', '/Library/Fonts', '/System/Library/Fonts',
|
|
]
|
|
|
|
# Set LABEL_FONT / LABEL_FONT_BOLD to a .ttf to override everything below.
|
|
FONT_ENV = {False: 'LABEL_FONT', True: 'LABEL_FONT_BOLD'}
|
|
|
|
_font_cache = {}
|
|
_font_files = {}
|
|
|
|
|
|
def _scan_font_dirs(bold):
|
|
"""Hunt the usual font directories for something sans-serif."""
|
|
hints = FONT_HINTS[bool(bold)]
|
|
for directory in FONT_DIRS:
|
|
if not os.path.isdir(directory):
|
|
continue
|
|
for root, _dirs, files in os.walk(directory):
|
|
for name in files:
|
|
if not name.lower().endswith(('.ttf', '.otf', '.ttc')):
|
|
continue
|
|
stem = name.lower().replace('_', '').replace('-', '')
|
|
plain = name.lower()
|
|
for hint in hints:
|
|
needle = hint.replace('-', '').replace(' ', '')
|
|
if needle in stem or hint in plain:
|
|
# Don't hand back a bold face when a regular was asked
|
|
# for, or every line on the label comes out heavy.
|
|
heavy = 'bold' in plain or plain.endswith('bd.ttf')
|
|
if heavy == bool(bold):
|
|
return os.path.join(root, name)
|
|
return None
|
|
|
|
|
|
def _matplotlib_font(bold):
|
|
"""matplotlib bundles DejaVu. If it is installed, borrow it."""
|
|
try:
|
|
import matplotlib
|
|
except Exception:
|
|
return None
|
|
path = os.path.join(os.path.dirname(matplotlib.__file__), 'mpl-data',
|
|
'fonts', 'ttf',
|
|
'DejaVuSans-Bold.ttf' if bold else 'DejaVuSans.ttf')
|
|
return path if os.path.exists(path) else None
|
|
|
|
|
|
def find_font_file(bold=False):
|
|
"""The best scalable font on this machine, or None. Cached."""
|
|
key = bool(bold)
|
|
if key in _font_files:
|
|
return _font_files[key]
|
|
|
|
found = None
|
|
override = os.environ.get(FONT_ENV[key]) or os.environ.get(FONT_ENV[False])
|
|
for candidate in ([override] if override else []) + FONT_CANDIDATES[key]:
|
|
if candidate and os.path.exists(candidate):
|
|
found = candidate
|
|
break
|
|
if not found:
|
|
found = _scan_font_dirs(key) or _matplotlib_font(key)
|
|
|
|
_font_files[key] = found
|
|
return found
|
|
|
|
|
|
def font_report():
|
|
"""
|
|
What the labels will actually be drawn with - for the settings page.
|
|
|
|
'bitmap' is the bad one: Pillow's built-in font ignores the requested
|
|
size and draws about 10 pixels tall whatever you ask, which at 300dpi is
|
|
letters under a millimetre high. If that is what you have, labels will
|
|
be unreadable no matter what Text size says.
|
|
"""
|
|
path = find_font_file(False)
|
|
if path:
|
|
return {'kind': 'system', 'name': os.path.basename(path),
|
|
'path': path, 'scalable': True}
|
|
try:
|
|
ImageFont.load_default(size=24)
|
|
return {'kind': 'pillow', 'name': "Pillow's built-in scalable font",
|
|
'path': None, 'scalable': True}
|
|
except TypeError:
|
|
return {'kind': 'bitmap', 'name': "Pillow's 10px bitmap font",
|
|
'path': None, 'scalable': False}
|
|
|
|
|
|
def load_font(size_px, bold=False):
|
|
"""
|
|
Best available sans font at this pixel size. Never raises.
|
|
|
|
Order: a path from LABEL_FONT, a known system font, anything sans-serif
|
|
found in the font directories, matplotlib's bundled DejaVu, and finally
|
|
Pillow's own font - asking for a size first, because older Pillow gives
|
|
a fixed bitmap font that would print sub-millimetre text.
|
|
"""
|
|
key = (int(size_px), bool(bold))
|
|
if key in _font_cache:
|
|
return _font_cache[key]
|
|
|
|
path = find_font_file(bold)
|
|
if path:
|
|
try:
|
|
font = ImageFont.truetype(path, int(size_px))
|
|
_font_cache[key] = font
|
|
return font
|
|
except Exception:
|
|
logger.warning('Could not load font %s', path)
|
|
_font_files[bool(bold)] = None
|
|
|
|
try:
|
|
font = ImageFont.load_default(size=int(size_px)) # Pillow >= 10.1
|
|
except TypeError:
|
|
logger.warning(
|
|
'No scalable font found: labels will be drawn with Pillow\'s '
|
|
'bitmap font and will print far too small. Install '
|
|
'fonts-dejavu-core, or set LABEL_FONT to a .ttf file.')
|
|
font = ImageFont.load_default()
|
|
_font_cache[key] = 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
|
|
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
|
|
# '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
|
|
image_percent: int = 30
|
|
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 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.
|
|
|
|
An unticked checkbox is simply absent from a form submission - the
|
|
browser sends nothing for it. So "rotate unticked" and "rotate never
|
|
mentioned" arrive here looking exactly the same, and a box that
|
|
defaults to on could never be turned off. FORM_MARKER is a hidden
|
|
field the options form always sends: when it is present we know every
|
|
checkbox was on screen, so absent means the user unticked it.
|
|
|
|
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
|
|
|
|
def number(key, default, cast=float):
|
|
try:
|
|
return cast(form.get(key, default))
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
def flag(key, default=False):
|
|
if key in form:
|
|
return str(form.get(key, '')).lower() in ('1', 'true', 'on', 'yes')
|
|
# Submitted without it: the user unticked it. Not submitted: default.
|
|
return False if submitted else default
|
|
|
|
fields = list(form.getlist('fields')) if hasattr(form, 'getlist') else []
|
|
if not fields and not submitted:
|
|
fields = list(DEFAULT_TAPE_FIELDS)
|
|
|
|
length = (form.get('fixed_length_mm') or '').strip() if hasattr(form, 'get') else ''
|
|
try:
|
|
fixed = float(length) if length else None
|
|
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 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))),
|
|
fixed_length_mm=fixed,
|
|
padding_mm=max(0.0, min(10.0, number('padding_mm', 1.5))),
|
|
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),
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------ text pieces
|
|
|
|
def _label_lines(label, fields):
|
|
"""The text that goes on the tape, as (text, weight) pairs."""
|
|
lines = []
|
|
if 'name' in fields:
|
|
title = (label.display_title if 'dimensions' in fields
|
|
else label.name)
|
|
if title:
|
|
lines.append((title, 'title'))
|
|
elif 'dimensions' in fields and label.dimensions:
|
|
lines.append((label.dimensions, 'title'))
|
|
|
|
if 'colour' in fields and label.colour_name:
|
|
lines.append((label.colour_name, 'sub'))
|
|
if 'category' in fields and label.category:
|
|
lines.append((label.category.full_name, 'sub'))
|
|
|
|
meta = []
|
|
if 'part_number' in fields and label.part_number:
|
|
meta.append(label.part_number)
|
|
if 'alt_numbers' in fields and label.alt_part_numbers:
|
|
meta.append(label.alt_part_numbers)
|
|
if 'category_code' in fields and label.category and label.category.display_code:
|
|
meta.append(label.category.display_code)
|
|
if meta:
|
|
lines.append((' \u00b7 '.join(meta), 'meta'))
|
|
|
|
if 'location' in fields and label.location:
|
|
lines.append((label.location, 'meta'))
|
|
return lines
|
|
|
|
|
|
def _text_width(draw, text, font):
|
|
try:
|
|
box = draw.textbbox((0, 0), text, font=font)
|
|
return box[2] - box[0]
|
|
except Exception:
|
|
return draw.textlength(text, font=font)
|
|
|
|
|
|
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
|
|
|
|
|
|
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 _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):
|
|
"""
|
|
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, dpi=DPI):
|
|
"""
|
|
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.
|
|
|
|
dpi defaults to the tape printer's 313. The Dymo path passes 300 so the
|
|
label is drawn at that printer's native resolution instead of being
|
|
resampled afterwards, which softens every letter.
|
|
"""
|
|
def px(mm):
|
|
return mm_to_px(mm, dpi)
|
|
|
|
# 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 = px(LABEL_WIDTH_MM)
|
|
height_mm = label_height_mm(len(labels), options.size_mode)
|
|
height_px = 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, 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 = 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, px(0.15)))
|
|
|
|
cursor_x = padding
|
|
if 'accent' in fields and label.category:
|
|
accent_w = 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
|
|
|
|
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 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 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, 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 > 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)
|
|
|
|
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
|
|
padding = mm_to_px(options.padding_mm)
|
|
|
|
fields = set(options.fields)
|
|
lines = _label_lines(label, fields)
|
|
|
|
base = height * 0.34 * options.font_scale
|
|
if len(lines) > 2:
|
|
base *= 0.78
|
|
sizes = {
|
|
'title': max(8, int(base)),
|
|
'sub': max(7, int(base * 0.72)),
|
|
'meta': max(6, int(base * 0.62)),
|
|
}
|
|
fonts = {
|
|
'title': load_font(sizes['title'], bold=True),
|
|
'sub': load_font(sizes['sub'], bold=False),
|
|
'meta': load_font(sizes['meta'], bold=False),
|
|
}
|
|
|
|
art = None
|
|
art_width = 0
|
|
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
|
|
|
|
probe = ImageDraw.Draw(Image.new('RGB', (10, 10)))
|
|
text_width = 0
|
|
for text, weight in lines:
|
|
text_width = max(text_width, _text_width(probe, text, fonts[weight]))
|
|
|
|
natural = accent_width + padding + art_width + text_width + 2 * padding
|
|
if options.fixed_length_mm:
|
|
width = mm_to_px(min(MAX_LENGTH_MM, max(MIN_LENGTH_MM,
|
|
options.fixed_length_mm)))
|
|
else:
|
|
width = max(mm_to_px(MIN_LENGTH_MM),
|
|
min(mm_to_px(MAX_LENGTH_MM), int(natural)))
|
|
|
|
canvas = Image.new('RGB', (width, height), 'white')
|
|
draw = ImageDraw.Draw(canvas)
|
|
|
|
if options.outline:
|
|
draw.rectangle([0, 0, width - 1, height - 1], outline=(190, 190, 190),
|
|
width=max(1, mm_to_px(0.2)))
|
|
|
|
cursor_x = 0
|
|
if accent_width:
|
|
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([0, 0, accent_width, height], fill=rgb)
|
|
cursor_x = accent_width + padding
|
|
|
|
if art is not None:
|
|
canvas.paste(art, (cursor_x, (height - art.height) // 2), art)
|
|
cursor_x += art.width + padding
|
|
|
|
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 = _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
|
|
|
|
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, 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)
|
|
|
|
# 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)
|
|
|
|
# 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 and not explicit:
|
|
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
|
|
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
|
|
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()
|
|
if mime_type == 'image/jpeg':
|
|
image.convert('RGB').save(buffer, 'JPEG', quality=95, dpi=(DPI, DPI))
|
|
elif mime_type == 'image/png':
|
|
image.save(buffer, 'PNG', dpi=(DPI, DPI))
|
|
elif mime_type == 'application/pdf':
|
|
image.convert('RGB').save(buffer, 'PDF', resolution=DPI)
|
|
else:
|
|
raise ValueError(f'Cannot produce {mime_type}')
|
|
buffer.seek(0)
|
|
return buffer.getvalue()
|
|
|
|
|
|
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, group_spec)
|
|
images = []
|
|
for item in plan:
|
|
for _ in range(options.copies):
|
|
images.append(render_group(item['labels'], options, upload_folder)
|
|
.convert('RGB'))
|
|
if not images:
|
|
return None
|
|
buffer = io.BytesIO()
|
|
images[0].save(buffer, 'PDF', resolution=DPI, save_all=True,
|
|
append_images=images[1:])
|
|
buffer.seek(0)
|
|
return buffer
|
|
|
|
|
|
def preview_png(labels, options, upload_folder, scale=11.0):
|
|
"""
|
|
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.
|
|
"""
|
|
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)
|
|
|
|
buffer = io.BytesIO()
|
|
preview.save(buffer, 'PNG')
|
|
buffer.seek(0)
|
|
return buffer
|