#!/usr/bin/env python3 """Isolated font-metric reference anchoring and visible-ink verse centering proof.""" from pathlib import Path import copy,hashlib,json,math,os,struct,subprocess import xml.etree.ElementTree as ET import numpy as np from PIL import Image,ImageDraw,ImageFont ROOT=Path(__file__).resolve().parent REPO=next(p for p in ROOT.parents if (p/'docs/card-layer-pipeline.md').is_file()) BASE=ROOT/'output-centered';BACK=ROOT/'output-retyped';OUT=ROOT/'output-baseline';OUT.mkdir(exist_ok=True) NS='{http://www.w3.org/2000/svg}';ET.register_namespace('','http://www.w3.org/2000/svg') fonts=REPO/'fonts';manifest=json.loads((fonts/'manifest.json').read_text()) def digest(p):return hashlib.sha256(p.read_bytes()).hexdigest() for name in ['P052-Bold.otf','SanctificationP052-Medium.otf']:assert digest(fonts/name)==manifest['files'][name]['sha256'] # Read SFNT tables directly: no dependency installation or word-dependent metrics. b=(fonts/'SanctificationP052-Medium.otf').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,linegap=struct.unpack_from('>hhh',tables['hhea'],4) ymin=struct.unpack_from('>h',tables['head'],38)[0] size=57;ascent=math.ceil(asc*size/upm);descent=math.ceil(max(-desc,-ymin)*size/upm);gap=20 config=OUT/'fonts.conf';config.write_text(f'{fonts}{OUT/"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 isolate(node,folder,name): page=ET.Element(NS+'svg',{'width':'2000','height':'2800','viewBox':'0 0 2000 2800'});g=ET.SubElement(page,NS+'g',{'fill':'#263f50','text-anchor':'middle'});g.append(copy.deepcopy(node)) src=folder/(name+'.svg');ET.ElementTree(page).write(src,encoding='unicode');dst=src.with_suffix('.png');render(src,dst);return Image.open(dst).convert('RGBA') roots={'uncommon':REPO/'in-progress/cards/BE-042-calling-of-the-first-disciples/revisions/v02','rare':REPO/'artifacts/cards/BE-010-burning-bush','extraordinary':REPO/'artifacts/cards/BE-002-the-fall'} # Immutable comparison inputs and canonical sources are hashed before/after. protected=[p for parent in [BASE,BACK,*roots.values(),REPO/'artifacts/layers'] for p in parent.rglob('*') if p.is_file() and not p.is_symlink()] before={str(p):digest(p) for p in protected} report={'status':'pending','scope':'Static typography preview only; production cards, shared layers, docs and harness unchanged.','referenceRule':'baseline = lower flourish visible ink top - 20 - fixed descender allowance','verseRule':'Center combined actual verse ink between upper flourish visible bottom and fixed reference line-box top.','fontMetrics':{'unitsPerEm':upm,'hheaAscender':asc,'hheaDescender':desc,'headYMin':ymin,'referenceSize':size,'lineBoxAscent':ascent,'lineBoxDescent':descent,'lineGapUsed':0,'policy':'Ceil hhea ascent; ceil maximum of hhea descent and global font outline descent to include expanded Medium glyphs.'},'tiers':[]} for tier,root in roots.items(): folder=OUT/tier;folder.mkdir(exist_ok=True);card=json.loads((root/'card.json').read_text()) lower_path=folder/'lower-flourish.png';render(BACK/tier/'backing-borderless-20.svg',lower_path,'--export-id=footer-bottom','--export-id-only','--export-area-page');lower=Image.open(lower_path).getchannel('A').getbbox() upper=Image.open(BASE/tier/'upper-flourish-guide.png').getchannel('A').getbbox() tree=ET.parse(BASE/tier/'text-centered.svg');nodes=list(tree.getroot().iter(NS+'text'));ref=next(n for n in nodes if n.get('id')=='reference');verses=[n for n in nodes if n.get('id','').startswith('verse-')] assert ref.get('font-family')=='Sanctification P052' and ref.get('font-weight')=='500' and float(ref.get('font-size'))==size baseline=lower[1]-gap-descent;ref.set('y',str(baseline));line_top=baseline-ascent;line_bottom=baseline+descent refim=isolate(ref,folder,'reference-unclipped');rb=refim.getchannel('A').getbbox() assert rb and rb[1]>=line_top and rb[3]<=line_bottom and lower[1]-rb[3]>=gap,(tier,rb,line_top,line_bottom) oldboxes=[Image.open(BASE/tier/(n.get('id')+'-unclipped.png')).getchannel('A').getbbox() for n in verses] oldcenter=(min(b[1] for b in oldboxes)+max(b[3] for b in oldboxes))/2;target=(upper[3]+line_top)/2 for n in verses:n.set('y',str(float(n.get('y'))+target-oldcenter)) glyphs=[] for n in verses: box=isolate(n,folder,n.get('id')+'-unclipped').getchannel('A').getbbox();assert box and box[0]>=160 and box[2]<=1840 glyphs.append({'id':n.get('id'),'baseline':float(n.get('y')),'inkBounds':list(box)}) vb=[min(g['inkBounds'][0] for g in glyphs),min(g['inkBounds'][1] for g in glyphs),max(g['inkBounds'][2] for g in glyphs),max(g['inkBounds'][3] for g in glyphs)] above=vb[1]-upper[3];below=line_top-vb[3];assert min(above,below)>=20 and abs(above-below)<=1,(tier,above,below) src=folder/'text-baseline.svg';tree.write(src,encoding='unicode');png=folder/'text-baseline.png';render(src,png);text=Image.open(png).convert('RGBA') assert np.array_equal(np.asarray(text)[:2200],np.asarray(Image.open(BASE/tier/'text-centered.png').convert('RGBA'))[:2200]) artpath=REPO/card['art'];art=Image.open(artpath).convert('RGBA');backing=Image.open(BACK/tier/'backing-borderless-20.png').convert('RGBA') assert np.all(np.asarray(backing.getchannel('A'))[np.asarray(text.getchannel('A'))>0]==255) face=Image.alpha_composite(Image.alpha_composite(art,backing),text) for width in [2000,1000,500]: export=face.resize((width,width*7//5),Image.Resampling.LANCZOS);assert export.getchannel('A').getextrema()==(255,255);export.save(folder/f'baseline-borderless-{width}.png') guide=face.convert('RGB');d=ImageDraw.Draw(guide);d.rectangle((160,line_top,1840,line_bottom),outline='#ab42d0',width=3);d.line((160,baseline,1840,baseline),fill='#dc3562',width=3);d.rectangle(tuple(rb),outline='#09a0ac',width=2);d.rectangle(tuple(vb),outline='#09a0ac',width=2) for y in [upper[3],lower[1]]:d.line((160,y,1840,y),fill='#26a33c',width=3) guide.crop((70,2165,1930,2755)).save(folder/'footer-guides.png') cases=[] for index,wording in enumerate(['Matthew 4:19 • NKJV','Genesis 3:9 • ESV','Exodus 3:6 • KJV','Philippians 2:11 • NKJV']): probe=copy.deepcopy(ref);probe.text=wording;box=isolate(probe,folder,f'stress-reference-{index}').getchannel('A').getbbox() assert box and box[1]>=line_top and box[3]<=line_bottom and lower[1]-box[3]>=gap,(tier,wording,box) cases.append({'text':wording,'baseline':baseline,'inkBounds':list(box),'actualClearance':lower[1]-box[3],'lineBoxClearance':lower[1]-line_bottom}) report['tiers'].append({'tier':tier,'cardId':card['cardId'],'referenceBaseline':baseline,'referenceLineBox':[line_top,line_bottom],'referenceInkBounds':list(rb),'lowerFlourishInkBounds':list(lower),'upperFlourishInkBounds':list(upper),'lineBoxToFlourishGap':lower[1]-line_bottom,'actualReferenceToFlourishGap':lower[1]-rb[3],'verseInkBounds':vb,'gapAboveVerse':above,'gapBelowVerseToLineBox':below,'centeringError':abs(above-below),'verseVerticalShift':target-oldcenter,'glyphs':glyphs,'stressReferences':cases,'scaling':{str(w):{'baseline':baseline*w/2000,'lineBoxGap':gap*w/2000,'rasterRoundingAllowance':1} for w in [2000,1000,500]},'artSHA256':digest(artpath),'backingSHA256':digest(BACK/tier/'backing-borderless-20.png')}) assert all(digest(Path(p))==h for p,h in before.items()) report['preservedInputFiles']=len(before) label=ImageFont.truetype(str(fonts/'P052-Bold.otf'),23) board=Image.new('RGB',(1740,490),'#11161c');d=ImageDraw.Draw(board);guides=Image.new('RGB',(1740,275),'#11161c');gd=ImageDraw.Draw(guides);lineup=Image.new('RGB',(1560,770),'#11161c');ld=ImageDraw.Draw(lineup) for col,tier in enumerate(roots): x=col*580+10 for row,name in enumerate(['Previous ink anchoring','Fixed reference baseline']): path=BASE/tier/'centered-borderless-2000.png' if row==0 else OUT/tier/'baseline-borderless-2000.png';im=Image.open(path).convert('RGB').crop((70,2165,1930,2755));im.thumbnail((560,197));d.text((x,row*245+8),tier.title()+' · '+name,font=label,fill='#f4ead4');board.paste(im,(x,row*245+42)) im=Image.open(OUT/tier/'footer-guides.png');im.thumbnail((560,197));gd.text((x,8),tier.title()+' · metrics / ink guides',font=label,fill='#f4ead4');guides.paste(im,(x,42)) ld.text((col*520+10,10),tier.title()+' · fixed baseline',font=label,fill='#f4ead4');lineup.paste(Image.open(OUT/tier/'baseline-borderless-500.png').convert('RGB'),(col*520+10,50)) board.save(OUT/'footers-comparison.png');guides.save(OUT/'footer-guides.png');lineup.save(OUT/'cards-baseline.png') # Check visible clearance on resized data, independently of geometric predictions. for entry in report['tiers']: folder=OUT/entry['tier'];checks=[] for width in [2000,1000,500]: scale=width/2000;dimensions=(width,width*7//5) def resized_bounds(name): alpha=Image.open(folder/name).getchannel('A').resize(dimensions,Image.Resampling.LANCZOS) return alpha.point(lambda a:255 if a>=128 else 0).getbbox() ornament=resized_bounds('lower-flourish.png') for i,case in enumerate(entry['stressReferences']): glyph=resized_bounds(f'stress-reference-{i}.png');clearance=ornament[1]-glyph[3] assert clearance>=gap*scale-1,(entry['tier'],width,case['text'],clearance) checks.append({'width':width,'text':case['text'],'visibleAlphaThreshold':128,'actualReferenceToFlourishGap':clearance,'minimumWithRounding':gap*scale-1}) entry['scaledRasterChecks']=checks report.update({'status':'passed-preview','builderSHA256':digest(Path(__file__)),'fontManifestSHA256':digest(fonts/'manifest.json'),'fontSHA256':digest(fonts/'SanctificationP052-Medium.otf'),'tools':{'inkscape':ink('--version'),'pillow':Image.__version__},'visualReview':'Pending static inspection; user acceptance pending. No GPU or moving-light review.'}) (OUT/'validation.json').write_text(json.dumps(report,indent=2)+'\n') print(json.dumps({'fontMetrics':report['fontMetrics'],'tiers':[{k:e[k] for k in ['tier','referenceBaseline','lineBoxToFlourishGap','actualReferenceToFlourishGap','gapAboveVerse','gapBelowVerseToLineBox']} for e in report['tiers']],'preservedFiles':len(before)},indent=2))