84 lines
12 KiB
Python
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()
|