Files
Sanctification/artifacts/cards/BP-045-timothy/build.py

84 lines
12 KiB
Python

#!/usr/bin/env python3
"""Build Timothy's approved Common preview as four printings with shared masks."""
from pathlib import Path
import json,os,subprocess,sys
import xml.etree.ElementTree as ET
import numpy as np
from PIL import Image,ImageDraw,ImageFont,ImageFilter
from xml.sax.saxutils import escape
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 assemble_card import document,metric_box,contrast,check,save
from finish_masks import prepare_illustration,compile_finish,RECIPE_PATH,RECIPE_SHA256
from card_workspace import refresh,update_index
def main():
manifest=json.loads((ROOT/'manifest.json').read_text());card=json.loads((ROOT/'card.json').read_text())
check(manifest['stage']!='approved','Approved cards are frozen; create a working revision.')
check(card['cardId']=='BP-045' and card['tier']=='common','This builder is for the reviewed Timothy Common layout.')
check(card.get('artApproval'),'Explicit artwork approval is required.')
check(card['excerpt']=='I have fought the good fight, I have finished the race, I have kept the faith.' and card['translation']=='ESV','The user-selected ESV excerpt changed.')
check(' '.join(card['verseLines'])==card['excerpt'] and len(card['verseLines'])==3,'The approved three-line stanza changed.')
check(card['referenceDisplay']==f'{card["reference"]} • {card["translation"]}','Reference formatting changed.')
shared=load_tier(card['tier'],card['layerVersion']);layout=shared['layout'];assets=shared['assets']
fonts=REPO/'fonts';fm=json.loads((fonts/'manifest.json').read_text());faces={role:dict(fm['productionTypography'][role]) for role 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'Shared font asset changed: {name}')
review=ROOT/'review';textdir=ROOT/'source/text';textdir.mkdir(parents=True,exist_ok=True);config=review/'fonts.conf';config.write_text(f'<fontconfig><dir>{fonts}</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,stderr=subprocess.PIPE).strip()
def render(src,dst):ink(src,'--export-type=png',f'--export-filename={dst}','--export-width=2000','--export-height=2800')
artpath=REPO/card['art'];art=Image.open(artpath).convert('RGBA');check(art.size==(2000,2800) and art.getchannel('A').getextrema()==(255,255),'Artwork must be opaque on the common canvas.');check(digest(artpath)==card['artSHA256'],'Approved artwork changed.')
sizes=dict(layout['fontSizes']);rows=[{'id':'title','role':'title','text':card['name'],'baseline':layout['baselines']['title']}]+[{'id':f'verse-{i}','role':'verse','text':line,'baseline':y} for i,(line,y) in enumerate(zip(card['verseLines'],layout['baselines']['verseByLineCount']['3']))]+[{'id':'reference','role':'reference','text':card['referenceDisplay'],'baseline':layout['baselines']['reference']}]
checks=[]
for role,face in faces.items():
font=fonts/face['file'];style='Bold' if role=='title' else 'Medium';matched=Path(subprocess.check_output(['fc-match','--format=%{file}',face['family']+':style='+style],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 minimum: {role}')
checks.append({'role':role,'path':str(font.relative_to(REPO)),'sha256':digest(font),'size':sizes[role],'weight':face['weight'],'fallbackUsed':False,'missingGlyphs':[]})
source=textdir/'text.svg';document(rows,sizes,faces).write(source,encoding='unicode');png=textdir/'text-2000.png';render(source,png);text=Image.open(png).convert('RGBA');ta=np.asarray(text.getchannel('A'));query={a[0]:list(map(float,a[1:])) for line in ink(source,'--query-all').splitlines() if len(a:=line.split(','))==5};glyphs=[]
for row in rows:
src=textdir/(row['id']+'-unclipped.svg');document([row],sizes,faces).write(src,encoding='unicode');dst=src.with_suffix('.png');render(src,dst);bb=Image.open(dst).getchannel('A').getbbox();rx,ry,rw,rh=layout['textRegions'][row['role']];x,y,w,h=query[row['id']]
check(bb and rx<=bb[0] and ry<=bb[1] and bb[2]<=rx+rw and bb[3]<=ry+rh,f'Actual glyph overflow: {row["id"]}');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':query[row['id']],'unclippedInkBounds':list(bb),'region':layout['textRegions'][row['role']],'pass':True})
check(np.array_equal(np.asarray(text),np.asarray(Image.open(review/'text-preview.png').convert('RGBA'))),'Fresh lettering does not match the approved preview.')
metrics=metric_box(fonts/faces['reference']['file'],sizes['reference']);refbase=rows[-1]['baseline'];refbox=[refbase-metrics['ascent'],refbase+metrics['descent']];rb=glyphs[-1]['unclippedInkBounds'];check(rb[1]>=refbox[0] and rb[3]<=refbox[1],'Reference escapes font-metric box.')
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 coverage failed.')
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':[r['baseline'] for r in rows if r['role']=='verse'],'titleBaselines':[rows[0]['baseline']],'referenceBaseline':refbase,'typographyPolicy':'Approved Common v2 regions and baselines; no central flourishes.'});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__)),'typographyHelperSHA256':digest(REPO/'tools/card-production/assemble_card.py')},'sharedLayers':{'tier':'common','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':checks,'glyphs':glyphs,'fontMetrics':metrics,'referenceLineBox':refbox,'textLayerSHA256':digest(png),'matchesApprovedPreview':True,'centeringPolicy':'Preserve the approved Common template baselines and regions. Common has no central flourish.'},'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},'textContrast':{},'tools':{'inkscape':ink('--version'),'pillow':Image.__version__,'numpy':np.__version__},'printings':{},'visualReview':{'static':'pending','movingLight':'not performed'}}
for kind in ['normal','borderless']:
arr=np.asarray(Image.open(assets['backing-'+kind]).convert('RGBA'));ratio=float(np.min(contrast([38,63,80],arr[:,:,:3][ta>=128])));check(ratio>=7,f'Text/backing contrast below 7:1: {kind}');report['textContrast'][kind]=round(ratio,2)
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:
check(overlay.size==art.size,'Overlay registration mismatch.');face=Image.alpha_composite(face,overlay)
if printing in ['normal','textless']:check(all(overlay.getpixel(xy)[3]==255 for xy in [(0,0),(1999,0),(0,2799),(1999,2799)]),'Framed corner transparency.')
if has_text:
check(np.all(np.asarray(overlay.getchannel('A'))[ta>0]==255),'Glyphs outside opaque backing.');face=Image.alpha_composite(face,text)
check(face.getchannel('A').getextrema()==(255,255),'Final face transparency.')
if printing=='boundless':check(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 components.')
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);im=face.resize(dims,Image.Resampling.LANCZOS);im.save(folder/'card.png')
for name,data in [('finish-mask.png',finish),('text-mask.png',tm)]:
reduced=data if width==2000 else data.resize(dims,Image.Resampling.BILINEAR);rgba=reduced.convert('RGBA');rgba.putalpha(255);rgba.save(folder/name)
if printing=='normal':check(np.array_equal(np.asarray(im),np.asarray(Image.open(review/f'normal-preview-{width}.png').convert('RGBA'))),f'Normal differs from approved preview at {width}.')
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'<image width="2000" height="2800" xlink:href="{escape(os.path.relpath(path,editable.parent))}"/>'
body=ET.tostring(document(rows,sizes,faces).getroot()[0],encoding='unicode') if has_text else ''
editable.write_text('<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">'+link(artpath)+(link(assets[key]) if key else '')+body+'</svg>');report['printings'][printing]={'overlay':key,'text':has_text,'exports':exports}
save(ROOT/'source/typography-layout.json',{'rows':rows,'fontSizes':sizes,'regions':layout['textRegions'],'fontMetrics':metrics,'policy':card['typographyPolicy']})
prior=json.loads((review/'original-input-hashes.json').read_text());check(all(digest(Path(p))==sha for p,sha in prior.items()),'Previous Timothy revision changed.');report['oldFilesPreserved']=len(prior)
font=ImageFont.truetype(str(fonts/'P052-Bold.otf'),22)
for filename,asset in [('printings-comparison.png','card.png'),('finish-masks-comparison.png','finish-mask.png')]:
board=Image.new('RGB',(1200,480),'#11161c');d=ImageDraw.Draw(board)
for i,printing in enumerate(['normal','boundless','borderless','textless']):
d.text((i*300+10,8),printing.title(),font=font,fill='#f4ead4');board.paste(Image.open(ROOT/'med'/printing/asset).convert('RGB').resize((280,392),Image.Resampling.LANCZOS),(i*300+10,50))
board.save(review/filename)
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({'selectedRevision':manifest['revision'],'stage':'assembly-review','approval':None});save(profile_path,profile);refresh(ROOT);update_index()
print('BP-045 v02: four printings at low/med/high, matching approved previews, fonts, glyphs, contrast, masks and preservation checks passed.')
if __name__=='__main__':main()