160 lines
14 KiB
Python
160 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Preview a shared 20px visible-ink gap for central backing flourishes."""
|
|
from pathlib import Path
|
|
import argparse,copy,json,os,re,subprocess,sys
|
|
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())
|
|
sys.path.insert(0,str(REPO/'artifacts/layers'))
|
|
from library import load_tier,digest
|
|
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--retype-text',action='store_true');args=parser.parse_args()
|
|
OUT=ROOT/('output-retyped' if args.retype_text else 'output');OUT.mkdir(exist_ok=True)
|
|
NS='{http://www.w3.org/2000/svg}'
|
|
ET.register_namespace('','http://www.w3.org/2000/svg');ET.register_namespace('xlink','http://www.w3.org/1999/xlink')
|
|
fonts=REPO/'fonts';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 query(path):
|
|
result={}
|
|
for row in ink(path,'--query-all').splitlines():
|
|
fields=row.split(',')
|
|
if len(fields)==5:result[fields[0]]=list(map(float,fields[1:]))
|
|
return result
|
|
|
|
def render(source,target):ink(source,'--export-type=png',f'--export-filename={target}','--export-width=2000','--export-height=2800')
|
|
|
|
def typeset(card,shared,newbounds,header,footer,folder):
|
|
fm=json.loads((fonts/'manifest.json').read_text())
|
|
families={'title':'P052','verse':'Sanctification P052','reference':'Sanctification P052'}
|
|
checks=[]
|
|
title_lines=card.get('titleLines',[card['name']])
|
|
assert ' '.join(title_lines)==card['name']
|
|
assert ' '.join(card['verseLines'])==card['excerpt']
|
|
assert card['referenceDisplay']==f'{card["reference"]} • {card["translation"]}'
|
|
sizes=dict(shared['layout']['fontSizes']);sizes['title']=100 if len(title_lines)==2 else 112
|
|
rows=[{'id':f'title-{i}','role':'title','text':line,'baseline':[176,266][i] if len(title_lines)==2 else 240} for i,line in enumerate(title_lines)]
|
|
rows += [{'id':f'verse-{i}','role':'verse','text':line,'baseline':y} for i,(line,y) in enumerate(zip(card['verseLines'],shared['layout']['baselines']['verseByLineCount'][str(len(card['verseLines']))]))]
|
|
rows += [{'id':'reference','role':'reference','text':card['referenceDisplay'],'baseline':2645}]
|
|
for role in families:
|
|
face=fm['productionTypography'][role];font=fonts/face['file'];assert digest(font)==fm['files'][font.name]['sha256']
|
|
matched=Path(subprocess.check_output(['fc-match','--format=%{file}',families[role]+(':style=Bold' if role=='title' else ':style=Medium')],env=ENV,text=True)).resolve();assert matched==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 sizes[role]>=shared['layout']['fontMinimums'][role]
|
|
checks.append({'role':role,'path':str(font.relative_to(REPO)),'sha256':digest(font),'weight':face['weight'],'size':sizes[role],'fallbackUsed':False,'missingGlyphs':[]})
|
|
def document(selected):
|
|
page=ET.Element(NS+'svg',{'width':'2000','height':'2800','viewBox':'0 0 2000 2800'})
|
|
group=ET.SubElement(page,NS+'g',{'fill':'#263f50','text-anchor':'middle'})
|
|
for row in selected:
|
|
role=row['role'];node=ET.SubElement(group,NS+'text',{'id':row['id'],'x':'1000','y':str(row['baseline']),'font-family':families[role],'font-weight':str(fm['productionTypography'][role]['weight']),'font-size':str(sizes[role])});node.text=row['text']
|
|
return ET.ElementTree(page)
|
|
source=folder/'text-preview.svg';document(rows).write(source,encoding='unicode');probe=query(source)
|
|
# Position reference using fresh, independently rendered actual glyph bounds.
|
|
ref=rows[-1];isolated=folder/'reference-probe.svg';document([ref]).write(isolated,encoding='unicode');probe_png=isolated.with_suffix('.png');render(isolated,probe_png)
|
|
rb=Image.open(probe_png).getchannel('A').getbbox();reference_bottom=int(np.floor(newbounds['footer-bottom'][1]))-20
|
|
ref['baseline']+=reference_bottom-rb[3]
|
|
reference_top=probe['reference'][1]+ref['baseline']-2645
|
|
# Center the whole verse block in the available band, with 40px above and 64px before attribution.
|
|
verse=[row for row in rows if row['role']=='verse'];vtop=min(probe[row['id']][1] for row in verse);vbottom=max(probe[row['id']][1]+probe[row['id']][3] for row in verse)
|
|
upper=newbounds['footer-top'][1]+newbounds['footer-top'][3]+40;lower=reference_top-64
|
|
assert vbottom-vtop<=lower-upper
|
|
delta=(upper+lower-vtop-vbottom)/2
|
|
for row in verse:row['baseline']+=delta
|
|
title=[row for row in rows if row['role']=='title'];ttop=min(probe[row['id']][1] for row in title);tbottom=max(probe[row['id']][1]+probe[row['id']][3] for row in title)
|
|
upper=float(header.get('y'))+float(header.get('stroke-width'))/2+20;lower=newbounds['header'][1]-20
|
|
assert tbottom-ttop<=lower-upper
|
|
delta=(upper+lower-ttop-tbottom)/2
|
|
for row in title:row['baseline']+=delta
|
|
document(rows).write(source,encoding='unicode');png=folder/'text-preview.png';render(source,png);bounds=query(source)
|
|
glyphs=[];ink_bounds={}
|
|
for row in rows:
|
|
isolated=folder/(row['id']+'-unclipped.svg');document([row]).write(isolated,encoding='unicode');im=isolated.with_suffix('.png');render(isolated,im);bb=Image.open(im).getchannel('A').getbbox()
|
|
region=[140,100,1720,200] if row['role']=='title' and len(title_lines)==2 else shared['layout']['textRegions'][row['role']]
|
|
x,y,w,h=bounds[row['id']];rx,ry,rw,rh=region
|
|
assert rx<=x and ry<=y and x+w<=rx+rw and y+h<=ry+rh,(card['cardId'],row,bounds[row['id']],region)
|
|
assert bb and rx<=bb[0] and ry<=bb[1] and bb[2]<=rx+rw and bb[3]<=ry+rh,(row,bb,region)
|
|
ink_bounds[row['id']]=bb;glyphs.append({**row,'objectBounds':bounds[row['id']],'unclippedInkBounds':list(bb),'region':region,'pass':True})
|
|
title_end=max(ink_bounds[row['id']][3] for row in title);verse_top=min(ink_bounds[row['id']][1] for row in verse);verse_end=max(ink_bounds[row['id']][3] for row in verse)
|
|
spaces={'titleToFlourish':newbounds['header'][1]-title_end,'upperFlourishToVerse':verse_top-(newbounds['footer-top'][1]+newbounds['footer-top'][3]),'verseToReference':ink_bounds['reference'][1]-verse_end,'referenceToLowerFlourish':newbounds['footer-bottom'][1]-ink_bounds['reference'][3]}
|
|
assert min(spaces.values())>=20,spaces
|
|
return Image.open(png).convert('RGBA'),{'fonts':checks,'glyphs':glyphs,'spacing':spaces,'layoutPolicy':{'title':'Whole ink block centered between top inner rule and flourish, each with 20px minimum clearance.','verse':'Whole ink block centered below upper flourish (+40px) and above reference (-64px).','reference':'Actual ink bottom anchored 20px above lower flourish.','titleSizes':'112px one-line; approved 100px two-line.','textColor':'#263f50'}},2645-ref['baseline']
|
|
|
|
cards=[('uncommon',REPO/'in-progress/cards/BE-042-calling-of-the-first-disciples/revisions/v02','text-2000.png','text.svg'),('rare',REPO/'artifacts/cards/BE-010-burning-bush','rare-shared-v2-text-2000.png','rare-shared-v2-text.svg'),('extraordinary',REPO/'artifacts/cards/BE-002-the-fall','extraordinary-shared-v2-text-2000.png','extraordinary-shared-v2-text.svg')]
|
|
report={'status':'pending','gapMasterPixels':20,'scope':'Separate Borderless preview; canonical shared layers, accepted cards and working exports are unchanged.','tools':{'inkscape':ink('--version'),'pillow':Image.__version__},'tiers':[]}
|
|
for tier,card_root,text_png,text_svg in cards:
|
|
shared=load_tier(tier,'v2');folder=OUT/tier;folder.mkdir(exist_ok=True)
|
|
card=json.loads((card_root/'card.json').read_text());art=Image.open(REPO/card['art']).convert('RGBA');oldtext=Image.open(card_root/'source/text'/text_png).convert('RGBA')
|
|
oldbacking=Image.open(shared['assets']['backing-borderless']).convert('RGBA')
|
|
oldface=Image.alpha_composite(Image.alpha_composite(art,oldbacking),oldtext)
|
|
tree=ET.parse(shared['directory']/'source/backing-borderless.svg');page=tree.getroot()
|
|
ornaments=[]
|
|
for node in page.iter(NS+'g'):
|
|
if node.get('id','').startswith('ornament-') or re.match(r'translate\(1000,',node.get('transform','')):
|
|
center=float(node.get('id').split('-')[1]) if node.get('id','').startswith('ornament-') else float(re.search(r'translate\(1000,([\d.]+)',node.get('transform')).group(1))
|
|
ornaments.append((center,node))
|
|
ornaments.sort(key=lambda v:v[0]);assert len(ornaments)==3
|
|
for (_,node),key in zip(ornaments,['header','footer-top','footer-bottom']):node.set('id',key)
|
|
source=folder/'backing-borderless-20.svg';tree.write(source,encoding='unicode');oldbounds=query(source)
|
|
rects=[node for node in page.iter(NS+'rect') if node.get('fill')=='none' and node.get('stroke')]
|
|
header=max((node for node in rects if float(node.get('y'))<400),key=lambda n:float(n.get('y')))
|
|
footer=max((node for node in rects if float(node.get('y'))>2000),key=lambda n:float(n.get('y')))
|
|
def edge(rect,top):
|
|
y=float(rect.get('y'));h=float(rect.get('height'));s=float(rect.get('stroke-width'))
|
|
return y+s/2 if top else y+h-s/2
|
|
edges={'header':edge(header,False),'footer-top':edge(footer,True),'footer-bottom':edge(footer,False)}
|
|
entries=[]
|
|
for (_,node),key in zip(ornaments,['header','footer-top','footer-bottom']):
|
|
x,y,w,h=oldbounds[key];oldedge=y if key=='footer-top' else y+h
|
|
target=edges[key]+20 if key=='footer-top' else edges[key]-20
|
|
delta=target-oldedge;node.set('transform',f'translate(0,{delta:.8f}) '+node.get('transform',''))
|
|
before=oldedge-edges[key] if key=='footer-top' else edges[key]-oldedge
|
|
entries.append({'placement':key,'innerRuleInkEdge':edges[key],'previousGap':round(before,4),'verticalShift':round(delta,4)})
|
|
tree.write(source,encoding='unicode');newbounds=query(source)
|
|
for entry in entries:
|
|
key=entry['placement'];x,y,w,h=newbounds[key];newedge=y if key=='footer-top' else y+h
|
|
gap=newedge-edges[key] if key=='footer-top' else edges[key]-newedge
|
|
assert abs(gap-20)<.02,(tier,key,gap);entry['newGap']=round(gap,4)
|
|
backing_png=folder/'backing-borderless-20.png';render(source,backing_png);backing=Image.open(backing_png).convert('RGBA')
|
|
typography=None
|
|
if args.retype_text:
|
|
text,typography,shift=typeset(card,shared,newbounds,header,footer,folder)
|
|
else:
|
|
# Preserve at least 20px of space for actual reference ink as the lower ornament moves inward.
|
|
texttree=ET.parse(card_root/'source/text'/text_svg);reference=next(node for node in texttree.getroot().iter(NS+'text') if node.get('id')=='reference')
|
|
isolated=card_root/'source/text'/('reference-unclipped.png' if tier=='uncommon' else f'{tier}-shared-v2-reference-unclipped.png')
|
|
refbb=Image.open(isolated).getchannel('A').getbbox();orn_top=newbounds['footer-bottom'][1]
|
|
shift=max(0,int(np.ceil(20-(orn_top-refbb[3]))))
|
|
if shift:reference.set('y',str(float(reference.get('y'))-shift))
|
|
newtext_svg=folder/'text-preview.svg';texttree.write(newtext_svg,encoding='unicode');newtext_png=folder/'text-preview.png';render(newtext_svg,newtext_png);text=Image.open(newtext_png).convert('RGBA')
|
|
assert np.all(np.asarray(backing.getchannel('A'))[np.asarray(text.getchannel('A'))>0]==255)
|
|
newface=Image.alpha_composite(Image.alpha_composite(art,backing),text)
|
|
for name,im in [('before',oldface),('spacing-20',newface)]:
|
|
for width in [2000,1000,500]:im.resize((width,width*7//5),Image.Resampling.LANCZOS).save(folder/f'{name}-borderless-{width}.png')
|
|
item={'tier':tier,'cardId':card['cardId'],'sharedVersion':'v2','sharedManifestSHA256':shared['manifestSHA256'],'artSHA256':digest(REPO/card['art']),'originalTextSHA256':digest(card_root/'source/text'/text_png),'flourishes':entries,'referenceShiftUpMasterPixels':shift,'referenceReason':'Maintain 20px minimum text clearance from the inward-moving lower flourish.','textOtherwiseUnchanged':not args.retype_text,'freshTypography':typography}
|
|
report['tiers'].append(item)
|
|
|
|
label=ImageFont.truetype(str(fonts/'P052-Bold.otf'),23)
|
|
# Full cards before/after, grouped by tier.
|
|
board=Image.new('RGB',(1040,2310),'#11161c');d=ImageDraw.Draw(board)
|
|
for row,(tier,*_) in enumerate(cards):
|
|
for col,(name,tag) in enumerate([('before','Current'),('spacing-20','20px + retyped' if args.retype_text else '20px spacing')]):
|
|
x=10+col*520;y=row*770;d.text((x,y+10),f'{tier.title()} · {tag}',font=label,fill='#f4ead4');im=Image.open(OUT/tier/f'{name}-borderless-500.png').convert('RGB');board.paste(im,(x,y+50))
|
|
board.save(OUT/'cards-before-after.png')
|
|
# Panels at matching scale, with the two treatments adjacent for each tier.
|
|
for panel,crop,height in [('headers',(70,45,1930,375),175),('footers',(70,2165,1930,2755),245)]:
|
|
treatments=[('before','Current'),('spacing-only','20px spacing only'),('spacing-20','20px + retyped')] if args.retype_text else [('before','Current'),('spacing-20','20px')]
|
|
sheet=Image.new('RGB',(1740,height*len(treatments)),'#11161c');draw=ImageDraw.Draw(sheet)
|
|
for col,(tier,*_) in enumerate(cards):
|
|
for row,(name,tag) in enumerate(treatments):
|
|
x=col*580+10;y=row*height;draw.text((x,y+8),f'{tier.title()} · {tag}',font=label,fill='#f4ead4');image_path=ROOT/'output'/tier/'spacing-20-borderless-2000.png' if name=='spacing-only' else OUT/tier/f'{name}-borderless-2000.png';im=Image.open(image_path).convert('RGB').crop(crop);im.thumbnail((560,height-48));sheet.paste(im,(x,y+42))
|
|
sheet.save(OUT/f'{panel}-comparison.png')
|
|
if args.retype_text:
|
|
lineup=Image.new('RGB',(1560,770),'#11161c');draw=ImageDraw.Draw(lineup)
|
|
for col,(tier,*_) in enumerate(cards):
|
|
x=col*520+10;draw.text((x,10),tier.title()+' · retyped',font=label,fill='#f4ead4');lineup.paste(Image.open(OUT/tier/'spacing-20-borderless-500.png').convert('RGB'),(x,50))
|
|
lineup.save(OUT/'cards-retyped.png')
|
|
report['status']='passed-preview';(OUT/'validation.json').write_text(json.dumps(report,indent=2)+'\n')
|
|
print(json.dumps([{k:v for k,v in item.items() if k in ['tier','flourishes','referenceShiftUpMasterPixels']} for item in report['tiers']],indent=2))
|