From 7e74ee56ede12134511a661bd92b7949a47a1991 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 13 Oct 2026 11:00:34 +1100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Vunz4VLxAYDBnU5tLciLDC --- app/services/dymo_sheet.py | 370 +++++++++++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 app/services/dymo_sheet.py diff --git a/app/services/dymo_sheet.py b/app/services/dymo_sheet.py new file mode 100644 index 0000000..d691ba2 --- /dev/null +++ b/app/services/dymo_sheet.py @@ -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