Files

96 lines
10 KiB
Python

#!/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'<fontconfig><dir>{fonts}</dir><cachedir>{OUT/"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,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))