Add 313dpi continuous-tape rendering for the VC-500W
This commit is contained in:
1 parent
832ab97d08
commit
7d32ad8fd6
1 file changed
+363
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
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
|
||||
Reference in new issue
Block a user