#!/usr/bin/env python3 """Center each verse's visible ink between the upper flourish and reference ink.""" from pathlib import Path import copy,hashlib,json,os,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-retyped';OUT=ROOT/'output-centered';OUT.mkdir(exist_ok=True) NS='{http://www.w3.org/2000/svg}';ET.register_namespace('','http://www.w3.org/2000/svg') fonts=REPO/'fonts';fm=json.loads((fonts/'manifest.json').read_text()) def digest(path):return hashlib.sha256(path.read_bytes()).hexdigest() for name in ['P052-Bold.otf','SanctificationP052-Medium.otf']:assert digest(fonts/name)==fm['files'][name]['sha256'] 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(source,target,*options):ink(source,*options,'--export-type=png',f'--export-filename={target}','--export-width=2000','--export-height=2800') 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'} report={'status':'pending','scope':'Separate static comparison; verse baselines only change.','centeringRule':'Center the combined unclipped verse ink bounds between the bottom visible edge of the upper flourish and top visible edge of the reference. No prescribed margin participates in placement.','minimumClearance':20,'clearanceRole':'Validation only; never used to offset the center.','tiers':[]} for tier,root in roots.items(): folder=OUT/tier;folder.mkdir(exist_ok=True);card=json.loads((root/'card.json').read_text()) ornament_png=folder/'upper-flourish-guide.png';render(BASE/tier/'backing-borderless-20.svg',ornament_png,'--export-id=footer-top','--export-id-only','--export-area-page') ornament_bounds=Image.open(ornament_png).getchannel('A').getbbox();reference_bounds=Image.open(BASE/tier/'reference-unclipped.png').getchannel('A').getbbox() tree=ET.parse(BASE/tier/'text-preview.svg');verses=[node for node in tree.getroot().iter(NS+'text') if node.get('id','').startswith('verse-')] assert verses old_boxes=[Image.open(BASE/tier/(node.get('id')+'-unclipped.png')).getchannel('A').getbbox() for node in verses] old_top=min(box[1] for box in old_boxes);old_bottom=max(box[3] for box in old_boxes) upper=ornament_bounds[3];lower=reference_bounds[1];target_center=(upper+lower)/2;delta=target_center-(old_top+old_bottom)/2 for node in verses:node.set('y',str(float(node.get('y'))+delta)) source=folder/'text-centered.svg';tree.write(source,encoding='unicode');png=folder/'text-centered.png';render(source,png);text=Image.open(png).convert('RGBA') glyphs=[] for node in verses: 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)) isolated=folder/(node.get('id')+'-unclipped.svg');ET.ElementTree(page).write(isolated,encoding='unicode');im=isolated.with_suffix('.png');render(isolated,im);box=Image.open(im).getchannel('A').getbbox() assert box and 160<=box[0] and 2260<=box[1] and box[2]<=1840 and box[3]<=2560,(tier,box) glyphs.append({'id':node.get('id'),'baseline':float(node.get('y')),'unclippedInkBounds':list(box)}) new_top=min(g['unclippedInkBounds'][1] for g in glyphs);new_bottom=max(g['unclippedInkBounds'][3] for g in glyphs) above=new_top-upper;below=lower-new_bottom;assert min(above,below)>=20,(tier,above,below);assert abs(above-below)<=1,(tier,above,below) oldtext=Image.open(BASE/tier/'text-preview.png').convert('RGBA');old=np.asarray(oldtext);new=np.asarray(text) # Header and attribution remain bit-for-bit identical; only verse rows move. assert np.array_equal(old[:2260],new[:2260]) and np.array_equal(old[2560:],new[2560:]),tier art=Image.open(REPO/card['art']).convert('RGBA');backing=Image.open(BASE/tier/'backing-borderless-20.png').convert('RGBA') assert np.all(np.asarray(backing.getchannel('A'))[new[:,:,3]>0]==255) face=Image.alpha_composite(Image.alpha_composite(art,backing),text) for width in [2000,1000,500]:face.resize((width,width*7//5),Image.Resampling.LANCZOS).save(folder/f'centered-borderless-{width}.png') report['tiers'].append({'tier':tier,'cardId':card['cardId'],'upperFlourishInkBounds':list(ornament_bounds),'referenceInkBounds':list(reference_bounds),'availableBand':[upper,lower],'targetCenter':target_center,'verseCenter':(new_top+new_bottom)/2,'verseVerticalShift':delta,'gapAboveVerse':above,'gapBelowVerse':below,'glyphs':glyphs,'backingSHA256':digest(BASE/tier/'backing-borderless-20.png'),'previousTextSHA256':digest(BASE/tier/'text-preview.png'),'newTextSHA256':digest(png)}) label=ImageFont.truetype(str(fonts/'P052-Bold.otf'),23) board=Image.new('RGB',(1740,490),'#11161c');d=ImageDraw.Draw(board) lineup=Image.new('RGB',(1560,770),'#11161c');ld=ImageDraw.Draw(lineup) for col,tier in enumerate(roots): for row,(name,parent) in enumerate([('Previous retyped',BASE),('Verse ink centered',OUT)]): path=parent/tier/('spacing-20-borderless-2000.png' if parent==BASE else 'centered-borderless-2000.png');im=Image.open(path).convert('RGB').crop((70,2165,1930,2755));im.thumbnail((560,197));x=col*580+10;y=row*245;d.text((x,y+8),tier.title()+' · '+name,font=label,fill='#f4ead4');board.paste(im,(x,y+42)) x=col*520+10;ld.text((x,10),tier.title()+' · centered',font=label,fill='#f4ead4');lineup.paste(Image.open(OUT/tier/'centered-borderless-500.png').convert('RGB'),(x,50)) board.save(OUT/'footers-comparison.png');lineup.save(OUT/'cards-centered.png') report.update({'status':'passed-preview','builderSHA256':digest(Path(__file__)),'sharedFontManifestSHA256':digest(fonts/'manifest.json'),'tools':{'inkscape':ink('--version'),'pillow':Image.__version__},'visualReview':'Pending static inspection; no moving-light review.'});(OUT/'validation.json').write_text(json.dumps(report,indent=2)+'\n') print(json.dumps([{'tier':e['tier'],'verticalShift':e['verseVerticalShift'],'gapAbove':e['gapAboveVerse'],'gapBelow':e['gapBelowVerse']} for e in report['tiers']],indent=2))