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
371 lines
13 KiB
Python
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
|