45 lines
5.8 KiB
Python
45 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Provisional Common Normal preview; no final printing or mask assembly."""
|
|
from pathlib import Path
|
|
import json,os,subprocess,sys,copy
|
|
import xml.etree.ElementTree as ET
|
|
import numpy as np
|
|
from PIL import Image,ImageDraw,ImageFont
|
|
REVIEW=Path(__file__).resolve().parent;ROOT=REVIEW.parent;R=next(p for p in ROOT.parents if (p/'docs/card-layer-pipeline.md').is_file())
|
|
sys.path.insert(0,str(R/'artifacts/layers'));from library import load_tier,digest
|
|
NS='{http://www.w3.org/2000/svg}';ET.register_namespace('','http://www.w3.org/2000/svg')
|
|
card=json.loads((ROOT/'card.json').read_text());shared=load_tier('common',card['layerVersion']);layout=shared['layout'];fonts=R/'fonts';fm=json.loads((fonts/'manifest.json').read_text())
|
|
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')
|
|
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']}]
|
|
assert len(card['verseLines'])==3 and ' '.join(card['verseLines'])==card['excerpt'];assert card['referenceDisplay']==f'{card["reference"]} • {card["translation"]}'
|
|
assert not card['excerpt'].startswith('“') and card['excerpt'].endswith('.')
|
|
checks=[]
|
|
for role in ['title','verse','reference']:
|
|
face=fm['productionTypography'][role];font=fonts/face['file'];assert digest(font)==fm['files'][font.name]['sha256'];family='P052' if role=='title' else 'Sanctification P052';style='Bold' if role=='title' else 'Medium';match=Path(subprocess.check_output(['fc-match','--format=%{file}',family+':style='+style],env=env,text=True)).resolve();assert match==font.resolve()
|
|
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()];assert all(any(lo<=ord(c)<=hi for lo,hi in ranges) for row in rows if row['role']==role for c in row['text']);assert layout['fontSizes'][role]>=layout['fontMinimums'][role]
|
|
checks.append({'role':role,'font':str(font.relative_to(R)),'sha256':digest(font),'weight':face['weight'],'size':layout['fontSizes'][role],'fallbackUsed':False,'missingGlyphs':[]})
|
|
def doc(selected):
|
|
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'})
|
|
for row in selected:
|
|
role=row['role'];n=ET.SubElement(g,NS+'text',{'id':row['id'],'x':'1000','y':str(row['baseline']),'font-family':'P052' if role=='title' else 'Sanctification P052','font-weight':str(fm['productionTypography'][role]['weight']),'font-size':str(layout['fontSizes'][role])});n.text=row['text']
|
|
return ET.ElementTree(page)
|
|
textsource=REVIEW/'text-preview.svg';doc(rows).write(textsource,encoding='unicode');textpng=REVIEW/'text-preview.png';render(textsource,textpng);text=Image.open(textpng).convert('RGBA');glyphs=[]
|
|
q={a[0]:list(map(float,a[1:])) for line in ink(textsource,'--query-all').splitlines() if len(a:=line.split(','))==5}
|
|
for row in rows:
|
|
src=REVIEW/(row['id']+'-unclipped.svg');doc([row]).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']];assert bb and rx<=bb[0] and ry<=bb[1] and bb[2]<=rx+rw and bb[3]<=ry+rh,(row,bb);x,y,w,h=q[row['id']];assert rx<=x and ry<=y and x+w<=rx+rw and y+h<=ry+rh
|
|
glyphs.append({**row,'unclippedInkBounds':list(bb),'region':layout['textRegions'][row['role']]})
|
|
art=Image.open(R/card['art']).convert('RGBA');assert art.size==(2000,2800) and art.getchannel('A').getextrema()==(255,255);overlay=Image.open(shared['assets']['overlay-normal']).convert('RGBA');assert np.all(np.asarray(overlay.getchannel('A'))[np.asarray(text.getchannel('A'))>0]==255)
|
|
face=Image.alpha_composite(Image.alpha_composite(art,overlay),text)
|
|
for width in [2000,1000,500]:face.resize((width,width*7//5),Image.Resampling.LANCZOS).save(REVIEW/f'normal-preview-{width}.png')
|
|
old=ROOT.parents[0]/'v01';font=ImageFont.truetype(str(fonts/'P052-Bold.otf'),23)
|
|
for name,first,second in [('normal-before-after',old/'high/normal/card.png',REVIEW/'normal-preview-2000.png'),('art-before-after',old/'high/boundless/card.png',R/card['art'])]:
|
|
board=Image.new('RGB',(1040,770),'#11161c');d=ImageDraw.Draw(board)
|
|
for col,(label,p) in enumerate([('Current Timothy',first),('Revised Common · preview',second)]):
|
|
d.text((col*520+10,10),label,font=font,fill='#f4ead4');im=Image.open(p).convert('RGB');im.thumbnail((500,700));board.paste(im,(col*520+10,50))
|
|
board.save(REVIEW/(name+'.png'))
|
|
prior=json.loads((REVIEW/'original-input-hashes.json').read_text());assert all(digest(Path(p))==sha for p,sha in prior.items())
|
|
(REVIEW/'preview-validation.json').write_text(json.dumps({'status':'passed-provisional-preview','scope':'Requested Timothy Normal review only. No final printing or mask exports.','cardId':'BP-045','revision':'v02','artSHA256':digest(R/card['art']),'sharedLayers':{'tier':'common','version':'v2','manifestSHA256':shared['manifestSHA256']},'fonts':checks,'glyphs':glyphs,'textEditorialNote':card['textEditorialNote'],'oldFilesPreserved':len(prior),'staticReview':'Pending inspection','movingLight':'not performed'},indent=2)+'\n')
|
|
print('Common Normal preview passed font, bounds, backing coverage and preservation checks.')
|