Created
May 10, 2026 17:53
-
-
Save Josema/8ca8d24e801b9f4a655f6f4a37ddcee8 to your computer and use it in GitHub Desktop.
Convert raster images into pixelated SVG grids
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">") | |
| .replace(/"/g, """); | |
| } | |
| 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