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