diff --git a/CLAUDE.md b/CLAUDE.md index f5d1080..b03f79d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ npm run build:css:watch # Watch mode for CSS npm run preview # Serve HTML at localhost:3000 npm run dev # CSS watch + preview server (concurrent) npm run pdf -- # Convert HTML file to PDF +npm run remove-bg -- # Remove white background from PNG icons ``` Generate images via Banatie API: @@ -30,6 +31,7 @@ src/ scripts/ generate-pdf.mjs — HTML → PDF via Puppeteer banatie.mjs — Banatie API client for image generation + remove-bg.mjs — Remove white background from PNGs (flood fill) tasks/ — JSON task definition files assets/ hero-images/ — spaceship1-6.jpeg (header hero images) @@ -154,6 +156,20 @@ REST API for generating images. Configuration is in `src/scripts/banatie.mjs`. Set the `BANATIE_API_KEY` environment variable for authentication. +## Background Removal + +Script `src/scripts/remove-bg.mjs` removes white backgrounds from PNG icons using flood-fill from edges (like magic wand in Photoshop). Uses sharp. White areas inside objects are preserved. + +```bash +node src/scripts/remove-bg.mjs [--threshold N] [--fuzz N] +``` + +- `--threshold` — whiteness threshold for R,G,B (default: 230, lower = more aggressive) +- `--fuzz` — anti-alias radius in pixels at the boundary (default: 0) +- Default input: `assets/icons/pack2/` + +This is optional — not all icons need it. After generating/splitting new icons, ask the user if they want background removal on specific files. + ## PDF Generation Puppeteer settings for A4 worksheets: diff --git a/assets/icons/pack2/elem1-3-3.png b/assets/icons/pack2/elem1-3-3.png index 23d6f4f..655fb79 100644 Binary files a/assets/icons/pack2/elem1-3-3.png and b/assets/icons/pack2/elem1-3-3.png differ diff --git a/assets/icons/pack2/elem3-0-2.png b/assets/icons/pack2/elem3-0-2.png index 257ef57..a84195f 100644 Binary files a/assets/icons/pack2/elem3-0-2.png and b/assets/icons/pack2/elem3-0-2.png differ diff --git a/assets/icons/pack2/elem3-2-2.png b/assets/icons/pack2/elem3-2-2.png index 4a81fec..bb0f0b3 100644 Binary files a/assets/icons/pack2/elem3-2-2.png and b/assets/icons/pack2/elem3-2-2.png differ diff --git a/package.json b/package.json index baeb53b..ac63f9f 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "preview": "npx serve output/html --cors -l 3000", "pdf": "node src/scripts/generate-pdf.mjs", "dev": "concurrently \"npm run build:css:watch\" \"npm run preview\"", - "split-sprites": "node src/scripts/split-sprites.mjs" + "split-sprites": "node src/scripts/split-sprites.mjs", + "remove-bg": "node src/scripts/remove-bg.mjs" }, "keywords": [], "author": "", diff --git a/src/scripts/remove-bg.mjs b/src/scripts/remove-bg.mjs new file mode 100644 index 0000000..a9ea578 --- /dev/null +++ b/src/scripts/remove-bg.mjs @@ -0,0 +1,184 @@ +import sharp from 'sharp'; +import { resolve, basename, extname, join } from 'path'; +import { existsSync, readdirSync, statSync } from 'fs'; +import { fileURLToPath } from 'url'; + +const PROJECT_ROOT = resolve(fileURLToPath(import.meta.url), '../../..'); +const DEFAULT_INPUT = resolve(PROJECT_ROOT, 'assets/icons/pack2'); +const DEFAULT_THRESHOLD = 230; +const DEFAULT_FUZZ = 0; +const PNG_EXTENSION = /\.png$/i; + +function parseArgs(args) { + const result = { input: DEFAULT_INPUT, threshold: DEFAULT_THRESHOLD, fuzz: DEFAULT_FUZZ }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--threshold') result.threshold = parseInt(args[++i], 10); + else if (args[i] === '--fuzz') result.fuzz = parseInt(args[++i], 10); + else if (!args[i].startsWith('--')) result.input = resolve(args[i]); + } + return result; +} + +function collectFiles(input) { + if (statSync(input).isFile()) return [input]; + return readdirSync(input) + .filter(f => PNG_EXTENSION.test(f)) + .sort() + .map(f => join(input, f)); +} + +function isWhitish(data, offset, threshold) { + return data[offset] >= threshold && data[offset + 1] >= threshold && data[offset + 2] >= threshold; +} + +function floodFillFromEdges(data, width, height, threshold) { + const total = width * height; + const visited = new Uint8Array(total); + const transparent = new Uint8Array(total); + const queue = []; + let head = 0; + let count = 0; + + const seed = (x, y) => { + const idx = y * width + x; + if (visited[idx]) return; + visited[idx] = 1; + const off = idx * 4; + if (data[off + 3] === 0 || isWhitish(data, off, threshold)) { + queue.push(idx); + } + }; + + for (let x = 0; x < width; x++) { + seed(x, 0); + seed(x, height - 1); + } + for (let y = 1; y < height - 1; y++) { + seed(0, y); + seed(width - 1, y); + } + + const dx = [-1, 1, 0, 0]; + const dy = [0, 0, -1, 1]; + + while (head < queue.length) { + const idx = queue[head++]; + transparent[idx] = 1; + data[idx * 4 + 3] = 0; + count++; + + const x = idx % width; + const y = (idx - x) / width; + + for (let d = 0; d < 4; d++) { + const nx = x + dx[d]; + const ny = y + dy[d]; + if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; + const nIdx = ny * width + nx; + if (visited[nIdx]) continue; + visited[nIdx] = 1; + const off = nIdx * 4; + if (data[off + 3] === 0 || isWhitish(data, off, threshold)) { + queue.push(nIdx); + } + } + } + + return { transparent, count }; +} + +function applyAntiAlias(data, width, height, transparent, fuzz) { + if (fuzz <= 0) return; + + const total = width * height; + const dist = new Float32Array(total); + const visited = new Uint8Array(total); + const queue = []; + let head = 0; + const dx = [-1, 1, 0, 0]; + const dy = [0, 0, -1, 1]; + + for (let idx = 0; idx < total; idx++) { + if (transparent[idx]) continue; + const x = idx % width; + const y = (idx - x) / width; + for (let d = 0; d < 4; d++) { + const nx = x + dx[d]; + const ny = y + dy[d]; + if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; + if (transparent[ny * width + nx]) { + dist[idx] = 1; + visited[idx] = 1; + queue.push(idx); + break; + } + } + } + + while (head < queue.length) { + const idx = queue[head++]; + data[idx * 4 + 3] = Math.round(255 * dist[idx] / (fuzz + 1)); + + if (dist[idx] >= fuzz) continue; + const x = idx % width; + const y = (idx - x) / width; + for (let d = 0; d < 4; d++) { + const nx = x + dx[d]; + const ny = y + dy[d]; + if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; + const nIdx = ny * width + nx; + if (visited[nIdx] || transparent[nIdx]) continue; + visited[nIdx] = 1; + dist[nIdx] = dist[idx] + 1; + queue.push(nIdx); + } + } +} + +async function removeBackground(filePath, threshold, fuzz) { + const { data, info } = await sharp(filePath) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + const { width, height } = info; + const { transparent, count } = floodFillFromEdges(data, width, height, threshold); + applyAntiAlias(data, width, height, transparent, fuzz); + + await sharp(data, { raw: { width, height, channels: 4 } }) + .png() + .toFile(filePath); + + return count; +} + +async function main() { + const { input, threshold, fuzz } = parseArgs(process.argv.slice(2)); + + if (!existsSync(input)) { + console.error(`Not found: ${input}`); + process.exit(1); + } + + const files = collectFiles(input); + + if (files.length === 0) { + console.error(`No PNG files found in: ${input}`); + process.exit(1); + } + + console.log(`Removing backgrounds from ${files.length} image(s) (threshold: ${threshold}, fuzz: ${fuzz})...`); + + const results = await Promise.all( + files.map(async (filePath) => { + const removed = await removeBackground(filePath, threshold, fuzz); + console.log(` ${basename(filePath)} -> ${removed} pixels made transparent`); + return removed; + }) + ); + + const totalPixels = results.reduce((sum, r) => sum + r, 0); + console.log(`Done. ${totalPixels} total pixels made transparent across ${files.length} file(s).`); +} + +main();