Files
xbsheng aa2a6a1743 tools: the card scripts name a font path that is not Fredoka's variable TTF
docs/workflows/press.md tells a contributor their font path is the copy of Fredoka they
downloaded, and the guard added for a missing one answers only for a path that is not
there. A path that is there but is not the variable font reached Pillow: Google Fonts
serves Fredoka as a zip, so the zip was 'OSError: unknown file format', and one of the
static single-weight files beside it has no weight axis, so it was 'OSError: invalid
argument' - a traceback in the middle of a draw that names neither the argument nor what
it should have been. All three scripts now open the font once, before anything is drawn,
and answer both with the file named and what it has to be.
2026-10-26 18:46:53 +08:00

89 lines
4.5 KiB
Python

"""Stacks the README stat strip, the Featured in panel and the before/after TVs into one card (docs/card.jpg),
the file the front page displays.
Run tools/readme_stats.py and tools/readme_featured.py first, then:
python3 tools/readme_card.py Fredoka.ttf docs/card.jpg
Fredoka is the variable TTF from Google Fonts, and its file is not in this repo: pass the path of the
copy you downloaded.
"""
import os
import sys
USAGE = ("usage: readme_card.py <font.ttf> <out.jpg>\n"
" Fredoka, the variable TTF from Google Fonts, is not in this repo: download it and pass the\n"
" path of the file you saved.")
def font_and_out(argv):
"""The font and the picture, or a usage line that says where the font comes from.
The font is the one argument a reader brings themselves: it is not in this repo, so a wrong path is
the first thing that fails here, and Pillow's `OSError: cannot open resource` names neither the file
it wanted nor where to get it.
"""
if len(argv) != 3:
sys.exit(USAGE)
font, out = argv[1], argv[2]
if not os.path.isfile(font):
sys.exit(f"cannot open the font at {font}: Fredoka, the variable TTF from Google Fonts, is not in\n"
"this repo, so pass the path of the copy you downloaded")
return font, out
FONT, OUT = font_and_out(sys.argv)
# See readme_stats.py: Pillow lives in the optional requirements file, so name it rather than crash.
try:
from PIL import Image, ImageDraw, ImageFilter, ImageFont
except ImportError:
sys.exit("readme_card.py needs Pillow: tools/venv/bin/pip install -r tools/requirements-perf.txt")
BG, LINE, DIM, GREEN = "#15181e", "#2c313a", "#aab3bd", "#cfffcf"
W = 1600
def f(size, wt):
# A path that exists is not yet a font: Google Fonts serves Fredoka as a zip holding the variable
# `Fredoka[wdth,wght].ttf` and a folder of static single-weight files beside it. `truetype` answers a
# zip with `OSError: unknown file format` and `set_variation_by_axes` answers a static file with
# `OSError: invalid argument` - neither names the argument that was wrong, and both arrive as a
# Pillow traceback in the middle of a draw. Name the file and what it has to be instead.
try:
x = ImageFont.truetype(FONT, size); x.set_variation_by_axes([wt])
except (OSError, ValueError) as error:
sys.exit(f"cannot use the font at {FONT}: {error}. The card is drawn with Fredoka's variable TTF\n"
"(Fredoka[wdth,wght].ttf from Google Fonts), not the zip it is downloaded in and not one of\n"
"the static single-weight files beside it, which have no weight to set. Nothing was written.")
return x
f(12, 400) # the font is the argument the reader brings, so a wrong one is named before any drawing
def spaced(d, cx, y, t, font, sp, fill):
w = sum(d.textlength(c, font=font) for c in t) + sp * (len(t) - 1); x = cx - w / 2
for c in t: d.text((x, y), c, font=font, fill=fill); x += d.textlength(c, font=font) + sp
# the TV screens, cropped to the bezel; the Wi-Fi name on the after shot is blurred
before = Image.open("docs/stock-home.jpg").convert("RGB").crop((130, 216, 1317, 864))
after = Image.open("docs/new-home.jpg").convert("RGB")
box = (1150, 236, 1240, 280); after.paste(after.crop(box).filter(ImageFilter.GaussianBlur(9)), box)
after = after.crop((203, 138, 1467, 792))
PAD, ARROW = 70, 90
tw = (W - 2 * PAD - ARROW) // 2
shots = [im.resize((tw, round(im.height * tw / im.width)), Image.LANCZOS) for im in (before, after)]
th = max(s.height for s in shots)
tv = Image.new("RGB", (W, 90 + th + 80), BG); d = ImageDraw.Draw(tv)
lab = f(30, 500)
for i, (s, t, c) in enumerate(zip(shots, ("BEFORE", "AFTER"), (DIM, GREEN))):
x = PAD + i * (tw + ARROW); y = 90 + (th - s.height) // 2
spaced(d, x + tw / 2, 34, t, lab, 8, c)
tv.paste(s, (x, y)); d.rectangle([x - 1, y - 1, x + s.width, y + s.height], outline=LINE, width=2)
ax, ay = W / 2, 90 + th / 2
d.line([(ax - 26, ay), (ax + 26, ay)], fill=GREEN, width=5)
d.line([(ax + 8, ay - 16), (ax + 27, ay), (ax + 8, ay + 16)], fill=GREEN, width=5, joint="curve")
parts = [Image.open("docs/stats.png").convert("RGB"), Image.open("docs/featured.png").convert("RGB"), tv]
SEP = 24 # extra air on each side of a divider
H = sum(p.height for p in parts) + 2 * SEP * (len(parts) - 1)
card = Image.new("RGB", (W, H), BG); d = ImageDraw.Draw(card); y = 0
for i, p in enumerate(parts):
if i: y += SEP; d.line([(PAD, y), (W - PAD, y)], fill=LINE, width=2); y += SEP
card.paste(p, (0, y)); y += p.height
card.save(OUT, quality=88, optimize=True)