← 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.")