#!/usr/bin/env node // Builds a labelled contact sheet of colourways. Names are drawn locally so spelling is exact. No API. // Usage: node scripts/variant-sheet.mjs --output sheet.jpg --variant "Navy=navy.jpg" --variant "Coral=coral.jpg" [--variant ...] [--columns 2] [--cell 1000] // Needs the `sharp` package. Between 2 and 6 variants. Cells take the ratio of the first image (--aspect auto, the default) and every image is fitted whole on white, never cropped. let sharp try { sharp = (await import('sharp')).default } catch { console.error('This script needs the sharp package. Run `npm install sharp` in the Skill folder (Node 20.9 or newer).') process.exit(1) } const usage = 'Usage: node scripts/variant-sheet.mjs --output sheet.jpg --variant "Name=file.jpg" (2 to 6 times) [--columns 2] [--cell 1000] [--aspect auto|1:1|4:3]' const allowed = new Set(['output', 'variant', 'columns', 'cell', 'aspect']) const options = { variant: [] } const args = process.argv.slice(2) for (let index = 0; index < args.length; index += 1) { const key = args[index] if (!key.startsWith('--') || !allowed.has(key.slice(2))) { console.error(`Unknown argument: ${key}`) console.error(usage) process.exit(1) } const value = args[index + 1] if (value === undefined || value.startsWith('--')) { console.error(`${key} needs a value.`) console.error(usage) process.exit(1) } if (key === '--variant') options.variant.push(value) else options[key.slice(2)] = value index += 1 } if (!options.output || options.variant.length < 2 || options.variant.length > 6) { console.error(usage) process.exit(1) } const number = (name, fallback, min, max) => { const value = options[name] === undefined ? fallback : Number(options[name]) if (!Number.isInteger(value) || value < min || value > max) { console.error(`--${name} must be a whole number from ${min} to ${max}.`) process.exit(1) } return value } const variants = options.variant.map((entry) => { const at = entry.lastIndexOf('=') if (at < 1) { console.error(`Variant "${entry}" must look like Name=file.jpg`); process.exit(1) } return { name: entry.slice(0, at).trim(), file: entry.slice(at + 1).trim() } }) import { existsSync } from 'node:fs' for (const variant of variants) if (!existsSync(variant.file)) { console.error(`File not found: ${variant.file}`); process.exit(1) } const columns = number('columns', options.variant.length === 2 ? 2 : options.variant.length === 3 ? 3 : options.variant.length === 4 ? 2 : 3, 1, 6) const first = await sharp(variants[0].file).metadata() let [aw, ah] = [first.width, first.height] if (options.aspect !== undefined && options.aspect !== 'auto') { ;[aw, ah] = options.aspect.split(':').map(Number) if (!(aw > 0 && ah > 0)) { console.error('--aspect must be auto or look like 4:3'); process.exit(1) } } const cell = Math.round(number('cell', 1000, 200, 2000)) const cellHeight = Math.round((cell * ah) / aw) const label = Math.round(cell * 0.09) const rows = Math.ceil(variants.length / columns) const escapeXml = (value) => value.replace(/[<>&'"]/g, (char) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[char]) const composites = [] for (const [index, variant] of variants.entries()) { const left = (index % columns) * cell const top = Math.floor(index / columns) * (cellHeight + label) const image = await sharp(variant.file).resize(cell, cellHeight, { fit: 'contain', background: '#ffffff' }).jpeg({ quality: 93 }).toBuffer() const svg = `${escapeXml(variant.name.toUpperCase())}` composites.push({ input: image, left, top }, { input: Buffer.from(svg), left, top: top + cellHeight }) } await sharp({ create: { width: cell * columns, height: rows * (cellHeight + label), channels: 3, background: '#ffffff' } }) .composite(composites) .jpeg({ quality: 92 }) .toFile(options.output) console.log(`${options.output}: ${variants.length} colourways, ${cell * columns}x${rows * (cellHeight + label)}`)