Files
Sanctification/spikes/timothy-layer-proof/build.mjs
2026-09-11 18:38:01 -07:00

139 lines
12 KiB
JavaScript
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.
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import assert from 'node:assert/strict';
const root = dirname(fileURLToPath(import.meta.url));
const layout = JSON.parse(readFileSync(resolve(root, 'layout.json')));
const card = JSON.parse(readFileSync(resolve(root, 'card.json')));
const { width: W, height: H, radius, frameInset: inset, fields } = layout;
const out = resolve(root, 'output');
for (const dir of ['layers', 'cards', 'masks', 'review']) mkdirSync(resolve(out, dir), { recursive: true });
const run = (bin, args, input) => execFileSync(bin, args, { input, maxBuffer: 128 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
const magick = process.env.MAGICK_BIN || 'magick';
const inkscape = process.env.INKSCAPE_BIN || 'inkscape';
const path = name => resolve(out, name);
const hash = data => createHash('sha256').update(data).digest('hex');
const escape = value => String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('"', '&quot;');
const rect = ([x, y, w, h], attrs = String()) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" ${attrs}/>`;
const svg = (body, w = W, h = H) => `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const round = (x, y, w, h, r) => `M${x+r},${y}H${x+w-r}A${r},${r} 0 0 1 ${x+w},${y+r}V${y+h-r}A${r},${r} 0 0 1 ${x+w-r},${y+h}H${x+r}A${r},${r} 0 0 1 ${x},${y+h-r}V${y+r}A${r},${r} 0 0 1 ${x+r},${y}Z`;
const runtimeFrame = `<path fill="${layout.inkColor}" fill-rule="evenodd" d="M0,0H${W}V${H}H0Z ${round(inset,inset,W-2*inset,H-2*inset,radius-inset)}"/>`;
const frame = `<path fill="${layout.inkColor}" fill-rule="evenodd" d="${round(0,0,W,H,radius)} ${round(inset,inset,W-2*inset,H-2*inset,radius-inset)}"/>`;
const backing = layout.panels.map(box => rect(box, `fill="${layout.panelColor}"`)).join('') +
`<path d="M48 357H1952 M48 2183H1952" stroke="${layout.inkColor}" stroke-width="6"/>`;
const textValues = { title: card.name.toUpperCase(), subtitle: card.subtitle, category: card.category.toUpperCase(), reference: `${card.reference} · ${card.translation}` };
assert.equal(card.verseLines.join(' '), card.verse, 'Verse line breaks must preserve exact source text');
const textElement = (id, f, value) => `<text id="${id}" x="${f.x}" y="${f.y}" font-family="${f.font}" font-size="${f.size}" text-anchor="${f.anchor}" fill="${layout.inkColor}">${escape(value)}</text>`;
const textMarkup = Object.entries(fields).map(([id, f]) => id === 'verse'
? card.verseLines.map((line, i) => textElement(`verse-${i}`, { ...f, y: f.y + i*f.lineHeight }, `${i === 0 ? '“' : ''}${line}${i === card.verseLines.length-1 ? '”' : ''}`)).join('')
: textElement(id, f, textValues[id])).join('');
const exportSvg = (name, body, w = W, h = H) => {
writeFileSync(path(`${name}.svg`), svg(body, w, h));
run(inkscape, [path(`${name}.svg`), '--export-area-page', '--export-png-color-mode=RGBA_8', `--export-filename=${path(`${name}.png`)}`]);
};
const rgba = file => run(magick, [file, '-alpha', 'on', '-depth', '8', 'rgba:-']);
const rawPng = (bytes, file, channels = 'rgba') => run(magick, ['-size', `${W}x${H}`, '-depth', '8', `${channels}:-`, file], bytes);
const source = resolve(root, 'source/timothy-art.png');
const dimensions = run(magick, ['identify', '-format', '%w %h', source]).toString().split(' ').map(Number);
assert.equal(dimensions[0]/dimensions[1], W/H, 'Source requires an explicitly reviewed crop');
run(magick, [source, '-resize', `${W}x${H}`, '-colorspace', 'sRGB', path('layers/art.png')]);
const fonts = {};
for (const family of new Set(Object.values(fields).map(f => f.font))) {
const [matched, file] = run('fc-match', ['-f', '%{family}\n%{file}', family]).toString().split('\n');
assert.equal(matched, family, `Missing font: ${family}`);
fonts[family] = { file, sha256: hash(readFileSync(file)) };
}
exportSvg('layers/frame', frame);
exportSvg('layers/frame-runtime', runtimeFrame);
exportSvg('layers/backing', backing);
exportSvg('layers/text', textMarkup);
exportSvg('layers/normal-overlay', backing + frame);
exportSvg('layers/normal-runtime-overlay', backing + runtimeFrame);
exportSvg('layers/card-silhouette', `<rect width="${W}" height="${H}" rx="${radius}" fill="white"/>`);
const queries = run(inkscape, [path('layers/text.svg'), '--query-all']).toString();
writeFileSync(path('review/text-bounds.csv'), queries);
const glyphBounds = {};
for (const row of queries.trim().split('\n')) { const [id, ...numbers] = row.split(','); glyphBounds[id] = numbers.map(Number); }
function fits(box, bounds) {
return bounds?.length === 4 && bounds[0] >= box[0] && bounds[1] >= box[1] && bounds[0]+bounds[2] <= box[0]+box[2] && bounds[1]+bounds[3] <= box[1]+box[3];
}
for (const [id, f] of Object.entries(fields)) {
const ids = id === 'verse' ? card.verseLines.map((_, i) => `verse-${i}`) : [id];
for (const textId of ids) assert.ok(fits(f.box, glyphBounds[textId]), `${textId} overflows: ${glyphBounds[textId]}`);
}
// Measure an actual overlong text object to prove the check rejects overflow.
writeFileSync(path('review/overflow-fixture.svg'), svg(textElement('too-long', fields.title, 'TIMOTHY '.repeat(12))));
const overflow = run(inkscape, [path('review/overflow-fixture.svg'), '--query-id=too-long', '--query-all']).toString().trim().split('\n').find(row => row.startsWith('too-long,'));
assert.ok(!fits(fields.title.box, overflow.split(',').slice(1).map(Number)), 'Overflow fixture was not rejected');
const art = rgba(path('layers/art.png'));
const normalOverlay = rgba(path('layers/normal-runtime-overlay.png'));
const backingPixels = rgba(path('layers/backing.png'));
const framePixels = rgba(path('layers/frame.png'));
const runtimeFramePixels = rgba(path('layers/frame-runtime.png'));
const alphaAt = (buf, x, y) => buf[(y*W+x)*4+3];
let inspectedJoinPixels = 0;
// Interior joins, deliberately away from the rounded exterior and antialias bands.
for (const [x0,y0,w,h] of [[50,110,20,230],[1930,110,20,230],[50,2200,20,470],[1930,2200,20,470],[110,50,1780,20],[110,2730,1780,20]]) {
for (let y=y0;y<y0+h;y++) for (let x=x0;x<x0+w;x++) {
assert.equal(alphaAt(normalOverlay,x,y),255, `Gap at ${x},${y}`); inspectedJoinPixels++;
}
}
assert.equal(alphaAt(normalOverlay,1000,1200),0, 'Art window must be transparent');
assert.equal(alphaAt(framePixels,1000,2400),0, 'Textless must expose the lower art');
const seamFixture = Buffer.from(normalOverlay);
seamFixture[(150*W+59)*4+3] = 0;
assert.notEqual(alphaAt(seamFixture,59,150),255, 'One-pixel seam fixture was not detected');
const smooth = (a,b,v) => { const t=Math.max(0,Math.min(1,(v-a)/(b-a))); return t*t*(3-2*t); };
const variants = { normal: ['normal-runtime-overlay','text'], textless: ['frame-runtime'], borderless: ['backing','text'], boundless: [] };
const decodedHashes = {};
for (const [name, layers] of Object.entries(variants)) {
const args = [path('layers/art.png')];
for (const layer of layers) args.push(path(`layers/${layer}.png`), '-compose', 'Over', '-composite');
// Runtime textures stay opaque through their square corners; harness geometry owns the rounded silhouette.
args.push('-alpha', 'off', path(`cards/timothy-${name}.png`));
run(magick,args);
const pixels = rgba(path(`cards/timothy-${name}.png`));
for (let i = 3; i < pixels.length; i += 4) assert.equal(pixels[i], 255, `${name} runtime texture has a transparent pixel at index ${Math.floor(i / 4)}`);
decodedHashes[name] = hash(pixels);
const mask = Buffer.alloc(W*H);
for(let i=0;i<W*H;i++) {
const p=i*4, r=art[p]/255,g=art[p+1]/255,b=art[p+2]/255;
const hi=Math.max(r,g,b),lo=Math.min(r,g,b),sat=hi===0?0:(hi-lo)/hi;
let coverage=smooth(.08,.58,sat)*smooth(.015,.16,hi);
// This proof treats the frame and text panels as printed ink; finish is on visible art.
if (name==='normal'||name==='textless') coverage*=1-runtimeFramePixels[p+3]/255;
if (name==='normal'||name==='borderless') coverage*=1-backingPixels[p+3]/255;
mask[i]=Math.round(255*coverage);
}
rawPng(mask,path(`masks/timothy-${name}-finish.png`),'gray');
for(const width of [1000,500]) run(magick,[path(`cards/timothy-${name}.png`),'-resize',String(width),path(`cards/timothy-${name}-${width}.png`)]);
for(const [id,f] of Object.entries(fields)) if(layers.includes('text')) {
const [x,y,w,h]=f.box;
for(let yy=y;yy<y+h;yy++) for(let xx=x;xx<x+w;xx++) assert.equal(mask[yy*W+xx],0,`Finish under ${id}`);
}
}
// The textless variants retain the original art's finish coverage in former panel regions.
const boundlessMask=run(magick,[path('masks/timothy-boundless-finish.png'),'-depth','8','gray:-']);
assert.ok(boundlessMask.subarray(2250*W,2530*W).some(v=>v>0),'Stale lower text protection in Boundless');
// Repeat an independent composition and compare decoded pixels, ignoring PNG metadata.
const repeated=run(magick,[path('layers/art.png'),path('layers/normal-runtime-overlay.png'),'-compose','Over','-composite',path('layers/text.png'),'-compose','Over','-composite','-alpha','off','-depth','8','rgba:-']);
assert.equal(hash(repeated),decodedHashes.normal,'Composition is not repeatable');
const embeddedArt=`<image width="${W}" height="${H}" xlink:href="data:image/png;base64,${readFileSync(source).toString('base64')}"/>`;
// Inkscape's actual namespace is required for editable layer groups.
const group=(label,body)=>`<g inkscape:groupmode="layer" inkscape:label="${label}" id="${label}">${body}</g>`;
writeFileSync(path('timothy-editable.svg'),svg(group('Art',embeddedArt)+group('Backing',backing)+group('Frame',frame)+group('Text',textMarkup)).replace('http://www.inkscape.org/namespaces/inkscape','http://www.inkscape.org/namespaces/inkscape'));
const guides=Object.entries(fields).map(([id,f])=>rect(f.box,'fill="none" stroke="#db4652" stroke-width="4"')+`<text x="${f.box[0]}" y="${f.box[1]-12}" font-family="Noto Sans" font-size="30" fill="#db4652">${id}</text>`).join('')+rect(layout.focalRegion,'fill="none" stroke="#4289bd" stroke-width="5" stroke-dasharray="20 12"');
exportSvg('review/layout-guide',embeddedArt+backing+frame+textMarkup+guides);
let sheet=`<rect width="2240" height="920" fill="#f6f3eb"/><text x="60" y="62" font-family="Noto Serif" font-size="32" fill="#304747">Timothy / Common — four printings, one illustration</text>`;
for(const [i,name] of Object.keys(variants).entries()) {
const x=60+i*550;
sheet+=`<text x="${x}" y="120" font-family="Noto Sans" font-size="22" fill="#304747">${name[0].toUpperCase()+name.slice(1)}</text><image x="${x}" y="150" width="500" height="700" xlink:href="data:image/png;base64,${readFileSync(path(`cards/timothy-${name}-500.png`)).toString('base64')}"/>`;
}
exportSvg('review/printings',sheet,2240,920);
const report={status:'passed',template:layout.revision,source:{dimensions,sha256:hash(readFileSync(source)),transform:`Uniform scale ${W/dimensions[0]}; no crop. Master is upscaled from generated source.`},tools:{inkscape:run(inkscape,['--version']).toString().trim(),imagemagick:run(magick,['-version']).toString().split('\n')[0]},fonts,glyphBounds,inspectedJoinPixels,runtimeTextureAlpha:'Every card pixel is fully opaque; harness geometry owns the rounded silhouette',negativeChecks:['Rendered overlong title rejected','One-pixel alpha seam detected'],decodedHashes,finishRecipe:'Timothy saturation smoothstep(0.08,0.58) × value smoothstep(0.015,0.16), occluded by active printed-ink frame/backing',limitations:['Visual approval pending','No automated semantic or optical-centering judgment','No runtime lighting review performed by this script','Two-line alternate title layout not implemented in this proof']};
writeFileSync(path('validation.json'),JSON.stringify(report,null,2)+'\n');
console.log(`Built four printings and aligned finish masks. Validation passed (${inspectedJoinPixels} join pixels). See ${path('review/printings.png')}`);