certificate-generator / certificate.py
abidlabs's picture
abidlabs HF Staff
Fix invisible dark-mode text and restore certificate generation (#1)
c8bd3fa
Raw
History Blame
10.2 kB
"""Certificate renderer.
Draws the participation certificate directly with Pillow, so the Space has no
runtime dependency on any other Space. Fonts come from `fonts-dejavu-core` and
`fonts-noto-cjk` (see packages.txt); set CERT_FONT_DIR to render with a
different font set.
"""
import functools
import os
from PIL import Image, ImageDraw, ImageFont
# --- canvas ---------------------------------------------------------------
W, H = 2000, 1414
SS = 2 # supersample factor; the final PNG is downsampled to W x H
PAPER = (253, 252, 249)
GRID = (243, 243, 240) # PAPER under 4.5% ink, i.e. the CSS grid lines
INK = (31, 41, 55)
ORANGE = (234, 88, 12)
MUTED = (107, 114, 128)
SEP = (211, 214, 220)
FONT_DIRS = [
os.environ.get("CERT_FONT_DIR"),
"/usr/share/fonts/truetype/dejavu",
"/usr/share/fonts/dejavu",
"/usr/share/fonts/TTF",
"/usr/share/fonts/opentype/noto",
"/usr/share/fonts/truetype/noto",
]
SERIF_BOLD, SANS, SANS_BOLD, MONO_BOLD = (
"DejaVuSerif-Bold.ttf", "DejaVuSans.ttf",
"DejaVuSans-Bold.ttf", "DejaVuSansMono-Bold.ttf",
)
# Participant names are arbitrary text, and DejaVu has no CJK coverage, so fall
# back to Noto for anything it can't draw (otherwise those names come out as
# tofu boxes). Debian splits the CJK weights across fonts-noto-cjk and
# fonts-noto-cjk-extra, so try both weights rather than assuming Bold exists.
BOLD_FALLBACKS = ("NotoSansCJK-Bold.ttc", "NotoSansCJK-Regular.ttc")
FALLBACKS = {
SERIF_BOLD: BOLD_FALLBACKS,
SANS_BOLD: BOLD_FALLBACKS,
MONO_BOLD: BOLD_FALLBACKS,
SANS: ("NotoSansCJK-Regular.ttc", "NotoSansCJK-Bold.ttc"),
}
def font_path(filename, required=True):
for d in FONT_DIRS:
if d and os.path.exists(os.path.join(d, filename)):
return os.path.join(d, filename)
if not required:
return None
raise FileNotFoundError(
f"{filename} not found in {[d for d in FONT_DIRS if d]}. "
"Install fonts-dejavu-core or set CERT_FONT_DIR."
)
@functools.lru_cache(maxsize=None)
def font(filename, size):
return ImageFont.truetype(font_path(filename), int(size * SS))
@functools.lru_cache(maxsize=None)
def fallback_path(filename):
"""First installed fallback font for `filename`, or None if none is."""
for candidate in FALLBACKS.get(filename, ()):
path = font_path(candidate, required=False)
if path:
return path
return None
@functools.lru_cache(maxsize=None)
def fallback_font(filename, size):
path = fallback_path(filename)
return ImageFont.truetype(path, int(size * SS)) if path else None
@functools.lru_cache(maxsize=None)
def covered(path):
"""Codepoints the font can actually draw (empty set = assume everything)."""
if path is None:
return frozenset()
try:
from fontTools.ttLib import TTCollection, TTFont
f = TTCollection(path).fonts[0] if path.endswith(".ttc") else TTFont(path, fontNumber=0)
with f:
return frozenset(f.getBestCmap())
except Exception:
return frozenset() # coverage unknown -> draw with the primary font
def runs(text, filename, size):
"""Split `text` into (string, font) runs, using the fallback font for
characters the primary font lacks and dropping ones neither can draw."""
primary, fb = font(filename, size), fallback_font(filename, size)
main_cmap, fb_cmap = covered(font_path(filename)), covered(fallback_path(filename))
out = []
for ch in text:
if not main_cmap or ord(ch) in main_cmap or ch == " ":
f = primary
elif fb is not None and (not fb_cmap or ord(ch) in fb_cmap):
f = fb
else:
continue # no font can draw it; a tofu box would look worse
if out and out[-1][1] is f:
out[-1][0] += ch
else:
out.append([ch, f])
return [(t, f) for t, f in out]
def runs_width(rs):
return sum(f.getlength(t) for t, f in rs)
def fit_runs(text, filename, size, max_width, min_size):
"""Shrink to fit `max_width`, then ellipsize if it still doesn't fit."""
while size > min_size:
rs = runs(text, filename, size)
if runs_width(rs) <= max_width * SS:
return rs
size -= 2
while text:
rs = runs(text + "…", filename, min_size)
if runs_width(rs) <= max_width * SS:
return rs
text = text[:-1]
return runs("…", filename, min_size)
def fit_font(filename, size, text, max_width, min_size=32):
"""Largest size <= `size` at which `text` fits in `max_width` design px."""
while size > min_size:
f = font(filename, size)
if f.getlength(text) <= max_width * SS:
return f
size -= 2
return font(filename, min_size)
def line_height(f):
return sum(f.getmetrics())
# --- text helpers ---------------------------------------------------------
def tracked_width(f, text, tracking):
return sum(f.getlength(c) for c in text) + tracking * SS * max(len(text) - 1, 0)
def draw_tracked(draw, cx, y, text, f, fill, tracking):
"""Centered text with CSS-style letter-spacing (Pillow has no tracking)."""
x = cx - tracked_width(f, text, tracking) / 2
for ch in text:
draw.text((x, y), ch, font=f, fill=fill, anchor="la")
x += f.getlength(ch) + tracking * SS
return y + line_height(f)
def draw_centered(draw, cx, y, text, f, fill):
draw.text((cx, y), text, font=f, fill=fill, anchor="ma")
return y + line_height(f)
def draw_runs_centered(draw, cx, y, rs, fill):
x = cx - runs_width(rs) / 2
for text, f in rs:
draw.text((x, y), text, font=f, fill=fill, anchor="la")
x += f.getlength(text)
return y + max(line_height(f) for _, f in rs)
def draw_rich(draw, cx, y, segments, max_width, leading):
"""Word-wrap `segments` [(text, font, color), ...] into centered lines."""
words = []
for text, f, color in segments:
words += [(w, f, color) for w in text.split()]
lines, line, width = [], [], 0.0
for w, f, color in words:
space = f.getlength(" ") if line else 0
adv = f.getlength(w)
if line and width + space + adv > max_width * SS:
lines.append(line)
line, width = [(w, f, color)], adv
else:
line.append((w, f, color))
width += space + adv
if line:
lines.append(line)
for ln in lines:
total = sum(f.getlength(w) for w, f, _ in ln)
total += sum(f.getlength(" ") for _, f, _ in ln[:-1])
x = cx - total / 2
for i, (w, f, color) in enumerate(ln):
draw.text((x, y), w, font=f, fill=color, anchor="la")
x += f.getlength(w) + (f.getlength(" ") if i < len(ln) - 1 else 0)
y += leading * SS
return y
def clean_name(name, limit=64):
"""Collapse whitespace and cap length so the layout can't be blown up."""
name = " ".join(str(name or "").split())
return name[:limit].rstrip() if len(name) > limit else name
# --- renderer -------------------------------------------------------------
def render_certificate(participant_name, stats_line, out_path):
name = clean_name(participant_name) or "Anonymous participant"
img = Image.new("RGB", (W * SS, H * SS), PAPER)
d = ImageDraw.Draw(img)
cx = W * SS // 2
for x in range(0, W + 1, 40):
d.line([(x * SS, 0), (x * SS, H * SS)], fill=GRID, width=SS)
for y in range(0, H + 1, 40):
d.line([(0, y * SS), (W * SS, y * SS)], fill=GRID, width=SS)
d.rounded_rectangle([60 * SS, 60 * SS, (W - 60) * SS, (H - 60) * SS],
radius=24 * SS, outline=INK, width=3 * SS)
d.rounded_rectangle([134 * SS, 134 * SS, (W - 134) * SS, (H - 134) * SS],
radius=16 * SS, outline=ORANGE, width=2 * SS)
y = 232 * SS
y = draw_tracked(d, cx, y, "REPRODUCING ICML 2026 · OPEN REPRODUCTIONS",
font(MONO_BOLD, 24), ORANGE, 8)
y += 26 * SS
y = draw_centered(d, cx, y, "Certificate of Participation",
fit_font(SERIF_BOLD, 92, "Certificate of Participation", 1640), INK)
y += 40 * SS
y = draw_centered(d, cx, y, "This certificate is proudly presented to",
font(SANS, 32), MUTED)
y += 30 * SS
name_runs = fit_runs(name, SERIF_BOLD, 88, 1560, 40)
if not name_runs: # every character was undrawable
name_runs = fit_runs("Anonymous participant", SERIF_BOLD, 88, 1560, 40)
y = draw_runs_centered(d, cx, y, name_runs, INK)
y += 34 * SS
d.rounded_rectangle([cx - 280 * SS, y, cx + 280 * SS, y + 3 * SS],
radius=2 * SS, fill=ORANGE)
y += 3 * SS
y += 48 * SS
body, body_bold = font(SANS, 32), font(SANS_BOLD, 32)
y = draw_rich(d, cx, y, [
("for contributing to the community reproduction of ICML 2026, with", body, INK),
(stats_line, body_bold, ORANGE),
("independently confirmed by the automated Logbook Judge, as part of "
"the largest open, claim-by-claim audit of a machine learning "
"conference to date.", body, INK),
], max_width=1380, leading=50)
y += 32 * SS
draw_tracked(d, cx, y, "JULY 15 – AUGUST 2, 2026", font(MONO_BOLD, 24), MUTED, 5)
# --- footer ---
partners = [("Trackio", INK), ("·", SEP), ("Hugging Face", INK),
("·", SEP), ("alphaXiv", INK)]
pf = font(SANS_BOLD, 30)
gap = 34 * SS
total = sum(pf.getlength(t) for t, _ in partners) + gap * (len(partners) - 1)
x, py = cx - total / 2, (H - 268) * SS
for text, color in partners:
d.text((x, py), text, font=pf, fill=color, anchor="la")
x += pf.getlength(text) + gap
org = ("Issued by the ICML 2026 Agent Repro organizers · "
"huggingface.co/ICML-2026-agent-repro")
d.text((cx, (H - 202) * SS), org, font=font(SANS, 22), fill=MUTED, anchor="ma")
img.resize((W, H), Image.LANCZOS).save(out_path, "PNG", optimize=True)
return out_path
if __name__ == "__main__": # local preview
render_certificate("Ada Lovelace",
"1673 claims verified across 347 papers",
"preview.png")