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:
1 parent
e1c8de9367
commit
4c05763a35
1 file changed
+157
-19
+157
-19
@@ -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),
|
||||||
|
|||||||
Reference in new issue
Block a user