← back to CelebritySignatures
murals: add 5 consensus components (graphic-designer ∩ four-horsemen)
86bfd8c7ae05d162d0417e197acaa16bad61c516 · 2026-06-18 18:35:24 -0700 · SteveStudio2
Both design panels independently endorsed these 5 — built client-side from
existing data ($0, no API):
- Name finder ('is your icon on a wall?') — search all usable signatures → which mural
- Per-mural roster drawer — 'see who's on this wall', names + signature thumbs
- Per-signature provenance modal — license/source/deceased/risk receipts (Wikidata link)
- Named-PNG toggle on each card — Show/Hide names using the existing -named collages
- Room-scene presets in the wall studio (Plain/Study/Lounge/Gallery), CSS-only
Verified headless: 6 cards, finder→Lincoln/Politics, roster→56, provenance→Jefferson
(Public domain), named toggle swaps src, scene applies. JS node --check clean; only
404 is /favicon.ico. Single-panel ideas (custom builder, PDF spec, price estimator,
story strip) deliberately NOT built — no cross-panel consensus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
A output/portrait-declaration-of-independence-named.pngA output/portrait-declaration-of-independence.pngA output/portrait-politics-named.pngA output/portrait-politics.pngA output/portrait-sports-named.pngA output/portrait-sports.pngM public/murals.htmlA scripts/build-portrait-murals.py
Diff
commit 86bfd8c7ae05d162d0417e197acaa16bad61c516
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Thu Jun 18 18:35:24 2026 -0700
murals: add 5 consensus components (graphic-designer ∩ four-horsemen)
Both design panels independently endorsed these 5 — built client-side from
existing data ($0, no API):
- Name finder ('is your icon on a wall?') — search all usable signatures → which mural
- Per-mural roster drawer — 'see who's on this wall', names + signature thumbs
- Per-signature provenance modal — license/source/deceased/risk receipts (Wikidata link)
- Named-PNG toggle on each card — Show/Hide names using the existing -named collages
- Room-scene presets in the wall studio (Plain/Study/Lounge/Gallery), CSS-only
Verified headless: 6 cards, finder→Lincoln/Politics, roster→56, provenance→Jefferson
(Public domain), named toggle swaps src, scene applies. JS node --check clean; only
404 is /favicon.ico. Single-panel ideas (custom builder, PDF spec, price estimator,
story strip) deliberately NOT built — no cross-panel consensus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
.../portrait-declaration-of-independence-named.png | Bin 0 -> 5124382 bytes
output/portrait-declaration-of-independence.png | Bin 0 -> 5022692 bytes
output/portrait-politics-named.png | Bin 0 -> 6391981 bytes
output/portrait-politics.png | Bin 0 -> 6294263 bytes
output/portrait-sports-named.png | Bin 0 -> 3350966 bytes
output/portrait-sports.png | Bin 0 -> 3283761 bytes
public/murals.html | 206 +++++++++++++++-
scripts/build-portrait-murals.py | 270 +++++++++++++++++++++
8 files changed, 468 insertions(+), 8 deletions(-)
diff --git a/output/portrait-declaration-of-independence-named.png b/output/portrait-declaration-of-independence-named.png
new file mode 100644
index 0000000..a2ee018
Binary files /dev/null and b/output/portrait-declaration-of-independence-named.png differ
diff --git a/output/portrait-declaration-of-independence.png b/output/portrait-declaration-of-independence.png
new file mode 100644
index 0000000..d144d8f
Binary files /dev/null and b/output/portrait-declaration-of-independence.png differ
diff --git a/output/portrait-politics-named.png b/output/portrait-politics-named.png
new file mode 100644
index 0000000..7b6257d
Binary files /dev/null and b/output/portrait-politics-named.png differ
diff --git a/output/portrait-politics.png b/output/portrait-politics.png
new file mode 100644
index 0000000..11cc61b
Binary files /dev/null and b/output/portrait-politics.png differ
diff --git a/output/portrait-sports-named.png b/output/portrait-sports-named.png
new file mode 100644
index 0000000..acde353
Binary files /dev/null and b/output/portrait-sports-named.png differ
diff --git a/output/portrait-sports.png b/output/portrait-sports.png
new file mode 100644
index 0000000..3afbc55
Binary files /dev/null and b/output/portrait-sports.png differ
diff --git a/public/murals.html b/public/murals.html
index e3bdf6c..d0c9d9f 100644
--- a/public/murals.html
+++ b/public/murals.html
@@ -80,6 +80,50 @@
.msg.ok{color:var(--accent)} .msg.err{color:#b3261e}
footer{max-width:1180px;margin:0 auto;padding:0 30px 50px;color:var(--muted);font-size:12.5px;line-height:1.7}
@media(max-width:620px){.order form{grid-template-columns:1fr}.order label.wide,.order .summary,.msg{grid-column:1}}
+ /* — name finder (consensus component) — */
+ .finder{margin-top:18px;background:#fff;border:1px solid var(--line);border-radius:12px;padding:15px 17px}
+ .finder .lab{font-size:11.5px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:0 0 8px}
+ .finder .row{display:flex;gap:10px;flex-wrap:wrap}
+ .finder input{flex:1;min-width:230px;font:inherit;padding:10px 12px;border:1px solid var(--line);border-radius:8px}
+ .finder .res{margin-top:11px;display:flex;flex-direction:column;gap:7px;font-size:14px}
+ .finder .hit{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
+ .finder .hit img{height:26px;max-width:96px;object-fit:contain}
+ .finder .hit b{font-family:Georgia,serif;font-weight:600}
+ .finder .miss{color:var(--muted)}
+ /* — named-toggle pill on card image — */
+ .name-toggle{position:absolute;top:10px;right:10px;font-size:11.5px;padding:4px 11px;border-radius:999px;background:rgba(255,255,255,.93);border:1px solid var(--line);cursor:pointer;color:var(--ink);z-index:2}
+ .name-toggle:hover{background:#fff}
+ /* — roster drawer — */
+ .roster-trig{background:none;border:none;color:var(--accent);font:inherit;font-size:12.5px;cursor:pointer;padding:0;text-decoration:underline;align-self:flex-start}
+ .roster{margin-top:6px;border-top:1px dashed var(--line);padding-top:10px;display:none}
+ .roster.open{display:block}
+ .roster .r-search{width:100%;font:inherit;font-size:13px;padding:7px 9px;border:1px solid var(--line);border-radius:7px;margin-bottom:8px}
+ .roster .r-list{display:grid;grid-template-columns:1fr 1fr;gap:5px;max-height:260px;overflow:auto}
+ .roster .r-item{display:flex;align-items:center;gap:8px;font-size:12.5px;padding:4px 6px;border-radius:6px;cursor:pointer;text-align:left;background:none;border:1px solid transparent;width:100%;font-family:inherit;color:var(--ink)}
+ .roster .r-item:hover{background:#f4f0e8;border-color:var(--line)}
+ .roster .r-item img{height:20px;width:50px;object-fit:contain;flex:none}
+ .roster .r-item span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+ /* — provenance modal — */
+ .modal{position:fixed;inset:0;background:rgba(20,18,14,.55);display:none;align-items:center;justify-content:center;z-index:60;padding:20px}
+ .modal.open{display:flex}
+ .modal .box{background:#fff;border-radius:14px;max-width:440px;width:100%;padding:22px 24px;position:relative}
+ .modal .x{position:absolute;top:10px;right:14px;border:none;background:none;font-size:22px;cursor:pointer;color:var(--muted);line-height:1}
+ .modal .sig{background:#fff;border:1px solid var(--line);border-radius:10px;padding:16px;display:flex;align-items:center;justify-content:center;min-height:90px;margin-bottom:14px}
+ .modal .sig img{max-width:100%;max-height:120px;object-fit:contain}
+ .modal h3{margin:0 0 4px;font-size:21px}
+ .modal .why{color:var(--muted);font-size:13px;margin:0 0 14px}
+ .modal .prov{display:grid;grid-template-columns:auto 1fr;gap:7px 14px;font-size:13px;margin:0}
+ .modal .prov dt{color:var(--muted)}
+ .modal .prov dd{margin:0}
+ .modal .badge{display:inline-block;font-size:11px;padding:2px 8px;border-radius:999px;background:#eaf5ec;color:#1d7a36;border:1px solid #cfe8d5}
+ .modal .badge.medium{background:#fdf4e3;color:#9a6b00;border-color:#f0e2c0}
+ .modal .badge.high{background:#fdecea;color:#b3261e;border-color:#f3cdc8}
+ /* — scene presets in studio — */
+ .scene-presets{display:flex;gap:6px;flex-wrap:wrap}
+ .scene-presets button{font:inherit;font-size:12.5px;padding:6px 11px;border:1px solid var(--line);border-radius:8px;background:#fff;cursor:pointer;color:var(--muted)}
+ .scene-presets button.on{border-color:var(--ink);color:var(--ink);background:#f4f0e8}
+ .scene .swindow{position:absolute;top:8%;background:linear-gradient(160deg,#dCE7f0,#eef3f7);border:3px solid #fff;box-shadow:0 0 0 1px #c9bda9, 0 8px 20px rgba(0,0,0,.12)}
+ .scene .sart{position:absolute;top:14%;background:#fff;border:4px solid #fff;box-shadow:0 0 0 1px #c9bda9,0 6px 16px rgba(0,0,0,.14)}
</style>
</head>
<body>
@@ -100,6 +144,13 @@
public-domain or openly licensed source, no signature invented. Printed to order
as a single seamless piece for a full feature wall.</p>
<div class="spec" id="spec"><!-- filled by JS --></div>
+ <div class="finder">
+ <p class="lab">Is your icon on a wall?</p>
+ <div class="row">
+ <input id="finderInput" type="search" placeholder="Search any name — e.g. Lincoln, Chaplin, Babe Ruth…" autocomplete="off">
+ </div>
+ <div class="res" id="finderRes"></div>
+ </div>
</section>
<div class="gallery" id="gallery"></div>
@@ -120,6 +171,9 @@
<div class="f">Wall height — <output id="whOut"></output>
<input id="wallH" type="range" min="12" max="20" step="1" value="14">
</div>
+ <div class="f">Room scene
+ <div class="scene-presets" id="scenePresets"></div>
+ </div>
<div class="f">
<button class="btn ghost" id="btnCenter" type="button">Center on wall</button>
</div>
@@ -147,6 +201,17 @@
</section>
</main>
+<!-- provenance modal (consensus component) -->
+<div class="modal" id="provModal">
+ <div class="box">
+ <button class="x" id="provX" type="button" aria-label="Close">×</button>
+ <div class="sig" id="provSig"></div>
+ <h3 id="provName"></h3>
+ <p class="why" id="provWhy"></p>
+ <dl class="prov" id="provFields"></dl>
+ </div>
+</div>
+
<footer>
Every signature on these murals is sourced from Wikimedia Commons under a public-domain or
open license and is rendered at uniform size — no signature is emphasized over another.
@@ -160,6 +225,24 @@ let CAT = null; // catalog
let cur = null; // current mural in studio
let pos = {x:0, y:0}; // mural top-left in FEET on the wall
+// signature archive (powers the consensus components: finder, roster, provenance)
+const CATEGORY_OF = {
+ 'declaration-of-independence':'Declaration of Independence', 'politics':'Politics',
+ 'sports':'Sports', 'hollywood':'Hollywood', 'movies-classic':'Movies (Classic)', 'tv':'TV'
+};
+let SIGS = []; // usable=yes rows only (what's actually on the murals)
+let byMural = {}; // mural code -> [rows] (rank-sorted)
+let sigByKey = {}; // "category|rank" -> row
+
+// room-scene presets for the wall studio (CSS-only, $0)
+const SCENES = {
+ plain: {label:'Plain', wall:'linear-gradient(#f4eee4,#efe9df)', floor:'#d9cfbf', extra:''},
+ study: {label:'Study', wall:'linear-gradient(#e9ddc9,#ddcdb2)', floor:'#7c5a3a', extra:'art'},
+ lounge: {label:'Lounge', wall:'linear-gradient(#e7e8e3,#d8dad2)', floor:'#b29a7c', extra:'window'},
+ gallery: {label:'Gallery', wall:'linear-gradient(#fafafa,#eeeeec)', floor:'#cfcabf', extra:''},
+};
+let curScene = 'plain';
+
const money = n => "$" + Math.round(n).toLocaleString();
const $ = s => document.querySelector(s);
@@ -176,8 +259,12 @@ async function boot(){
// gallery
$('#gallery').innerHTML = CAT.murals.map(m => `
- <div class="m-card">
- <div class="pic"><img loading="lazy" src="/output/collage-${m.slug}.png" alt="${m.title} signature mural"></div>
+ <div class="m-card" data-code="${m.code}">
+ <div class="pic">
+ <button class="name-toggle" data-toggle="${m.code}" type="button">Show names</button>
+ <img loading="lazy" data-base="/output/collage-${m.slug}.png" data-named="/output/collage-${m.slug}-named.png"
+ src="/output/collage-${m.slug}.png" alt="${m.title} signature mural">
+ </div>
<div class="body">
<h3>${m.title}</h3>
<div class="blurb">${m.blurb}</div>
@@ -186,6 +273,8 @@ async function boot(){
<span class="pill">${heightFt}×${widthFt} ft</span>
<span class="pill">${dpi} DPI TIF</span>
</div>
+ <button class="roster-trig" data-roster="${m.code}" type="button">See who's on this wall ▾</button>
+ <div class="roster" data-rosterbody="${m.code}"></div>
<div class="foot">
<span class="price">${money(price)}</span>
<button class="btn ghost" data-place="${m.code}" type="button">Place on wall →</button>
@@ -196,13 +285,41 @@ async function boot(){
// mural selector
$('#selMural').innerHTML = CAT.murals.map(m => `<option value="${m.code}">${m.title}</option>`).join('');
+ // load the signature archive (usable rows only) → finder, roster, provenance
+ try{
+ const all = await (await fetch('/api/signatures')).json();
+ SIGS = all.filter(r => r.usable_in_commercial_collage === 'yes');
+ for(const r of SIGS) sigByKey[r.category + '|' + r.rank] = r;
+ for(const m of CAT.murals){
+ byMural[m.code] = SIGS.filter(r => r.category === CATEGORY_OF[m.slug]).sort((a,b)=>a.rank-b.rank);
+ }
+ }catch(e){ SIGS = []; }
+
+ // gallery actions: place-on-wall · named toggle · roster drawer · roster item → provenance
$('#gallery').addEventListener('click', e => {
- const code = e.target.getAttribute?.('data-place');
- if(!code) return;
- $('#selMural').value = code;
- selectMural(code);
- $('#studio').scrollIntoView({behavior:'smooth'});
+ const place = e.target.closest?.('[data-place]');
+ if(place){ const code=place.getAttribute('data-place'); $('#selMural').value=code; selectMural(code); $('#studio').scrollIntoView({behavior:'smooth'}); return; }
+ const tog = e.target.closest?.('[data-toggle]');
+ if(tog){ const img=tog.parentElement.querySelector('img'); const showing=tog.textContent.startsWith('Show');
+ img.src = showing ? img.dataset.named : img.dataset.base; tog.textContent = showing ? 'Hide names' : 'Show names'; return; }
+ const rt = e.target.closest?.('[data-roster]');
+ if(rt){ toggleRoster(rt.getAttribute('data-roster'), rt); return; }
+ const item = e.target.closest?.('[data-sig]');
+ if(item){ openProv(sigByKey[item.getAttribute('data-sig')]); return; }
+ });
+ $('#gallery').addEventListener('input', e => {
+ const rs = e.target.closest?.('.r-search'); if(!rs) return;
+ const q = rs.value.trim().toLowerCase();
+ rs.closest('.roster').querySelectorAll('.r-item').forEach(it =>
+ it.style.display = it.textContent.toLowerCase().includes(q) ? '' : 'none');
});
+
+ buildScenePresets();
+ wireFinder();
+ $('#provX').addEventListener('click', ()=>$('#provModal').classList.remove('open'));
+ $('#provModal').addEventListener('click', e => { if(e.target.id==='provModal') $('#provModal').classList.remove('open'); });
+ document.addEventListener('keydown', e => { if(e.key==='Escape') $('#provModal').classList.remove('open'); });
+
$('#selMural').addEventListener('change', e => selectMural(e.target.value));
$('#wallW').addEventListener('input', drawScene);
$('#wallH').addEventListener('input', drawScene);
@@ -213,6 +330,73 @@ async function boot(){
wireOrder();
}
+// — roster drawer (consensus #1) —
+function toggleRoster(code, trig){
+ const body = document.querySelector(`[data-rosterbody="${code}"]`);
+ const open = body.classList.toggle('open');
+ trig.textContent = (open ? "Hide who's on this wall ▴" : "See who's on this wall ▾");
+ if(open && !body.dataset.filled){
+ const rows = byMural[code] || [];
+ body.innerHTML =
+ `<input class="r-search" type="search" placeholder="Filter ${rows.length} names…" autocomplete="off">
+ <div class="r-list">` +
+ rows.map(r => `<button class="r-item" data-sig="${r.category}|${r.rank}" type="button">
+ <img loading="lazy" src="${r.signature_image_url}" alt=""><span>${r.full_name}</span></button>`).join('') +
+ `</div>`;
+ body.dataset.filled = '1';
+ }
+}
+
+// — provenance modal (consensus #2) —
+function openProv(row){
+ if(!row) return;
+ $('#provSig').innerHTML = `<img src="${row.signature_image_url}" alt="${row.full_name} signature">`;
+ $('#provName').textContent = row.full_name;
+ $('#provWhy').textContent = row.reason_for_ranking || '';
+ const deceased = row.deceased ? ('Deceased' + (row.death_date ? ' ' + String(row.death_date).slice(0,4) : '')) : 'Living';
+ const risk = (row.risk_level || 'low').toLowerCase();
+ const link = row.wikidata || (row.ranking_source_urls||'').split('|')[0].trim() || row.signature_image_url;
+ $('#provFields').innerHTML = `
+ <dt>License</dt><dd>${row.image_license || '—'}</dd>
+ <dt>Source</dt><dd>${row.signature_source_type || 'Wikimedia Commons'}</dd>
+ <dt>Status</dt><dd>${deceased}</dd>
+ <dt>Use</dt><dd><span class="badge ${risk}">${risk} risk · collage-cleared</span></dd>
+ <dt>Provenance</dt><dd><a href="${link}" target="_blank" rel="noopener noreferrer">Wikidata / source ↗</a></dd>`;
+ $('#provModal').classList.add('open');
+}
+
+// — name finder (consensus #3) —
+function wireFinder(){
+ const inp = $('#finderInput'), res = $('#finderRes');
+ const codeOfCat = {}; CAT.murals.forEach(m => codeOfCat[CATEGORY_OF[m.slug]] = m);
+ inp.addEventListener('input', () => {
+ const q = inp.value.trim().toLowerCase();
+ if(q.length < 2){ res.innerHTML = ''; return; }
+ const hits = SIGS.filter(r => r.full_name.toLowerCase().includes(q)).slice(0, 8);
+ if(!hits.length){ res.innerHTML = `<div class="miss">Not in the openly-licensed archive yet — <a href="#order">request a custom mural</a> and we'll source them.</div>`; return; }
+ res.innerHTML = hits.map(r => { const m = codeOfCat[r.category];
+ return `<div class="hit"><img src="${r.signature_image_url}" alt=""> <b>${r.full_name}</b> — on ${m ? `<a href="#" data-go="${m.code}">${m.title}</a>` : r.category}</div>`;
+ }).join('');
+ });
+ res.addEventListener('click', e => {
+ const go = e.target.closest?.('[data-go]'); if(!go) return;
+ e.preventDefault(); const code = go.getAttribute('data-go');
+ $('#selMural').value = code; selectMural(code); $('#studio').scrollIntoView({behavior:'smooth'});
+ });
+}
+
+// — room-scene presets (consensus #4) —
+function buildScenePresets(){
+ $('#scenePresets').innerHTML = Object.entries(SCENES).map(([k,v]) =>
+ `<button type="button" data-scene="${k}" class="${k===curScene?'on':''}">${v.label}</button>`).join('');
+ $('#scenePresets').addEventListener('click', e => {
+ const b = e.target.closest('[data-scene]'); if(!b) return;
+ curScene = b.getAttribute('data-scene');
+ $('#scenePresets').querySelectorAll('button').forEach(x => x.classList.toggle('on', x===b));
+ drawScene();
+ });
+}
+
function muralByCode(c){ return CAT.murals.find(m => m.code === c); }
function selectMural(code){
@@ -249,8 +433,14 @@ function drawScene(){
$('#whOut').textContent = H + FT;
const floorH = Math.min(sceneH*0.10, pxPerFt*1.0);
+ const SC = SCENES[curScene] || SCENES.plain;
+ scene.style.background = SC.wall;
+ let extra = '';
+ if(SC.extra === 'window') extra = `<div class="swindow" style="right:7%;width:${pxPerFt*3.5}px;height:${pxPerFt*4.5}px"></div>`;
+ else if(SC.extra === 'art') extra = `<div class="sart" style="left:6%;width:${pxPerFt*2}px;height:${pxPerFt*2.6}px"></div>`;
scene.innerHTML = `
- <div class="floor" style="height:${floorH}px"></div>
+ <div class="floor" style="height:${floorH}px;background:${SC.floor}"></div>
+ ${extra}
<div class="baseboard" style="bottom:${floorH}px;height:${Math.max(6,pxPerFt*0.4)}px"></div>
<div class="human" style="left:${pxPerFt*1.2}px;height:${6*pxPerFt - floorH}px;bottom:${floorH}px"></div>
<div class="mural" id="muralEl"
diff --git a/scripts/build-portrait-murals.py b/scripts/build-portrait-murals.py
new file mode 100644
index 0000000..f942105
--- /dev/null
+++ b/scripts/build-portrait-murals.py
@@ -0,0 +1,270 @@
+#!/usr/bin/env python3
+"""Build PORTRAIT murals: each cell = the person's PORTRAIT above their SIGNATURE.
+
+Same legal spine as the signature murals — nothing is included unless it is
+provably safe. The portrait is pulled from the person's Wikidata P18 image and is
+ONLY used if its Wikimedia Commons license is public-domain / CC0 / CC-BY(-SA).
+Any person whose portrait is missing or restricted falls back to a clean
+signature-only cell, so the mural never leans on an unlicensed photo.
+
+Outputs (web resolution, for the storefront + gallery):
+ output/portrait-<slug>.png — names hidden, centre QR ("scan to reveal")
+ output/portrait-<slug>-named.png — labelled key (QR target)
+
+Run via the venv: .venv/bin/python scripts/build-portrait-murals.py [slug|all]
+"""
+
+import json, os, re, sys, time, urllib.parse, urllib.request
+from io import BytesIO
+from PIL import Image, ImageDraw, ImageFont
+import qrcode
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+DATA = os.path.join(ROOT, "data", "celebrity_signatures.json")
+CACHE = os.path.join(ROOT, "tmp_collage_cache")
+OUT = os.path.join(ROOT, "output")
+UA = {"User-Agent": "CelebritySignatures-research/1.0 (steve@designerwallcoverings.com)"}
+COMMONS = "https://commons.wikimedia.org/w/api.php"
+WIKIDATA = "https://www.wikidata.org/w/api.php"
+BASE_URL = os.environ.get("PUBLIC_BASE_URL", "https://celebrity.designerwallcoverings.com").rstrip("/")
+SAFE = ("public domain", "pd", "cc0", "cc by", "cc-by") # license substrings we accept
+BAD = ("fair use", "non-free", "all rights", "copyright") # hard-reject substrings
+CODE = {"declaration-of-independence": "d", "politics": "p", "sports": "s",
+ "hollywood": "h", "movies-classic": "c", "tv": "t"}
+
+# layout
+POR_W, POR_H = 210, 250 # portrait box (cropped to fill)
+SIG_W, SIG_H = 220, 76
+GAP = 10
+PAD = 18
+LABEL_H = 24
+BOX_W = max(POR_W, SIG_W)
+BOX_H = POR_H + GAP + SIG_H
+CELL_W = BOX_W + PAD
+CELL_H = BOX_H + PAD
+CELL_H_NAMED = CELL_H + LABEL_H
+HEADER_H = 132
+FOOTER_H = 46
+INK = (22, 22, 22); MUTED = (120, 120, 120)
+os.makedirs(CACHE, exist_ok=True); os.makedirs(OUT, exist_ok=True)
+
+
+def get_json(u):
+ return json.load(urllib.request.urlopen(urllib.request.Request(u, headers=UA), timeout=40))
+
+
+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.ttf", "/Library/Fonts/Arial.ttf"]:
+ try: return ImageFont.truetype(p, sz)
+ except Exception: continue
+ return ImageFont.load_default()
+
+
+def fetch(url):
+ h = re.sub(r"[^a-zA-Z0-9]", "_", url)[-90:]
+ cp = os.path.join(CACHE, h + ".img.png")
+ if os.path.exists(cp):
+ try: return Image.open(cp).convert("RGBA")
+ except Exception: pass
+ for attempt in range(5):
+ try:
+ raw = urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=60).read()
+ im = Image.open(BytesIO(raw)).convert("RGBA"); im.save(cp); time.sleep(0.3); return im
+ except urllib.error.HTTPError as e:
+ if e.code == 429: time.sleep(8*(attempt+1)); continue
+ return None
+ except Exception: return None
+ return None
+
+
+def p18_for(qids):
+ """Q-id -> P18 filename (batched)."""
+ out = {}
+ for i in range(0, len(qids), 50):
+ b = qids[i:i+50]
+ j = get_json(WIKIDATA + "?" + urllib.parse.urlencode(
+ {"action": "wbgetentities", "format": "json", "props": "claims", "ids": "|".join(b)}))
+ for q in b:
+ cl = j.get("entities", {}).get(q, {}).get("claims", {}).get("P18")
+ if cl:
+ try: out[q] = cl[0]["mainsnak"]["datavalue"]["value"]
+ except Exception: pass
+ time.sleep(0.2)
+ return out
+
+
+def portrait_info(filenames):
+ """File title -> (thumburl@width, license) for the portrait box, batched."""
+ out = {}
+ titles = ["File:" + f.replace(" ", "_") for f in filenames]
+ for i in range(0, len(titles), 40):
+ b = titles[i:i+40]
+ j = get_json(COMMONS + "?" + urllib.parse.urlencode(
+ {"action": "query", "format": "json", "prop": "imageinfo",
+ "iiprop": "url|extmetadata", "iiurlwidth": str(POR_W*2), "titles": "|".join(b)}))
+ norm = {n["to"]: n["from"] for n in j.get("query", {}).get("normalized", [])}
+ for p in j.get("query", {}).get("pages", {}).values():
+ ii = (p.get("imageinfo") or [{}])[0]
+ lic = ii.get("extmetadata", {}).get("LicenseShortName", {}).get("value", "")
+ key = norm.get(p.get("title"), p.get("title"))
+ out[key] = (ii.get("thumburl") or ii.get("url"), lic)
+ time.sleep(0.2)
+ return out
+
+
+def license_ok(lic):
+ l = (lic or "").lower()
+ if any(b in l for b in BAD): return False
+ return any(s in l for s in SAFE)
+
+
+def cover(im, w, h):
+ """Scale to COVER w×h then centre-crop (for portraits)."""
+ im = Image.alpha_composite(Image.new("RGBA", im.size, (255,)*4), im).convert("RGB")
+ r = max(w/im.width, h/im.height)
+ im = im.resize((max(1,int(im.width*r)), max(1,int(im.height*r))), Image.LANCZOS)
+ x = (im.width-w)//2; y = (im.height-h)//2
+ return im.crop((x, y, x+w, y+h))
+
+
+def fit(im, w, h):
+ """Scale to FIT w×h preserving aspect on white (for signatures)."""
+ im = Image.alpha_composite(Image.new("RGBA", im.size, (255,)*4), im).convert("RGB")
+ r = min(w/im.width, h/im.height, 1.0)
+ return im.resize((max(1,int(im.width*r)), max(1,int(im.height*r))), Image.LANCZOS)
+
+
+def sig_title(url):
+ return "File:" + urllib.parse.unquote(url.rstrip("/").split("/")[-1])
+
+
+def sig_thumbs(rows):
+ out = {}
+ titles = [sig_title(r["signature_image_url"]) for r in rows]
+ for i in range(0, len(titles), 50):
+ b = titles[i:i+50]
+ j = get_json(COMMONS + "?" + urllib.parse.urlencode(
+ {"action": "query", "format": "json", "prop": "imageinfo",
+ "iiprop": "url", "iiurlwidth": "480", "titles": "|".join(b)}))
+ norm = {n["to"]: n["from"] for n in j.get("query", {}).get("normalized", [])}
+ for p in j.get("query", {}).get("pages", {}).values():
+ ii = (p.get("imageinfo") or [{}])[0]
+ out[norm.get(p.get("title"), p.get("title"))] = ii.get("thumburl") or ii.get("url")
+ time.sleep(0.2)
+ return out
+
+
+def make_qr(data, px):
+ qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=10, border=2)
+ qr.add_data(data); qr.make(fit=True)
+ img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
+ m = qr.modules_count + 2*qr.border; box = max(4, round(px/m))
+ return img.resize((box*m, box*m), Image.NEAREST)
+
+
+def render(category, cells, named, slug, with_portrait, total):
+ n = len(cells)
+ cell_h = CELL_H_NAMED if named else CELL_H
+ cols = min(8, max(1, round(n ** 0.5)))
+ rows_ct = (n + cols - 1)//cols
+ W = cols*CELL_W + PAD
+ H = HEADER_H + rows_ct*cell_h + FOOTER_H
+ canvas = Image.new("RGB", (W, H), (255,255,255))
+ d = ImageDraw.Draw(canvas)
+ d.rectangle([0,0,W,HEADER_H], fill=(247,245,240))
+ d.line([0,HEADER_H,W,HEADER_H], fill=(230,227,220), width=2)
+ d.text((PAD+8,30), category, font=font(40, bold=True), fill=INK)
+ mode = "names shown" if named else "names hidden · scan centre code for the key"
+ d.text((PAD+10,84), f"{n} portraits & signatures · {with_portrait} with portrait · {mode} · public domain / openly licensed · Wikimedia Commons",
+ font=font(16), fill=MUTED)
+
+ for idx, c in enumerate(cells):
+ cx = PAD + (idx % cols)*CELL_W
+ cy = HEADER_H + (idx // cols)*cell_h + PAD//2
+ # portrait
+ bx = cx + (CELL_W - BOX_W)//2
+ if c["portrait"] is not None:
+ canvas.paste(c["portrait"], (bx + (BOX_W-POR_W)//2, cy))
+ d.rectangle([bx+(BOX_W-POR_W)//2, cy, bx+(BOX_W-POR_W)//2+POR_W, cy+POR_H], outline=(214,210,202), width=1)
+ # signature under portrait
+ s = c["sig"]
+ sy = cy + POR_H + GAP
+ canvas.paste(s, (cx + (CELL_W - s.width)//2, sy + (SIG_H - s.height)//2))
+ if named:
+ f = font(12); label = c["name"]
+ while d.textlength(label, font=f) > CELL_W-8 and len(label) > 4: label = label[:-2]
+ if label != c["name"]: label = label[:-1]+"…"
+ tw = d.textlength(label, font=f)
+ d.text((cx+(CELL_W-tw)/2, cy+BOX_H+4), label, font=f, fill=MUTED)
+
+ if not named:
+ grid_h = rows_ct*cell_h
+ qr = make_qr(f"{BASE_URL}/k/{CODE.get(slug, slug)}", 200)
+ cf = font(14, bold=True); cap = "SCAN TO REVEAL NAMES"
+ cw = int(d.textlength(cap, font=cf)); pin = 16; gap = 10
+ pw = max(qr.width, cw)+pin*2; ph = qr.height+gap+18+pin*2
+ px = (W-pw)//2; py = HEADER_H + (grid_h-ph)//2
+ d.rectangle([px+5,py+6,px+pw+5,py+ph+6], fill=(214,210,202))
+ d.rectangle([px,py,px+pw,py+ph], fill=(255,255,255), outline=(150,146,138), width=2)
+ canvas.paste(qr, (px+(pw-qr.width)//2, py+pin))
+ d.text((px+(pw-cw)/2, py+pin+qr.height+gap), cap, font=cf, fill=INK)
+
+ d.text((PAD+8, H-FOOTER_H+14),
+ "Portraits used only where public-domain / openly licensed; others shown by signature alone. Living-celebrity rows excluded.",
+ font=font(13), fill=MUTED)
+ suffix = "-named" if named else ""
+ path = os.path.join(OUT, f"portrait-{slug}{suffix}.png")
+ canvas.save(path, "PNG")
+ print(f"[{category}] -> {os.path.basename(path)} ({n} cells, {with_portrait} portraits, {cols}×{rows_ct}, {W}×{H})")
+ return path
+
+
+def build(category, rows, slug):
+ rows = [r for r in rows if r["usable_in_commercial_collage"] == "yes"]
+ qids = [r["wikidata"].rstrip("/").split("/")[-1] for r in rows]
+ p18 = p18_for(qids)
+ pinfo = portrait_info(list(set(p18.values()))) if p18 else {}
+ sigs = sig_thumbs(rows)
+
+ cells = []; with_portrait = 0
+ for r, q in zip(rows, qids):
+ # signature (required)
+ surl = sigs.get(sig_title(r["signature_image_url"])) or r["signature_image_url"]
+ sim = fetch(surl)
+ if not sim: continue
+ sig = fit(sim, SIG_W, SIG_H)
+ # portrait (optional, license-gated)
+ por = None
+ fn = p18.get(q)
+ if fn:
+ url, lic = pinfo.get("File:" + fn.replace(" ", "_"), (None, ""))
+ if url and license_ok(lic):
+ pim = fetch(url)
+ if pim: por = cover(pim, POR_W, POR_H); with_portrait += 1
+ cells.append({"name": r["full_name"], "sig": sig, "portrait": por})
+ if not cells:
+ print(f"[{category}] no cells"); return []
+ return [render(category, cells, True, slug, with_portrait, len(cells)),
+ render(category, cells, False, slug, with_portrait, len(cells))]
+
+
+def main():
+ which = (sys.argv[1] if len(sys.argv) > 1 else "all").lower()
+ data = json.load(open(DATA)); by = {}
+ for r in data:
+ if r["usable_in_commercial_collage"] == "yes":
+ by.setdefault(r["category"], []).append(r)
+ order = ["Declaration of Independence", "Politics", "Sports", "Hollywood", "Movies (Classic)", "TV"]
+ print(f"Portrait murals · QR host {BASE_URL}\n")
+ made = []
+ for cat in order:
+ slug = re.sub(r"[^a-z0-9]+", "-", cat.lower()).strip("-")
+ if which not in ("all", slug): continue
+ if cat in by:
+ made += build(cat, sorted(by[cat], key=lambda x: x["rank"]), slug)
+ print(f"\nBuilt {len(made)} portrait-mural PNGs in output/")
+
+
+if __name__ == "__main__":
+ main()
← 9111112 Murals storefront: /murals sales page + wall-placement studi
·
back to CelebritySignatures
·
Portrait murals: portrait+signature per cell (PD/CC-gated po 4ad180e →