364 lines
12 KiB
Python
364 lines
12 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
|
|
|
|
ORIENTATION: the tape feeds lengthwise, so the raster sent to the printer is
|
|
normally rotated 90 degrees - the image is tape-width across and
|
|
label-length tall. That is the default. If your first test print comes out
|
|
sideways, untick "Rotate for tape feed" and print again; it is the one
|
|
setting worth checking on a new printer.
|
|
|
|
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)))
|
|
|
|
|
|
@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,
|
|
'Tiny. One line, part number only.'),
|
|
TapePreset('cz12', 'CZ tape 12mm', 12.0, 11.0,
|
|
'One bold line, or two small ones.'),
|
|
TapePreset('cz19', 'CZ tape 19mm', 19.0, 17.5,
|
|
'Two comfortable lines. Good for bags.'),
|
|
TapePreset('cz25', 'CZ tape 25mm', 25.0, 23.5,
|
|
'The default roll. Image plus two lines.'),
|
|
TapePreset('cz50', 'CZ tape 50mm', 50.0, 48.0,
|
|
'Wide. Big artwork, drawer-front labels.'),
|
|
]
|
|
}
|
|
|
|
DEFAULT_TAPE = 'cz25'
|
|
|
|
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',
|
|
'/Library/Fonts/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',
|
|
'/Library/Fonts/Arial Bold.ttf',
|
|
],
|
|
}
|
|
_font_cache = {}
|
|
|
|
|
|
def load_font(size_px, bold=False):
|
|
"""Best available sans font at this pixel size. Never raises."""
|
|
key = (int(size_px), bool(bold))
|
|
if key in _font_cache:
|
|
return _font_cache[key]
|
|
for path in FONT_CANDIDATES[bool(bold)]:
|
|
if os.path.exists(path):
|
|
try:
|
|
font = ImageFont.truetype(path, int(size_px))
|
|
_font_cache[key] = font
|
|
return font
|
|
except Exception:
|
|
continue
|
|
# Pillow's built-in bitmap font: ugly at 313dpi but never fails.
|
|
font = ImageFont.load_default()
|
|
_font_cache[key] = font
|
|
return font
|
|
|
|
|
|
@dataclass
|
|
class TapeOptions:
|
|
tape: str = DEFAULT_TAPE
|
|
fields: List[str] = field(default_factory=lambda: [
|
|
'image', 'name', 'dimensions', 'part_number', 'accent'])
|
|
rotate: bool = True
|
|
copies: int = 1
|
|
# Fixed length in mm, or None to size each label to its 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
|
|
|
|
|
|
def get_tape(key):
|
|
return TAPE_PRESETS.get(key or DEFAULT_TAPE, TAPE_PRESETS[DEFAULT_TAPE])
|
|
|
|
|
|
def options_from_form(form):
|
|
"""Build TapeOptions from request.form / request.args."""
|
|
|
|
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 not in form:
|
|
return default
|
|
return str(form.get(key, '')).lower() in ('1', 'true', 'on', 'yes')
|
|
|
|
fields = form.getlist('fields') if hasattr(form, 'getlist') else None
|
|
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
|
|
|
|
return TapeOptions(
|
|
tape=form.get('tape') or DEFAULT_TAPE,
|
|
fields=fields if fields else ['image', 'name', 'dimensions',
|
|
'part_number', 'accent'],
|
|
rotate=flag('rotate', True),
|
|
copies=max(1, min(99, number('copies', 1, int))),
|
|
fixed_length_mm=fixed,
|
|
padding_mm=max(0.0, min(10.0, number('padding_mm', 1.5))),
|
|
font_scale=max(0.5, min(2.5, number('font_scale', 1.0))),
|
|
image_percent=max(0, min(60, number('image_percent', 30, int))),
|
|
align=form.get('align') or 'left',
|
|
outline=flag('outline', False),
|
|
)
|
|
|
|
|
|
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 render_label(label, options, upload_folder):
|
|
"""
|
|
Render one PartLabel as a PIL image, unrotated (reading left to right).
|
|
|
|
Height is the printable tape width; width is the label length.
|
|
"""
|
|
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)
|
|
|
|
# Type sizes scale off tape width so 9mm doesn't get 25mm's typography.
|
|
base = height * 0.34 * options.font_scale
|
|
if len(lines) > 2:
|
|
base *= 0.78
|
|
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),
|
|
}
|
|
|
|
# Artwork
|
|
art = None
|
|
art_width = 0
|
|
if 'image' in fields and label.image and options.image_percent:
|
|
path = os.path.join(upload_folder, label.image.replace('/', os.sep))
|
|
if os.path.exists(path):
|
|
try:
|
|
art = Image.open(path).convert('RGBA')
|
|
box = height - 2 * padding
|
|
art.thumbnail((box, box), Image.LANCZOS)
|
|
art_width = art.width + padding
|
|
except Exception as exc:
|
|
logger.warning('Could not load part image %s: %s', path, exc)
|
|
art = None
|
|
|
|
accent_width = mm_to_px(1.2) if ('accent' in fields and label.category) else 0
|
|
|
|
# Measure to find the natural length
|
|
probe = ImageDraw.Draw(Image.new('RGB', (10, 10)))
|
|
text_width = 0
|
|
for text, weight in lines:
|
|
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
|
|
|
|
# Vertically centre the text block
|
|
total_text = sum(sizes[w] * 1.22 for _, w in lines)
|
|
y = max(padding, (height - total_text) / 2)
|
|
available = width - cursor_x - padding
|
|
|
|
for text, weight in lines:
|
|
font = fonts[weight]
|
|
shown = text
|
|
while (_text_width(draw, shown, font) > available and len(shown) > 2):
|
|
shown = shown[:-2]
|
|
if shown != text and len(shown) > 1:
|
|
shown = shown[:-1] + '\u2026'
|
|
x = cursor_x
|
|
if options.align == 'center':
|
|
x = cursor_x + max(0, (available - _text_width(draw, shown, font)) / 2)
|
|
draw.text((x, y), shown, font=font, fill=(0, 0, 0))
|
|
y += sizes[weight] * 1.22
|
|
|
|
if options.rotate:
|
|
canvas = canvas.rotate(90, expand=True)
|
|
|
|
return canvas
|
|
|
|
|
|
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):
|
|
"""All selected labels as one multi-page PDF, one tape label per page."""
|
|
images = []
|
|
for label in labels:
|
|
for _ in range(options.copies):
|
|
images.append(render_label(label, 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(label, options, upload_folder, scale=2.0):
|
|
"""
|
|
A screen-sized preview. Rendered unrotated however the print option is
|
|
set, because a sideways preview is useless to look at.
|
|
"""
|
|
saved = options.rotate
|
|
options.rotate = False
|
|
try:
|
|
image = render_label(label, options, upload_folder)
|
|
finally:
|
|
options.rotate = saved
|
|
|
|
target_height = int(get_tape(options.tape).printable_mm * scale * 4)
|
|
ratio = target_height / image.height
|
|
image = image.resize((max(1, int(image.width * ratio)), target_height),
|
|
Image.LANCZOS)
|
|
buffer = io.BytesIO()
|
|
image.save(buffer, 'PNG')
|
|
buffer.seek(0)
|
|
return buffer
|