Files

221 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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-p052-{name}.svg'; p=OUT/'layers'/f'calling-disciples-SOL-fonts-p052-{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-p052-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-p052-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="P052" 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="P052" font-size="80">{xml.escape(line)}</text>'
text_body+=f'<text id="reference" x="1000" y="2644" font-family="P052" font-size="57" letter-spacing="1.5">{ref}</text></g>'
text=render('text',text_body)
combined=OUT/'layers'/'calling-disciples-SOL-fonts-p052-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-p052-{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 [('P052', 'P052-Roman.otf',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-p052-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-p052-{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-p052-{printing}-finish-2000.png'
Image.fromarray(coverage).save(mask)
checks=[]
for size in LAYOUT['runtimeSizes']:
cardpath=OUT/'cards'/f'calling-disciples-SOL-fonts-p052-{printing}-{size}.png'
maskpath=OUT/'masks'/f'calling-disciples-SOL-fonts-p052-{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-p052-{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-p052-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-p052-printings.png'
tiles=[]
for printing in components:
tile=OUT/'review'/f'calling-disciples-SOL-fonts-p052-{printing}-tile.png'
run('magick',OUT/'cards'/f'calling-disciples-SOL-fonts-p052-{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-p052-finish-masks.png'
masktiles=[]
for printing in components:
tile=OUT/'review'/f'calling-disciples-SOL-fonts-p052-{printing}-mask-tile.png'
run('magick',OUT/'masks'/f'calling-disciples-SOL-fonts-p052-{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-p052-{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-p052-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-p052-{printing}-{size}.png'))
newmask=np.asarray(Image.open(OUT/'masks'/f'calling-disciples-SOL-fonts-p052-{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-p052-validation.json').write_text(json.dumps(report,indent=2)+'\n')
print(f'SOL v2 proof built and structurally validated: {ROOT}')