From e1c8de93670e2d4e392412c1fb4837474d8a2366 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 13 Oct 2026 11:58:39 +1100 Subject: [PATCH] Draw at 300dpi and print one bit per dot Two things were costing sharpness on the Dymo. Labels were rendered at the tape printer's 313dpi and resampled to 300, softening every letter; they are now drawn at the target resolution. And the PDF embedded a JPEG, whose ringing around 2mm text the thermal printer turned to mush - the pages are now bitonal, which Pillow writes as lossless CCITT G4 at about 3KB a page instead of 45KB. The new Ink setting decides how the page is reduced: threshold (crisp text), Floyd-Steinberg (photos) or leave it in colour. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Vunz4VLxAYDBnU5tLciLDC --- app/services/dymo_sheet.py | 78 ++++++++++++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/app/services/dymo_sheet.py b/app/services/dymo_sheet.py index d691ba2..6b11b82 100644 --- a/app/services/dymo_sheet.py +++ b/app/services/dymo_sheet.py @@ -92,6 +92,21 @@ LAYOUTS = [ ] 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 @@ -109,9 +124,13 @@ class SheetOptions: gap_mm: float = DEFAULT_GAP_MM cut_guides: bool = True centre: bool = True - monochrome: bool = True # the printer is thermal: no colour at all + 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. @@ -136,6 +155,10 @@ def sheet_options_from_form(form, stock_default=None): 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, @@ -143,7 +166,7 @@ def sheet_options_from_form(form, stock_default=None): 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), + ink=ink, start_at=max(0, min(50, number('start_at', 0, int))), ) @@ -298,21 +321,39 @@ def render_sheet(placements, options, tape_options, upload_folder, dpi=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. + # 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) + 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])) - 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 + 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)): @@ -348,10 +389,13 @@ def render_pdf(plan, options, tape_options, upload_folder, copies=1, dpi=DPI): 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].convert('RGB').save( - buffer, 'PDF', resolution=dpi, save_all=True, - append_images=[i.convert('RGB') for i in images[1:]]) + images[0].save(buffer, 'PDF', resolution=dpi, save_all=True, + append_images=images[1:]) buffer.seek(0) return buffer @@ -365,6 +409,8 @@ def preview_png(image, scale=7.0): 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') + # 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