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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vunz4VLxAYDBnU5tLciLDC
This commit is contained in:
jessikittyandClaude Opus 5 committed 2026-10-13 12:01:19 +11:00
1 parent e1c8de9367
commit 4c05763a35
1 file changed
+157 -19
+157 -19
View File
@@ -189,40 +189,171 @@ def group_by_drawer(labels, enabled=True):
# ------------------------------------------------------------------ fonts # ------------------------------------------------------------------ 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 = { FONT_CANDIDATES = {
False: [ # regular False: [ # regular
'C:\\Windows\\Fonts\\segoeui.ttf', 'C:\\Windows\\Fonts\\segoeui.ttf',
'C:\\Windows\\Fonts\\arial.ttf', 'C:\\Windows\\Fonts\\arial.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
'/usr/share/fonts/truetype/liberation/LiberationSans-Regular.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', '/Library/Fonts/Arial.ttf',
'/System/Library/Fonts/Helvetica.ttc',
'/System/Library/Fonts/Supplemental/Arial.ttf',
], ],
True: [ # bold True: [ # bold
'C:\\Windows\\Fonts\\segoeuib.ttf', 'C:\\Windows\\Fonts\\segoeuib.ttf',
'C:\\Windows\\Fonts\\arialbd.ttf', 'C:\\Windows\\Fonts\\arialbd.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
'/usr/share/fonts/truetype/liberation/LiberationSans-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', '/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_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): 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)) key = (int(size_px), bool(bold))
if key in _font_cache: if key in _font_cache:
return _font_cache[key] return _font_cache[key]
for path in FONT_CANDIDATES[bool(bold)]:
if os.path.exists(path): path = find_font_file(bold)
try: if path:
font = ImageFont.truetype(path, int(size_px)) try:
_font_cache[key] = font font = ImageFont.truetype(path, int(size_px))
return font _font_cache[key] = font
except Exception: return font
continue except Exception:
# Pillow's built-in bitmap font: ugly at 313dpi but never fails. logger.warning('Could not load font %s', path)
font = ImageFont.load_default() _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 _font_cache[key] = font
return font return font
@@ -474,34 +605,41 @@ def _load_art(label, upload_folder, box_px):
# -------------------------------------------------------- fixed-size draw # -------------------------------------------------------- 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: 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. 40mm wide, 15mm tall for one part, 20mm for two, taller beyond that.
`labels` is every part type in that drawer - one row each. `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 # 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 # 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. # was quietly dropped instead of getting a label of its own.
labels = list(labels) or [] labels = list(labels) or []
rows = max(1, len(labels)) 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_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') canvas = Image.new('RGB', (width_px, height_px), 'white')
draw = ImageDraw.Draw(canvas) draw = ImageDraw.Draw(canvas)
if options.outline: if options.outline:
draw.rectangle([0, 0, width_px - 1, height_px - 1], 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) fields = set(options.fields)
# Deliberately tighter than the fit-tape padding: 1.5mm top and bottom # Deliberately tighter than the fit-tape padding: 1.5mm top and bottom
# would eat a third of a two-part label's 10mm row. # 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 row_h = height_px / rows
for index, label in enumerate(labels): 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. # A hairline between parts so the two halves read as separate.
if index: if index:
draw.line([(padding, top), (width_px - padding, top)], 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 cursor_x = padding
if 'accent' in fields and label.category: if 'accent' in fields and label.category:
accent_w = mm_to_px(1.2) accent_w = px(1.2)
colour = label.category.accent_colour() colour = label.category.accent_colour()
try: try:
rgb = tuple(int(colour.lstrip('#')[i:i + 2], 16) 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_font = meta_px = None
meta_h = 0 meta_h = 0
leftover = room - title_h 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( meta_font, meta_px = fit_font(
draw, meta_text, available, draw, meta_text, available,
min(inner_h * 0.26 * options.font_scale, leftover / 1.15), min(inner_h * 0.26 * options.font_scale, leftover / 1.15),