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
This commit is contained in:
1 parent
6a5a6eafff
commit
7e74ee56ed
1 file changed
+370
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
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
|
||||
Reference in new issue
Block a user