139 lines
12 KiB
JavaScript
139 lines
12 KiB
JavaScript
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('&', '&').replaceAll('<', '<').replaceAll('"', '"');
|
||
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')}`);
|