#!/usr/bin/env node // Adds exact-text feature callouts (dot, leader line, label) to a clean product photo, locally. // Usage: node scripts/add-callouts.mjs --input hero.jpg --spec callouts.json --output out.png [--format portrait|square] [--grid] // --grid draws a 10 percent grid with labels, to help read target fractions off the photo. // Needs sharp: run `npm install sharp` in the Skill folder. import sharp from 'sharp' import { readFileSync } from 'node:fs' const usage = 'Usage: node scripts/add-callouts.mjs --input hero.jpg --spec callouts.json --output out.png [--format portrait|square] [--grid]' const valueOptions = new Set(['input', 'spec', 'output', 'format']) const options = {} const args = process.argv.slice(2) for (let index = 0; index < args.length; index += 1) { const key = args[index] if (key === '--grid') { options.grid = true continue } if (!key.startsWith('--') || !valueOptions.has(key.slice(2))) { console.error(`Unknown option "${key}".\n${usage}`) process.exit(1) } const value = args[index + 1] if (value === undefined || value.startsWith('--')) { console.error(`Option ${key} needs a value.\n${usage}`) process.exit(1) } options[key.slice(2)] = value index += 1 } if (!options.input || !options.spec || !options.output) { console.error(usage) process.exit(1) } const fail = (message) => { console.error(`Error: ${message}`) process.exit(1) } const warnings = [] const warn = (message) => { warnings.push(message) console.error(`WARNING: ${message}`) } const format = options.format ?? 'portrait' if (format !== 'portrait' && format !== 'square') fail('--format must be portrait or square.') const spec = JSON.parse(readFileSync(options.spec, 'utf8')) const items = Array.isArray(spec.callouts) ? spec.callouts : [] if (items.length < 3 || items.length > 6) fail('The spec needs between 3 and 6 callouts.') const style = { accent: '#E8573A', ink: '#1B1B1D', muted: '#55555A', line: '#1B1B1D', // Fonts are not embedded. The first installed family wins; the last is the generic fallback. font: 'Helvetica Neue, Helvetica, Arial, Liberation Sans, DejaVu Sans, sans-serif', ...(spec.style ?? {}), } const inUnit = (value) => Number.isFinite(value) && value >= 0 && value <= 1 const escapeXml = (value) => String(value).replace( /[<>&'"]/g, (char) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[char], ) items.forEach((item, index) => { const name = `callout ${index + 1}` if (!item || typeof item.text !== 'string' || !item.text.trim()) fail(`${name} has no text.`) if (item.align !== 'left' && item.align !== 'right') fail(`${name} ("${item.text}"): align must be "left" or "right".`) if (!Array.isArray(item.target) || item.target.length !== 2 || !item.target.every(inUnit)) fail(`${name} ("${item.text}"): target must be [x, y] with both between 0 and 1.`) const pos = item.label?.[format] if (!Array.isArray(pos) || pos.length !== 2 || !pos.every(inUnit)) fail(`${name} ("${item.text}"): label.${format} must be [x, y] with both between 0 and 1.`) }) // Crop to the format at native pixels (crop = [left, top, width, height] as fractions of the source). Never upscaled. const meta = await sharp(options.input).metadata() const crop = (style.crop ?? {})[format] ?? (format === 'portrait' ? [0, 0, 1, 1] : [0, 0.1, 1, 0.8]) if (crop.length !== 4 || crop[0] < 0 || crop[1] < 0 || crop[0] + crop[2] > 1.0001 || crop[1] + crop[3] > 1.0001) fail('style.crop must be [left, top, width, height] inside the photo.') const region = { left: Math.round(crop[0] * meta.width), top: Math.round(crop[1] * meta.height), width: Math.round(crop[2] * meta.width), height: Math.round(crop[3] * meta.height), } const width = region.width const height = region.height const ratio = width / height if (format === 'portrait' && Math.abs(ratio - 0.8) > 0.01) warn(`Portrait crop ratio is ${ratio.toFixed(3)}, not 4:5.`) if (format === 'square' && Math.abs(ratio - 1) > 0.01) warn(`Square crop ratio is ${ratio.toFixed(3)}, not 1:1.`) const unit = width / 1600 const base = await sharp(options.input).extract(region).png().toBuffer() const mapPoint = ([fx, fy]) => [ (fx * meta.width - region.left) * width / region.width, (fy * meta.height - region.top) * height / region.height, ] const titleSize = (style.title_size ?? 44) * unit const detailSize = (style.detail_size ?? 30) * unit const maxLeader = 0.35 * width const laid = items.map((item) => { const [tx, ty] = mapPoint(item.target) const [lx, ly] = item.label[format].map((value, i) => value * (i === 0 ? width : height)) const right = item.align === 'right' const textWidth = item.text.length * titleSize * 0.56 const detailWidth = String(item.detail ?? '').length * detailSize * 0.52 const ruleLength = Math.min(560 * unit, Math.max(textWidth, detailWidth)) const x0 = right ? lx - ruleLength : lx const x1 = right ? lx : lx + ruleLength // The rule sits on the side of the text that faces the target, so the leader never runs through the words. const above = ty < ly const ruleY = above ? ly - titleSize - 6 * unit : ly + 14 * unit // The leader leaves the rule at the point nearest the target, so lines run short and straight. const startX = Math.min(Math.max(tx, x0), x1) const box = { x0, x1, y0: Math.min(ly - titleSize, ruleY), y1: ly + 28 * unit + (item.detail ? detailSize : 0) + 8 * unit } return { item, tx, ty, lx, ly, right, ruleY, x0, x1, startX, box } }) if (targetsOutside(laid)) process.exit(1) function targetsOutside(list) { let bad = false for (const entry of list) { if (entry.tx < 0 || entry.tx > width || entry.ty < 0 || entry.ty > height) { console.error(`Error: the target of "${entry.item.text}" falls outside the ${format} crop. Change its target or style.crop.`) bad = true } } return bad } const segment = (entry) => [[entry.startX, entry.ruleY], [entry.tx, entry.ty]] const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) const segmentsIntersect = ([p1, p2], [p3, p4]) => { const d1 = cross(p3, p4, p1) const d2 = cross(p3, p4, p2) const d3 = cross(p1, p2, p3) const d4 = cross(p1, p2, p4) return ((d1 > 0) !== (d2 > 0)) && ((d3 > 0) !== (d4 > 0)) } const segmentHitsBox = ([p, q], box) => { const corners = [[box.x0, box.y0], [box.x1, box.y0], [box.x1, box.y1], [box.x0, box.y1]] for (let i = 0; i < 4; i += 1) if (segmentsIntersect([p, q], [corners[i], corners[(i + 1) % 4]])) return true return false } let crossings = 0 laid.forEach((a, i) => { const name = `"${a.item.text}"` if (a.box.x0 < 0 || a.box.x1 > width || a.box.y0 < 0 || a.box.y1 > height) warn(`Label ${name} runs off the canvas. Move it inward.`) else if (a.box.x0 < 0.05 * width || a.box.x1 > 0.95 * width) warn(`Label ${name} is inside the 5 percent edge margin.`) const [p, q] = segment(a) const length = Math.hypot(q[0] - p[0], q[1] - p[1]) if (length > maxLeader) warn(`The leader of ${name} is ${Math.round(length / width * 100)} percent of the width (limit 35). Move the label closer to its target.`) if ((a.right ? a.tx > a.lx : a.tx < a.lx) && Math.abs(a.tx - a.lx) > 0.15 * width) warn(`${name} sits on the opposite side of its target. Put labels on the same side as their feature.`) laid.forEach((b, j) => { if (j <= i) return if (segmentsIntersect(segment(a), segment(b))) { console.error(`ERROR: the leaders of "${a.item.text}" and "${b.item.text}" cross. Reorder labels top to bottom per side or move a label.`) crossings += 1 } if (a.box.x0 < b.box.x1 && b.box.x0 < a.box.x1 && a.box.y0 < b.box.y1 && b.box.y0 < a.box.y1) warn(`Labels "${a.item.text}" and "${b.item.text}" overlap.`) }) laid.forEach((b, j) => { if (j !== i && segmentHitsBox(segment(a), b.box)) warn(`The leader of ${name} runs through the label "${b.item.text}".`) }) }) if (crossings > 0) { console.error(`${crossings} leader crossing(s). Nothing was written.`) process.exit(1) } const parts = laid.map((e) => { const anchor = e.right ? 'end' : 'start' const detail = e.item.detail ? `${escapeXml(e.item.detail)}` : '' return ` ${escapeXml(e.item.text)} ${detail}` }).join('') let grid = '' if (options.grid) { for (let step = 1; step < 10; step += 1) { const x = (step / 10) * width const y = (step / 10) * height grid += `${(step / 10).toFixed(1)}` grid += `${(step / 10).toFixed(1)}` } } const overlay = Buffer.from(`${options.grid ? grid : parts}`) const output = sharp(base).composite([{ input: overlay, left: 0, top: 0 }]) if (/\.jpe?g$/i.test(options.output)) await output.jpeg({ quality: 92 }).toFile(options.output) else if (/\.webp$/i.test(options.output)) await output.webp({ quality: 88 }).toFile(options.output) else await output.png().toFile(options.output) console.log(options.output + (warnings.length ? ` (${warnings.length} warning(s))` : ''))