Files
Sanctification/spikes/rejected-compositions/tier-layer-library-proof/build.py

169 lines
14 KiB
Python

#!/usr/bin/env python3
"""Four specific retained-art proofs for review of the shared tier library."""
from pathlib import Path
import argparse, hashlib, json, os, subprocess, sys
from xml.sax.saxutils import escape
import xml.etree.ElementTree as ET
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter
ROOT=Path(__file__).resolve().parent
REPO=next(p for p in ROOT.parents if (p/'docs/card-layer-pipeline.md').is_file())
sys.path.insert(0,str(REPO/'artifacts/layers'))
sys.path.insert(0,str(REPO/'tools/card-production'))
from card_workspace import resolve_revision
from library import load_tier, digest
from finish_masks import prepare_illustration, compile_finish, export_finish, RECIPE_PATH, RECIPE_SHA256
OUT=ROOT/'output'
for folder in ['cards','text','masks','editable','review','runtime']:(OUT/folder).mkdir(parents=True,exist_ok=True)
FONT_DIR=REPO/'fonts'
FM=json.loads((FONT_DIR/'manifest.json').read_text())
for role in ['title','verse','reference']:
file=FM['productionTypography'][role]['file']; assert digest(FONT_DIR/file)==FM['files'][file]['sha256']
config=OUT/'fonts.conf'; config.write_text(f'<fontconfig><dir>{FONT_DIR}</dir><cachedir>{OUT/"font-cache"}</cachedir></fontconfig>')
ENV=dict(os.environ,FONTCONFIG_FILE=str(config))
def ink(*args):return subprocess.check_output(['inkscape',*map(str,args)],text=True,env=ENV).strip()
def save_json(path,data):path.write_text(json.dumps(data,indent=2)+'\n')
def svg(body):return f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" height="2800" viewBox="0 0 2000 2800">{body}</svg>'
def luminance(rgb):
c=np.asarray(rgb,dtype=float)/255; c=np.where(c<=.04045,c/12.92,((c+.055)/1.055)**2.4)
return float(c@np.array([.2126,.7152,.0722]))
def contrast(a,b):
lo,hi=sorted([luminance(a),luminance(b)]);return (hi+.05)/(lo+.05)
report={'status':'pending','scope':'Four retained-art review proofs; historical outputs and harness fixtures unchanged.','sharedLayerManifestSHA256':digest(REPO/'artifacts/layers/manifest.json'),'sharedFontManifestSHA256':digest(FONT_DIR/'manifest.json'),'sharedLayerLoaderSHA256':digest(REPO/'artifacts/layers/library.py'),'builderSHA256':digest(Path(__file__)),'cardDataSHA256':digest(ROOT/'cards.json'),'finishRecipe':{'id':'raw-ridges-v1','sha256':RECIPE_SHA256,'moduleSHA256':digest(REPO/'tools/card-production/finish_masks.py')},'tools':{'inkscape':ink('--version'),'pillow':Image.__version__,'numpy':np.__version__},'cards':[],'visualReview':{'static':'pending','userTemplateApproval':'pending','movingLight':'not performed'}}
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check-paths',action='store_true',help='Verify canonical inputs and compatibility exports without rendering.')
parser.add_argument('--tier',choices=['common','uncommon','rare','extraordinary'],help='Rebuild one tier and refresh comparison sheets, preserving verified unchanged exports for other tiers.')
args=parser.parse_args()
previous=json.loads((OUT/'validation.json').read_text()) if args.tier else None
report['rebuildScope']=args.tier or 'all'
card_references=json.loads((ROOT/'cards.json').read_text())
records=[json.loads((resolve_revision(ref['cardId'],ref['revision'])/'card.json').read_text()) for ref in card_references]
parser_revision_paths=[resolve_revision(ref['cardId'],ref['revision']) for ref in card_references]
if not args.check_paths and any(json.loads((path/'manifest.json').read_text())['stage']=='approved' for path,record in zip(parser_revision_paths,records) if not args.tier or record['tier']==args.tier):
raise SystemExit('Approved revisions are frozen; select a working revision before rebuilding.')
if args.check_paths:
for reference,record,directory in zip(card_references,records,parser_revision_paths):
assert digest(REPO/record['art'])==record['artSHA256']
for label,width in [('low',500),('med',1000),('high',2000)]:
for printing in ['normal','boundless','borderless','textless']:
for old,filename in [(OUT/'cards'/f'{record['exportPrefix']}-{printing}-{width}.png','card.png'),(OUT/'masks'/f'{record['exportPrefix']}-{printing}-{width}-mask.png','finish-mask.png'),(OUT/'masks'/f'{record['exportPrefix']}-{printing}-{width}-text-mask.png','text-mask.png')]:
assert old.resolve()==directory/label/printing/filename,(old,directory)
assert (OUT/'editable'/f'{record['exportPrefix']}-{printing}.svg').resolve()==directory/'high'/printing/'card.svg'
print('Four canonical card inputs and all compatibility export paths verified; no images rendered.')
raise SystemExit(0)
for card in records:
tier=card['tier']; selected=load_tier(tier,card['layerVersion']); layout=selected['layout']; assets=selected['assets']; id=card['exportPrefix']
if args.tier and tier!=args.tier:
retained=next(entry for entry in previous['cards'] if entry['tier']==tier)
assert retained['sharedLayers']['manifestSHA256']==selected['manifestSHA256'],f'Unselected tier changed: {tier}'
for printing,record in retained['printings'].items():
for export in record['exports']:
width=export['width']
for path,key in [(OUT/'cards'/f'{id}-{printing}-{width}.png','faceSHA256'),(OUT/'masks'/f'{id}-{printing}-{width}-mask.png','finishSHA256'),(OUT/'masks'/f'{id}-{printing}-{width}-text-mask.png','textMaskSHA256')]:
assert digest(path)==export[key],f'Unselected export changed: {path}'
retained['reusedUnchangedExports']=True
report['cards'].append(retained)
continue
art_path=REPO/card['art']; art=Image.open(art_path).convert('RGBA');assert art.size==(2000,2800) and art.getchannel('A').getextrema()==(255,255)
assert digest(art_path)==card['artSHA256'],'Retained art changed'
assert card['referenceDisplay']==f'{card["reference"]} • {card["translation"]}'
assert ' '.join(card['verseLines'])==card['excerpt']
sizes=layout['fontSizes']; regions=layout['textRegions']; families={'title':'P052','verse':'Sanctification P052','reference':'Sanctification P052'}
body='<g fill="#263f50" text-anchor="middle">'
text_rows=[('title','title',card['name'],layout['baselines']['title'])]+[(f'verse-{i}','verse',line,y) for i,(line,y) in enumerate(zip(card['verseLines'],card['verseBaselines']))]+[('reference','reference',card['referenceDisplay'],layout['baselines']['reference'])]
assert len(card['verseLines'])<=layout['maximumVerseLines']
assert card['verseBaselines']==layout['baselines']['verseByLineCount'][str(len(card['verseLines']))]
font_checks=[]
for role in ['title','verse','reference']:
face=FM['productionTypography'][role]; file=FONT_DIR/face['file']; style='Bold' if role=='title' else 'Medium'
matched=Path(subprocess.check_output(['fc-match','--format=%{file}',f'{families[role]}:style={style}'],env=ENV,text=True)).resolve()
assert matched==file.resolve(),(role,matched,file)
ranges=[(int(t.split('-')[0],16),int(t.split('-')[-1],16)) for t in subprocess.check_output(['fc-query','--format=%{charset}',str(file)],text=True).split()]
content=''.join(row[2] for row in text_rows if row[1]==role)
assert all(any(lo<=ord(c)<=hi for lo,hi in ranges) for c in content)
font_checks.append({'role':role,'file':str(file.relative_to(REPO)),'sha256':digest(file),'weight':face['weight'],'fallbackUsed':False,'missingGlyphs':[]})
for key,role,content,y in text_rows:
assert sizes[role]>=layout['fontMinimums'][role]
body+=f'<text id="{key}" x="1000" y="{y}" font-family="{families[role]}" font-weight="{FM["productionTypography"][role]["weight"]}" font-size="{sizes[role]}">{escape(content)}</text>'
body+='</g>'
source=OUT/'text'/f'{id}-text.svg'; source.write_text(svg(body));text_path=OUT/'text'/f'{id}-text-2000.png'
ink(source,'--export-type=png',f'--export-filename={text_path}','--export-width=2000','--export-height=2800')
text=Image.open(text_path).convert('RGBA');ta=np.asarray(text)[:,:,3]
bounds={}
for row in ink(source,'--query-all').splitlines():
parts=row.split(',')
if len(parts)==5:bounds[parts[0]]=list(map(float,parts[1:]))
glyphs=[]
for key,role,content,y in text_rows:
rx,ry,rw,rh=regions[role];x,yy,w,h=bounds[key]
assert rx<=x and ry<=yy and x+w<=rx+rw and yy+h<=ry+rh,(id,key,bounds[key],regions[role])
node=next(n for n in ET.fromstring(body) if n.get('id')==key)
isolated=OUT/'text'/f'{id}-{key}-unclipped.svg';isolated.write_text(svg('<g text-anchor="middle" fill="#263f50">'+ET.tostring(node,encoding='unicode')+'</g>'))
png=isolated.with_suffix('.png');ink(isolated,'--export-type=png',f'--export-filename={png}')
bb=Image.open(png).getchannel('A').getbbox();assert bb and rx<=bb[0] and ry<=bb[1] and bb[2]<=rx+rw and bb[3]<=ry+rh
glyphs.append({'id':key,'objectBounds':bounds[key],'unclippedInkBounds':bb,'region':regions[role],'pass':True})
protected=text.getchannel('A').filter(ImageFilter.MaxFilter(25)).filter(ImageFilter.GaussianBlur(8))
analysis=prepare_illustration(art)
entry={'id':id,'tier':tier,'sharedLayers':{'version':selected['version'],'manifestSHA256':selected['manifestSHA256'],'assets':{k:{'path':str(v.relative_to(REPO)),'sha256':digest(v)} for k,v in assets.items()}},'art':{'path':card['art'],'sha256':digest(art_path),'pixelsUnchanged':True},'typography':{'fonts':font_checks,'glyphs':glyphs},'printings':{}}
for printing,overlay_key,has_text in [('normal','overlay-normal',True),('textless','frame-textless',False),('borderless','backing-borderless',True),('boundless',None,False)]:
overlay=Image.open(assets[overlay_key]).convert('RGBA') if overlay_key else None
face=art.copy()
if overlay:face=Image.alpha_composite(face,overlay)
if has_text:
assert np.all(np.asarray(overlay)[:,:,3][ta>0]==255),'Text outside opaque backing'
face=Image.alpha_composite(face,text)
assert face.getchannel('A').getextrema()==(255,255)
if printing=='boundless':assert np.array_equal(np.asarray(face),np.asarray(art))
finishes=export_finish(compile_finish(analysis,printing,overlay,text if has_text else None),OUT/'masks',id,printing)
files=[]
for width in [2000,1000,500]:
target=OUT/'cards'/f'{id}-{printing}-{width}.png';im=face if width==2000 else face.resize((width,width*7//5),Image.Resampling.LANCZOS);im.save(target)
mask_path=OUT/'masks'/f'{id}-{printing}-{width}-text-mask.png'
m=protected if has_text else Image.new('L',(2000,2800),0)
if width!=2000:m=m.resize(im.size,Image.Resampling.BILINEAR)
rgba=m.convert('RGBA');rgba.putalpha(255);rgba.save(mask_path)
fm_path=OUT/'masks'/f'{id}-{printing}-{width}-mask.png'; fm_image=Image.open(fm_path).convert('RGBA');arr=np.asarray(fm_image)
assert fm_image.size==im.size and np.all(arr[:,:,3]==255) and np.array_equal(arr[:,:,0],arr[:,:,1]) and np.array_equal(arr[:,:,1],arr[:,:,2])
if width==1000:
for path in [target,fm_path]+([mask_path] if has_text else []):(OUT/'runtime'/path.name).write_bytes(path.read_bytes())
files.append({'width':width,'faceSHA256':digest(target),'finishSHA256':digest(fm_path),'textMaskSHA256':digest(mask_path),'opaque':True})
# Linked shared layers remain editable centrally; lettering remains real text objects.
editable=(OUT/'editable'/f'{id}-{printing}.svg').resolve()
def link(p):return f'<image width="2000" height="2800" xlink:href="{escape(os.path.relpath(p,editable.parent))}"/>'
editable.write_text(svg(link(art_path)+(link(assets[overlay_key]) if overlay_key else '')+(body if has_text else '')))
entry['printings'][printing]={'exports':files,'finishProvenance':finishes,'overlay':overlay_key,'text':has_text}
if tier!='common':
contrasts=[]
for width in [2000,1000,500]:
scale=width/2000
b=np.asarray(Image.open(selected['directory']/'exports'/f'backing-borderless-{width}.png').convert('RGBA'))
xx=int((layout['borderlessBacking'][0][0]+layout['primaryRuleInset'])*scale); yy=int(180*scale)
sample=b[yy,max(0,xx-1):xx+2,:3]; bg=b[yy,int(500*scale),:3]
ratio=max(contrast(c,bg) for c in sample)
assert ratio>=3,(id,width,ratio)
contrasts.append({'width':width,'primaryRuleToCream':round(ratio,2),'minimum':3})
entry['backingContrast']=contrasts
entry['textContrast']=round(contrast([38,63,80],[244,234,212]),2);assert entry['textContrast']>=7
report['cards'].append(entry);print(f'{tier}: all printings and text checks passed',flush=True)
report['status']='passed';save_json(OUT/'validation.json',report)
# Comparison sheets use identical scale and neutral backdrops, with labels outside assets.
FONT=ImageFont.truetype(str(FONT_DIR/'P052-Bold.otf'),24)
def sheet(rows,path):
cellw,cellh=340,510; im=Image.new('RGB',(4*cellw,len(rows)*cellh),(14,18,22));draw=ImageDraw.Draw(im)
for row,(label,paths,checker) in enumerate(rows):
for col,(tier,p) in enumerate(zip([c['tier'] for c in records],paths)):
x=col*cellw+20;y=row*cellh+50;draw.text((x,row*cellh+12),f'{tier.title()} · {label}',font=FONT,fill='#eee6d5')
bg=Image.new('RGBA',(300,420),(40,46,52,255))
if checker:
d=ImageDraw.Draw(bg)
for yy in range(0,420,20):
for xx in range(0,300,20):
if (xx//20+yy//20)%2:d.rectangle([xx,yy,xx+19,yy+19],fill=(61,66,72,255))
layer=Image.open(p).convert('RGBA').resize((300,420),Image.Resampling.LANCZOS);bg=Image.alpha_composite(bg,layer);im.paste(bg.convert('RGB'),(x,y))
im.save(path)
new=[load_tier(c['tier'],c['layerVersion']) for c in records];old=[load_tier(c['tier'],'v1') for c in records]
sheet([(label,[s['assets'][key] for s in new],True) for label,key in [('Normal','overlay-normal'),('Borderless','backing-borderless'),('Textless','frame-textless')]],OUT/'review/tier-layers.png')
sheet([('Before',[REPO/c['previousNormal'] for c in records],False),('After',[OUT/'cards'/f'{c["exportPrefix"]}-normal-1000.png' for c in records],False)],OUT/'review/cards-before-after.png')
sheet([('Before',[s['assets']['overlay-normal'] for s in old],True),('After',[s['assets']['overlay-normal'] for s in new],True)],OUT/'review/layers-before-after.png')
print(OUT/'review')