Skip to content

Instantly share code, notes, and snippets.

@Josema
Created May 10, 2026 17:53
Show Gist options
  • Select an option

  • Save Josema/8ca8d24e801b9f4a655f6f4a37ddcee8 to your computer and use it in GitHub Desktop.

Select an option

Save Josema/8ca8d24e801b9f4a655f6f4a37ddcee8 to your computer and use it in GitHub Desktop.
Convert raster images into pixelated SVG grids
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
function printHelp() {
console.log(`Usage:
node imagetopixel.js <image> [options]
npm run imagetopixel.js -- <image> [options]
npm exec imagetopixel -- <image> [options]
Examples:
node imagetopixel.js image.jpg
npm run imagetopixel.js -- image.jpg
node imagetopixel.js image.jpg --cols 48 --rows 32 --gap 2 --out image.svg
node imagetopixel.js image.jpg --cols 80 --gap 1 --shape circle
Options:
--cols <n> Number of pixel columns (default: 64)
--rows <n> Number of pixel rows. If omitted, keeps image ratio
-g, --gap <n> Space between cells in SVG units (default: 0)
-s, --cell-size <n> Size of each cell in SVG units (default: 10)
-o, --out <file> Output SVG path (default: input name with .svg)
--shape <rect|circle> Cell shape (default: rect)
--background <color> SVG background color, e.g. white or #111
--no-alpha Ignore image alpha channel
-h, --help Show this help
`);
}
function parseNumber(value, name, { integer = false, min = 0 } = {}) {
const number = Number(value);
if (!Number.isFinite(number) || number < min || (integer && !Number.isInteger(number))) {
throw new Error(`${name} must be ${integer ? "an integer" : "a number"} >= ${min}`);
}
return number;
}
function parseArgs(argv) {
const options = {
cols: 64,
rows: null,
gap: 0,
cellSize: 10,
out: null,
shape: "rect",
background: null,
alpha: true,
};
let input = null;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "-h" || arg === "--help") {
return { help: true, options };
}
if (!arg.startsWith("-") && input === null) {
input = arg;
continue;
}
const readValue = (name) => {
const value = argv[index + 1];
if (value === undefined || value.startsWith("-")) {
throw new Error(`${name} needs a value`);
}
index += 1;
return value;
};
switch (arg) {
case "--cols":
options.cols = parseNumber(readValue(arg), arg, { integer: true, min: 1 });
break;
case "--rows":
options.rows = parseNumber(readValue(arg), arg, { integer: true, min: 1 });
break;
case "-g":
case "--gap":
options.gap = parseNumber(readValue(arg), arg, { min: 0 });
break;
case "-s":
case "--cell-size":
options.cellSize = parseNumber(readValue(arg), arg, { min: 0.01 });
break;
case "-o":
case "--out":
options.out = readValue(arg);
break;
case "--shape":
options.shape = readValue(arg);
if (!["rect", "circle"].includes(options.shape)) {
throw new Error("--shape must be rect or circle");
}
break;
case "--background":
options.background = readValue(arg);
break;
case "--no-alpha":
options.alpha = false;
break;
default:
throw new Error(`Unknown option: ${arg}`);
}
}
return { input, options };
}
function escapeXml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function rgbaToSvgColor(r, g, b, alpha, includeAlpha) {
if (!includeAlpha || alpha === 255) {
return { fill: `rgb(${r},${g},${b})`, opacity: null };
}
return { fill: `rgb(${r},${g},${b})`, opacity: Number((alpha / 255).toFixed(4)) };
}
function getDefaultOutputPath(input) {
const parsed = path.parse(input);
return path.join(parsed.dir, `${parsed.name}.svg`);
}
async function main() {
let parsed;
try {
parsed = parseArgs(process.argv.slice(2));
} catch (error) {
console.error(error.message);
console.error("Run with --help for usage.");
process.exit(1);
}
if (parsed.help) {
printHelp();
return;
}
const { input, options } = parsed;
if (!input) {
printHelp();
process.exit(1);
}
let sharp;
try {
sharp = require("sharp");
} catch (error) {
console.error("Missing dependency: sharp");
console.error("Install it with: npm install");
process.exit(1);
}
if (!fs.existsSync(input)) {
console.error(`Image not found: ${input}`);
process.exit(1);
}
const image = sharp(input).ensureAlpha();
const metadata = await image.metadata();
if (!metadata.width || !metadata.height) {
throw new Error("Could not read image dimensions");
}
const widthCells = options.cols;
const heightCells = options.rows || Math.max(1, Math.round((metadata.height / metadata.width) * widthCells));
const { data } = await image
.resize(widthCells, heightCells, {
fit: "fill",
kernel: sharp.kernel.nearest,
})
.raw()
.toBuffer({ resolveWithObject: true });
const step = options.cellSize + options.gap;
const svgWidth = widthCells * options.cellSize + (widthCells - 1) * options.gap;
const svgHeight = heightCells * options.cellSize + (heightCells - 1) * options.gap;
const outPath = options.out || getDefaultOutputPath(input);
const lines = [
`<svg xmlns="http://www.w3.org/2000/svg" width="${svgWidth}" height="${svgHeight}" viewBox="0 0 ${svgWidth} ${svgHeight}" shape-rendering="crispEdges">`,
];
if (options.background) {
lines.push(` <rect width="100%" height="100%" fill="${escapeXml(options.background)}"/>`);
}
for (let y = 0; y < heightCells; y += 1) {
for (let x = 0; x < widthCells; x += 1) {
const offset = (y * widthCells + x) * 4;
const r = data[offset];
const g = data[offset + 1];
const b = data[offset + 2];
const alpha = options.alpha ? data[offset + 3] : 255;
if (alpha === 0) {
continue;
}
const { fill, opacity } = rgbaToSvgColor(r, g, b, alpha, options.alpha);
const common = [
`fill="${fill}"`,
opacity !== null ? `fill-opacity="${opacity}"` : null,
].filter(Boolean).join(" ");
const px = x * step;
const py = y * step;
if (options.shape === "circle") {
const radius = options.cellSize / 2;
lines.push(` <circle cx="${px + radius}" cy="${py + radius}" r="${radius}" ${common}/>`);
} else {
lines.push(` <rect x="${px}" y="${py}" width="${options.cellSize}" height="${options.cellSize}" ${common}/>`);
}
}
}
lines.push("</svg>");
fs.writeFileSync(outPath, `${lines.join("\n")}\n`, "utf8");
console.log(`Wrote ${outPath}`);
console.log(`${widthCells} x ${heightCells} cells, gap ${options.gap}, cell size ${options.cellSize}`);
}
main().catch((error) => {
console.error(error.message);
process.exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment