#!/usr/bin/env python3
"""Assemble the approved BE-042 v02 artwork with pinned shared Uncommon assets."""
from pathlib import Path
import 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 library import load_tier, digest
from card_workspace import refresh, update_index
from finish_masks import prepare_illustration, compile_finish, RECIPE_PATH, RECIPE_SHA256
def save(path,value):path.write_text(json.dumps(value,indent=2)+'\n')
def svg(body):return f''
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)
def check(condition,message):
if not condition:raise ValueError(message)
def check_decoration_clearance(title_alpha,ornament_alpha,padding):
expanded=title_alpha.filter(ImageFilter.MaxFilter(padding*2+1))
check(not np.any((np.asarray(expanded)>0)&(np.asarray(ornament_alpha)>0)),
'Title overlaps the header ornament or its required breathing room.')
def main():
manifest=json.loads((ROOT/'manifest.json').read_text())
check(manifest['stage']!='approved','Approved cards are frozen; create a working revision.')
card=json.loads((ROOT/'card.json').read_text())
check(bool(card.get('artApproval')),'Explicit artwork approval is required before assembly.')
check(card['cardId']=='BE-042' and card['tier']=='uncommon','Wrong card or tier for this builder.')
shared=load_tier(card['tier'],card['layerVersion']);layout=shared['layout'];assets=shared['assets']
font_dir=REPO/'fonts';fm=json.loads((font_dir/'manifest.json').read_text())
for file,record in fm['files'].items():check(digest(font_dir/file)==record['sha256'],f'Shared font changed: {file}')
review=ROOT/'review';text_dir=ROOT/'source/text';text_dir.mkdir(exist_ok=True)
config=review/'fonts.conf';config.write_text(f'{font_dir}{review/"font-cache"}')
env=dict(os.environ,FONTCONFIG_FILE=str(config))
def ink(*args):return subprocess.check_output(['inkscape',*map(str,args)],env=env,text=True).strip()
art_path=REPO/card['art'];art=Image.open(art_path).convert('RGBA')
check(art.size==(2000,2800) and art.getchannel('A').getextrema()==(255,255),'Master must be opaque on the common canvas.')
check(digest(art_path)==card['artSHA256'],'Approved artwork bytes changed.')
check(card['referenceDisplay']==f'{card["reference"]} • {card["translation"]}','Reference formatting changed.')
check(' '.join(card['verseLines'])==card['excerpt'],'Verse lines differ from the retained quotation.')
check(' '.join(card['titleLines'])==card['name'],'Title lines differ from the full catalogue title.')
check(len(card['verseLines'])<=layout['maximumVerseLines'],'Too many verse lines.')
check(card['verseBaselines']==layout['baselines']['verseByLineCount'][str(len(card['verseLines']))],'Verse layout differs from pinned template.')
treatment=json.loads((ROOT/'source/typography-layout.json').read_text())
regions=dict(layout['textRegions']);regions['title']=treatment['title']['region']
tx,ty,tw,th=regions['title'];px,py,pw,ph=layout['borderlessBacking'][0]
check(px<=tx and py<=ty and tx+tw<=px+pw and ty+th<=py+ph,'Alternate title region leaves the existing panel.')
sizes=dict(layout['fontSizes']);sizes['title']=treatment['title']['fontSize'];families={'title':'P052','verse':'Sanctification P052','reference':'Sanctification P052'}
rows=[(f'title-{i}','title',line,y) for i,(line,y) in enumerate(zip(card['titleLines'],treatment['title']['baselines']))]
check(len(rows)==len(card['titleLines'])==2,'Reviewed title uses exactly two lines.')
rows += [(f'verse-{i}','verse',line,y) for i,(line,y) in enumerate(zip(card['verseLines'],card['verseBaselines']))]
rows += [('reference','reference',card['referenceDisplay'],layout['baselines']['reference'])]
font_checks=[]
for role in families:
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()
check(matched==file.resolve(),f'Font fallback for {role}: {matched}')
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()]
check(all(any(lo<=ord(c)<=hi for lo,hi in ranges) for row in rows if row[1]==role for c in row[2]),f'Missing glyph in {role}.')
check(sizes[role]>=layout['fontMinimums'][role],f'Font below minimum in {role}.')
font_checks.append({'role':role,'file':str(file.relative_to(REPO)),'sha256':digest(file),'weight':face['weight'],'fallbackUsed':False,'missingGlyphs':[]})
nodes=[f'{escape(content)}' for key,role,content,y in rows]
body=''+''.join(nodes)+''
source=text_dir/'text.svg';source.write_text(svg(body));text_path=text_dir/'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.getchannel('A'));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),node in zip(rows,nodes):
rx,ry,rw,rh=regions[role];x,yy,w,h=bounds[key]
check(rx<=x and ry<=yy and x+w<=rx+rw and yy+h<=ry+rh,f'Object overflow: {key}: {bounds[key]} against {regions[role]}')
isolated=text_dir/f'{key}-unclipped.svg';isolated.write_text(svg(''+node+''))
png=isolated.with_suffix('.png');ink(isolated,'--export-type=png',f'--export-filename={png}')
bb=Image.open(png).getchannel('A').getbbox()
check(bb and rx<=bb[0] and ry<=bb[1] and bb[2]<=rx+rw and bb[3]<=ry+rh,f'Unclipped glyph overflow: {key}: {bb}')
glyphs.append({'id':key,'objectBounds':bounds[key],'unclippedInkBounds':list(bb),'region':regions[role],'pass':True})
decoration_checks=[]
padding=treatment['title']['minimumDecorationClearance']
title_alpha=text.getchannel('A').crop((0,0,2000,360))
for kind in ['normal','borderless']:
ornament_source=shared['directory']/'source'/f'backing-{kind}.svg'
diagnostic=review/f'header-ornament-{kind}.png'
ink(ornament_source,'--export-id=ornament-299','--export-id-only','--export-area-page',
'--export-type=png',f'--export-filename={diagnostic}','--export-width=2000','--export-height=2800')
ornament=Image.open(diagnostic).getchannel('A');ob=ornament.getbbox()
check(bool(ob),'Expected header ornament missing from selected template.')
check_decoration_clearance(title_alpha,ornament.crop((0,0,2000,360)),padding)
last=max(g['unclippedInkBounds'][3] for g in glyphs if g['id'].startswith('title-'))
decoration_checks.append({'printing':kind,'source':str(ornament_source.relative_to(REPO)),
'sourceSHA256':digest(ornament_source),'ornamentInkBounds':list(ob),
'verticalClearance':ob[1]-last,'minimumClearance':padding,
'actualExpandedInkIntersection':False,'pass':True})
protected=text.getchannel('A').filter(ImageFilter.MaxFilter(25)).filter(ImageFilter.GaussianBlur(8))
check(np.min(np.asarray(protected)[ta>=128])>=216,'Text protection does not cover rendered glyphs.')
analysis=prepare_illustration(art)
Image.fromarray(analysis.raw_ridges).save(ROOT/'source/raw-ridges.png')
Image.fromarray(np.rint(analysis.pane_weights*255).astype(np.uint8)).save(ROOT/'source/pane-coverage.png')
report={'status':'pending','cardId':card['cardId'],'revision':manifest['revision'],'artApproval':card['artApproval'],'finalCardApproval':None,
'inputs':{'art':{'path':card['art'],'sha256':digest(art_path)},'fit':json.loads((ROOT/'source/art-provenance.json').read_text()),'cardSHA256':digest(ROOT/'card.json'),'builderSHA256':digest(Path(__file__))},
'sharedLayers':{'tier':card['tier'],'version':shared['version'],'manifestSHA256':shared['manifestSHA256'],'reviewStatus':shared['reviewStatus'],'assets':{k:{'path':str(v.relative_to(REPO)),'sha256':digest(v)} for k,v in assets.items()}},
'sharedFontManifestSHA256':digest(font_dir/'manifest.json'),'typography':{'fonts':font_checks,'glyphs':glyphs,'titleLayout':treatment,'decorationClearance':decoration_checks,'textLayerSHA256':digest(text_path)},
'textMask':{'expansionRadius':12,'gaussianRadius':8,'units':'master pixels','derivation':'Actual rendered text alpha; MaxFilter(25), GaussianBlur(8)','minimumProtectionAtGlyphAlpha128':int(np.min(np.asarray(protected)[ta>=128]))},
'finishRecipe':{'id':'raw-ridges-v1','sha256':RECIPE_SHA256,'path':str(RECIPE_PATH.relative_to(REPO)),'moduleSHA256':digest(REPO/'tools/card-production/finish_masks.py'),'rawRidgeSHA256':digest(ROOT/'source/raw-ridges.png'),'paneCoverageSHA256':digest(ROOT/'source/pane-coverage.png'),'illustrationAnalyses':1},
'tools':{'inkscape':ink('--version'),'pillow':Image.__version__,'numpy':np.__version__},'printings':{},'visualReview':{'static':'pending','movingLight':'not performed'}}
for printing,overlay_key,has_text in [('normal','overlay-normal',True),('boundless',None,False),('borderless','backing-borderless',True),('textless','frame-textless',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:
check(np.all(np.asarray(overlay.getchannel('A'))[ta>0]==255),'Text outside fully opaque backing.')
face=Image.alpha_composite(face,text)
check(face.getchannel('A').getextrema()==(255,255),'Face has transparency.')
if printing=='boundless':check(np.array_equal(np.asarray(face),np.asarray(art)),'Boundless changed artwork pixels.')
finish=compile_finish(analysis,printing,overlay,text if has_text else None)
if overlay:check(np.all(np.asarray(finish)[np.asarray(overlay.getchannel('A'))==255]==0),'Finish leaks onto opaque overlay.')
tm=protected if has_text else Image.new('L',(2000,2800),0)
exports=[]
for label,width in [('low',500),('med',1000),('high',2000)]:
folder=ROOT/label/printing;folder.mkdir(parents=True,exist_ok=True);size=(width,width*7//5)
f=face if width==2000 else face.resize(size,Image.Resampling.LANCZOS);f.save(folder/'card.png')
for name,m in [('finish-mask.png',finish),('text-mask.png',tm)]:
data=m if width==2000 else m.resize(size,Image.Resampling.BILINEAR)
rgba=data.convert('RGBA');rgba.putalpha(255);rgba.save(folder/name)
exports.append({'resolution':label,'dimensions':list(size),'files':{name:digest(folder/name) for name in ['card.png','finish-mask.png','text-mask.png']}})
editable=ROOT/'high'/printing/'card.svg'
def link(path):return f''
editable.write_text(svg(link(art_path)+(link(assets[overlay_key]) if overlay_key else '')+(body if has_text else '')))
report['printings'][printing]={'overlay':overlay_key,'text':has_text,'exports':exports}
contrasts=[]
for width in [2000,1000,500]:
scale=width/2000;b=np.asarray(Image.open(shared['directory']/'exports'/f'backing-borderless-{width}.png').convert('RGBA'))
xx=int((layout['borderlessBacking'][0][0]+layout['primaryRuleInset'])*scale);yy=int(180*scale)
ratio=max(contrast(c,b[yy,int(500*scale),:3]) for c in b[yy,max(0,xx-1):xx+2,:3])
check(ratio>=3,f'Backing rule contrast too low at {width}.');contrasts.append({'width':width,'primaryRuleToCream':round(ratio,2),'minimum':3})
report['backingContrast']=contrasts;report['textContrast']=round(contrast([38,63,80],[244,234,212]),2)
check(report['textContrast']>=7,'Text contrast below 7:1.')
# Labeled review sheet keeps annotations outside printing assets.
board=Image.new('RGB',(1200,480),'#11161c');draw=ImageDraw.Draw(board);label_font=ImageFont.truetype(str(font_dir/'P052-Bold.otf'),22)
for i,printing in enumerate(['normal','boundless','borderless','textless']):
draw.text((i*300+10,8),printing.title(),font=label_font,fill='#f4ead4')
im=Image.open(ROOT/'med'/printing/'card.png').convert('RGB').resize((280,392),Image.Resampling.LANCZOS);board.paste(im,(i*300+10,50))
board.save(review/'printings-comparison.png')
report['status']='passed';save(review/'build-validation.json',report)
manifest['stage']='assembly-review';save(ROOT/'manifest.json',manifest)
profile_path=ROOT.parents[1]/'card.json';profile=json.loads(profile_path.read_text());profile['stage']='assembly-review';save(profile_path,profile)
refresh(ROOT);update_index()
print('BE-042 v02: all four printings at low/med/high, glyph bounds, fonts, contrast and data-map checks passed.')
if __name__=='__main__':main()