svg-bbox
Version:
A set of tools to compute and to use a SVG bounding box you can trust (as opposed to the unreliable .getBBox() scourge)
701 lines (612 loc) • 26.2 kB
JavaScript
#!/usr/bin/env node
/**
* sbb-inkscape-svg2png.cjs
*
* Export SVG files to PNG format using Inkscape with comprehensive control
* over all export parameters including color modes, compression, antialiasing,
* background, and area settings.
*
* Requires Inkscape to be installed on your system.
*
* Part of the svg-bbox toolkit - Inkscape Tools Collection.
*/
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const { promisify } = require('util');
const { getVersion } = require('./version.cjs');
const execFilePromise = promisify(execFile);
// SECURITY: Import security utilities
const {
validateFilePath,
validateOutputPath,
SVGBBoxError,
ValidationError
} = require('./lib/security-utils.cjs');
const { runCLI, printSuccess, printError, printInfo } = require('./lib/cli-utils.cjs');
// ═══════════════════════════════════════════════════════════════════════════
// HELP TEXT
// ═══════════════════════════════════════════════════════════════════════════
function printHelp() {
console.log(`
╔════════════════════════════════════════════════════════════════════════════╗
║ sbb-inkscape-svg2png.cjs - Advanced SVG to PNG Export Tool ║
╚════════════════════════════════════════════════════════════════════════════╝
DESCRIPTION:
Export SVG files to PNG format using Inkscape with comprehensive control
over all export parameters including dimensions, DPI, color modes,
compression, antialiasing, background, and export areas.
USAGE:
node sbb-inkscape-svg2png.cjs input.svg [options]
node sbb-inkscape-svg2png.cjs --batch <file> [options]
DIMENSION & RESOLUTION OPTIONS:
--output <file> Output PNG file (default: <input>.png)
--width <pixels> Export width in pixels
--height <pixels> Export height in pixels
--dpi <dpi> Export DPI (default: 96)
96 DPI = 1 SVG user unit (px) = 1 bitmap pixel
--margin <pixels> Margin around export area in pixels
EXPORT AREA OPTIONS:
--area-drawing Export bounding box of all objects (default)
--area-page Export full SVG page/viewBox area
--area-snap Snap export area outwards to nearest integer px
(preserves pixel alignment for pixel-snapped graphics)
--id <object-id> Export specific object by ID (with --area-drawing)
COLOR & QUALITY OPTIONS:
--color-mode <mode> Bit depth and color type:
Gray_1, Gray_2, Gray_4, Gray_8, Gray_16
RGB_8, RGB_16
GrayAlpha_8, GrayAlpha_16
RGBA_8, RGBA_16 (default)
--compression <0-9> PNG compression level (default: 6)
0=no compression, 9=maximum compression
--antialias <0-3> Antialiasing level (default: 2)
0=none, 3=maximum
BACKGROUND OPTIONS:
--background <color> Background color (SVG color string)
Examples: "#ff007f", "rgb(255,0,128)", "white"
--background-opacity <n> Background opacity: 0.0-1.0 or 1-255
(default: 255 = fully opaque if --background set)
LEGACY FILE HANDLING:
--convert-dpi <method> Method for legacy (pre-0.92) files (default: none)
none - No change (renders at 94% size)
scale-viewbox - Rescale globally
scale-document - Rescale each length individually
BATCH PROCESSING:
--batch <file> Batch export mode using file list
Format: svg_path.svg (one file per line)
All export options apply to each file
OTHER OPTIONS:
--help Show this help
--version Show version
EXAMPLES:
# Basic PNG export (default: area-drawing, 96 DPI)
node sbb-inkscape-svg2png.cjs icon.svg
# Export with specific dimensions
node sbb-inkscape-svg2png.cjs icon.svg --width 512 --height 512
# Export at high DPI with margin
node sbb-inkscape-svg2png.cjs icon.svg --dpi 300 --margin 10
# Export specific object by ID
node sbb-inkscape-svg2png.cjs sprite.svg --id icon_home --output home.png
# Export full page area with white background
node sbb-inkscape-svg2png.cjs document.svg --area-page \\
--background white --background-opacity 1.0
# High-quality export with maximum compression
node sbb-inkscape-svg2png.cjs logo.svg --width 1024 --height 1024 \\
--antialias 3 --compression 9
# Export to grayscale 8-bit PNG
node sbb-inkscape-svg2png.cjs drawing.svg --color-mode Gray_8
# Pixel-perfect export with snap
node sbb-inkscape-svg2png.cjs pixel-art.svg --area-snap --dpi 96
# Batch export with shared settings
node sbb-inkscape-svg2png.cjs --batch icons.txt \\
--width 256 --height 256 --compression 9
OUTPUT:
Creates PNG file(s) from SVG input with specified parameters.
Exit codes:
• 0: Export successful
• 1: Error occurred
• 2: Invalid arguments
NOTES:
- By default, exported area is the bounding box of all objects (--area-drawing)
- Default DPI of 96 means 1 SVG user unit = 1 bitmap pixel
- --area-snap is useful for preserving pixel alignment in pixel art
- Legacy file handling only affects pre-Inkscape 0.92 files
- Text baseline spacing is never converted (--no-convert-text-baseline-spacing)
`);
}
function printVersion(toolName) {
const version = getVersion();
console.log(`${toolName} v${version} | svg-bbox toolkit`);
}
// ═══════════════════════════════════════════════════════════════════════════
// ARGUMENT PARSING
// ═══════════════════════════════════════════════════════════════════════════
function parseArgs(argv) {
const args = {
input: null,
output: null,
// Dimensions
width: null,
height: null,
dpi: null,
margin: null,
// Export area
areaDrawing: true, // Default
areaPage: false,
areaSnap: false,
objectId: null,
// Color & Quality
colorMode: null,
compression: null,
antialias: null,
// Background
background: null,
backgroundOpacity: null,
// Legacy handling
convertDpiMethod: 'none',
// Batch
batch: null
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--help' || arg === '-h') {
printHelp();
process.exit(0);
} else if (arg === '--version' || arg === '-v') {
printVersion('sbb-inkscape-svg2png');
process.exit(0);
} else if (arg === '--output' && i + 1 < argv.length) {
args.output = argv[++i];
} else if (arg === '--width' && i + 1 < argv.length) {
args.width = parseInt(argv[++i], 10);
if (isNaN(args.width) || args.width <= 0) {
console.error('Error: --width must be a positive number');
process.exit(2);
}
} else if (arg === '--height' && i + 1 < argv.length) {
args.height = parseInt(argv[++i], 10);
if (isNaN(args.height) || args.height <= 0) {
console.error('Error: --height must be a positive number');
process.exit(2);
}
} else if (arg === '--dpi' && i + 1 < argv.length) {
args.dpi = parseInt(argv[++i], 10);
if (isNaN(args.dpi) || args.dpi <= 0) {
console.error('Error: --dpi must be a positive number');
process.exit(2);
}
} else if (arg === '--margin' && i + 1 < argv.length) {
args.margin = parseFloat(argv[++i]);
if (isNaN(args.margin) || args.margin < 0) {
console.error('Error: --margin must be a non-negative number');
process.exit(2);
}
} else if (arg === '--area-drawing') {
args.areaDrawing = true;
args.areaPage = false;
} else if (arg === '--area-page') {
args.areaPage = true;
args.areaDrawing = false;
} else if (arg === '--area-snap') {
args.areaSnap = true;
} else if (arg === '--id' && i + 1 < argv.length) {
args.objectId = argv[++i];
} else if (arg === '--color-mode' && i + 1 < argv.length) {
args.colorMode = argv[++i];
const validModes = [
'Gray_1',
'Gray_2',
'Gray_4',
'Gray_8',
'Gray_16',
'RGB_8',
'RGB_16',
'GrayAlpha_8',
'GrayAlpha_16',
'RGBA_8',
'RGBA_16'
];
if (!validModes.includes(args.colorMode)) {
console.error(`Error: --color-mode must be one of: ${validModes.join(', ')}`);
process.exit(2);
}
} else if (arg === '--compression' && i + 1 < argv.length) {
args.compression = parseInt(argv[++i], 10);
if (isNaN(args.compression) || args.compression < 0 || args.compression > 9) {
console.error('Error: --compression must be between 0 and 9');
process.exit(2);
}
} else if (arg === '--antialias' && i + 1 < argv.length) {
args.antialias = parseInt(argv[++i], 10);
if (isNaN(args.antialias) || args.antialias < 0 || args.antialias > 3) {
console.error('Error: --antialias must be between 0 and 3');
process.exit(2);
}
} else if (arg === '--background' && i + 1 < argv.length) {
args.background = argv[++i];
} else if (arg === '--background-opacity' && i + 1 < argv.length) {
args.backgroundOpacity = parseFloat(argv[++i]);
if (isNaN(args.backgroundOpacity) || args.backgroundOpacity < 0) {
console.error('Error: --background-opacity must be >= 0');
process.exit(2);
}
// Convert 0.0-1.0 range to 0-255 range if needed
if (args.backgroundOpacity <= 1.0) {
args.backgroundOpacity = Math.round(args.backgroundOpacity * 255);
} else if (args.backgroundOpacity > 255) {
console.error('Error: --background-opacity must be 0.0-1.0 or 1-255');
process.exit(2);
}
} else if (arg === '--convert-dpi' && i + 1 < argv.length) {
args.convertDpiMethod = argv[++i];
if (!['none', 'scale-viewbox', 'scale-document'].includes(args.convertDpiMethod)) {
console.error('Error: --convert-dpi must be "none", "scale-viewbox", or "scale-document"');
process.exit(2);
}
} else if (arg === '--batch' && i + 1 < argv.length) {
args.batch = argv[++i];
} else if (!arg.startsWith('-')) {
if (!args.input) {
args.input = arg;
} else {
console.error(`Error: Unexpected argument: ${arg}`);
process.exit(2);
}
} else {
console.error(`Error: Unknown option: ${arg}`);
process.exit(2);
}
}
// Validate required arguments
if (!args.batch && !args.input) {
console.error('Error: Input SVG file required (or use --batch <file>)');
console.error('Usage: node sbb-inkscape-svg2png.cjs input.svg [options]');
console.error(' or: node sbb-inkscape-svg2png.cjs --batch <file> [options]');
process.exit(2);
}
// Batch mode cannot have individual input file
if (args.batch && args.input) {
console.error('Error: --batch mode cannot be combined with individual SVG file argument');
process.exit(2);
}
// Set default output file (only for non-batch mode)
if (!args.batch && !args.output) {
const inputBase = path.basename(args.input, path.extname(args.input));
args.output = `${inputBase}.png`;
}
return args;
}
// ═══════════════════════════════════════════════════════════════════════════
// BATCH FILE PROCESSING
// ═══════════════════════════════════════════════════════════════════════════
/**
* Read and parse batch export file (one SVG path per line)
* Returns array of SVG file paths
*/
function readBatchFile(batchFilePath) {
// SECURITY: Validate batch file path
const safeBatchPath = validateFilePath(batchFilePath, {
requiredExtensions: ['.txt'],
mustExist: true
});
const content = fs.readFileSync(safeBatchPath, 'utf-8');
const lines = content
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'));
if (lines.length === 0) {
throw new ValidationError('Batch file is empty or contains no valid SVG paths');
}
return lines;
}
// ═══════════════════════════════════════════════════════════════════════════
// INKSCAPE PNG EXPORT
// ═══════════════════════════════════════════════════════════════════════════
async function exportPngWithInkscape(inputPath, outputPath, options = {}) {
// SECURITY: Validate input file path
const safeInputPath = validateFilePath(inputPath, {
requiredExtensions: ['.svg'],
mustExist: true
});
// SECURITY: Validate output file path
const safeOutputPath = validateOutputPath(outputPath, {
requiredExtensions: ['.png']
});
const {
width = null,
height = null,
dpi = null,
margin = null,
areaDrawing = true,
areaPage = false,
areaSnap = false,
objectId = null,
colorMode = null,
compression = null,
antialias = null,
background = null,
backgroundOpacity = null,
convertDpiMethod = 'none'
} = options;
// Build Inkscape command arguments
// Based on Inkscape CLI documentation and Python reference implementation
// Uncomment the optional parameters when you need them. Do not remove the comments.
const inkscapeArgs = [
// Export as PNG format
'--export-type=png',
// Overwrite existing output file without prompting
'--export-overwrite',
// Use 'no-convert-text-baseline-spacing' to do not automatically fix text baselines in legacy
// (pre-0.92) files on opening. Inkscape 0.92 adopts the CSS standard definition for the
// 'line-height' property, which differs from past versions. By default, the line height values
// in files created prior to Inkscape 0.92 will be adjusted on loading to preserve the intended
// text layout. This command line option will skip that adjustment.
'--no-convert-text-baseline-spacing',
// Choose 'convert-dpi-method' method to rescale legacy (pre-0.92) files which render slightly
// smaller due to the switch from 90 DPI to 96 DPI when interpreting lengths expressed in units
// of pixels. Possible values are "none" (no change, document will render at 94% of its original
// size), "scale-viewbox" (document will be rescaled globally, individual lengths will stay
// untouched) and "scale-document" (each length will be re-scaled individually).
`--convert-dpi-method=${convertDpiMethod}`,
// Output filename
`--export-filename=${safeOutputPath}`
];
// Export area mode (optional)
if (areaPage) {
// Export the full SVG page/viewBox area
inkscapeArgs.push('--export-area-page');
} else if (areaDrawing) {
// By default the exported area is the viewbox, but if 'export-area-drawing' option is used
// the exported area will be the whole drawing, i.e. the bounding box of all objects of the
// document (or of the exported object if --export-id is used). With this option, the exported
// image will display all the visible objects of the document without margins or cropping.
inkscapeArgs.push('--export-area-drawing');
}
// Area snap (optional)
if (areaSnap) {
// The option 'export-area-snap' will snap the export area outwards to the nearest integer px
// values. If you are using the default export resolution of 96 dpi and your graphics are
// pixel-snapped to minimize antialiasing, this switch allows you to preserve this alignment
// even if you are exporting some object's bounding box (with --export-area-drawing) which is
// itself not pixel-aligned.
inkscapeArgs.push('--export-area-snap');
}
// Export specific object by ID (optional)
if (objectId) {
// Specify the ID of the object to export
inkscapeArgs.push(`--export-id=${objectId}`);
// Export only the specified object (no other objects)
inkscapeArgs.push('--export-id-only');
}
// Dimensions and DPI (optional)
if (dpi !== null) {
// The resolution used for PNG export. It is also used for fallback rasterization of filtered
// objects when exporting to PS, EPS, or PDF (unless you specify --export-ignore-filters to
// suppress rasterization). The default is 96 dpi, which corresponds to 1 SVG user unit (px,
// also called "user unit") exporting to 1 bitmap pixel. This value overrides the DPI hint if
// used with --export-use-hints.
inkscapeArgs.push(`--export-dpi=${dpi}`);
}
if (width !== null) {
// Export width in pixels
inkscapeArgs.push(`--export-width=${width}`);
}
if (height !== null) {
// Export height in pixels
inkscapeArgs.push(`--export-height=${height}`);
}
if (margin !== null) {
// Margin around export area in pixels
inkscapeArgs.push(`--export-margin=${margin}`);
}
// Color mode, compression, antialiasing (optional)
if (colorMode !== null) {
// Set the color mode (bit depth and color type) for exported bitmaps
// (Gray_1/Gray_2/Gray_4/Gray_8/Gray_16/RGB_8/RGB_16/GrayAlpha_8/GrayAlpha_16/RGBA_8/RGBA_16)
inkscapeArgs.push(`--export-png-color-mode=${colorMode}`);
}
if (compression !== null) {
// Compression LEVEL: (0 to 9); default is 6.
inkscapeArgs.push(`--export-png-compression=${compression}`);
}
if (antialias !== null) {
// Antialiasing LEVEL: (0 to 3); default is 2.
inkscapeArgs.push(`--export-png-antialias=${antialias}`);
}
// Background color and opacity (optional)
if (background !== null) {
// Background color of exported PNG. This may be any SVG supported color string,
// for example "#ff007f" or "rgb(255, 0, 128)".
inkscapeArgs.push(`--export-background=${background}`);
}
if (backgroundOpacity !== null) {
// Opacity of the background of exported PNG. This may be a value either between 0.0 and 1.0
// (0.0 meaning full transparency, 1.0 full opacity) or greater than 1 up to 255 (255 meaning
// full opacity). If not set but the --export-background option is used, then the value of 255
// (full opacity) will be used.
inkscapeArgs.push(`--export-background-opacity=${backgroundOpacity}`);
} else if (background !== null) {
// Default to fully opaque if background set but opacity not specified
inkscapeArgs.push('--export-background-opacity=255');
}
// Add input file as last argument
inkscapeArgs.push(safeInputPath);
try {
// Execute Inkscape with timeout
const { stdout, stderr } = await execFilePromise('inkscape', inkscapeArgs, {
timeout: 30000, // 30 second timeout
maxBuffer: 10 * 1024 * 1024 // 10MB buffer
});
// Check if output file was created
if (!fs.existsSync(safeOutputPath)) {
throw new SVGBBoxError('Inkscape did not create output PNG file');
}
// Get output file size
const stats = fs.statSync(safeOutputPath);
return {
inputPath: safeInputPath,
outputPath: safeOutputPath,
fileSize: stats.size,
width,
height,
dpi,
margin,
areaDrawing,
areaPage,
areaSnap,
objectId,
colorMode,
compression,
antialias,
background,
backgroundOpacity,
convertDpiMethod,
stdout: stdout.trim(),
stderr: stderr.trim()
};
} catch (error) {
if (error.code === 'ENOENT') {
throw new SVGBBoxError(
'Inkscape not found. Please install Inkscape and ensure it is in your PATH.\n' +
'Download from: https://inkscape.org/release/'
);
} else if (error.killed) {
throw new SVGBBoxError('Inkscape process timed out (30s limit)');
} else {
throw new SVGBBoxError(`Inkscape PNG export failed: ${error.message}`, error);
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN
// ═══════════════════════════════════════════════════════════════════════════
async function main() {
const args = parseArgs(process.argv);
printInfo(`sbb-inkscape-svg2png v${getVersion()} | svg-bbox toolkit\n`);
// BATCH MODE: Export multiple SVG files
if (args.batch) {
const svgFiles = readBatchFile(args.batch);
console.log(`Processing ${svgFiles.length} SVG files from ${args.batch}...\n`);
const results = [];
for (let i = 0; i < svgFiles.length; i++) {
const svgPath = svgFiles[i];
const inputBase = path.basename(svgPath, path.extname(svgPath));
const pngPath = `${inputBase}.png`;
console.log(`[${i + 1}/${svgFiles.length}] Exporting ${svgPath}...`);
try {
const result = await exportPngWithInkscape(svgPath, pngPath, {
width: args.width,
height: args.height,
dpi: args.dpi,
margin: args.margin,
areaDrawing: args.areaDrawing,
areaPage: args.areaPage,
areaSnap: args.areaSnap,
objectId: args.objectId,
colorMode: args.colorMode,
compression: args.compression,
antialias: args.antialias,
background: args.background,
backgroundOpacity: args.backgroundOpacity,
convertDpiMethod: args.convertDpiMethod
});
results.push({
success: true,
input: result.inputPath,
output: result.outputPath,
fileSize: result.fileSize
});
printSuccess(
` ✓ Created ${result.outputPath} (${(result.fileSize / 1024).toFixed(1)} KB)`
);
} catch (error) {
results.push({
success: false,
input: svgPath,
error: error.message
});
printError(` ✗ Failed: ${error.message}`);
}
}
// Summary
const successful = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
console.log(`\n${'═'.repeat(78)}`);
console.log(`Summary: ${successful} successful, ${failed} failed`);
console.log('═'.repeat(78));
return;
}
// SINGLE FILE MODE
console.log(`Exporting ${args.input} to PNG...`);
const result = await exportPngWithInkscape(args.input, args.output, {
width: args.width,
height: args.height,
dpi: args.dpi,
margin: args.margin,
areaDrawing: args.areaDrawing,
areaPage: args.areaPage,
areaSnap: args.areaSnap,
objectId: args.objectId,
colorMode: args.colorMode,
compression: args.compression,
antialias: args.antialias,
background: args.background,
backgroundOpacity: args.backgroundOpacity,
convertDpiMethod: args.convertDpiMethod
});
printSuccess('✓ PNG export successful');
console.log(` Input: ${result.inputPath}`);
console.log(` Output: ${result.outputPath}`);
console.log(` Size: ${(result.fileSize / 1024).toFixed(1)} KB`);
// Export settings
if (result.width || result.height) {
const dims = [];
if (result.width) {
dims.push(`${result.width}px`);
}
if (result.height) {
dims.push(`${result.height}px`);
}
console.log(` Dimensions: ${dims.join(' × ')}`);
}
if (result.dpi) {
console.log(` DPI: ${result.dpi}`);
}
if (result.margin) {
console.log(` Margin: ${result.margin}px`);
}
// Area mode
const areaMode = result.areaPage ? 'page' : 'drawing';
const areaExtra = result.areaSnap ? ' (snap)' : '';
console.log(` Export area: ${areaMode}${areaExtra}`);
if (result.objectId) {
console.log(` Object ID: ${result.objectId}`);
}
// Color & quality
if (result.colorMode) {
console.log(` Color mode: ${result.colorMode}`);
}
if (result.compression !== null) {
console.log(` Compression: ${result.compression}/9`);
}
if (result.antialias !== null) {
console.log(` Antialias: ${result.antialias}/3`);
}
// Background
if (result.background) {
const opacity =
result.backgroundOpacity !== null ? ` (opacity: ${result.backgroundOpacity}/255)` : '';
console.log(` Background: ${result.background}${opacity}`);
}
if (result.convertDpiMethod !== 'none') {
console.log(` DPI method: ${result.convertDpiMethod}`);
}
// Show Inkscape warnings if any
if (result.stderr) {
printInfo(`\nInkscape warnings:\n${result.stderr}`);
}
}
// SECURITY: Run with CLI error handling
runCLI(main);
module.exports = { main, exportPngWithInkscape };