#!/usr/bin/env python3 """Assemble a reviewed card with pinned central-flourish layers and P052 text.""" from pathlib import Path import argparse,copy,json,os,struct,subprocess,sys import xml.etree.ElementTree as ET from xml.sax.saxutils import escape import numpy as np from PIL import Image,ImageDraw,ImageFont,ImageFilter REPO=Path(__file__).resolve().parents[2] sys.path.insert(0,str(REPO/'artifacts/layers'));sys.path.insert(0,str(Path(__file__).resolve().parent)) 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 NS='{http://www.w3.org/2000/svg}';ET.register_namespace('','http://www.w3.org/2000/svg') def check(ok,msg): if not ok:raise ValueError(msg) def save(p,v):p.write_text(json.dumps(v,indent=2)+'\n') def metric_box(font,size): b=font.read_bytes();tables={} for i in range(struct.unpack_from('>H',b,4)[0]): tag,_,offset,length=struct.unpack_from('>4sIII',b,12+16*i);tables[tag.decode()]=b[offset:offset+length] upm=struct.unpack_from('>H',tables['head'],18)[0];asc,desc,_=struct.unpack_from('>hhh',tables['hhea'],4);ymin=struct.unpack_from('>h',tables['head'],38)[0] import math return {'unitsPerEm':upm,'hheaAscender':asc,'hheaDescender':desc,'headYMin':ymin,'size':size,'ascent':math.ceil(asc*size/upm),'descent':math.ceil(max(-desc,-ymin)*size/upm),'lineGapUsed':0} def luminance(colors): c=np.asarray(colors,dtype=float)/255;c=np.where(c<=.04045,c/12.92,((c+.055)/1.055)**2.4);return c@np.array([.2126,.7152,.0722]) def contrast(a,b): aa=luminance(a);bb=luminance(b);return (np.maximum(aa,bb)+.05)/(np.minimum(aa,bb)+.05) def document(rows,sizes,faces): page=ET.Element(NS+'svg',{'width':'2000','height':'2800','viewBox':'0 0 2000 2800'});group=ET.SubElement(page,NS+'g',{'fill':'#263f50','text-anchor':'middle'}) for row in rows: role=row['role'];n=ET.SubElement(group,NS+'text',{'id':row['id'],'x':'1000','y':str(row['baseline']),'font-family':faces[role]['family'],'font-weight':str(faces[role]['weight']),'font-size':str(sizes[role])});n.text=row['text'] return ET.ElementTree(page) def assemble(root): root=Path(root).resolve();manifest=json.loads((root/'manifest.json').read_text());card=json.loads((root/'card.json').read_text());check(manifest['stage']!='approved','Approved revisions are frozen.');check(card.get('artApproval'),'Artwork approval required before assembly.') shared=load_tier(card['tier'],card['layerVersion']);layout=shared['layout'];assets=shared['assets'];check('spacing' in layout,'This builder requires an explicitly pinned central-flourish spacing layout.') fonts=REPO/'fonts';fm=json.loads((fonts/'manifest.json').read_text());faces={r:dict(fm['productionTypography'][r]) for r in ['title','verse','reference']} for role,face in faces.items():face['family']='P052' if role=='title' else 'Sanctification P052' for name,record in fm['files'].items():check(digest(fonts/name)==record['sha256'],f'Font input changed: {name}') textdir=root/'source/text';textdir.mkdir(parents=True,exist_ok=True);review=root/'review';review.mkdir(exist_ok=True);config=review/'fonts.conf';config.write_text(f'{fonts}{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,stderr=subprocess.PIPE).strip() def render(src,dst,*args):ink(src,*args,'--export-type=png',f'--export-filename={dst}','--export-width=2000','--export-height=2800') def query(src): return {f[0]:list(map(float,f[1:])) for line in ink(src,'--query-all').splitlines() if len(f:=line.split(','))==5} def row_image(rows,name): source=textdir/(name+'.svg');document(rows,sizes,faces).write(source,encoding='unicode');png=source.with_suffix('.png');render(source,png);return Image.open(png).convert('RGBA') artpath=REPO/card['art'];art=Image.open(artpath).convert('RGBA');check(art.size==(2000,2800) and art.getchannel('A').getextrema()==(255,255),'Master artwork must be opaque on the shared canvas.');check(digest(artpath)==card['artSHA256'],'Approved artwork hash mismatch.') check(card['referenceDisplay']==f'{card["reference"]} • {card["translation"]}','Reference format mismatch.');check(' '.join(card['titleLines'])==card['name'],'Title content mismatch.');check(' '.join(card['verseLines'])==card['excerpt'],'Excerpt content mismatch.');check(len(card['verseLines'])<=layout['maximumVerseLines'],'Too many verse lines.');check(len(card['titleLines']) in [1,2],'Unsupported title line count.') sizes=dict(layout['fontSizes']);sizes['title']=card['titleFontSize'];title=[{'id':f'title-{i}','role':'title','text':line,'baseline':[176,266][i] if len(card['titleLines'])==2 else 240} for i,line in enumerate(card['titleLines'])] seed=layout['baselines']['verseByLineCount'][str(len(card['verseLines']))];verse=[{'id':f'verse-{i}','role':'verse','text':line,'baseline':y} for i,(line,y) in enumerate(zip(card['verseLines'],seed))];ref={'id':'reference','role':'reference','text':card['referenceDisplay'],'baseline':2645};rows=title+verse+[ref] fontchecks=[] for role,face in faces.items(): font=fonts/face['file'];matched=Path(subprocess.check_output(['fc-match','--format=%{file}',face['family']+(':style=Bold' if role=='title' else ':style=Medium')],env=env,text=True)).resolve();check(matched==font.resolve(),f'Font fallback: {role}') ranges=[(int(t.split('-')[0],16),int(t.split('-')[-1],16)) for t in subprocess.check_output(['fc-query','--format=%{charset}',str(font)],text=True).split()];check(all(any(lo<=ord(c)<=hi for lo,hi in ranges) for row in rows if row['role']==role for c in row['text']),f'Missing glyph: {role}');check(sizes[role]>=layout['fontMinimums'][role],f'Font below approved minimum: {role}') fontchecks.append({'role':role,'path':str(font.relative_to(REPO)),'sha256':digest(font),'size':sizes[role],'weight':face['weight'],'fallbackUsed':False,'missingGlyphs':[]}) ornaments={} for kind in ['normal','borderless']: source=shared['directory']/'source'/f'backing-{kind}.svg';ornaments[kind]={} for ident in ['header','footer-top','footer-bottom']: png=review/f'{kind}-{ident}.png';render(source,png,f'--export-id={ident}','--export-id-only','--export-area-page');bb=Image.open(png).getchannel('A').getbbox();check(bb,f'Missing flourish: {kind}/{ident}');ornaments[kind][ident]=bb check(ornaments['normal']==ornaments['borderless'],'Normal and Borderless must share central flourish vertical geometry.') upper=ornaments['borderless']['footer-top'][3];lower=ornaments['borderless']['footer-bottom'][1];metrics=metric_box(fonts/faces['reference']['file'],sizes['reference']);ref['baseline']=lower-layout['spacing']['referenceLineBoxToLowerFlourish']-metrics['descent'];line_top=ref['baseline']-metrics['ascent'];line_bottom=ref['baseline']+metrics['descent'] # Title centering preserves the reviewed visual-bounds policy and multiline size. initial=textdir/'initial-title.svg';document(title,sizes,faces).write(initial,encoding='unicode');qb=query(initial);top=min(qb[r['id']][1] for r in title);bottom=max(qb[r['id']][1]+qb[r['id']][3] for r in title) geom=layout['spacing']['geometry']['borderless'];header_visual=next(e['flourishVisualBounds'] for e in geom['flourishes'] if e['id']=='header');pad=layout['spacing']['titleClearance'];tupper=geom['headerInnerTop']+pad;tlower=header_visual[1]-pad;check(bottom-top<=tlower-tupper,'Title cannot fit with required flourish clearance.') shift=(tupper+tlower-top-bottom)/2 for row in title:row['baseline']+=shift # Verse centering uses actual rendered combined ink, not fixed margins. vi=row_image(verse,'initial-verse-unclipped');bb=vi.getchannel('A').getbbox();check(bb,'No verse ink.');delta=(upper+line_top-bb[1]-bb[3])/2 for row in verse:row['baseline']+=delta textpath=textdir/'text-2000.png';textsource=textdir/'text.svg';document(rows,sizes,faces).write(textsource,encoding='unicode');render(textsource,textpath);text=Image.open(textpath).convert('RGBA');ta=np.asarray(text.getchannel('A'));q=query(textsource);regions=dict(layout['textRegions']);regions['title']=[140,100,1720,200] if len(title)==2 else regions['title'];glyphs=[];boxes={} for row in rows: bb=row_image([row],row['id']+'-unclipped').getchannel('A').getbbox();boxes[row['id']]=bb;rx,ry,rw,rh=regions[row['role']];x,y,w,h=q[row['id']] check(bb and rx<=bb[0] and ry<=bb[1] and bb[2]<=rx+rw and bb[3]<=ry+rh,f'Unclipped glyph overflow: {row["id"]}: {bb}');check(rx<=x and ry<=y and x+w<=rx+rw and y+h<=ry+rh,f'Object overflow: {row["id"]}') glyphs.append({**row,'objectBounds':q[row['id']],'unclippedInkBounds':list(bb),'region':regions[row['role']],'pass':True}) rb=boxes['reference'];check(rb[1]>=line_top and rb[3]<=line_bottom,'Reference ink escapes fixed line box.');check(lower-rb[3]>=20,'Reference glyph clearance below minimum.') vb=[min(boxes[r['id']][0] for r in verse),min(boxes[r['id']][1] for r in verse),max(boxes[r['id']][2] for r in verse),max(boxes[r['id']][3] for r in verse)];above=vb[1]-upper;below=line_top-vb[3];check(min(above,below)>=20 and abs(above-below)<=1,'Verse centering or collision check failed.') title_end=max(boxes[r['id']][3] for r in title);titleclear=ornaments['borderless']['header'][1]-title_end;check(titleclear>=pad,'Title touches flourish.') # Check actual shape collision, including any larger central ornaments. title_alpha=np.zeros_like(ta);title_alpha[:360]=ta[:360];expanded=Image.fromarray(title_alpha).filter(ImageFilter.MaxFilter(pad*2+1)) for kind in ['normal','borderless']: oa=np.asarray(Image.open(review/f'{kind}-header.png').getchannel('A'));check(not np.any((np.asarray(expanded)>0)&(oa>0)),'Expanded title ink intersects header flourish.') stress=[] for i,wording in enumerate(['Matthew 4:19 • NKJV','Genesis 3:9 • ESV','Exodus 3:6 • KJV','Philippians 2:11 • NKJV']): probe=dict(ref);probe['text']=wording;image=row_image([probe],f'stress-reference-{i}');bb=image.getchannel('A').getbbox();check(bb and bb[1]>=line_top and bb[3]<=line_bottom and lower-bb[3]>=20,'Stress reference line-box or clearance failure.');stress.append({'text':wording,'baseline':ref['baseline'],'inkBounds':list(bb),'actualClearance':lower-bb[3]}) scaled=[] for width in [2000,1000,500]: dims=(width,width*7//5) def resized_bounds(path):return Image.open(path).getchannel('A').resize(dims,Image.Resampling.LANCZOS).point(lambda a:255 if a>=128 else 0).getbbox() ob=resized_bounds(review/'borderless-footer-bottom.png') for i,case in enumerate(stress): bb=resized_bounds(textdir/f'stress-reference-{i}.png');gap=ob[1]-bb[3];check(gap>=20*width/2000-1,'Scaled reference clearance failure.');scaled.append({'width':width,'text':case['text'],'actualClearance':gap,'minimumWithRounding':20*width/2000-1,'alphaThreshold':128}) protected=text.getchannel('A').filter(ImageFilter.MaxFilter(25)).filter(ImageFilter.GaussianBlur(8));coverage=int(np.min(np.asarray(protected)[ta>=128]));check(coverage>=216,'Text protection fails to cover 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') card.update({'verseBaselines':[row['baseline'] for row in verse],'titleBaselines':[row['baseline'] for row in title],'referenceBaseline':ref['baseline'],'typographyPolicy':'fixed-reference-line-box-and-visible-verse-centering'});save(root/'card.json',card) report={'status':'pending','cardId':card['cardId'],'revision':manifest['revision'],'artApproval':card['artApproval'],'finalCardApproval':None,'inputs':{'art':{'path':card['art'],'sha256':digest(artpath)},'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(fonts/'manifest.json'),'typography':{'fonts':fontchecks,'glyphs':glyphs,'fontMetrics':metrics,'referenceBaseline':ref['baseline'],'referenceLineBox':[line_top,line_bottom],'referenceLineBoxToFlourish':lower-line_bottom,'actualReferenceClearance':lower-rb[3],'upperFlourishToVerse':above,'verseToReferenceLineBox':below,'centeringError':abs(above-below),'titleToFlourish':titleclear,'stressReferences':stress,'scaledRasterChecks':scaled,'textLayerSHA256':digest(textpath)},'textMask':{'expansionRadius':12,'gaussianRadius':8,'units':'master pixels','derivation':'Actual lettering alpha: MaxFilter(25), GaussianBlur(8)','minimumProtectionAtGlyphAlpha128':coverage},'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'}} backingcontrast=[] for kind in ['normal','borderless']: base=Image.open(assets['backing-'+kind]).convert('RGBA');arr=np.asarray(base);actual=float(np.min(contrast([38,63,80],arr[:,:,:3][ta>=128])));check(actual>=7,'Actual text/backing contrast below 7:1.') report.setdefault('textContrast',{})[kind]=round(actual,2) for width in [2000,1000,500]: scale=width/2000;arr=np.asarray(Image.open(shared['directory']/'exports'/f'backing-{kind}-{width}.png').convert('RGBA'));x=int((layout[kind+'Backing'][0][0]+layout['primaryRuleInset'])*scale);y=int(180*scale);ratio=float(max(contrast(c,arr[y,int(500*scale),:3]) for c in arr[y,max(0,x-1):x+2,:3]));check(ratio>=3,f'Backing-rule contrast too low: {kind}/{width}');backingcontrast.append({'printing':kind,'width':width,'primaryRuleToCream':round(ratio,2),'minimum':3}) report['backingContrast']=backingcontrast for printing,key,has_text in [('normal','overlay-normal',True),('boundless',None,False),('borderless','backing-borderless',True),('textless','frame-textless',False)]: overlay=Image.open(assets[key]).convert('RGBA') if 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),'Glyphs outside fully opaque backing.');face=Image.alpha_composite(face,text) check(face.getchannel('A').getextrema()==(255,255),'Face transparency.');check(printing!='boundless' or np.array_equal(np.asarray(face),np.asarray(art)),'Boundless changed artwork.');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);dims=(width,width*7//5);face.resize(dims,Image.Resampling.LANCZOS).save(folder/'card.png') for name,data in [('finish-mask.png',finish),('text-mask.png',tm)]: mapdata=data if width==2000 else data.resize(dims,Image.Resampling.BILINEAR);rgba=mapdata.convert('RGBA');rgba.putalpha(255);rgba.save(folder/name) exports.append({'resolution':label,'dimensions':list(dims),'files':{n:digest(folder/n) for n in ['card.png','finish-mask.png','text-mask.png']}}) editable=root/'high'/printing/'card.svg' def link(path):return f'' textbody=ET.tostring(document(rows,sizes,faces).getroot()[0],encoding='unicode') if has_text else '' editable.write_text(''+link(artpath)+(link(assets[key]) if key else '')+textbody+'');report['printings'][printing]={'overlay':key,'text':has_text,'exports':exports} save(root/'source/typography-layout.json',{'fontMetrics':metrics,'regions':regions,'rows':rows,'policy':'Approved 20px flourish spacing; fixed reference line box; actual verse ink centering.','fontSizes':sizes}) board=Image.new('RGB',(1200,480),'#11161c');d=ImageDraw.Draw(board);label_font=ImageFont.truetype(str(fonts/'P052-Bold.otf'),22) for i,printing in enumerate(['normal','boundless','borderless','textless']): d.text((i*300+10,8),printing.title(),font=label_font,fill='#f4ead4');board.paste(Image.open(root/'med'/printing/'card.png').convert('RGB').resize((280,392),Image.Resampling.LANCZOS),(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.update({'stage':'assembly-review','selectedRevision':manifest['revision'],'approval':None});save(profile_path,profile);refresh(root);update_index() print(f'{card["cardId"]} {manifest["revision"]}: four printings at low/med/high; fonts, glyphs, baseline, centering, contrast and masks passed.') if __name__=='__main__': parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--root',type=Path,required=True);assemble(parser.parse_args().root)