Moved some files around and made cleanup better
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
#!/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))
|
||||
Reference in New Issue
Block a user