Skip to content

Instantly share code, notes, and snippets.

@eulersson
Created June 28, 2026 15:48
Show Gist options
  • Select an option

  • Save eulersson/0afb3ce37120cd8a0443d4371dc3383e to your computer and use it in GitHub Desktop.

Select an option

Save eulersson/0afb3ce37120cd8a0443d4371dc3383e to your computer and use it in GitHub Desktop.
gen_certificates.py
#!/usr/bin/env python3
"""Generate Vidaya completion certificates, one per name in names.csv.
Template = page 0 of certs.pdf. For each name we:
- remove the old name text and insert the new name in Flatlion (centered, fit to width)
- change the year's final digit 5 -> 6 (OpenSans-Italic, matching the others)
- place the signature firma.png on the line above LAURA CASSADO
Background image and the printed rules are left untouched (text-only redaction).
"""
import sys, os, re
import fitz
from PIL import Image
import numpy as np
SRC = "certs.pdf"
FLATLION = "/Users/ramon/Library/Fonts/Flatlion Personal Use Only.ttf"
OPENSANS_IT = "/Applications/TouchDesigner.app/Contents/Resources/tfs/Samples/Fonts/OpenSans-Italic.ttf"
OUTDIR = "certificates"
INK = (61/255, 59/255, 58/255) # 0x3d3b3a, the body text colour
# --- geometry captured from the template (page 0) ---
NAME_BBOX = fitz.Rect(302.1, 297.7, 540.1, 388.7)
NAME_BASE_Y = 375.5
NAME_SIZE = 91.0
NAME_CX = 421.125 # page centre
NAME_RULE = (217.7, 624.5) # underline extent
NAME_MAXW = 400.0 # max name width (inside the rule)
YEAR5_BBOX = fitz.Rect(464.9, 422.6, 471.5, 438.9)
YEAR_X = 464.9
YEAR_BASE_Y = 435.4
YEAR_SIZE = 12.0
# signature: sit on the rule above LAURA CASSADO (rule y=495.2, centre x=421.1)
SIG_CX = 421.1
SIG_BOTTOM = 500.0 # just over the rule, above LAURA text (503.7)
SIG_HEIGHT = 58.0
def make_signature_png():
"""Trim firma.png to its ink (black strokes on transparent) and return
(png_bytes, aspect w/h). Forces black ink + clean alpha from luminance."""
im = Image.open("firma.png").convert("RGBA")
a = np.array(im)
lum = a[:, :, :3].mean(axis=2)
alpha = a[:, :, 3]
ink = (lum < 160) & (alpha > 20) # dark, visible pixels
ys, xs = np.where(ink)
x0, x1, y0, y1 = xs.min(), xs.max(), ys.min(), ys.max()
pad = 6
x0 = max(0, x0 - pad); y0 = max(0, y0 - pad)
x1 = min(im.width - 1, x1 + pad); y1 = min(im.height - 1, y1 + pad)
crop = a[y0:y1 + 1, x0:x1 + 1].copy()
cl = crop[:, :, :3].mean(axis=2)
ca = crop[:, :, 3]
out = np.zeros_like(crop)
mask = (cl < 200) & (ca > 20) # ink -> opaque black
out[:, :, 0] = 0; out[:, :, 1] = 0; out[:, :, 2] = 0
out[:, :, 3] = np.where(mask, 255, 0).astype("uint8")
res = Image.fromarray(out, "RGBA")
import io
buf = io.BytesIO(); res.save(buf, format="PNG")
res.save("firma_trim.png") # for inspection
return buf.getvalue(), res.width / res.height
def build_one(name, sig_png, sig_aspect, out_path):
doc = fitz.open(SRC)
# keep only the template page
doc.select([0])
page = doc[0]
# remove old name + old final year digit (text only, keep background & rules)
page.add_redact_annot(NAME_BBOX, fill=None)
page.add_redact_annot(YEAR5_BBOX, fill=None)
page.apply_redactions(images=fitz.PDF_REDACT_IMAGE_NONE,
graphics=fitz.PDF_REDACT_LINE_ART_NONE)
# new name: fit to width, centred on the rule, baseline on the line
font = fitz.Font(fontfile=FLATLION)
size = NAME_SIZE
w = font.text_length(name, size)
if w > NAME_MAXW:
size *= NAME_MAXW / w
w = font.text_length(name, size)
x = NAME_CX - w / 2
page.insert_text((x, NAME_BASE_Y), name, fontfile=FLATLION,
fontname="flatlion", fontsize=size, color=INK)
# year final digit 5 -> 6
page.insert_text((YEAR_X, YEAR_BASE_Y), "6", fontfile=OPENSANS_IT,
fontname="osit", fontsize=YEAR_SIZE, color=INK)
# signature above LAURA CASSADO
sh = SIG_HEIGHT
sw = sh * sig_aspect
rect = fitz.Rect(SIG_CX - sw / 2, SIG_BOTTOM - sh, SIG_CX + sw / 2, SIG_BOTTOM)
page.insert_image(rect, stream=sig_png, keep_proportion=True, overlay=True)
doc.save(out_path, garbage=4, deflate=True)
doc.close()
def safe_filename(name):
s = name.strip()
s = re.sub(r"\s+", " ", s)
s = s.replace("/", "-")
return s
def main():
names = [l.strip() for l in open("names.csv", encoding="utf-8") if l.strip()]
os.makedirs(OUTDIR, exist_ok=True)
sig_png, sig_aspect = make_signature_png()
only = sys.argv[1] if len(sys.argv) > 1 else None
if only is not None:
names = [names[int(only)]]
for i, name in enumerate(names):
out = os.path.join(OUTDIR, f"{safe_filename(name)}.pdf")
build_one(name, sig_png, sig_aspect, out)
print("wrote", out)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment