""" 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