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()) => ``; const svg = (body, w = W, h = H) => `${body}`; 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 = ``; const frame = ``; const backing = layout.panels.map(box => rect(box, `fill="${layout.panelColor}"`)).join('') + ``; 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) => `${escape(value)}`; 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', ``); 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 { 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;iv>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=``; // Inkscape's actual namespace is required for editable layer groups. const group=(label,body)=>`${body}`; 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"')+`${id}`).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=`Timothy / Common — four printings, one illustration`; for(const [i,name] of Object.keys(variants).entries()) { const x=60+i*550; sheet+=`${name[0].toUpperCase()+name.slice(1)}`; } 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')}`);