alpha-mask-events
Version:
Enable click‑through on transparent regions of images (PNG, WebP, AVIF, GIF, SVG) using alpha masks.
199 lines (175 loc) • 6.75 kB
JavaScript
/**
* Read image files with transparency support (PNG, WebP, AVIF, etc.)
* and emit a compact bitmask for opaque pixels as rectangles.
*
* Supports all formats that node-canvas can process with alpha channels:
* - PNG (full alpha channel support)
* - WebP (with transparency)
* - AVIF (with transparency)
* - GIF (with transparency)
* - And other formats supported by the canvas library
*/
import fs from 'fs';
import { createCanvas, loadImage } from 'canvas';
// Fix yargs import for ESM compatibility
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
// Prevent CLI execution during test imports (but allow when called via execFile)
const isTestEnvironment = process.env.NODE_ENV === 'test' && process.env.JEST_WORKER_ID !== undefined && !process.argv.includes('--out');
let argv;
// Only parse arguments when not in test environment OR when explicitly called with arguments
if (!isTestEnvironment || process.argv.includes('--out')) {
argv = yargs(hideBin(process.argv))
.usage('Usage: $0 <images...> --out [file] --threshold [num]')
.demandCommand(1)
.option('out', { type: 'string', demandOption: true })
.option('threshold', { type: 'number', default: 0.1 })
.option('blur', { type: 'number', default: 1, describe: 'Blur radius in pixels' })
.parse();
}
// Simple box blur on alpha channel
function blurAlpha(data, width, height, radius) {
const out = new Float32Array(data.length);
for (let y = 0; y < height; ++y) {
for (let x = 0; x < width; ++x) {
let sum = 0, count = 0;
for (let dy = -radius; dy <= radius; ++dy) {
for (let dx = -radius; dx <= radius; ++dx) {
const nx = x + dx, ny = y + dy;
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
sum += data[(ny * width + nx) * 4 + 3];
count++;
}
}
}
out[y * width + x] = sum / count;
}
}
return out;
}
// Find rectangles of contiguous opaque pixels in each row
function maskToRects(mask, width, height) {
const rects = [];
for (let y = 0; y < height; ++y) {
let x = 0;
while (x < width) {
// Find start of opaque run
while (x < width && !mask[y * width + x]) x++;
if (x >= width) break;
let x0 = x;
// Find end of opaque run
while (x < width && mask[y * width + x]) x++;
rects.push({ x: x0, y, w: x - x0, h: 1 });
}
}
// Optionally, merge vertically adjacent rectangles
// (left as an exercise for further compression)
return rects;
}
/**
* Detect image format from file path or URL
* @param {string} path - File path or URL
* @returns {string|null} - Detected format (lowercase) or null if unknown
*/
export function detectImageFormat(path) {
// Remove query parameters and fragments from URL
const cleanPath = path.split('?')[0].split('#')[0];
const extension = cleanPath.toLowerCase().split('.').pop();
const formatMap = {
'png': 'png',
'jpg': 'jpg',
'jpeg': 'jpeg',
'webp': 'webp',
'avif': 'avif',
'gif': 'gif',
'bmp': 'bmp',
'tiff': 'tiff',
'tif': 'tiff',
'svg': 'svg',
'ico': 'ico'
};
return formatMap[extension] || null;
}
/**
* Validate if the file is a supported image format with potential transparency
* @param {string} path - File path to validate
* @returns {boolean} - True if format is potentially supported
*/
export function isSupportedImageFormat(path) {
// Remove query parameters and fragments, similar to detectImageFormat
const cleanPath = path.split('?')[0].split('#')[0];
const extension = cleanPath.toLowerCase().split('.').pop();
const supportedExtensions = ['.png', '.webp', '.avif', '.gif', '.bmp', '.tiff', '.jpg', '.jpeg', '.svg', '.ico'];
return supportedExtensions.includes(`.${extension}`);
}
/**
* Check if image has transparency by examining alpha channel
* @param {ImageData} imageData - Canvas image data
* @returns {boolean} - True if image has any transparency
*/
export function hasTransparency(imageData) {
const { data } = imageData;
for (let i = 3; i < data.length; i += 4) {
if (data[i] < 255) return true; // Found non-opaque pixel
}
return false;
}
// Only run the main script if this file is executed directly (and arguments are available)
if (import.meta.url === `file://${process.argv[1]}` && argv) {
(async () => {
const output = {};
const errors = [];
for (const path of argv._) {
try {
// Validate file format
if (!isSupportedImageFormat(path)) {
console.warn(`⚠️ Skipping ${path}: Unsupported format. Supported: PNG, WebP, AVIF, GIF, BMP, TIFF`);
continue;
}
console.log(`📸 Processing ${path}...`);
const img = await loadImage(path);
const canvas = createCanvas(img.width, img.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const { width, height } = img;
const imageData = ctx.getImageData(0, 0, width, height);
// Check if image actually has transparency
if (!hasTransparency(imageData)) {
console.warn(`⚠️ ${path}: No transparency detected, but processing anyway...`);
}
let alpha = imageData.data;
// Blur alpha channel
const blurredAlpha = blurAlpha(alpha, width, height, argv.blur);
// Threshold to create mask
const mask = [];
for (let i = 0; i < width * height; ++i) {
mask[i] = (blurredAlpha[i] / 255) > argv.threshold ? 1 : 0;
}
// Convert mask to rectangles
const rects = maskToRects(mask, width, height);
output[path] = { width, height, rects };
console.log(`✅ Successfully processed ${path} (${width}x${height}, ${rects.length} rectangles)`);
} catch (error) {
const errorMsg = `❌ Failed to process ${path}: ${error.message}`;
console.error(errorMsg);
errors.push({ path, error: error.message });
// Continue processing other files instead of stopping
continue;
}
}
// Write output and show summary
if (Object.keys(output).length > 0) {
fs.writeFileSync(argv.out, JSON.stringify(output, null, 2));
console.log(`\n🎉 Masks written to ${argv.out}`);
console.log(`📊 Successfully processed ${Object.keys(output).length} images`);
} else {
console.error('\n❌ No images were successfully processed');
process.exit(1);
}
if (errors.length > 0) {
console.warn(`\n⚠️ ${errors.length} images failed to process:`);
errors.forEach(({ path, error }) => console.warn(` - ${path}: ${error}`));
}
})();
}