From 4c05763a35c82ff5578e6ce4a13300d83367c0c1 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 13 Oct 2026 12:01:19 +1100 Subject: [PATCH] Never silently fall back to a 10-pixel font This is why printed labels were illegible. Pillow uses a built-in bitmap font when it cannot load a TrueType one, and that font ignores the size it is asked for - at 300dpi it draws letters under a millimetre tall, which no Text size setting could fix. python:3.12-slim ships no fonts at all. load_font now tries LABEL_FONT, a wider list of system paths, a scan of the font directories, matplotlib's bundled DejaVu, and finally Pillow's scalable default. font_report() says which one won, so the pages can warn instead of quietly printing micro-text. render_fixed also takes a dpi, so the Dymo path draws at 300 rather than being resampled from 313. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Vunz4VLxAYDBnU5tLciLDC --- app/services/tape_printer.py | 176 +++++++++++++++++++++++++++++++---- 1 file changed, 157 insertions(+), 19 deletions(-) diff --git a/app/services/tape_printer.py b/app/services/tape_printer.py index eab0153..3771fb0 100644 --- a/app/services/tape_printer.py +++ b/app/services/tape_printer.py @@ -189,40 +189,171 @@ def group_by_drawer(labels, enabled=True): # ------------------------------------------------------------------ fonts +# Finding a real font matters more than anything else on these labels. +# Pillow falls back to a 10-pixel bitmap font when it can't load a TrueType +# one, and that font ignores the size you ask for: at 300dpi it prints +# letters under a millimetre tall. A slim container image ships no fonts at +# all, so "my labels are illegible" is almost always this. FONT_CANDIDATES = { False: [ # regular 'C:\\Windows\\Fonts\\segoeui.ttf', 'C:\\Windows\\Fonts\\arial.ttf', '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf', + '/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf', + '/usr/share/fonts/TTF/DejaVuSans.ttf', + '/usr/share/fonts/dejavu/DejaVuSans.ttf', + '/usr/share/fonts/liberation-sans/LiberationSans-Regular.ttf', + '/usr/share/fonts/truetype/freefont/FreeSans.ttf', + '/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf', '/Library/Fonts/Arial.ttf', + '/System/Library/Fonts/Helvetica.ttc', + '/System/Library/Fonts/Supplemental/Arial.ttf', ], True: [ # bold 'C:\\Windows\\Fonts\\segoeuib.ttf', 'C:\\Windows\\Fonts\\arialbd.ttf', '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf', + '/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf', + '/usr/share/fonts/TTF/DejaVuSans-Bold.ttf', + '/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf', + '/usr/share/fonts/liberation-sans/LiberationSans-Bold.ttf', + '/usr/share/fonts/truetype/freefont/FreeSansBold.ttf', + '/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf', '/Library/Fonts/Arial Bold.ttf', + '/System/Library/Fonts/Supplemental/Arial Bold.ttf', ], } + +# Anything with these in the filename will do if the named paths miss. +FONT_HINTS = { + False: ('dejavusans', 'liberationsans', 'arial', 'helvetica', 'notosans', + 'roboto', 'freesans', 'opensans'), + True: ('dejavusans-bold', 'liberationsans-bold', 'arialbd', 'arial bold', + 'notosans-bold', 'roboto-bold', 'freesansbold'), +} + +FONT_DIRS = [ + '/usr/share/fonts', '/usr/local/share/fonts', '/usr/share/texmf/fonts', + os.path.expanduser('~/.fonts'), os.path.expanduser('~/.local/share/fonts'), + 'C:\\Windows\\Fonts', '/Library/Fonts', '/System/Library/Fonts', +] + +# Set LABEL_FONT / LABEL_FONT_BOLD to a .ttf to override everything below. +FONT_ENV = {False: 'LABEL_FONT', True: 'LABEL_FONT_BOLD'} + _font_cache = {} +_font_files = {} + + +def _scan_font_dirs(bold): + """Hunt the usual font directories for something sans-serif.""" + hints = FONT_HINTS[bool(bold)] + for directory in FONT_DIRS: + if not os.path.isdir(directory): + continue + for root, _dirs, files in os.walk(directory): + for name in files: + if not name.lower().endswith(('.ttf', '.otf', '.ttc')): + continue + stem = name.lower().replace('_', '').replace('-', '') + plain = name.lower() + for hint in hints: + needle = hint.replace('-', '').replace(' ', '') + if needle in stem or hint in plain: + # Don't hand back a bold face when a regular was asked + # for, or every line on the label comes out heavy. + heavy = 'bold' in plain or plain.endswith('bd.ttf') + if heavy == bool(bold): + return os.path.join(root, name) + return None + + +def _matplotlib_font(bold): + """matplotlib bundles DejaVu. If it is installed, borrow it.""" + try: + import matplotlib + except Exception: + return None + path = os.path.join(os.path.dirname(matplotlib.__file__), 'mpl-data', + 'fonts', 'ttf', + 'DejaVuSans-Bold.ttf' if bold else 'DejaVuSans.ttf') + return path if os.path.exists(path) else None + + +def find_font_file(bold=False): + """The best scalable font on this machine, or None. Cached.""" + key = bool(bold) + if key in _font_files: + return _font_files[key] + + found = None + override = os.environ.get(FONT_ENV[key]) or os.environ.get(FONT_ENV[False]) + for candidate in ([override] if override else []) + FONT_CANDIDATES[key]: + if candidate and os.path.exists(candidate): + found = candidate + break + if not found: + found = _scan_font_dirs(key) or _matplotlib_font(key) + + _font_files[key] = found + return found + + +def font_report(): + """ + What the labels will actually be drawn with - for the settings page. + + 'bitmap' is the bad one: Pillow's built-in font ignores the requested + size and draws about 10 pixels tall whatever you ask, which at 300dpi is + letters under a millimetre high. If that is what you have, labels will + be unreadable no matter what Text size says. + """ + path = find_font_file(False) + if path: + return {'kind': 'system', 'name': os.path.basename(path), + 'path': path, 'scalable': True} + try: + ImageFont.load_default(size=24) + return {'kind': 'pillow', 'name': "Pillow's built-in scalable font", + 'path': None, 'scalable': True} + except TypeError: + return {'kind': 'bitmap', 'name': "Pillow's 10px bitmap font", + 'path': None, 'scalable': False} def load_font(size_px, bold=False): - """Best available sans font at this pixel size. Never raises.""" + """ + Best available sans font at this pixel size. Never raises. + + Order: a path from LABEL_FONT, a known system font, anything sans-serif + found in the font directories, matplotlib's bundled DejaVu, and finally + Pillow's own font - asking for a size first, because older Pillow gives + a fixed bitmap font that would print sub-millimetre text. + """ key = (int(size_px), bool(bold)) if key in _font_cache: return _font_cache[key] - for path in FONT_CANDIDATES[bool(bold)]: - if os.path.exists(path): - try: - font = ImageFont.truetype(path, int(size_px)) - _font_cache[key] = font - return font - except Exception: - continue - # Pillow's built-in bitmap font: ugly at 313dpi but never fails. - font = ImageFont.load_default() + + path = find_font_file(bold) + if path: + try: + font = ImageFont.truetype(path, int(size_px)) + _font_cache[key] = font + return font + except Exception: + logger.warning('Could not load font %s', path) + _font_files[bool(bold)] = None + + try: + font = ImageFont.load_default(size=int(size_px)) # Pillow >= 10.1 + except TypeError: + logger.warning( + 'No scalable font found: labels will be drawn with Pillow\'s ' + 'bitmap font and will print far too small. Install ' + 'fonts-dejavu-core, or set LABEL_FONT to a .ttf file.') + font = ImageFont.load_default() _font_cache[key] = font return font @@ -474,34 +605,41 @@ def _load_art(label, upload_folder, box_px): # -------------------------------------------------------- fixed-size draw -def render_fixed(labels, options, upload_folder): +def render_fixed(labels, options, upload_folder, dpi=DPI): """ One drawer label at the house size, drawn flat and reading normally: 40mm wide, 15mm tall for one part, 20mm for two, taller beyond that. `labels` is every part type in that drawer - one row each. + + dpi defaults to the tape printer's 313. The Dymo path passes 300 so the + label is drawn at that printer's native resolution instead of being + resampled afterwards, which softens every letter. """ + def px(mm): + return mm_to_px(mm, dpi) + # Whatever is handed in gets drawn. Deciding how many parts belong on # one label is plan_groups' job - doing it here too once meant a part # was quietly dropped instead of getting a label of its own. labels = list(labels) or [] rows = max(1, len(labels)) - width_px = mm_to_px(LABEL_WIDTH_MM) + width_px = px(LABEL_WIDTH_MM) height_mm = label_height_mm(len(labels), options.size_mode) - height_px = mm_to_px(height_mm) + height_px = px(height_mm) canvas = Image.new('RGB', (width_px, height_px), 'white') draw = ImageDraw.Draw(canvas) if options.outline: draw.rectangle([0, 0, width_px - 1, height_px - 1], - outline=(190, 190, 190), width=max(1, mm_to_px(0.2))) + outline=(190, 190, 190), width=max(1, px(0.2))) fields = set(options.fields) # Deliberately tighter than the fit-tape padding: 1.5mm top and bottom # would eat a third of a two-part label's 10mm row. - padding = mm_to_px(min(options.padding_mm, 0.8)) + padding = px(min(options.padding_mm, 0.8)) row_h = height_px / rows for index, label in enumerate(labels): @@ -512,11 +650,11 @@ def render_fixed(labels, options, upload_folder): # A hairline between parts so the two halves read as separate. if index: draw.line([(padding, top), (width_px - padding, top)], - fill=(205, 205, 205), width=max(1, mm_to_px(0.15))) + fill=(205, 205, 205), width=max(1, px(0.15))) cursor_x = padding if 'accent' in fields and label.category: - accent_w = mm_to_px(1.2) + accent_w = px(1.2) colour = label.category.accent_colour() try: rgb = tuple(int(colour.lstrip('#')[i:i + 2], 16) @@ -586,7 +724,7 @@ def render_fixed(labels, options, upload_folder): meta_font = meta_px = None meta_h = 0 leftover = room - title_h - if meta_text and leftover > mm_to_px(1.2): + if meta_text and leftover > px(1.2): meta_font, meta_px = fit_font( draw, meta_text, available, min(inner_h * 0.26 * options.font_scale, leftover / 1.15),