#!/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(``)
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))` : ''))