221 lines
17 KiB
Python
221 lines
17 KiB
Python
#!/usr/bin/env python3
|
||
"""Independent SOL v2 proof: deterministic SVG/Inkscape and ImageMagick composition."""
|
||
from pathlib import Path
|
||
import base64, hashlib, json, os, subprocess, xml.sax.saxutils as xml
|
||
import numpy as np
|
||
from PIL import Image
|
||
|
||
ROOT = Path(__file__).resolve().parent
|
||
OUT = ROOT / 'output'
|
||
for sub in ['layers', 'cards', 'masks', 'review', 'svg']:
|
||
(OUT / sub).mkdir(parents=True, exist_ok=True)
|
||
CARD = json.loads((ROOT/'card.json').read_text())
|
||
LAYOUT = json.loads((ROOT/'layout.json').read_text())
|
||
W,H = LAYOUT['canvas']
|
||
def run(*args, env=None):
|
||
return subprocess.check_output([str(x) for x in args], text=True, env=env).strip()
|
||
def sha(path): return hashlib.sha256(path.read_bytes()).hexdigest()
|
||
font_config = OUT/'fonts.conf'
|
||
font_config.write_text(f'<fontconfig><dir>{ROOT / "fonts"}</dir><cachedir>{OUT / "font-cache"}</cachedir></fontconfig>')
|
||
ENV = dict(os.environ, FONTCONFIG_FILE=str(font_config))
|
||
def ink(*args): return run('inkscape', *args, env=ENV)
|
||
def uri(path): return 'data:image/png;base64,'+base64.b64encode(path.read_bytes()).decode()
|
||
def svg(body): return f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="2000" height="2800" viewBox="0 0 2000 2800">{body}</svg>'
|
||
def rects(items, rounded=False):
|
||
# Opaque gradient foundations maintain the original overlay seams and finish occlusion.
|
||
body = '<defs><linearGradient id="cream-vellum" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f6efdf"/><stop offset="0.48" stop-color="#f4ead4"/><stop offset="1" stop-color="#eee2c9"/></linearGradient><pattern id="vellum-fibers" width="100" height="80" patternUnits="userSpaceOnUse"><g fill="none" stroke="#9e8454" stroke-width="0.9" opacity="0.075"><path d="M 7 14 l 17 -1 M 53 39 l 21 1 M 23 67 l 11 -1 M 80 8 l 7 1"/></g></pattern></defs>'
|
||
for index,(x,y,w,h) in enumerate(items):
|
||
radius = 28 if rounded else 0
|
||
body += f'<rect id="panel-{index}" x="{x}" y="{y}" width="{w}" height="{h}" rx="{radius}" fill="url(#cream-vellum)"/>'
|
||
body += f'<rect id="panel-texture-{index}" x="{x}" y="{y}" width="{w}" height="{h}" rx="{radius}" fill="url(#vellum-fibers)"/>'
|
||
body += f'<rect id="panel-inset-{index}" x="{x+17}" y="{y+17}" width="{w-34}" height="{h-34}" rx="{max(0,radius-12)}" fill="none" stroke="#9e8454" stroke-opacity="0.65" stroke-width="2"/>'
|
||
# Small, static geometry suggests book illumination without adding a finish effect.
|
||
for y in [299,2231,2694]:
|
||
body += f'<g id="ornament-{y}" fill="#9e8454" stroke="#9e8454" stroke-width="2"><path d="M 755 {y} H 972 M 1028 {y} H 1245" fill="none"/><path d="M 1000 {y-8} L 1008 {y} L 1000 {y+8} L 992 {y} Z"/></g>'
|
||
return body
|
||
def render(name, body):
|
||
s=OUT/'svg'/f'calling-disciples-SOL-fonts-noto-serif-{name}.svg'; p=OUT/'layers'/f'calling-disciples-SOL-fonts-noto-serif-{name}.png'
|
||
s.write_text(svg(body)); ink(s,'--export-type=png',f'--export-filename={p}','--export-width=2000','--export-height=2800')
|
||
return p
|
||
|
||
source = ROOT/CARD['sourceArtwork']; frame_source=ROOT/CARD['frameInput']
|
||
assert Image.open(source).size == tuple(LAYOUT['sourceTransform']['native'])
|
||
assert Image.open(frame_source).size == (W,H)
|
||
art=OUT/'layers'/'calling-disciples-SOL-fonts-noto-serif-art-master.png'
|
||
run('magick',source,'-filter','Lanczos','-resize','2000x2800', '-alpha','on', '-channel','A','-evaluate','set','100%','+channel',art)
|
||
frame=OUT/'layers'/'calling-disciples-SOL-fonts-noto-serif-frame-runtime.png'
|
||
frame.write_bytes(frame_source.read_bytes())
|
||
normal_body=rects(LAYOUT['normalBacking'])
|
||
borderless_body=rects(LAYOUT['borderlessBacking'],True)
|
||
normal_back=render('backing-normal',normal_body)
|
||
borderless_back=render('backing-borderless',borderless_body)
|
||
reference_display=f"{CARD['reference']} • {CARD['translation']}"
|
||
assert reference_display == CARD['referenceDisplay'] == 'Matthew 4:19 • NKJV'
|
||
name=xml.escape(CARD['name']); ref=xml.escape(reference_display)
|
||
text_body=f'<g fill="#263f50" text-anchor="middle"><text id="title" x="1000" y="225" font-family="Noto Serif" font-size="112" letter-spacing="0.5">{name}</text>'
|
||
for i,line in enumerate(LAYOUT['verseLines']):
|
||
text_body += f'<text id="verse-{i}" x="1000" y="{2370+i*125}" font-family="Noto Serif" font-size="80">{xml.escape(line)}</text>'
|
||
text_body+=f'<text id="reference" x="1000" y="2644" font-family="Noto Serif" font-size="57" letter-spacing="1.5">{ref}</text></g>'
|
||
text=render('text',text_body)
|
||
combined=OUT/'layers'/'calling-disciples-SOL-fonts-noto-serif-normal-runtime-overlay.png'
|
||
run('magick',normal_back,frame,'-compose','over','-composite',combined)
|
||
components={'normal':[combined,text], 'textless':[frame], 'borderless':[borderless_back,text], 'boundless':[]}
|
||
art_body=f'<image id="art" width="2000" height="2800" href="{uri(art)}"/>'
|
||
frame_body=f'<image id="frame" width="2000" height="2800" href="{uri(frame)}"/>'
|
||
for printing in components:
|
||
body=art_body
|
||
if printing in ['normal','borderless']: body+=f'<g id="backing">{normal_body if printing=="normal" else borderless_body}</g>'
|
||
if printing in ['normal','textless']: body+=frame_body
|
||
if printing in ['normal','borderless']: body+=f'<g id="lettering">{text_body}</g>'
|
||
(OUT/f'calling-disciples-SOL-fonts-noto-serif-{printing}-editable.svg').write_text(svg(body))
|
||
|
||
report={'status':'pending','proof':'Controlled font comparison; exact SOL v2 art, frame and backing reused', 'inputs':{}, 'tools':{}, 'sourceTransform':LAYOUT['sourceTransform'], 'checks':{}, 'limitations':['Native artwork is 1060 × 1484 and uniformly enlarged, not native 2000 × 2800 generation.','No moving-light harness validation or human visual approval performed.','Cream vellum backing and title-only typography are text-treatment proof choices requiring visual review.','Finish recipe is a starting recipe from general documentation, not tuned or approved for this illustration.','Rebuild uses retained generated artwork; image generation itself is not deterministic.','Supplied scripture was reproduced verbatim without independent online verification.']}
|
||
for p in [source,frame_source,ROOT/'source'/'calling-disciples-SOL-prompt.txt',ROOT/'fonts'/'NotoSerif-Regular.ttf',ROOT/'fonts'/'NotoSans-Regular.ttf']:
|
||
report['inputs'][str(p.relative_to(ROOT))]={'sha256':sha(p)}
|
||
chosen_font=ROOT/'fonts'/CARD['fontChoice']['filename']
|
||
report['inputs'][str(chosen_font.relative_to(ROOT))]={'sha256':sha(chosen_font)}
|
||
report['tools']={tool:run(*cmd) for tool,cmd in {'inkscape':['inkscape','--version'],'imagemagick':['magick','--version'],'python':['python3','--version']}.items()}
|
||
report['tools']['numpy']=np.__version__; report['tools']['pillow']=Image.__version__
|
||
font_checks=[]
|
||
for family,filename,content in [('Noto Serif', 'NotoSerif-Regular.ttf',CARD['name']+CARD['excerpt']+reference_display),('Noto Sans','NotoSans-Regular.ttf',CARD['subjectType']+CARD['reference'])]:
|
||
font_path=ROOT/'fonts'/filename
|
||
selected=Path(run('fc-match','--format=%{file}',family,env=ENV)).resolve()
|
||
assert selected==font_path.resolve(), f'Unpinned font selected for {family}: {selected}'
|
||
charset=run('fc-query','--format=%{charset}',font_path)
|
||
ranges=[]
|
||
for item in charset.split():
|
||
ends=item.split('-'); ranges.append((int(ends[0],16),int(ends[-1],16)))
|
||
missing=[c for c in set(content) if not any(lo<=ord(c)<=hi for lo,hi in ranges)]
|
||
assert not missing, f'Missing glyphs in {filename}: {missing}'
|
||
font_checks.append({'family':family,'selected':str(font_path.relative_to(ROOT)),'missingGlyphs':missing})
|
||
expected_excerpt=' '.join(LAYOUT['verseLines'])
|
||
assert expected_excerpt == CARD['excerpt'], 'Verse altered in layout'
|
||
report['checks']['exactContent']={'pass':True,'excerpt':expected_excerpt,'reference':CARD['reference'],'referenceDisplay':reference_display,'referenceFormat':'Book chapter:verse • Translation','translation':CARD['translation'],'subjectType':CARD['subjectType'],'visibleCategory':False,'descriptor':None}
|
||
# Query actual, unclipped glyph bounds from the same Inkscape renderer.
|
||
bounds={}
|
||
for row in ink(OUT/'svg'/'calling-disciples-SOL-fonts-noto-serif-text.svg','--query-all').splitlines():
|
||
vals=row.split(',')
|
||
if len(vals)==5: bounds[vals[0]]=list(map(float,vals[1:]))
|
||
text_checks=[]
|
||
for key in ['title','verse-0','verse-1','reference']:
|
||
region_key='verse' if key.startswith('verse') else key
|
||
x,y,w,h=bounds[key]; rx,ry,rw,rh=LAYOUT['textRegions'][region_key]
|
||
passed=x>=rx and y>=ry and x+w<=rx+rw and y+h<=ry+rh
|
||
assert passed, f'Text overflow: {key} {bounds[key]}'
|
||
text_checks.append({'id':key,'glyphBounds':bounds[key],'region':LAYOUT['textRegions'][region_key],'pass':passed})
|
||
assert all(LAYOUT['fontSizes'][k]>=v for k,v in LAYOUT['fontMinimums'].items())
|
||
report['checks']['typography']={'pass':True,'glyphs':text_checks,'minimumSizes':True,'verseLineCount':2,'fontIsolation':'Pinned local font files via FONTCONFIG_FILE','fontChoice':CARD['fontChoice'],'fonts':font_checks}
|
||
|
||
rgb=np.asarray(Image.open(art).convert('RGB'),dtype=np.float32)/255
|
||
v=rgb.max(axis=2); mn=rgb.min(axis=2); sat=(v-mn)/np.maximum(v,1e-8)
|
||
def smooth(a,b,x):
|
||
t=np.clip((x-a)/(b-a),0,1); return t*t*(3-2*t)
|
||
base=smooth(.08,.58,sat)*smooth(.015,.16,v)
|
||
report['checks']['printings']={}
|
||
def alpha(p): return np.asarray(Image.open(p).convert('RGBA'))[:,:,3]/255.
|
||
for printing,layers in components.items():
|
||
master=OUT/'cards'/f'calling-disciples-SOL-fonts-noto-serif-{printing}-2000.png'
|
||
args=['magick',art]
|
||
for layer in layers: args += [layer,'-compose','over','-composite']
|
||
args += ['-alpha','on','-channel','A','-evaluate','set','100%','+channel',master]
|
||
run(*args)
|
||
protection=np.zeros((H,W),dtype=np.float64)
|
||
if printing=='normal': protection=alpha(combined)
|
||
elif printing=='textless': protection=alpha(frame)
|
||
elif printing=='borderless': protection=alpha(borderless_back)
|
||
coverage=np.rint(base*(1-protection)*255).astype(np.uint8)
|
||
mask=OUT/'masks'/f'calling-disciples-SOL-fonts-noto-serif-{printing}-finish-2000.png'
|
||
Image.fromarray(coverage).save(mask)
|
||
checks=[]
|
||
for size in LAYOUT['runtimeSizes']:
|
||
cardpath=OUT/'cards'/f'calling-disciples-SOL-fonts-noto-serif-{printing}-{size}.png'
|
||
maskpath=OUT/'masks'/f'calling-disciples-SOL-fonts-noto-serif-{printing}-finish-{size}.png'
|
||
if size!=2000:
|
||
run('magick',master,'-filter','Lanczos','-resize',f'{size}x{size*7//5}',cardpath)
|
||
# Data-map triangle filtering avoids ringing outside protected regions.
|
||
run('magick',mask,'-filter','Triangle','-resize',f'{size}x{size*7//5}',maskpath)
|
||
image=np.asarray(Image.open(cardpath).convert('RGBA')); m=np.asarray(Image.open(maskpath).convert('L'))
|
||
assert image.shape[:2]==(size*7//5,size)
|
||
assert np.all(image[:,:,3]==255)
|
||
corners=[(0,0),(0,size-1),(size*7//5-1,0),(size*7//5-1,size-1)]
|
||
corner_colors=[image[y,x,:3].tolist() for y,x in corners]
|
||
corner_masks=[int(m[y,x]) for y,x in corners]
|
||
if printing in ['normal','textless']:
|
||
assert all(c==[38,63,80] for c in corner_colors)
|
||
assert all(c==0 for c in corner_masks)
|
||
checks.append({'size':[size,size*7//5],'opaque':True,'cornerRGB':corner_colors,'cornerFinish':corner_masks,'decodedSHA256':hashlib.sha256(image.tobytes()).hexdigest()})
|
||
assert np.all(coverage[protection==1]==0)
|
||
report['checks']['printings'][printing]={'components':['art']+({'normal':['frame+backing','text'],'textless':['frame'],'borderless':['backing','text'],'boundless':[]}[printing]),'finishProtection':True,'exports':checks,'finishMean':float(coverage.mean()/255)}
|
||
# Composition repeatability at decoded pixel level.
|
||
second=OUT/'layers'/f'calling-disciples-SOL-fonts-noto-serif-{printing}-repeat.png'
|
||
run(*(args[:-1]+[second]))
|
||
assert np.array_equal(np.asarray(Image.open(master)),np.asarray(Image.open(second)))
|
||
second.unlink()
|
||
report['checks']['repeatComposition']={'pass':True,'printings':list(components),'note':'All four ImageMagick compositions rerun and decoded pixels compared.'}
|
||
|
||
# Validate the overlay separately: opaque artwork cannot hide overlay holes.
|
||
overlay_alpha=np.asarray(Image.open(combined).convert('RGBA'))[:,:,3]
|
||
required=np.zeros((H,W),bool)
|
||
for x,y,w,h in LAYOUT['normalBacking']: required[y:y+h,x:x+w]=True
|
||
required |= np.asarray(Image.open(frame).convert('RGBA'))[:,:,3]==255
|
||
assert np.all(overlay_alpha[required]==255)
|
||
assert np.all(overlay_alpha[430:2100,160:1840]==0)
|
||
report['checks']['seams']={'pass':True,'requiredOpaquePixels':int(required.sum()),'clearWindowPixels':1670*1680,'fixture':'Injected transparent pixel inside a required join is rejected.'}
|
||
bad=overlay_alpha.copy(); bad[100,65]=0
|
||
assert not np.all(bad[required]==255)
|
||
for size in [1000,500]:
|
||
sampled=OUT/'layers'/f'calling-disciples-SOL-fonts-noto-serif-overlay-{size}.png'
|
||
run('magick',combined,'-filter','Lanczos','-resize',f'{size}x{size*7//5}',sampled)
|
||
a=np.asarray(Image.open(sampled).convert('RGBA'))[:,:,3]
|
||
factor=size/2000
|
||
# Perimeter plus deeply interior backing pixels: avoid intentional art-window AA band.
|
||
assert np.all(a[:int(48*factor),:]==255)
|
||
assert np.all(a[-int(48*factor):,:]==255)
|
||
for x,y,w,h in LAYOUT['normalBacking']:
|
||
xx,yy,ww,hh=[int(v*factor) for v in [x+12,y+12,w-24,h-24]]
|
||
assert np.all(a[yy:yy+hh,xx:xx+ww]==255)
|
||
report['checks']['downsampledOverlay']={'pass':True,'sizes':[1000,500],'note':'Opaque panel interiors/perimeter checked apart from intentional window antialiasing.'}
|
||
|
||
font=ROOT/'fonts'/'NotoSans-Regular.ttf'
|
||
review=OUT/'review'/'calling-disciples-SOL-fonts-noto-serif-printings.png'
|
||
tiles=[]
|
||
for printing in components:
|
||
tile=OUT/'review'/f'calling-disciples-SOL-fonts-noto-serif-{printing}-tile.png'
|
||
run('magick',OUT/'cards'/f'calling-disciples-SOL-fonts-noto-serif-{printing}-500.png','-background','#ede9df','-gravity','north','-splice','0x62','-font',font,'-fill','#263f50','-pointsize','25','-gravity','north','-annotate','+0+15',printing.title(),tile)
|
||
tiles.append(tile)
|
||
run('magick','montage',*tiles,'-tile','4x1','-geometry','500x762+18+18','-background','#ede9df',review)
|
||
maskreview=OUT/'review'/'calling-disciples-SOL-fonts-noto-serif-finish-masks.png'
|
||
masktiles=[]
|
||
for printing in components:
|
||
tile=OUT/'review'/f'calling-disciples-SOL-fonts-noto-serif-{printing}-mask-tile.png'
|
||
run('magick',OUT/'masks'/f'calling-disciples-SOL-fonts-noto-serif-{printing}-finish-500.png','-background','#ede9df','-gravity','north','-splice','0x62','-font',font,'-fill','#263f50','-pointsize','25','-gravity','north','-annotate','+0+15',printing.title(),tile)
|
||
masktiles.append(tile)
|
||
run('magick','montage',*masktiles,'-tile','4x1','-geometry','500x762+18+18','-background','#ede9df',maskreview)
|
||
# An actual 1000px sheet and 2000px sheet support inspection without pretending thumbnail review is enough.
|
||
for size in [1000,2000]:
|
||
run('magick','montage',*[OUT/'cards'/f'calling-disciples-SOL-fonts-noto-serif-{p}-{size}.png' for p in components],'-tile','4x1','-geometry',f'{size}x{size*7//5}+16+16','-background','#ede9df',OUT/'review'/f'calling-disciples-SOL-fonts-noto-serif-printings-{size}.png')
|
||
|
||
baseline=json.loads((ROOT/'source/reuse-metadata.json').read_text())
|
||
reuse={}
|
||
for relative,expected in baseline['sourceAssets'].items():
|
||
assert sha(ROOT/relative)==expected['originalSHA256']
|
||
reuse[relative]={'byteIdentical':True,'originalSHA256':expected['originalSHA256']}
|
||
assert hashlib.sha256(np.asarray(Image.open(art)).tobytes()).hexdigest()==baseline['artMasterDecodedSHA256']
|
||
for printing,sizes in baseline['unchangedPrintings'].items():
|
||
for size,expected in sizes.items():
|
||
new=np.asarray(Image.open(OUT/'cards'/f'calling-disciples-SOL-fonts-noto-serif-{printing}-{size}.png'))
|
||
newmask=np.asarray(Image.open(OUT/'masks'/f'calling-disciples-SOL-fonts-noto-serif-{printing}-finish-{size}.png'))
|
||
assert hashlib.sha256(new.tobytes()).hexdigest()==expected['cardDecodedSHA256']
|
||
assert hashlib.sha256(newmask.tobytes()).hexdigest()==expected['maskDecodedSHA256']
|
||
report['checks']['unchangedArtworkFrame']={'pass':True,'sourceAssets':reuse,'artMasterPixelIdentical':True,'textlessBoundlessCardsAndMasksPixelIdenticalAtAllSizes':True,'baseline':'source/reuse-metadata.json; hashes captured from original SOL outputs'}
|
||
report['textTreatment']=LAYOUT['backingTreatment']
|
||
report['inputs']['source/font-comparison-brief.txt']={'sha256':sha(ROOT/CARD['revisionPrompt'])}
|
||
report['inputs']['source/reuse-metadata.json']={'sha256':sha(ROOT/'source/reuse-metadata.json')}
|
||
report['inputs']['layout.json']={'sha256':sha(ROOT/'layout.json')}
|
||
report['inputs']['card.json']={'sha256':sha(ROOT/'card.json')}
|
||
|
||
report['fontChoice']=CARD['fontChoice']
|
||
report['status']='passed'
|
||
(OUT/'calling-disciples-SOL-fonts-noto-serif-validation.json').write_text(json.dumps(report,indent=2)+'\n')
|
||
print(f'SOL v2 proof built and structurally validated: {ROOT}')
|