Moved some files around and made cleanup better

This commit is contained in:
2026-09-14 19:01:08 -07:00
parent 50b234104a
commit 97ed7f05c8
866 changed files with 404 additions and 1612 deletions

View File

@@ -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))