Files

114 lines
15 KiB
Python

#!/usr/bin/env python3
"""Provisional bespoke Legendary decoration and gilded-pronoun preview for David."""
from pathlib import Path
import hashlib,json,os,subprocess
from xml.sax.saxutils import escape
import numpy as np
from PIL import Image,ImageDraw,ImageFilter
P=Path(__file__).resolve().parent
REV=P.parents[1]
R=next(p for p in P.parents if (p/'docs/card-workspace.md').is_file())
ART=REV/'source/art-master.png'
FOREGROUND=P.parent/'layout-preview-astra-v4/mantle-overlap-preview.png'
sha=lambda p:hashlib.sha256(p.read_bytes()).hexdigest()
art_hash=sha(ART);foreground_hash=sha(FOREGROUND)
fm=json.loads((R/'fonts/manifest.json').read_text());font_files={'title':'P052-Bold.otf','verse':'SanctificationP052-Medium.otf','reference':'SanctificationP052-Medium.otf'}
for name in set(font_files.values()):assert sha(R/'fonts'/name)==fm['files'][name]['sha256']
(P/'fonts.conf').write_text(f'<fontconfig><dir>{R/"fonts"}</dir><cachedir>{P/"font-cache"}</cachedir></fontconfig>')
env=dict(os.environ,FONTCONFIG_FILE=str(P/'fonts.conf'))
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')
def fc_file(family):return Path(ink('dummy','--version')) if False else Path(subprocess.check_output(['fc-match','-f','%{file}',family],env=env,text=True).strip()).resolve()
assert fc_file('P052:style=Bold')==(R/'fonts/P052-Bold.otf').resolve()
assert fc_file('Sanctification P052:style=Medium')==(R/'fonts/SanctificationP052-Medium.otf').resolve()
head='<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">'
defs='''<defs>
<linearGradient id="paper" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fbf3df"/><stop offset="0.52" stop-color="#f4ead4"/><stop offset="1" stop-color="#ead8b7"/></linearGradient>
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f3d879"/><stop offset="0.42" stop-color="#c89732"/><stop offset="1" stop-color="#7b5317"/></linearGradient>
<pattern id="grain" width="32" height="32" patternUnits="userSpaceOnUse"><path d="M2 9 Q9 5 16 9 T30 9 M1 24 Q9 20 17 24 T31 24" fill="none" stroke="#a58348" stroke-width="1" opacity=".08"/></pattern>
<g id="leaf"><path d="M0 0 C-17-12-29-35-21-53 C-3-45 10-26 0 0Z" fill="url(#gold)" stroke="#684713" stroke-width="3"/></g>
</defs>'''
base=f'<image xlink:href="../../source/art-master.png" width="2000" height="2800"/>'
# Narrow layered perimeter with restrained illuminated-manuscript foliage.
frame='''<g id="legendary-frame">
<rect x="14" y="14" width="1972" height="2772" rx="34" fill="none" stroke="#3b2914" stroke-width="28"/>
<rect x="24" y="24" width="1952" height="2752" rx="27" fill="none" stroke="url(#gold)" stroke-width="14"/>
<rect x="42" y="42" width="1916" height="2716" rx="20" fill="none" stroke="#173e59" stroke-width="10"/>
<rect x="52" y="52" width="1896" height="2696" rx="16" fill="none" stroke="#d7b35a" stroke-width="4"/>
<g stroke="#b7882d" stroke-width="6" fill="none" stroke-linecap="round">
<path d="M64 310 C112 274 123 206 101 139 C92 111 78 88 60 72"/>
<path d="M1936 2490 C1888 2526 1877 2594 1899 2661 C1908 2689 1922 2712 1940 2728"/>
</g>
<use xlink:href="#leaf" transform="translate(105 224) rotate(56) scale(.72)"/><use xlink:href="#leaf" transform="translate(111 160) rotate(83) scale(.63)"/><use xlink:href="#leaf" transform="translate(1895 2576) rotate(236) scale(.72)"/><use xlink:href="#leaf" transform="translate(1889 2640) rotate(263) scale(.63)"/>
<g stroke="#bb8b2e" stroke-width="5" fill="none" stroke-linecap="round"><path d="M61 430 C121 470 118 539 67 580 C24 615 32 687 82 730"/><path d="M1939 585 C1879 625 1882 694 1933 735 C1976 770 1968 842 1918 885"/></g>
<use xlink:href="#leaf" transform="translate(105 505) rotate(70) scale(.52)"/><use xlink:href="#leaf" transform="translate(86 648) rotate(107) scale(.46)"/><use xlink:href="#leaf" transform="translate(1895 660) rotate(250) scale(.52)"/><use xlink:href="#leaf" transform="translate(1914 802) rotate(287) scale(.46)"/>
<circle cx="67" cy="67" r="12" fill="url(#gold)" stroke="#533810" stroke-width="3"/><circle cx="1933" cy="67" r="12" fill="url(#gold)" stroke="#533810" stroke-width="3"/><circle cx="67" cy="2733" r="12" fill="url(#gold)" stroke="#533810" stroke-width="3"/><circle cx="1933" cy="2733" r="12" fill="url(#gold)" stroke="#533810" stroke-width="3"/>
</g>'''
# Bespoke cream panels: double gold rules, restrained grain and low-profile flourishes.
title_path='M1090 58 H1897 L1938 99 V326 L1899 365 H1090 L1048 323 V100 Z'
verse_path='M500 2075 H1898 Q1942 2075 1942 2119 V2720 Q1942 2764 1898 2764 H500 Q466 2764 466 2730 V2109 Q466 2075 500 2075 Z'
def panel(path,inner,ident):return f'<g id="{ident}"><path d="{path}" fill="url(#paper)" stroke="#654516" stroke-width="10"/><path d="{path}" fill="url(#grain)" stroke="url(#gold)" stroke-width="5"/>{inner}</g>'
title_inner='<path d="M1105 78 H1886 L1918 110 V315 L1887 345 H1104 L1068 312 V112 Z" fill="none" stroke="#9b732b" stroke-width="3"/><path d="M1328 310 H1454 M1526 310 H1652" stroke="#98702a" stroke-width="4"/><path d="M1490 297 l13 13 -13 13 -13-13Z" fill="url(#gold)" stroke="#664716" stroke-width="2"/>'
verse_inner='<path d="M510 2095 H1890 Q1922 2095 1922 2127 V2712 Q1922 2744 1890 2744 H510 Q486 2744 486 2720 V2119 Q486 2095 510 2095Z" fill="none" stroke="#9b732b" stroke-width="3"/><path d="M1150 2150 H1278 M1362 2150 H1490" stroke="#98702a" stroke-width="4"/><path d="M1320 2137 l13 13 -13 13 -13-13Z" fill="url(#gold)" stroke="#664716" stroke-width="2"/><path d="M1162 2705 H1282 M1358 2705 H1478" stroke="#98702a" stroke-width="4"/><path d="M1320 2693 l12 12 -12 12 -12-12Z" fill="url(#gold)" stroke="#664716" stroke-width="2"/><g stroke="#a17628" stroke-width="4" fill="none" stroke-linecap="round"><path d="M1884 2169 C1828 2190 1812 2238 1841 2290"/><path d="M1884 2670 C1828 2649 1812 2601 1841 2549"/></g><g fill="url(#gold)" stroke="#6b4918" stroke-width="2"><path d="M1840 2203 C1814 2193 1797 2206 1800 2233 C1824 2236 1841 2228 1840 2203Z"/><path d="M1827 2251 C1803 2245 1789 2262 1797 2285 C1820 2283 1834 2273 1827 2251Z"/><path d="M1840 2636 C1814 2646 1797 2633 1800 2606 C1824 2603 1841 2611 1840 2636Z"/><path d="M1827 2588 C1803 2594 1789 2577 1797 2554 C1820 2556 1834 2566 1827 2588Z"/></g>'
panels=panel(title_path,title_inner,'title-backing')+panel(verse_path,verse_inner,'verse-backing')
foreground='<image xlink:href="../layout-preview-astra-v4/mantle-overlap-preview.png" width="2000" height="2800"/>'
rows=[('title',[('DAVID',False)],1490,244,138,'P052',700),('verse-0',[('“',False),('I',True),(' have found David',False)],1320,2264,80,'Sanctification P052',500),('verse-1',[('the son of Jesse, a man',False)],1320,2358,80,'Sanctification P052',500),('verse-2',[('after ',False),('My',True),(' own heart,',False)],1320,2452,80,'Sanctification P052',500),('verse-3',[('who will do all ',False),('My',True),(' will.”',False)],1320,2546,80,'Sanctification P052',500),('reference',[('Acts 13:22 • NKJV',False)],1320,2655,57,'Sanctification P052',500)]
def text_nodes(gold_only=False):
out=[]
for ident,parts,x,y,size,family,weight in rows:
spans=''
for value,gilded in parts:
if gilded:style='fill="url(#gold)" stroke="#5a3e17" stroke-width="4" paint-order="stroke fill"'
elif gold_only:style='fill="#000" fill-opacity="0" stroke="none"'
else:style='fill="#263f50"'
spans+=f'<tspan {style}>{escape(value)}</tspan>'
out.append(f'<text id="{ident}" x="{x}" y="{y}" font-family="{family}" font-weight="{weight}" font-size="{size}" text-anchor="middle" xml:space="preserve">{spans}</text>')
return ''.join(out)
texts=text_nodes();gold_only=text_nodes(True)
(P/'text-preview.svg').write_text(head+defs+texts+'</svg>');render(P/'text-preview.svg',P/'text-preview.png')
(P/'gilded-lettering-preview.svg').write_text(head+defs+gold_only+'</svg>');render(P/'gilded-lettering-preview.svg',P/'gilded-lettering-preview.png')
(P/'border-backing-preview.svg').write_text(head+defs+frame+panels+'</svg>');render(P/'border-backing-preview.svg',P/'border-backing-preview.png')
# Seam guard: extend dark lead beneath the foreground's antialiased edge only where a backing is active.
fg_alpha=Image.open(FOREGROUND).convert('RGBA').getchannel('A');expanded=fg_alpha.filter(ImageFilter.MaxFilter(5))
backing_alpha=Image.open(P/'border-backing-preview.png').convert('RGBA').getchannel('A')
ga=np.asarray(expanded,dtype=np.uint16);ba=np.asarray(backing_alpha,dtype=np.uint16);guard_alpha=np.where((ga>0)&(ba>0),255,0).astype('uint8')
# Restrict this compositing bleed to the lower backing; it must not alter exposed illustration edges elsewhere.
guard_alpha[:2060,:]=0;guard_alpha[:,:450]=0;guard_alpha[:,1950:]=0
rgba=np.zeros((2800,2000,4),dtype=np.uint8);rgba[:,:,:3]=[29,23,18];rgba[:,:,3]=guard_alpha
Image.fromarray(rgba,'RGBA').save(P/'foreground-occlusion-guard.png')
guard='<image xlink:href="foreground-occlusion-guard.png" width="2000" height="2800"/>'
for name,gold in [('composed-preview',True),('plain-pronouns',False)]:
active=texts if gold else texts.replace('fill="url(#gold)" stroke="#5a3e17" stroke-width="4" paint-order="stroke fill"','fill="#263f50"')
src=P/(name+'.svg');src.write_text(head+defs+base+frame+panels+guard+foreground+active+'</svg>');render(src,P/(name+'-2000.png'));Image.open(P/(name+'-2000.png')).convert('RGB').resize((1000,1400),Image.Resampling.LANCZOS).save(P/(name+'-1000.png'))
# Per-row actual ink, panel coverage and foreground-clearance checks.
backing=np.asarray(Image.open(P/'border-backing-preview.png').convert('RGBA').getchannel('A'));fg=np.asarray(Image.open(FOREGROUND).convert('RGBA').getchannel('A'));checks=[];verse_bounds=[]
for ident,parts,x,y,size,family,weight in rows:
row=next(t for t in texts.split('</text>') if f'id="{ident}"' in t)+'</text>';src=P/(ident+'-ink.svg');src.write_text(head+defs+row+'</svg>');dst=P/(ident+'-ink.png');render(src,dst);inkalpha=np.asarray(Image.open(dst).convert('RGBA').getchannel('A'));ys,xs=np.where(inkalpha>0);assert len(xs);bounds=[int(xs.min()),int(ys.min()),int(xs.max()+1),int(ys.max()+1)];assert np.all(backing[inkalpha>0]==255),ident;assert not np.any((inkalpha>0)&(fg>0)),ident
role='title' if ident=='title' else ('reference' if ident=='reference' else 'verse');text=''.join(v for v,_ in parts);font=R/'fonts'/font_files[role];charset=ink('dummy','--version') if False else subprocess.check_output(['fc-query','--format','%{charset}',str(font)],text=True);coverage=set()
for span in charset.split():lohi=[int(v,16) for v in span.split('-')];coverage.update(range(lohi[0],lohi[-1]+1))
assert all(ord(c) in coverage for c in text),ident
checks.append({'id':ident,'text':text,'baseline':y,'font':family,'weight':weight,'size':size,'inkBounds':bounds,'glyphPixelsOutsideOpaqueBacking':0,'foregroundIntersectionPixels':0});verse_bounds.append(bounds) if role=='verse' else None
# Validate exact content and gilded tokens.
card=json.loads((REV/'card.json').read_text());assert ' '.join(c['text'] for c in checks if c['id'].startswith('verse-'))=='“'+card['excerpt']+'”';assert checks[-1]['text']==card['referenceDisplay']
gold_alpha=np.asarray(Image.open(P/'gilded-lettering-preview.png').convert('RGBA').getchannel('A'));assert np.count_nonzero(gold_alpha)>0;assert not np.any((gold_alpha>0)&(fg>0));assert np.all(backing[gold_alpha>0]==255)
# Center verse ink union between upper flourish and reference's fixed line-box top for this candidate.
union=[min(b[0] for b in verse_bounds),min(b[1] for b in verse_bounds),max(b[2] for b in verse_bounds),max(b[3] for b in verse_bounds)];upper_flourish_bottom=2163;reference_linebox_top=2613;above=union[1]-upper_flourish_bottom;below=reference_linebox_top-union[3]
# Comparison sheets.
def sheet(items,out):
board=Image.new('RGB',(1000,750),'#11161c');d=ImageDraw.Draw(board)
for i,(label,path) in enumerate(items):d.text((i*500+12,12),label,fill='#f4ead4');im=Image.open(path).convert('RGB');im.thumbnail((480,672));board.paste(im,(i*500+10,45))
board.save(P/out)
sheet([('Previous simple layout',P.parent/'layout-preview-astra-v3/composed-preview-1000.png'),('Legendary design pass',P/'composed-preview-1000.png')],'design-comparison.png')
sheet([('Shared teal pronouns',P/'plain-pronouns-1000.png'),('Gilded I / My / My',P/'composed-preview-1000.png')],'gilding-comparison.png')
# Enlarged bottom-panel typography comparison.
board=Image.new('RGB',(1520,430),'#11161c');d=ImageDraw.Draw(board)
for i,(label,path) in enumerate([('Shared teal pronouns',P/'plain-pronouns-1000.png'),('Gilded I / My / My',P/'composed-preview-1000.png')]):
d.text((i*760+12,12),label,fill='#f4ead4');crop=Image.open(path).convert('RGB').crop((230,1025,980,1390));crop.thumbnail((740,360));board.paste(crop,(i*760+10,52))
board.save(P/'gilding-detail.png')
assert sha(ART)==art_hash and sha(FOREGROUND)==foreground_hash
report={'status':'passed','scope':'Provisional Legendary frame/backing/typography and gilded-pronoun preview','canvas':[2000,2800],'sourceArtwork':{'path':str(ART.relative_to(R)),'sha256':art_hash,'unchanged':True},'foreground':{'path':str(FOREGROUND.relative_to(R)),'sha256':foreground_hash,'unchanged':True,'occlusionGuard':{'path':'foreground-occlusion-guard.png','darkLeadRGB':[29,23,18],'expansionRadiusMasterPixels':2,'activeRegion':[450,2060,1500,740]}},'geometry':{'titleBackingPath':title_path,'verseBackingPath':verse_path,'verseUpperFlourishBottom':upper_flourish_bottom,'referenceLineBoxTop':reference_linebox_top},'palette':{'backingBase':'#f4ead4','standardText':'#263f50','ornament':'#98702a','frameEnamel':'#173e59','gildedTextGradient':['#f3d879','#c89732','#7b5317'],'gildedTextOutline':'#5a3e17'},'fontManifestSHA256':sha(R/'fonts/manifest.json'),'textChecks':checks,'spacing':{'verseInkUnion':union,'upperFlourishToVerseInk':above,'verseInkToReferenceLineBox':below,'centeringDifference':abs(above-below),'lineBaselineGap':94,'referenceToLowerFlourish':'review visually; candidate-specific'},'gilding':{'tokens':[{'text':'I','line':'verse-0'},{'text':'My','line':'verse-2'},{'text':'My','line':'verse-3'}],'separateEditableLayer':'gilded-lettering-preview.svg','separateAlphaPreview':'gilded-lettering-preview.png','glyphPixelsOutsideOpaqueBacking':0,'foregroundIntersectionPixels':0,'finishDecision':'Pending: static gilded ink is previewed; production must decide whether this alpha receives metallic finish or remains protected lettering.'},'notPerformed':['Final printing exports','Production finish/glyph/gilding masks','Harness fixture installation','GPU/moving-light validation'],'staticReview':'pending user review'}
partial=(np.asarray(fg_alpha)>0)&(np.asarray(fg_alpha)<255)&(ba>0);ring=(ga>0)&(np.asarray(fg_alpha)==0)&(ba>0);active=np.zeros(partial.shape,bool);active[2060:,450:1950]=True
assert np.all(guard_alpha[partial&active]>0) and np.all(guard_alpha[ring&active]>0)
report['foreground']['occlusionGuard'].update({'partialEdgePixelsProtected':int(np.count_nonzero(partial&active)),'exteriorBleedPixels':int(np.count_nonzero(ring&active)),'unprotectedActivePartialEdgePixels':int(np.count_nonzero((partial&active)&(guard_alpha==0))),'purpose':'Prevent backing/rule color from showing through the antialiased illustrated edge; composed beneath foreground.'})
(P/'preview-validation.json').write_text(json.dumps(report,indent=2)+'\n');print(json.dumps({'status':'passed','spacing':report['spacing'],'gilding':report['gilding'],'preview':str((P/'composed-preview-1000.png').relative_to(R))},indent=2))