Files
Sanctification/artifacts/cards/BP-002-david/review/layout-preview-astra-v3/build-preview.py

102 lines
12 KiB
Python

#!/usr/bin/env python3
"""Requested provisional David layout only; no production printing or finish masks."""
from pathlib import Path
import hashlib,json,os,subprocess
from xml.sax.saxutils import escape
import numpy as np
from PIL import Image,ImageDraw
P=Path(__file__).resolve().parent
R=next(p for p in P.parents if (p/'docs/card-workspace.md').is_file())
ART=P.parents[1]/'source/art-master.png'
sha=lambda p:hashlib.sha256(p.read_bytes()).hexdigest()
original=sha(ART)
fonts=json.loads((R/'fonts/manifest.json').read_text())
for name in ['P052-Bold.otf','SanctificationP052-Medium.otf']:
assert sha(R/'fonts'/name)==fonts['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)
def render(src,dst):ink(src,'--export-type=png',f'--export-filename={dst}','--export-width=2000','--export-height=2800')
# Hand-authored silhouette of the ENTIRE connected drape, in native 1060 x 1484 coordinates.
# Keep original cloth outside the bounded artistic donor; no color-based alpha or hole repair.
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">'
path="M 244 930 L 333 930 C 351 988 364 1031 374 1081 C 383 1122 386 1168 392 1206 C 399 1248 403 1293 401 1327 Q 401 1341 392 1346 L 272 1346 C 275 1370 278 1390 282 1402 L 292 1484 L -5 1484 C 27 1398 52 1338 93 1302 C 134 1277 194 1254 239 1237 C 218 1240 198 1247 183 1254 C 208 1205 232 1159 251 1106 C 272 1047 260 985 244 930 Z"
scale=2000/1060
donor_scale=730/1319
donor_transform=f'translate(150 2100) scale({donor_scale})'
# Follow the dark internal fold seam on the left; the right and hem are artistically redrawn.
donor_path="M 786 0 L 1033 0 C 1042 112 1055 234 1071 365 C 1090 486 1097 546 1065 631 C 1030 724 965 771 863 803 C 784 829 709 831 660 844 L 648 790 L 630 650 L 629 510 C 677 523 730 550 770 590 C 783 556 776 426 778 222 C 780 120 783 40 786 0 Z"
replacement_path="M 786 0 C 783 40 780 120 778 222 C 776 426 783 556 770 590 C 730 550 677 523 629 510 L 630 650 L 648 790 L 660 844 L 1319 844 L 1319 0 Z"
clip=f'<defs><clipPath id="connected-drape"><path transform="scale({scale})" d="{path}"/></clipPath><clipPath id="adapted-hem"><path transform="{donor_transform}" d="{donor_path}"/></clipPath><linearGradient id="entry" x1="0" y1="2100" x2="0" y2="2132" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="black"/><stop offset="1" stop-color="white"/></linearGradient><mask id="donor-entry" maskUnits="userSpaceOnUse" x="0" y="0" width="2000" height="2800"><rect width="2000" height="2800" fill="url(#entry)"/></mask><mask id="remove-old" maskUnits="userSpaceOnUse" x="0" y="0" width="2000" height="2800"><rect width="2000" height="2800" fill="white"/><path transform="{donor_transform}" d="{replacement_path}" fill="black"/></mask></defs>'
fragment_svg=P/'mantle-overlap-preview.svg'
fragment_svg.write_text(head+clip+'<image xlink:href="../../source/art-master.png" width="2000" height="2800" clip-path="url(#connected-drape)" mask="url(#remove-old)"/>'+f'<g clip-path="url(#adapted-hem)" mask="url(#donor-entry)"><image xlink:href="source/hem-donor-native.png" x="150" y="2100" width="730" height="{1193*donor_scale}"/></g></svg>')
render(fragment_svg,P/'mantle-overlap-preview.png')
alpha=np.asarray(Image.open(P/'mantle-overlap-preview.png').convert('RGBA').getchannel('A'))
(P/'mantle-contour.json').write_text(json.dumps({'method':'Hand-authored original silhouette plus built-in-imagegen adapted hem; donor RGB intentionally changes','nativeCanvas':[1060,1484],'path':path,'uniformScale':scale,'component':'Original connected foreground with a generated curved back-drape donor','donorNativeCanvas':[1319,1193],'donorPath':donor_path,'replacedOriginalRegionPath':replacement_path,'donorTransform':{'translate':[150,2100],'uniformScale':donor_scale},'topEntryBlendMasterPixels':32},indent=2)+'\n')
rows=[('title','DAVID',1490,255,138,'P052',700)]
verses=['“I have found David','the son of Jesse, a man','after My own heart,','who will do all My will.”']
for i,t in enumerate(verses):rows.append((f'verse-{i}',t,1320,2306+i*85,80,'Sanctification P052',500))
rows.append(('reference','Acts 13:22 • NKJV',1320,2660,57,'Sanctification P052',500))
for ident,text,x,y,size,family,weight in rows:
font=R/'fonts'/('P052-Bold.otf' if weight==700 else 'SanctificationP052-Medium.otf')
charset=subprocess.check_output(['fc-query','--format','%{charset}',str(font)],text=True)
coverage=set()
for span in charset.split():
bounds=[int(v,16) for v in span.split('-')];coverage.update(range(bounds[0],bounds[-1]+1))
assert all(ord(c) in coverage for c in text),ident
texts=''.join(f'<text id="{ident}" x="{x}" y="{y}" font-family="{family}" font-weight="{weight}" font-size="{size}" text-anchor="middle" fill="#263f50">{escape(text)}</text>' for ident,text,x,y,size,family,weight in rows)
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">'
(P/'text-preview.svg').write_text(head+texts+'</svg>');render(P/'text-preview.svg',P/'text-preview.png')
# Simple SVG geometry is intentionally undecorated until layout review.
base='<image xlink:href="../../source/art-master.png" width="2000" height="2800"/>'
frame='<rect x="0" y="0" width="2000" height="2800" fill="none" stroke="#aa853f" stroke-width="36"/><rect x="30" y="30" width="1940" height="2740" fill="none" stroke="#dfbd70" stroke-width="4"/>'
panels='<rect x="1080" y="90" width="820" height="260" rx="18" fill="#f4ead4"/><rect x="1094" y="104" width="792" height="232" rx="10" fill="none" stroke="#806438" stroke-width="4"/><rect x="500" y="2200" width="1440" height="540" rx="20" fill="#f4ead4"/><rect x="514" y="2214" width="1412" height="512" rx="12" fill="none" stroke="#806438" stroke-width="4"/>'
(P/'backing-review.svg').write_text(head+panels+'</svg>');render(P/'backing-review.svg',P/'backing-review.png')
backing_alpha=np.asarray(Image.open(P/'backing-review.png').convert('RGBA').getchannel('A'))
overlap='<image xlink:href="mantle-overlap-preview.png" width="2000" height="2800"/>'
for name,fg in [('composed-preview',overlap),('without-overlap', '')]:
src=P/(name+'.svg');src.write_text(head+base+frame+panels+fg+texts+'</svg>');render(src,P/(name+'-2000.png'))
image=Image.open(P/(name+'-2000.png')).convert('RGB');image.resize((1000,1400),Image.Resampling.LANCZOS).save(P/(name+'-1000.png'))
# Measure each independently rendered line so no clipping can hide an overlap.
checks=[]
for ident,text,x,y,size,family,weight in rows:
node=next(v for v in texts.split('</text>') if f'id="{ident}"' in v)+'</text>'
src=P/(ident+'-ink.svg');src.write_text(head+node+'</svg>');dst=P/(ident+'-ink.png');render(src,dst);bounds=Image.open(dst).convert('RGBA').getchannel('A').getbbox();assert bounds
box=(1098,108,1882,332) if ident=='title' else (410,2240,1910,2700)
assert bounds[0]>=box[0] and bounds[1]>=box[1] and bounds[2]<=box[2] and bounds[3]<=box[3],(ident,bounds,box)
inkalpha=np.asarray(Image.open(dst).convert('RGBA').getchannel('A'));assert not np.any((inkalpha>0)&(alpha>0)),ident
assert np.all(backing_alpha[inkalpha>0]==255),ident
checks.append({'id':ident,'text':text,'font':family,'weight':weight,'size':size,'inkBounds':list(bounds),'safeBox':list(box),'mantleIntersectionPixels':0,'glyphPixelsOutsideOpaqueBacking':0,'glyphCoverageVerified':True})
board=Image.new('RGB',(1000,750),'#11161c');d=ImageDraw.Draw(board)
for i,(label,path) in enumerate([('Simple backing, no overlap',P/'without-overlap-1000.png'),('Provisional mantle overlap',P/'composed-preview-1000.png')]):
d.text((i*500+12,12),label,fill='#f4ead4');im=Image.open(path);im.thumbnail((480,672));board.paste(im,(i*500+10,45))
board.save(P/'overlap-comparison.png')
assert sha(ART)==original
report={'status':'passed','scope':'Provisional composed layout preview only','artSHA256':original,'sourceArtworkUnchanged':True,'canvas':[2000,2800],'titleBacking':[1080,90,820,260],'verseBacking':[500,2200,1440,540],'palette':{'backing':'#f4ead4','text':'#263f50','rule':'#806438'},'fontManifestSHA256':sha(R/'fonts/manifest.json'),'textChecks':checks,'overlap':{'method':'Original connected contour plus bounded generated curved-hem donor; same-canvas composition.','contour':'mantle-contour.json','note':'Upper connection retained; lower/right hem artistically adapted with built-in imagegen. Provisional contour and donor pending review.','letteringIntersectionPixels':0},'notPerformed':['Decorative asset generation','Final printing exports','Finish or glyph-protection masks','Harness fixture installation','GPU/moving-light validation']}
(P/'preview-validation.json').write_text(json.dumps(report,indent=2)+'\n');print(json.dumps({'status':'passed','preview':str((P/'composed-preview-1000.png').relative_to(R)),'inkChecks':checks},indent=2))
assert (P/'text-preview.png').read_bytes()==(P.parent/'layout-preview-astra-v2/text-preview.png').read_bytes()
report.update({'letteringPNGMatchesV2':True,'foregroundRGBPolicy':'Artistic foreground adaptation explicitly permitted; donor RGB intentionally differs from original. Base source artwork remains unchanged.','donor':{'path':'source/hem-donor-native.png','sha256':sha(P/'source/hem-donor-native.png'),'nativeCanvas':[1319,1193],'uniformScale':donor_scale,'translate':[150,2100],'placedSize':[730,1193*donor_scale],'editTargetMasterCrop':[150,2100,880,2760],'topEntryBlendMasterPixels':32,'method':'Built-in imagegen edit; bounded SVG donor over original foreground, exact original lettering composed last.'},'glyphCoverageVerified':True,'glyphPixelsOutsideOpaqueBacking':0,'staticReview':{'status':'provisional','assessment':'Adapted drape uses a curved tapered hem and flowing pane boundaries instead of the source ledge-shaped termination. Upper connection and broad cloth plane remain.','limitation':'Generated donor and its transition remain provisional; static visual review only.'}})
(P/'preview-validation.json').write_text(json.dumps(report,indent=2)+'\n')
for filename,crop,size in [('before-after.png',None,(1000,750)),('junction-comparison.png',(100,1970,980,2800),(1000,710))]:
board=Image.new('RGB',size,'#11161c');d=ImageDraw.Draw(board)
for i,(label,f) in enumerate([('Previous: source-shaped hem',P.parent/'layout-preview-astra-v2/composed-preview-2000.png'),('Adapted curved hanging hem',P/'composed-preview-2000.png')]):
d.text((i*500+12,12),label,fill='#f4ead4');im=Image.open(f)
if crop: im=im.crop(crop)
im.thumbnail((480,645 if crop else 672));board.paste(im,(i*500+10,45))
board.save(P/filename)
previous=np.asarray(Image.open(P.parent/'layout-preview-astra-v2/composed-preview-2000.png').convert('RGB'))
current=np.asarray(Image.open(P/'composed-preview-2000.png').convert('RGB'))
changed=np.any(previous!=current,axis=2)
yy,xx=np.nonzero(changed)
assert len(xx)>0
bounds=[int(xx.min()),int(yy.min()),int(xx.max())+1,int(yy.max())+1]
allowed=np.zeros(changed.shape,dtype=bool);allowed[2100:2600,490:780]=True
assert not np.any(changed&~allowed),bounds
report['changedForegroundVsPrevious']={'pixelCount':int(changed.sum()),'bounds':bounds,'allowedMasterRegion':[490,2100,780,2600],'changedPixelsOutsideAllowedRegion':0}
(P/'preview-validation.json').write_text(json.dumps(report,indent=2)+'\n')
(P/'source/art-provenance.json').write_text(json.dumps({'tool':'built-in imagegen','editPrompt':'edit-prompt.txt','nativeDonor':'hem-donor-native.png','nativeDonorSHA256':sha(P/'source/hem-donor-native.png'),'editTarget':'edit-target-crop.png','editTargetSHA256':sha(P/'source/edit-target-crop.png'),'displayedInput':'RGB JPEG thumbnail of the target crop, 650px wide, quality 80; num_last_images_to_include=1','fit':report['donor'],'baseArtworkSHA256':original,'baseArtworkUnchanged':True,'artisticForegroundAdaptation':True},indent=2)+'\n')
print(json.dumps(report['changedForegroundVsPrevious'],indent=2))