← back to CelebritySignatures

scripts/build-oldest-signatures.py

241 lines

#!/usr/bin/env python3
"""
Oldest Signatures in History — spreadsheet + mural builder.

- Resolves each entry's Wikimedia Commons signature image (File: page -> rasterized
  thumbnail) and verifies it loads.
- For every entry, finds a PUBLIC-DOMAIN artwork from the Met Museum Open Access API
  (collectionapi.metmuseum.org, no key) to use as the visual plate — essential for the
  ancient clay-tablet / papyrus / seal entries that have no clean signature image.
- Writes data/oldest-signatures.csv (the spreadsheet).
- Composes output/mural-oldest-signatures(.png / -named.png): a chronological grid,
  signature image where one exists, otherwise the Met artwork, each cell labeled.

Free / local except for public no-key HTTP (Wikimedia + Met). $0.
"""
import json, os, re, time, urllib.parse, urllib.request, csv
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA = os.path.join(ROOT, "data", "oldest-signatures.json")
CSV_OUT = os.path.join(ROOT, "data", "oldest-signatures.csv")
CACHE = os.path.join(ROOT, "tmp_oldest_cache")
OUT = os.path.join(ROOT, "output")
UA = "CelebritySignatures-research/1.0 (steve@designerwallcoverings.com)"
COMMONS = "https://commons.wikimedia.org/w/api.php"
MET = "https://collectionapi.metmuseum.org/public/collection/v1"
os.makedirs(CACHE, exist_ok=True); os.makedirs(OUT, exist_ok=True)


def get(url, tries=3):
    for i in range(tries):
        try:
            req = urllib.request.Request(url, headers={"User-Agent": UA})
            with urllib.request.urlopen(req, timeout=25) as r:
                return r.read()
        except Exception as e:
            if i == tries - 1:
                print(f"   ! fetch failed {url[:80]} :: {e}")
                return None
            time.sleep(1.2 * (i + 1))


def getjson(url):
    b = get(url)
    try:
        return json.loads(b) if b else None
    except Exception:
        return None


def cache_img(url, key):
    if not url:
        return None
    ext = ".png"
    p = os.path.join(CACHE, re.sub(r"[^a-z0-9]+", "-", key.lower()).strip("-") + ext)
    if os.path.exists(p) and os.path.getsize(p) > 200:
        return p
    b = get(url)
    if not b:
        return None
    try:
        im = Image.open(BytesIO(b)).convert("RGBA")
        im.save(p)
        return p
    except Exception as e:
        print(f"   ! decode failed {key}: {e}")
        return None


def resolve_commons(file_page_url, width=700):
    """Commons 'File:Name' page URL -> rasterized thumbnail URL (handles SVG)."""
    if not file_page_url or "File:" not in file_page_url:
        return None
    title = "File:" + file_page_url.split("File:")[-1]
    title = urllib.parse.unquote(title)
    q = (f"{COMMONS}?action=query&titles={urllib.parse.quote(title)}"
         f"&prop=imageinfo&iiprop=url&iiurlwidth={width}&format=json")
    d = getjson(q)
    try:
        pages = d["query"]["pages"]
        info = next(iter(pages.values()))["imageinfo"][0]
        return info.get("thumburl") or info.get("url")
    except Exception:
        return None


def met_public_domain(query):
    """Return first public-domain Met object with an image for a search query."""
    s = getjson(f"{MET}/search?q={urllib.parse.quote(query)}&hasImages=true")
    ids = (s or {}).get("objectIDs") or []
    for oid in ids[:12]:
        o = getjson(f"{MET}/objects/{oid}")
        if not o:
            continue
        if o.get("isPublicDomain") and (o.get("primaryImageSmall") or o.get("primaryImage")):
            return {
                "met_object_id": oid,
                "met_title": o.get("title", ""),
                "met_date": o.get("objectDate", ""),
                "met_culture": o.get("culture") or o.get("department", ""),
                "met_image_url": o.get("primaryImageSmall") or o.get("primaryImage"),
                "met_object_url": o.get("objectURL", ""),
            }
        time.sleep(0.05)
    return None


def font(sz, bold=False):
    for p in ([
        "/System/Library/Fonts/Supplemental/Georgia Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Georgia.ttf",
        "/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Arial.ttf",
    ]):
        try:
            return ImageFont.truetype(p, sz)
        except Exception:
            continue
    return ImageFont.load_default()


def gather():
    data = json.load(open(DATA))
    data.sort(key=lambda r: r["era_sort"])
    for r in data:
        print(f"· {r['name']} ({r['approx_date']})")
        # signature image
        r["sig_thumb_url"] = ""
        r["sig_local"] = ""
        if r.get("signature_image_url"):
            t = resolve_commons(r["signature_image_url"])
            if t:
                r["sig_thumb_url"] = t
                r["sig_local"] = cache_img(t, "sig-" + r["name"]) or ""
                print(f"   sig: {'OK' if r['sig_local'] else 'resolve-only'}")
        # Met public-domain art
        m = met_public_domain(r.get("met_query", r["name"]))
        if m:
            r.update(m)
            r["met_local"] = cache_img(m["met_image_url"], "met-" + r["name"]) or ""
            print(f"   met: PD #{m['met_object_id']} — {m['met_title'][:48]}")
        else:
            for k in ("met_object_id", "met_title", "met_date", "met_culture", "met_image_url", "met_object_url"):
                r[k] = ""
            r["met_local"] = ""
            print("   met: none found")
        time.sleep(0.1)
    return data


def write_csv(data):
    cols = ["era_sort", "name", "approx_date", "category_type", "what_was_signed",
            "significance", "signature_image_available", "signature_image_url",
            "met_object_id", "met_title", "met_date", "met_culture", "met_object_url",
            "source_url", "notes"]
    with open(CSV_OUT, "w", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=cols, extrasaction="ignore")
        w.writeheader()
        for r in data:
            w.writerow(r)
    print(f"\nSpreadsheet -> {os.path.relpath(CSV_OUT, ROOT)} ({len(data)} rows)")


def fit(im, bw, bh):
    im = im.copy(); im.thumbnail((bw, bh), Image.LANCZOS); return im


def build_mural(data, named):
    COLS = 4
    rows = (len(data) + COLS - 1) // COLS
    PLATE_W, PLATE_H = 360, 300
    PAD = 26
    LABEL_H = 96 if named else 0
    CELL_W = PLATE_W + PAD
    CELL_H = PLATE_H + LABEL_H + PAD
    HEADER_H = 200
    FOOT = 70
    W = COLS * CELL_W + PAD
    H = HEADER_H + rows * CELL_H + FOOT
    INK = (26, 24, 22); MUTED = (120, 116, 110); LINE = (224, 220, 214)
    img = Image.new("RGB", (W, H), (250, 249, 246))
    d = ImageDraw.Draw(img)
    # header
    d.text((PAD + 8, 54), "Oldest Signatures in History", font=font(52, True), fill=INK)
    d.text((PAD + 10, 122),
           "From the earliest cuneiform scribal marks (c. 3100 BCE) to the Renaissance — paired with public-domain artifacts from The Met.",
           font=font(20), fill=MUTED)
    d.line([(PAD + 8, HEADER_H - 18), (W - PAD - 8, HEADER_H - 18)], fill=LINE, width=2)

    for i, r in enumerate(data):
        cx = PAD + (i % COLS) * CELL_W
        cy = HEADER_H + (i // COLS) * CELL_H
        # plate background
        d.rectangle([cx, cy, cx + PLATE_W, cy + PLATE_H], fill=(255, 255, 255), outline=LINE, width=2)
        used = None
        # prefer the signature image; else the Met artwork
        src = r.get("sig_local") or r.get("met_local")
        is_met = not r.get("sig_local") and r.get("met_local")
        if src and os.path.exists(src):
            try:
                im = Image.open(src).convert("RGBA")
                bg = Image.new("RGBA", im.size, (255, 255, 255, 255))
                im = Image.alpha_composite(bg, im).convert("RGB")
                im = fit(im, PLATE_W - 28, PLATE_H - 28)
                img.paste(im, (cx + (PLATE_W - im.width) // 2, cy + (PLATE_H - im.height) // 2))
                used = "met" if is_met else "sig"
            except Exception as e:
                print(f"   ! paste {r['name']}: {e}")
        if not used:
            d.text((cx + 18, cy + PLATE_H // 2 - 10), "(no public-domain image)", font=font(15), fill=MUTED)
        # tiny provenance tag
        tag = "Met artifact" if used == "met" else ("signature" if used == "sig" else "")
        if tag:
            d.text((cx + 12, cy + 10), tag, font=font(13, True), fill=(150, 120, 60) if used == "met" else (90, 130, 90))
        if named:
            ly = cy + PLATE_H + 12
            d.text((cx + 8, ly), r["name"][:34], font=font(19, True), fill=INK)
            d.text((cx + 8, ly + 28), f"{r['approx_date']}  ·  {r['category_type']}", font=font(15), fill=MUTED)
            if used == "met" and r.get("met_title"):
                d.text((cx + 8, ly + 50), ("art: " + r["met_title"])[:46], font=font(13), fill=(150, 120, 60))

    d.text((PAD + 8, H - 48),
           "Research-grade chronology · 'oldest signature' is contested (Kushim/GAR.AMA debate; Cleopatra papyrus disputed). Artifacts: The Met Open Access (public domain).",
           font=font(14), fill=MUTED)
    suffix = "-named" if named else ""
    out = os.path.join(OUT, f"mural-oldest-signatures{suffix}.png")
    img.save(out)
    print(f"Mural -> {os.path.relpath(out, ROOT)} ({W}×{H}px, {len(data)} cells)")


if __name__ == "__main__":
    data = gather()
    write_csv(data)
    build_mural(data, named=True)
    build_mural(data, named=False)
    # provenance sidecar for the site / catalog
    json.dump(data, open(os.path.join(ROOT, "data", "oldest-signatures-enriched.json"), "w"),
              indent=2, ensure_ascii=False)
    n_sig = sum(1 for r in data if r.get("sig_local"))
    n_met = sum(1 for r in data if r.get("met_local"))
    print(f"\nDone. {len(data)} entries · {n_sig} with signature image · {n_met} with Met public-domain art.")