Moved some files around and made cleanup better
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assemble the approved BE-042 v02 artwork with pinned shared Uncommon assets."""
|
||||
from pathlib import Path
|
||||
import json, os, subprocess, sys
|
||||
from xml.sax.saxutils import escape
|
||||
import xml.etree.ElementTree as ET
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
||||
|
||||
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 card_workspace import refresh, update_index
|
||||
from finish_masks import prepare_illustration, compile_finish, RECIPE_PATH, RECIPE_SHA256
|
||||
|
||||
|
||||
def save(path,value):path.write_text(json.dumps(value,indent=2)+'\n')
|
||||
def svg(body):return f'<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">{body}</svg>'
|
||||
def luminance(rgb):
|
||||
c=np.asarray(rgb,dtype=float)/255
|
||||
c=np.where(c<=.04045,c/12.92,((c+.055)/1.055)**2.4)
|
||||
return float(c@np.array([.2126,.7152,.0722]))
|
||||
def contrast(a,b):
|
||||
lo,hi=sorted([luminance(a),luminance(b)]);return (hi+.05)/(lo+.05)
|
||||
def check(condition,message):
|
||||
if not condition:raise ValueError(message)
|
||||
|
||||
|
||||
def check_decoration_clearance(title_alpha,ornament_alpha,padding):
|
||||
expanded=title_alpha.filter(ImageFilter.MaxFilter(padding*2+1))
|
||||
check(not np.any((np.asarray(expanded)>0)&(np.asarray(ornament_alpha)>0)),
|
||||
'Title overlaps the header ornament or its required breathing room.')
|
||||
|
||||
|
||||
def main():
|
||||
manifest=json.loads((ROOT/'manifest.json').read_text())
|
||||
check(manifest['stage']!='approved','Approved cards are frozen; create a working revision.')
|
||||
card=json.loads((ROOT/'card.json').read_text())
|
||||
check(bool(card.get('artApproval')),'Explicit artwork approval is required before assembly.')
|
||||
check(card['cardId']=='BE-042' and card['tier']=='uncommon','Wrong card or tier for this builder.')
|
||||
shared=load_tier(card['tier'],card['layerVersion']);layout=shared['layout'];assets=shared['assets']
|
||||
font_dir=REPO/'fonts';fm=json.loads((font_dir/'manifest.json').read_text())
|
||||
for file,record in fm['files'].items():check(digest(font_dir/file)==record['sha256'],f'Shared font changed: {file}')
|
||||
review=ROOT/'review';text_dir=ROOT/'source/text';text_dir.mkdir(exist_ok=True)
|
||||
config=review/'fonts.conf';config.write_text(f'<fontconfig><dir>{font_dir}</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).strip()
|
||||
art_path=REPO/card['art'];art=Image.open(art_path).convert('RGBA')
|
||||
check(art.size==(2000,2800) and art.getchannel('A').getextrema()==(255,255),'Master must be opaque on the common canvas.')
|
||||
check(digest(art_path)==card['artSHA256'],'Approved artwork bytes changed.')
|
||||
check(card['referenceDisplay']==f'{card["reference"]} • {card["translation"]}','Reference formatting changed.')
|
||||
check(' '.join(card['verseLines'])==card['excerpt'],'Verse lines differ from the retained quotation.')
|
||||
check(' '.join(card['titleLines'])==card['name'],'Title lines differ from the full catalogue title.')
|
||||
check(len(card['verseLines'])<=layout['maximumVerseLines'],'Too many verse lines.')
|
||||
check(card['verseBaselines']==layout['baselines']['verseByLineCount'][str(len(card['verseLines']))],'Verse layout differs from pinned template.')
|
||||
treatment=json.loads((ROOT/'source/typography-layout.json').read_text())
|
||||
regions=dict(layout['textRegions']);regions['title']=treatment['title']['region']
|
||||
tx,ty,tw,th=regions['title'];px,py,pw,ph=layout['borderlessBacking'][0]
|
||||
check(px<=tx and py<=ty and tx+tw<=px+pw and ty+th<=py+ph,'Alternate title region leaves the existing panel.')
|
||||
sizes=dict(layout['fontSizes']);sizes['title']=treatment['title']['fontSize'];families={'title':'P052','verse':'Sanctification P052','reference':'Sanctification P052'}
|
||||
rows=[(f'title-{i}','title',line,y) for i,(line,y) in enumerate(zip(card['titleLines'],treatment['title']['baselines']))]
|
||||
check(len(rows)==len(card['titleLines'])==2,'Reviewed title uses exactly two lines.')
|
||||
rows += [(f'verse-{i}','verse',line,y) for i,(line,y) in enumerate(zip(card['verseLines'],card['verseBaselines']))]
|
||||
rows += [('reference','reference',card['referenceDisplay'],layout['baselines']['reference'])]
|
||||
font_checks=[]
|
||||
for role in families:
|
||||
face=fm['productionTypography'][role];file=font_dir/face['file'];style='Bold' if role=='title' else 'Medium'
|
||||
matched=Path(subprocess.check_output(['fc-match','--format=%{file}',f'{families[role]}:style={style}'],env=env,text=True)).resolve()
|
||||
check(matched==file.resolve(),f'Font fallback for {role}: {matched}')
|
||||
ranges=[(int(t.split('-')[0],16),int(t.split('-')[-1],16)) for t in subprocess.check_output(['fc-query','--format=%{charset}',str(file)],text=True).split()]
|
||||
check(all(any(lo<=ord(c)<=hi for lo,hi in ranges) for row in rows if row[1]==role for c in row[2]),f'Missing glyph in {role}.')
|
||||
check(sizes[role]>=layout['fontMinimums'][role],f'Font below minimum in {role}.')
|
||||
font_checks.append({'role':role,'file':str(file.relative_to(REPO)),'sha256':digest(file),'weight':face['weight'],'fallbackUsed':False,'missingGlyphs':[]})
|
||||
nodes=[f'<text id="{key}" x="1000" y="{y}" font-family="{families[role]}" font-weight="{fm["productionTypography"][role]["weight"]}" font-size="{sizes[role]}">{escape(content)}</text>' for key,role,content,y in rows]
|
||||
body='<g fill="#263f50" text-anchor="middle">'+''.join(nodes)+'</g>'
|
||||
source=text_dir/'text.svg';source.write_text(svg(body));text_path=text_dir/'text-2000.png'
|
||||
ink(source,'--export-type=png',f'--export-filename={text_path}','--export-width=2000','--export-height=2800')
|
||||
text=Image.open(text_path).convert('RGBA');ta=np.asarray(text.getchannel('A'));bounds={}
|
||||
for row in ink(source,'--query-all').splitlines():
|
||||
parts=row.split(',')
|
||||
if len(parts)==5:bounds[parts[0]]=list(map(float,parts[1:]))
|
||||
glyphs=[]
|
||||
for (key,role,content,y),node in zip(rows,nodes):
|
||||
rx,ry,rw,rh=regions[role];x,yy,w,h=bounds[key]
|
||||
check(rx<=x and ry<=yy and x+w<=rx+rw and yy+h<=ry+rh,f'Object overflow: {key}: {bounds[key]} against {regions[role]}')
|
||||
isolated=text_dir/f'{key}-unclipped.svg';isolated.write_text(svg('<g text-anchor="middle" fill="#263f50">'+node+'</g>'))
|
||||
png=isolated.with_suffix('.png');ink(isolated,'--export-type=png',f'--export-filename={png}')
|
||||
bb=Image.open(png).getchannel('A').getbbox()
|
||||
check(bb and rx<=bb[0] and ry<=bb[1] and bb[2]<=rx+rw and bb[3]<=ry+rh,f'Unclipped glyph overflow: {key}: {bb}')
|
||||
glyphs.append({'id':key,'objectBounds':bounds[key],'unclippedInkBounds':list(bb),'region':regions[role],'pass':True})
|
||||
decoration_checks=[]
|
||||
padding=treatment['title']['minimumDecorationClearance']
|
||||
title_alpha=text.getchannel('A').crop((0,0,2000,360))
|
||||
for kind in ['normal','borderless']:
|
||||
ornament_source=shared['directory']/'source'/f'backing-{kind}.svg'
|
||||
diagnostic=review/f'header-ornament-{kind}.png'
|
||||
ink(ornament_source,'--export-id=ornament-299','--export-id-only','--export-area-page',
|
||||
'--export-type=png',f'--export-filename={diagnostic}','--export-width=2000','--export-height=2800')
|
||||
ornament=Image.open(diagnostic).getchannel('A');ob=ornament.getbbox()
|
||||
check(bool(ob),'Expected header ornament missing from selected template.')
|
||||
check_decoration_clearance(title_alpha,ornament.crop((0,0,2000,360)),padding)
|
||||
last=max(g['unclippedInkBounds'][3] for g in glyphs if g['id'].startswith('title-'))
|
||||
decoration_checks.append({'printing':kind,'source':str(ornament_source.relative_to(REPO)),
|
||||
'sourceSHA256':digest(ornament_source),'ornamentInkBounds':list(ob),
|
||||
'verticalClearance':ob[1]-last,'minimumClearance':padding,
|
||||
'actualExpandedInkIntersection':False,'pass':True})
|
||||
protected=text.getchannel('A').filter(ImageFilter.MaxFilter(25)).filter(ImageFilter.GaussianBlur(8))
|
||||
check(np.min(np.asarray(protected)[ta>=128])>=216,'Text protection does not cover rendered glyphs.')
|
||||
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')
|
||||
report={'status':'pending','cardId':card['cardId'],'revision':manifest['revision'],'artApproval':card['artApproval'],'finalCardApproval':None,
|
||||
'inputs':{'art':{'path':card['art'],'sha256':digest(art_path)},'fit':json.loads((ROOT/'source/art-provenance.json').read_text()),'cardSHA256':digest(ROOT/'card.json'),'builderSHA256':digest(Path(__file__))},
|
||||
'sharedLayers':{'tier':card['tier'],'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(font_dir/'manifest.json'),'typography':{'fonts':font_checks,'glyphs':glyphs,'titleLayout':treatment,'decorationClearance':decoration_checks,'textLayerSHA256':digest(text_path)},
|
||||
'textMask':{'expansionRadius':12,'gaussianRadius':8,'units':'master pixels','derivation':'Actual rendered text alpha; MaxFilter(25), GaussianBlur(8)','minimumProtectionAtGlyphAlpha128':int(np.min(np.asarray(protected)[ta>=128]))},
|
||||
'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},
|
||||
'tools':{'inkscape':ink('--version'),'pillow':Image.__version__,'numpy':np.__version__},'printings':{},'visualReview':{'static':'pending','movingLight':'not performed'}}
|
||||
for printing,overlay_key,has_text in [('normal','overlay-normal',True),('boundless',None,False),('borderless','backing-borderless',True),('textless','frame-textless',False)]:
|
||||
overlay=Image.open(assets[overlay_key]).convert('RGBA') if overlay_key else None
|
||||
face=art.copy()
|
||||
if overlay:face=Image.alpha_composite(face,overlay)
|
||||
if has_text:
|
||||
check(np.all(np.asarray(overlay.getchannel('A'))[ta>0]==255),'Text outside fully opaque backing.')
|
||||
face=Image.alpha_composite(face,text)
|
||||
check(face.getchannel('A').getextrema()==(255,255),'Face has transparency.')
|
||||
if printing=='boundless':check(np.array_equal(np.asarray(face),np.asarray(art)),'Boundless changed artwork pixels.')
|
||||
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 overlay.')
|
||||
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);size=(width,width*7//5)
|
||||
f=face if width==2000 else face.resize(size,Image.Resampling.LANCZOS);f.save(folder/'card.png')
|
||||
for name,m in [('finish-mask.png',finish),('text-mask.png',tm)]:
|
||||
data=m if width==2000 else m.resize(size,Image.Resampling.BILINEAR)
|
||||
rgba=data.convert('RGBA');rgba.putalpha(255);rgba.save(folder/name)
|
||||
exports.append({'resolution':label,'dimensions':list(size),'files':{name:digest(folder/name) for name 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))}"/>'
|
||||
editable.write_text(svg(link(art_path)+(link(assets[overlay_key]) if overlay_key else '')+(body if has_text else '')))
|
||||
report['printings'][printing]={'overlay':overlay_key,'text':has_text,'exports':exports}
|
||||
contrasts=[]
|
||||
for width in [2000,1000,500]:
|
||||
scale=width/2000;b=np.asarray(Image.open(shared['directory']/'exports'/f'backing-borderless-{width}.png').convert('RGBA'))
|
||||
xx=int((layout['borderlessBacking'][0][0]+layout['primaryRuleInset'])*scale);yy=int(180*scale)
|
||||
ratio=max(contrast(c,b[yy,int(500*scale),:3]) for c in b[yy,max(0,xx-1):xx+2,:3])
|
||||
check(ratio>=3,f'Backing rule contrast too low at {width}.');contrasts.append({'width':width,'primaryRuleToCream':round(ratio,2),'minimum':3})
|
||||
report['backingContrast']=contrasts;report['textContrast']=round(contrast([38,63,80],[244,234,212]),2)
|
||||
check(report['textContrast']>=7,'Text contrast below 7:1.')
|
||||
# Labeled review sheet keeps annotations outside printing assets.
|
||||
board=Image.new('RGB',(1200,480),'#11161c');draw=ImageDraw.Draw(board);label_font=ImageFont.truetype(str(font_dir/'P052-Bold.otf'),22)
|
||||
for i,printing in enumerate(['normal','boundless','borderless','textless']):
|
||||
draw.text((i*300+10,8),printing.title(),font=label_font,fill='#f4ead4')
|
||||
im=Image.open(ROOT/'med'/printing/'card.png').convert('RGB').resize((280,392),Image.Resampling.LANCZOS);board.paste(im,(i*300+10,50))
|
||||
board.save(review/'printings-comparison.png')
|
||||
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['stage']='assembly-review';save(profile_path,profile)
|
||||
refresh(ROOT);update_index()
|
||||
print('BE-042 v02: all four printings at low/med/high, glyph bounds, fonts, contrast and data-map checks passed.')
|
||||
|
||||
if __name__=='__main__':main()
|
||||
Reference in New Issue
Block a user