Moved some files around and made cleanup better

This commit is contained in:
2026-09-14 19:01:08 -07:00
parent 50b234104a
commit 97ed7f05c8
866 changed files with 404 additions and 1612 deletions

View File

@@ -0,0 +1,165 @@
#!/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'<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)
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'<fontconfig><dir>{font_dir}</dir><cachedir>{review/"font-cache"}</cachedir></fontconfig>')
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'<text id="{key}" x="1000" y="{y}" font-family="{families[role]}" font-weight="{fm["productionTypography"][role]["weight"]}" font-size="{sizes[role]}">{escape(content)}</text>' for key,role,content,y in rows]
body='<g fill="#263f50" text-anchor="middle">'+''.join(nodes)+'</g>'
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('<g text-anchor="middle" fill="#263f50">'+node+'</g>'))
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'<image width="2000" height="2800" xlink:href="{escape(os.path.relpath(path,editable.parent))}"/>'
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()