Files
lego-instructions-manager/app/services/dymo_sheet.py
T
jessikittyandClaude Opus 5 7e74ee56ed Gang drawer labels onto Dymo die-cut stock
Stock presets, column packing, 300dpi rendering and a PDF whose page size
is exactly the sticker size. Labels are drawn by tape_printer.render_fixed
so they match the tape-printed ones.

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

371 lines
13 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'
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
monochrome: bool = True # the printer is thermal: no colour at all
start_at: int = 0 # skip this many slots on the first sticker
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
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),
monochrome=flag('monochrome', True),
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, then resampled from
# 313dpi to the LabelWriter's 300. Keeping one renderer means a
# drawer label looks identical whichever printer it came off.
flat = tape_printer.render_fixed(
place['item']['labels'], tape_options, upload_folder)
if flat.size != (box[2], box[3]):
flat = flat.resize((box[2], box[3]), Image.LANCZOS)
canvas.paste(flat, (box[0], box[1]))
if options.monochrome:
# Direct thermal has no colour and no real greys: it is one dot or
# no dot. Converting here means we choose how that looks instead of
# leaving it to the driver.
canvas = canvas.convert('L').convert('RGB')
return canvas
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
buffer = io.BytesIO()
images[0].convert('RGB').save(
buffer, 'PDF', resolution=dpi, save_all=True,
append_images=[i.convert('RGB') for i in 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()
image.resize(target, Image.LANCZOS).save(buffer, 'PNG')
buffer.seek(0)
return buffer