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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vunz4VLxAYDBnU5tLciLDC
This commit is contained in:
jessikittyandClaude Opus 5 committed 2026-10-13 11:58:39 +11:00
1 parent 4132e1031b
commit e1c8de9367
1 file changed
+62 -16
+62 -16
View File
@@ -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