Files
lego-instructions-manager/app/services/dymo_sheet.py
T
jessikittyandClaude Opus 5 e1c8de9367 Draw at 300dpi and print one bit per dot
Two things were costing sharpness on the Dymo. Labels were rendered at
the tape printer's 313dpi and resampled to 300, softening every letter;
they are now drawn at the target resolution. And the PDF embedded a JPEG,
whose ringing around 2mm text the thermal printer turned to mush - the
pages are now bitonal, which Pillow writes as lossless CCITT G4 at about
3KB a page instead of 45KB.

The new Ink setting decides how the page is reduced: threshold (crisp
text), Floyd-Steinberg (photos) or leave it in colour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vunz4VLxAYDBnU5tLciLDC
2026-10-13 11:58:39 +11:00

417 lines
15 KiB
Python

"""
Drawer labels ganged up onto Dymo LabelWriter stock.
The tape printer gives you one label at a time on continuous tape. A Dymo
LabelWriter 4XL is die-cut stock: one fixed rectangle at a time, 101.6 x
54mm for the large shipping label. A 40 x 15mm drawer label is small
enough that several fit on one, so this lays them out in a grid, draws cut
guides between them, and hands back a PDF at exactly the stock size.
Print it at 100% / "Actual size" with the Dymo driver set to the matching
label, cut along the guides, and the stickers come out identical to the
tape-printed ones - same 40 x 15mm house size, same layout code
(tape_printer.render_fixed draws every label here).
LabelWriter facts this is built around:
* 300 dpi, direct thermal, MONOCHROME - no colour, no greys of its own
* 4XL takes stock up to 4 inches (101.6mm) wide
* the printable area is the die-cut label; there are no bleed margins
Created by: Jess Rogerson (yelling commands at Claude.AI)
"""
import io
import logging
from dataclasses import dataclass
from PIL import Image, ImageDraw
from app.services import tape_printer
logger = logging.getLogger(__name__)
# LabelWriters print at 300 dpi. Rendering at the native resolution means
# the driver never has to resample, which is what keeps 8pt type readable.
DPI = 300
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 LabelStock:
key: str
name: str
width_mm: float
height_mm: float
note: str = ''
@property
def area_mm(self):
return self.width_mm * self.height_mm
# Dymo part numbers, with the sizes Dymo publish for them.
STOCKS = {
s.key: s for s in [
LabelStock('dymo_99014', 'Dymo 99014 - 101 x 54mm shipping',
101.6, 54.0,
'The default. Four to six drawer labels per sticker.'),
LabelStock('dymo_30256', 'Dymo 30256 - 59 x 102mm shipping',
59.0, 101.6,
'Portrait. One label across, six or seven down.'),
LabelStock('dymo_1744907', 'Dymo 1744907 - 104 x 159mm (4 x 6in)',
104.0, 159.0,
'4XL extra large. Twelve or more per sticker.'),
LabelStock('dymo_99012', 'Dymo 99012 - 89 x 36mm address',
89.0, 36.0,
'Two drawer labels per sticker.'),
LabelStock('dymo_99010', 'Dymo 99010 - 89 x 28mm address',
89.0, 28.0,
'Two across, one row. Standard size only.'),
]
}
DEFAULT_STOCK = 'dymo_99014'
# Layout defaults in mm. The margin keeps ink off the die-cut edge, where
# feed tolerance means a millimetre either way; the gap is cutting room.
DEFAULT_MARGIN_MM = 2.0
DEFAULT_GAP_MM = 2.0
LAYOUTS = [
('auto', 'Fit as many as possible'),
('2x2', '4 per label (2 across, 2 down)'),
('2x3', '6 per label (2 across, 3 down)'),
('1x1', '1 per label'),
]
DEFAULT_LAYOUT = 'auto'
# How the page is reduced for a direct thermal printer, which has exactly
# two states per dot: burnt or not. Doing it here rather than leaving it to
# the driver is the difference between crisp letters and mush.
INK_MODES = [
('crisp', 'Crisp black and white (best for text)'),
('photo', 'Dithered (better for part photos)'),
('colour', 'Leave in colour'),
]
DEFAULT_INK = 'crisp'
# Anything lighter than this becomes white in crisp mode. High enough to
# keep antialiased letter edges and the grey meta line, low enough that a
# part photo's white background stays white.
INK_THRESHOLD = 186
FORM_MARKER = tape_printer.FORM_MARKER
def get_stock(key):
return STOCKS.get(key or DEFAULT_STOCK, STOCKS[DEFAULT_STOCK])
@dataclass
class SheetOptions:
"""How the drawer labels sit on the stock. What goes ON them is
tape_printer.TapeOptions - this is only the ganging up."""
stock: str = DEFAULT_STOCK
layout: str = DEFAULT_LAYOUT
margin_mm: float = DEFAULT_MARGIN_MM
gap_mm: float = DEFAULT_GAP_MM
cut_guides: bool = True
centre: bool = True
ink: str = 'crisp' # crisp | photo | colour - see INK_MODES
start_at: int = 0 # skip this many slots on the first sticker
@property
def monochrome(self):
return self.ink != 'colour'
def sheet_options_from_form(form, stock_default=None):
"""SheetOptions from request.form / request.args.
Same unticked-checkbox problem as the tape options, same cure: the
hidden FORM_MARKER tells a submitted form from a fresh arrival.
"""
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')
return False if submitted else default
layout = form.get('layout') or DEFAULT_LAYOUT
if layout not in {key for key, _ in LAYOUTS}:
layout = DEFAULT_LAYOUT
ink = form.get('ink') or DEFAULT_INK
if ink not in {key for key, _ in INK_MODES}:
ink = DEFAULT_INK
return SheetOptions(
stock=form.get('stock') or stock_default or DEFAULT_STOCK,
layout=layout,
margin_mm=max(0.0, min(10.0, number('margin_mm', DEFAULT_MARGIN_MM))),
gap_mm=max(0.0, min(10.0, number('gap_mm', DEFAULT_GAP_MM))),
cut_guides=flag('cut_guides', True),
centre=flag('centre', True),
ink=ink,
start_at=max(0, min(50, number('start_at', 0, int))),
)
# ------------------------------------------------------------- packing
def grid_for(stock, options, label_w_mm, label_h_mm):
"""How many columns and rows of this label size fit the stock."""
usable_w = stock.width_mm - 2 * options.margin_mm
usable_h = stock.height_mm - 2 * options.margin_mm
def count(usable, size):
if size <= 0:
return 0
fit = int((usable + options.gap_mm) // (size + options.gap_mm))
return max(0, fit)
if options.layout != 'auto':
cols, rows = (int(v) for v in options.layout.split('x'))
# A forced grid still has to physically fit, or the labels would
# print off the edge of the sticker.
cols = min(cols, max(1, count(usable_w, label_w_mm)))
rows = min(rows, max(1, count(usable_h, label_h_mm)))
return cols, rows
return max(1, count(usable_w, label_w_mm)), max(1, count(usable_h, label_h_mm))
def plan_sheets(plan, options, copies=1):
"""
Turn a tape_printer plan into pages of placed labels.
Labels are stacked down a column and then across, rather than left to
right in rows. On die-cut stock that matters: a 40 x 20mm two-part
label next to a 15mm one would waste the 5mm difference on every row,
and a column fills right to the bottom edge instead. Five labels fit a
99014 that way where rows managed four.
Returns a list of pages, each a list of placements in mm from the top
left of the sticker.
"""
stock = get_stock(options.stock)
usable_w = stock.width_mm - 2 * options.margin_mm
usable_h = stock.height_mm - 2 * options.margin_mm
width = tape_printer.LABEL_WIDTH_MM
columns, row_limit = grid_for(stock, options, width,
tape_printer.label_height_mm(1))
if options.layout == 'auto':
row_limit = None
# Blank slots first, so "start partway down a part-used sticker" skips
# over the peeled-off ones instead of printing on the backing paper.
tallest = max([item.get('height_mm') or tape_printer.ONE_PART_HEIGHT_MM
for item in plan] or [tape_printer.ONE_PART_HEIGHT_MM])
queue = [{'labels': [], 'height_mm': tallest, 'blank': True}
for _ in range(options.start_at)]
for item in plan:
for _ in range(max(1, copies)):
queue.append(item)
pages = []
page = []
column = 0
cursor_y = 0.0
in_column = 0
for item in queue:
height = item.get('height_mm') or tape_printer.ONE_PART_HEIGHT_MM
full = (cursor_y + height > usable_h + 0.001
or (row_limit and in_column >= row_limit))
if full:
column += 1
cursor_y = 0.0
in_column = 0
if column >= columns:
if page:
pages.append(page)
page = []
column = 0
cursor_y = 0.0
in_column = 0
page.append({
'item': item,
'x_mm': options.margin_mm + column * (width + options.gap_mm),
'y_mm': options.margin_mm + cursor_y,
'w_mm': width,
'h_mm': height,
'blank': item.get('blank', False),
})
cursor_y += height + options.gap_mm
in_column += 1
if page:
pages.append(page)
if options.centre:
_centre(pages, stock, options, columns, width)
return pages
def _centre(pages, stock, options, columns, width):
"""Nudge the block of labels into the middle of the sticker.
Measured per page from what actually landed on it, so a half-full
sticker still comes out balanced rather than hard against the margin.
One offset for the whole block, not per column: shifting each column
by its own height would stagger them against each other and the cut
lines would stop lining up.
"""
for page in pages:
if not page:
continue
used_cols = len({round(place['x_mm'], 3) for place in page})
block_w = used_cols * width + (used_cols - 1) * options.gap_mm
block_bottom = max(place['y_mm'] + place['h_mm'] for place in page)
dx = max(0.0, (stock.width_mm - block_w) / 2.0 - options.margin_mm)
dy = max(0.0, (stock.height_mm - (block_bottom - options.margin_mm))
/ 2.0 - options.margin_mm)
for place in page:
place['x_mm'] += dx
place['y_mm'] += dy
def capacity(options, part_count=1):
"""How many labels of this size fit one sticker - for the UI."""
stock = get_stock(options.stock)
height = tape_printer.label_height_mm(part_count)
cols, rows = grid_for(stock, options, tape_printer.LABEL_WIDTH_MM, height)
return cols * rows, cols, rows
# ------------------------------------------------------------ rendering
def render_sheet(placements, options, tape_options, upload_folder, dpi=DPI):
"""One sticker, drawn at the printer's native resolution."""
stock = get_stock(options.stock)
page_w = mm_to_px(stock.width_mm, dpi)
page_h = mm_to_px(stock.height_mm, dpi)
canvas = Image.new('RGB', (page_w, page_h), 'white')
draw = ImageDraw.Draw(canvas)
for place in placements:
box = (mm_to_px(place['x_mm'], dpi), mm_to_px(place['y_mm'], dpi),
mm_to_px(place['w_mm'], dpi), mm_to_px(place['h_mm'], dpi))
if options.cut_guides:
_dashed_box(draw, box, dpi)
if place.get('blank') or not place['item'].get('labels'):
continue
# Drawn by the same code as the tape labels, but at the
# LabelWriter's own resolution: resampling 313dpi artwork to 300
# softens every letter, and a thermal printer has no grey to render
# a soft edge with.
flat = tape_printer.render_fixed(
place['item']['labels'], tape_options, upload_folder, dpi=dpi)
if flat.size != (box[2], box[3]):
flat = flat.resize((box[2], box[3]), Image.LANCZOS)
canvas.paste(flat, (box[0], box[1]))
return reduce_ink(canvas, options.ink)
def reduce_ink(image, ink=DEFAULT_INK):
"""
Take the page down to what a direct thermal printer can actually do.
It has one state per dot: burnt or not. Left in colour or grey, the
driver decides how to halftone it, and halftoned text at this size
comes out as mush - which is exactly what "legible on screen, not on
paper" looks like.
crisp threshold, so letters stay solid black with hard edges
photo Floyd-Steinberg, which keeps photographic shading at the cost
of speckling the type
colour leave it alone and let the driver cope
"""
if ink == 'colour':
return image
grey = image.convert('L')
if ink == 'photo':
return grey.convert('1') # dithered
return grey.point(lambda v: 255 if v >= INK_THRESHOLD else 0, mode='1')
def _dashed_box(draw, box, dpi, dash_mm=1.2, colour=(170, 170, 170)):
"""A dashed rectangle to cut along."""
x, y, w, h = box
step = max(2, mm_to_px(dash_mm, dpi))
width = max(1, mm_to_px(0.15, dpi))
for start in range(x, x + w, step * 2):
end = min(start + step, x + w)
draw.line([(start, y), (end, y)], fill=colour, width=width)
draw.line([(start, y + h), (end, y + h)], fill=colour, width=width)
for start in range(y, y + h, step * 2):
end = min(start + step, y + h)
draw.line([(x, start), (x, end)], fill=colour, width=width)
draw.line([(x + w, start), (x + w, end)], fill=colour, width=width)
def render_pages(plan, options, tape_options, upload_folder, copies=1,
dpi=DPI):
"""Every sticker a print run needs, as images."""
pages = plan_sheets(plan, options, copies=copies)
return [render_sheet(page, options, tape_options, upload_folder, dpi)
for page in pages]
def render_pdf(plan, options, tape_options, upload_folder, copies=1, dpi=DPI):
"""
The print-ready PDF: one page per sticker, page size exactly the stock
size so the Dymo driver has nothing to scale.
"""
images = render_pages(plan, options, tape_options, upload_folder,
copies=copies, dpi=dpi)
if not images:
return None
# Mode '1' pages go into the PDF as lossless CCITT G4. Converting to RGB
# first would hand Pillow a JPEG instead, and JPEG ringing around 2mm
# letters is what made the first printed sheets unreadable.
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(image, scale=7.0):
"""A screen-size copy of one sticker.
7 px per mm puts a 101mm sticker at about 710px - wide enough to read
the part names in the preview, which is the point of having one.
"""
target = (max(1, int(px_to_mm(image.width) * scale)),
max(1, int(px_to_mm(image.height) * scale)))
buffer = io.BytesIO()
# A 1-bit page has to go back to greyscale before it is scaled down, or
# the browser shows a moire of the dot pattern rather than the label.
image.convert('L').resize(target, Image.LANCZOS).save(buffer, 'PNG')
buffer.seek(0)
return buffer