Created
June 28, 2026 15:48
-
-
Save eulersson/0afb3ce37120cd8a0443d4371dc3383e to your computer and use it in GitHub Desktop.
gen_certificates.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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