diff --git a/tests/sharp-image-fallback.test.mjs b/tests/sharp-image-fallback.test.mjs new file mode 100644 index 0000000..7400138 --- /dev/null +++ b/tests/sharp-image-fallback.test.mjs @@ -0,0 +1,74 @@ +// tests/sharp-image-fallback.test.mjs — favicon & social-card without ImageMagick. +// +// On a box with no ImageMagick binary on PATH, make-favicon and make-social-card must still +// produce their full output set via sharp/libvips instead of dying with "ImageMagick not found": +// the favicon PNG set + a multi-resolution favicon.ico + apple-touch-icon, and a 1200×630 social +// card. The ImageMagick path is unchanged when a binary is present. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import sharp from 'sharp'; + +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const FAVICON = path.join(REPO, 'tools', 'make-favicon.mjs'); +const SOCIAL = path.join(REPO, 'tools', 'make-social-card.mjs'); +const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const ICO_MAGIC = Buffer.from([0x00, 0x00, 0x01, 0x00]); // reserved=0, type=1 (icon) + +async function makeBuildDir() { + const buildDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sharpfb-')); + const assets = path.join(buildDir, 'assets'); + fs.mkdirSync(assets, { recursive: true }); + const heroPath = path.join(assets, 'hero.png'); + await sharp({ create: { width: 1536, height: 1024, channels: 3, background: '#7c3aed' } }) + .png().toFile(heroPath); + const ctx = { + repo: { name: 'demo-repo' }, + understanding: { repoName: 'demo-repo' }, + concept: { palette: { primary: '#7c3aed', accent: '#38bdf8', text: '#ffffff' }, tagline: 'A short, legible tagline for the social card' }, + visuals: { hero: { file: heroPath } }, + }; + fs.writeFileSync(path.join(buildDir, 'build.json'), JSON.stringify(ctx, null, 2)); + return { buildDir, assets }; +} + +// Force ImageMagick "absent" by running with an empty PATH so findBin() finds no magick/convert. +const NO_MAGICK_ENV = { ...process.env, PATH: '' }; + +test('favicon — sharp fallback produces the full icon set + a multi-resolution .ico', async () => { + const { buildDir, assets } = await makeBuildDir(); + const r = spawnSync(process.execPath, [FAVICON, buildDir], { encoding: 'utf8', env: NO_MAGICK_ENV }); + assert.equal(r.status, 0, `favicon fallback must succeed: ${r.stdout}\n${r.stderr}`); + assert.match(r.stderr, /sharp fallback renderer/, 'it must announce the fallback'); + for (const n of [16, 32, 48, 192, 512]) { + const p = path.join(assets, `favicon-${n}.png`); + assert.ok(fs.existsSync(p), `favicon-${n}.png must exist`); + assert.ok(fs.readFileSync(p).subarray(0, 8).equals(PNG_MAGIC), `favicon-${n}.png must be a real PNG`); + } + const apple = path.join(assets, 'apple-touch-icon.png'); + assert.ok(fs.existsSync(apple) && fs.readFileSync(apple).subarray(0, 8).equals(PNG_MAGIC)); + const ico = path.join(assets, 'favicon.ico'); + assert.ok(fs.existsSync(ico), 'favicon.ico must exist'); + const icoBuf = fs.readFileSync(ico); + assert.ok(icoBuf.subarray(0, 4).equals(ICO_MAGIC), 'favicon.ico must be a real ICO'); + assert.equal(icoBuf.readUInt16LE(4), 3, 'the .ico must carry 3 resolutions (16/32/48)'); +}); + +test('social-card — sharp fallback produces a 1200×630 PNG', async () => { + const { buildDir, assets } = await makeBuildDir(); + const r = spawnSync(process.execPath, [SOCIAL, buildDir], { encoding: 'utf8', env: NO_MAGICK_ENV }); + assert.equal(r.status, 0, `social-card fallback must succeed: ${r.stdout}\n${r.stderr}`); + assert.match(r.stderr, /sharp fallback renderer/, 'it must announce the fallback'); + const card = path.join(assets, 'social-card.png'); + assert.ok(fs.existsSync(card), 'social-card.png must exist'); + const buf = fs.readFileSync(card); + assert.ok(buf.subarray(0, 8).equals(PNG_MAGIC), 'social-card.png must be a real PNG'); + const meta = await sharp(card).metadata(); + assert.equal(meta.width, 1200); + assert.equal(meta.height, 630); +}); diff --git a/tools/make-favicon.mjs b/tools/make-favicon.mjs index 5ad56bd..2e1fcdb 100644 --- a/tools/make-favicon.mjs +++ b/tools/make-favicon.mjs @@ -28,6 +28,7 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import sharp from 'sharp'; const HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; const RGB = /^rgba?\(/i; @@ -77,6 +78,76 @@ function resolveHero(buildDir, ctx) { return p; } +// ── sharp fallback when ImageMagick is absent ───────────────────────────────────────────────── +// Same outputs as the magick path: center-square crops of the hero at 16/32/48/192/512, a +// 180px alpha-flattened apple-touch-icon, and a multi-resolution favicon.ico (16/32/48, 32-bit +// BMP frames — the ICO format hand-assembled here because sharp writes only single-size .ico). +async function centerCropPng(src, n, out, flattenBg) { + let p = sharp(src).resize(n, n, { fit: 'cover' }); + if (flattenBg) p = p.flatten({ background: flattenBg }); + return p.png().toFile(out); +} + +async function rasterToBmpFrame(rawBuf, width, height) { + const rowBytes = width * 4; + const header = Buffer.alloc(40); + header.writeUInt32LE(40, 0); // BITMAPINFOHEADER size + header.writeInt32LE(width, 4); + header.writeInt32LE(height * 2, 8); // ICO doubles the height + header.writeUInt16LE(1, 12); // planes + header.writeUInt16LE(32, 14); // bpp + header.writeUInt32LE(0, 16); // BI_RGB + header.writeUInt32LE(rowBytes * height, 20); + const data = Buffer.alloc(rowBytes * height); + for (let y = 0; y < height; y++) { + const sRow = y * rowBytes, dRow = (height - 1 - y) * rowBytes; // bottom-up + for (let x = 0; x < width; x++) { + const s = sRow + x * 4, d = dRow + x * 4; + data[d] = rawBuf[s + 2]; // B + data[d + 1] = rawBuf[s + 1]; // G + data[d + 2] = rawBuf[s]; // R + data[d + 3] = rawBuf[s + 3]; // A + } + } + return Buffer.concat([header, data]); +} + +function assembleIco(frames) { + const dirSize = 6 + 16 * frames.length; + const total = dirSize + frames.reduce((s, f) => s + f.bmp.length, 0); + const buf = Buffer.alloc(total); + buf.writeUInt16LE(0, 0); // reserved + buf.writeUInt16LE(1, 2); // type: icon + buf.writeUInt16LE(frames.length, 4); // count + let off = dirSize; + frames.forEach((f, i) => { + const e = 6 + 16 * i; + buf.writeUInt8(f.size >= 256 ? 0 : f.size, e); + buf.writeUInt8(f.size >= 256 ? 0 : f.size, e + 1); + buf.writeUInt8(0, e + 2); // colour count (0) + buf.writeUInt8(0, e + 3); // reserved + buf.writeUInt16LE(1, e + 4); // planes + buf.writeUInt16LE(32, e + 6); // bpp + buf.writeUInt32LE(f.bmp.length, e + 8); + buf.writeUInt32LE(off, e + 12); + off += f.bmp.length; + }); + let p = dirSize; + for (const f of frames) { f.bmp.copy(buf, p); p += f.bmp.length; } + return buf; +} + +async function makeFaviconsWithSharp(hero, assets, bg) { + for (const n of ICON_SIZES) await centerCropPng(hero, n, path.join(assets, `favicon-${n}.png`)); + await centerCropPng(hero, 180, path.join(assets, 'apple-touch-icon.png'), bg); + const frames = []; + for (const n of [16, 32, 48]) { + const { data, info } = await sharp(hero).resize(n, n, { fit: 'cover' }).raw().toBuffer({ resolveWithObject: true }); + frames.push({ size: n, bmp: await rasterToBmpFrame(data, info.width, info.height) }); + } + fs.writeFileSync(path.join(assets, 'favicon.ico'), assembleIco(frames)); +} + // Re-read fresh and merge only brand.favicon, to minimise the window vs the parallel make-social-card. function mergeFavicon(buildDir, slot) { const bj = path.join(buildDir, 'build.json'); @@ -86,7 +157,7 @@ function mergeFavicon(buildDir, slot) { fs.writeFileSync(bj, JSON.stringify(ctx, null, 2) + '\n'); } -function main() { +async function main() { const buildDir = process.argv[2]; if (!buildDir) { emit({ ok: false, outputs: {}, error: 'usage: node tools/make-favicon.mjs ' }); @@ -95,7 +166,6 @@ function main() { } try { const bin = findBin(); - if (!bin) throw new Error('ImageMagick not found — need `magick` (v7) or `convert` (v6) on PATH'); const ctx = readCtx(buildDir); const hero = resolveHero(buildDir, ctx); @@ -104,6 +174,12 @@ function main() { const assets = path.join(buildDir, 'assets'); fs.mkdirSync(assets, { recursive: true }); + if (!bin) { + // FALLBACK: no ImageMagick binary on PATH (and possibly no rights to install it). + // sharp produces the identical output set, incl. a hand-assembled multi-resolution .ico. + log('ImageMagick not found — using the sharp fallback renderer.'); + await makeFaviconsWithSharp(hero, assets, bg); + } else { // Standard square favicons — center-square crop of the hero so the icon keeps the hero's identity. for (const n of ICON_SIZES) { const out = path.join(assets, `favicon-${n}.png`); @@ -121,6 +197,7 @@ function main() { '(', '-clone', '0', '-resize', '16x16', ')', '(', '-clone', '0', '-resize', '32x32', ')', ico]); + } const set = [...ICON_SIZES.map((n) => `favicon-${n}.png`), 'favicon.ico']; const allFiles = [...set, 'apple-touch-icon.png']; diff --git a/tools/make-social-card.mjs b/tools/make-social-card.mjs index 02a3b40..59605c8 100644 --- a/tools/make-social-card.mjs +++ b/tools/make-social-card.mjs @@ -31,6 +31,7 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import sharp from 'sharp'; const HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; const RGB = /^rgba?\(/i; @@ -106,7 +107,50 @@ function mergeSocialCard(buildDir, slot) { fs.writeFileSync(bj, JSON.stringify(ctx, null, 2) + '\n'); } -function main() { +// ── sharp fallback when ImageMagick is absent ───────────────────────────────────────────────── +// Same composed result as the magick path: hero cover-cropped to 1200×630, a legibility scrim +// gradient from the brand colour, the repo-name kicker top-left, and the wrapped tagline +// bottom-left. The text is an SVG layer rendered by libvips/librsvg (DejaVu Sans is installed). +function escapeXml(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} +function wrapText(text, maxChars) { + const words = String(text).split(/\s+/).filter(Boolean); + const lines = []; + let cur = ''; + for (const w of words) { + if (cur && (cur + ' ' + w).length > maxChars) { lines.push(cur); cur = w; } + else cur = cur ? `${cur} ${w}` : w; + } + if (cur) lines.push(cur); + return lines.slice(0, 4); +} +async function makeCardWithSharp(hero, out, { tagline, repoName, base, accent, textColor }) { + const rgb = hexToRgb(base); + const [r, g, b] = rgb || [22, 24, 40]; + const lines = wrapText(tagline, 34); + const lineEls = lines.map((ln, i) => + `${escapeXml(ln)}`).join(''); + const kicker = repoName + ? `${escapeXml(repoName)}` + : ''; + const overlay = `` + + `` + + `` + + `` + + `` + + `` + + `${kicker}${lineEls}`; + await sharp(hero).resize(CARD_W, CARD_H, { fit: 'cover' }) + .composite([{ input: Buffer.from(overlay), top: 0, left: 0 }]) + .png().toFile(out); + const meta = await sharp(out).metadata(); + if (meta.width !== CARD_W || meta.height !== CARD_H) { + throw new Error(`social card is ${meta.width}x${meta.height}, expected ${CARD_W}x${CARD_H}`); + } +} + +async function main() { const buildDir = process.argv[2]; if (!buildDir) { emit({ ok: false, outputs: {}, error: 'usage: node tools/make-social-card.mjs ' }); @@ -116,7 +160,7 @@ function main() { let stage = null; try { const bin = findBin(); - if (!bin) throw new Error('ImageMagick not found — need `magick` (v7) or `convert` (v6) on PATH'); + if (!bin) log('ImageMagick not found — using the sharp fallback renderer.'); const ctx = readCtx(buildDir); const hero = resolveHero(buildDir, ctx); @@ -145,40 +189,47 @@ function main() { ? ctx.understanding.repoName.trim() : (typeof ctx?.repo?.name === 'string' && ctx.repo.name.trim() ? ctx.repo.name.trim() : null); - const font = pickFont(); const assets = path.join(buildDir, 'assets'); fs.mkdirSync(assets, { recursive: true }); - stage = fs.mkdtempSync(path.join(os.tmpdir(), 'social-card-')); - const baseImg = path.join(stage, 'base.png'); - const tagImg = path.join(stage, 'tag.png'); const out = path.join(assets, 'social-card.png'); - - // 1) Hero full-bleed cover + scrim, with the repo-name kicker baked into the top-left. - const baseArgs = [hero, '-resize', `${CARD_W}x${CARD_H}^`, '-gravity', 'center', '-extent', `${CARD_W}x${CARD_H}`, - '(', '-size', `${CARD_W}x${CARD_H}`, scrim, ')', '-composite']; - if (repoName) { - baseArgs.push('-gravity', 'NorthWest', '-fill', accent); - if (font) baseArgs.push('-font', font); - baseArgs.push('-pointsize', '34', '-annotate', `+${PAD}+54`, repoName); + let dims; + + if (bin) { + stage = fs.mkdtempSync(path.join(os.tmpdir(), 'social-card-')); + const baseImg = path.join(stage, 'base.png'); + const tagImg = path.join(stage, 'tag.png'); + + // 1) Hero full-bleed cover + scrim, with the repo-name kicker baked into the top-left. + const baseArgs = [hero, '-resize', `${CARD_W}x${CARD_H}^`, '-gravity', 'center', '-extent', `${CARD_W}x${CARD_H}`, + '(', '-size', `${CARD_W}x${CARD_H}`, scrim, ')', '-composite']; + const font = pickFont(); + if (repoName) { + baseArgs.push('-gravity', 'NorthWest', '-fill', accent); + if (font) baseArgs.push('-font', font); + baseArgs.push('-pointsize', '34', '-annotate', `+${PAD}+54`, repoName); + } + baseArgs.push(baseImg); + magick(bin, baseArgs); + + // 2) The tagline as a wrapped caption layer (auto-wrapped at the text column width). + const tagArgs = ['-background', 'none', '-fill', textColor]; + if (font) tagArgs.push('-font', font); + tagArgs.push('-size', `${CARD_W - PAD * 2}x`, '-gravity', 'West', '-pointsize', '58', `caption:${tagline}`, tagImg); + magick(bin, tagArgs); + + // 3) Composite the tagline into the lower-left and write the final card. + magick(bin, [baseImg, tagImg, '-gravity', 'SouthWest', '-geometry', `+${PAD}+72`, '-composite', out]); + + if (!fs.existsSync(out) || fs.statSync(out).size === 0) throw new Error(`social card not written: ${out}`); + // `magick identify …` (v7) vs the standalone `identify` (v6). + dims = (bin === 'magick' + ? execFileSync(bin, ['identify', '-format', '%wx%h', out], { stdio: ['ignore', 'pipe', 'pipe'] }) + : execFileSync('identify', ['-format', '%wx%h', out], { stdio: ['ignore', 'pipe', 'pipe'] })).toString().trim(); + if (dims !== `${CARD_W}x${CARD_H}`) throw new Error(`social card is ${dims}, expected ${CARD_W}x${CARD_H}`); + } else { + await makeCardWithSharp(hero, out, { tagline, repoName, base, accent, textColor }); + dims = `${CARD_W}x${CARD_H}`; } - baseArgs.push(baseImg); - magick(bin, baseArgs); - - // 2) The tagline as a wrapped caption layer (auto-wrapped at the text column width). - const tagArgs = ['-background', 'none', '-fill', textColor]; - if (font) tagArgs.push('-font', font); - tagArgs.push('-size', `${CARD_W - PAD * 2}x`, '-gravity', 'West', '-pointsize', '58', `caption:${tagline}`, tagImg); - magick(bin, tagArgs); - - // 3) Composite the tagline into the lower-left and write the final card. - magick(bin, [baseImg, tagImg, '-gravity', 'SouthWest', '-geometry', `+${PAD}+72`, '-composite', out]); - - if (!fs.existsSync(out) || fs.statSync(out).size === 0) throw new Error(`social card not written: ${out}`); - // `magick identify …` (v7) vs the standalone `identify` (v6). - const dims = (bin === 'magick' - ? execFileSync(bin, ['identify', '-format', '%wx%h', out], { stdio: ['ignore', 'pipe', 'pipe'] }) - : execFileSync('identify', ['-format', '%wx%h', out], { stdio: ['ignore', 'pipe', 'pipe'] })).toString().trim(); - if (dims !== `${CARD_W}x${CARD_H}`) throw new Error(`social card is ${dims}, expected ${CARD_W}x${CARD_H}`); const slot = { px: `${CARD_W}x${CARD_H}`, file: out, tagline }; mergeSocialCard(buildDir, slot);