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)
1,336 lines (1,209 loc) • 87.1 kB
JavaScript
#!/usr/bin/env node
/**
* extract_svg_objects.js
*
* Advanced SVG object tooling using Puppeteer + SvgVisualBBox.
*
* MODES
* =====
*
* 1) LIST OBJECTS (HTML overview + optional fixed SVG with IDs)
* ------------------------------------------------------------
* node extract_svg_objects.js input.svg --list
* [--assign-ids --out-fixed fixed.svg]
* [--out-html list.html]
* [--auto-open] # Opens HTML in Chrome/Chromium ONLY (not Safari!)
* [--json]
*
* • Produces an HTML page with a big table of objects:
* - Column 1: OBJECT ID
* - Column 2: Tag name (<path>, <g>, <use>, …)
* - Column 3: Small preview <svg> using the object’s visual bbox
* and <use href="#OBJECT_ID"> so we only embed one
* hidden SVG and reuse it.
* - Column 4: “New ID name” – a text box + checkbox for renaming.
*
* • The HTML page adds a “Save JSON with renaming” button:
* - It gathers rows where the checkbox is checked and the text box
* contains a new ID, validates them, and downloads a JSON file
* with mappings [{from, to}, …].
* - Validates:
* 1. ID syntax (XML-ish ID: /^[A-Za-z_][A-Za-z0-9_.:-]*$/)
* 2. No collision with existing IDs in the SVG
* 3. No collision with earlier new IDs in the table
* (higher rows win, lower rows are rejected)
*
* • Filters in the HTML (client-side, JS):
* - Regex filter (applies to ID, tag name, group IDs)
* - Tag filter (type: path/rect/g/etc.)
* - Area filter by bbox coordinates (minX, minY, maxX, maxY)
* - Group filter: only show objects that are descendants of a
* given group ID.
*
* • --assign-ids:
* - Auto-assigns IDs (e.g. "auto_id_path_1") to objects that have
* no ID, IN-MEMORY.
* - With --out-fixed, saves a fixed SVG with those IDs.
*
* • --json:
* - Prints JSON metadata about the listing instead of human text.
*
*
* 2) RENAME IDS USING A JSON MAPPING
* ----------------------------------
* node extract_svg_objects.js input.svg --rename mapping.json output.svg
* [--json]
*
* • Applies ID renaming according to mapping.json, typically generated
* by the HTML from --list.
*
* • JSON format (produced by HTML page):
* {
* "sourceSvgFile": "original.svg",
* "createdAt": "ISO timestamp",
* "mappings": [
* { "from": "oldId", "to": "newId" },
* ...
* ]
* }
*
* • Also accepts:
* - A plain array: [ {from,to}, ... ]
* - A simple object: { "oldId": "newId", ... }
*
* • The script:
* - Resolves mappings in order (row order priority).
* - Skips mappings whose "from" ID doesn’t exist.
* - Validates ID syntax.
* - Avoids collisions:
* * If target already exists on a different element, mapping is skipped.
* * If target was already used by a previous mapping, this mapping is skipped.
* * If the same "from" appears multiple times, the first mapping wins.
* - Updates references in:
* * href / xlink:href attributes equal to "#oldId"
* * Any attribute containing "url(#oldId)" (e.g. fill, stroke, filter, mask)
*
* • Writes a new SVG file with renamed IDs and updated references.
*
*
* 3) EXTRACT ONE OBJECT BY ID
* ---------------------------
* node extract_svg_objects.js input.svg --extract id output.svg
* [--margin N] [--include-context] [--json]
*
* • Computes the "visual" bbox of the object (including strokes, filters,
* markers, etc.) using SvgVisualBBox.
* • Sets the root <svg> viewBox to that bbox (+ margin).
* • Copies <defs> from the original SVG so filters, patterns, etc. keep working.
*
* Two important behaviors:
*
* - Default (NO --include-context): "pure cut-out"
* • Only the chosen object and its ancestor groups are kept.
* • No siblings, no overlay rectangles, no other objects.
* • Clean asset you can reuse elsewhere.
*
* - With --include-context: "cut-out with context"
* • All other objects remain (just like in the full drawing).
* • The root viewBox is still cropped to the object’s bbox + margin.
* • So a big semi-transparent blue rectangle above the object, or a
* big blur filter, still changes how the object looks, but you
* only see the area of the object’s bbox region.
*
*
* 4) EXPORT ALL OBJECTS
* ---------------------
* node extract_svg_objects.js input.svg --export-all out-dir
* [--margin N] [--export-groups] [--json]
*
* • “Objects” = path, rect, circle, ellipse, polygon, polyline, text,
* image, use, symbol, and (optionally) g.
* • Each object is exported to its own SVG file with:
* - A viewBox = visual bbox (+ margin).
* - The ancestor chain from root to object, so transforms/groups
* are preserved for that object.
* - All <defs>.
* • If --export-groups is used:
* - Each <g> is also exported as its own SVG, with its subtree.
* - Recursively, each child object/group inside that group is exported
* again as a separate SVG (prefixed file names).
* - Even if two groups have the same content or one is nested in the
* other, each group gets its own SVG.
*
*
* JSON OUTPUT (--json)
* ====================
* • For any mode, adding --json returns a machine-readable summary:
* - list: objects, any fixed svg/html written, etc.
* - rename: applied + skipped mappings, output path.
* - extract: bbox + paths.
* - exportAll: array of exported objects with ids, files, bboxes.
*
*
* INTERNAL NORMALIZATION
* ======================
* On load, the script uses SvgVisualBBox to compute the full visual bbox
* of the root <svg>. If the SVG is missing viewBox / width / height:
* - It sets them IN MEMORY ONLY, so all bboxes are computed in a sane
* coordinate system.
* Your original SVG file is not modified by this script.
*/
const fs = require('fs');
const path = require('path');
const puppeteer = require('puppeteer');
const { execFile: _execFile } = require('child_process');
const { openInChrome } = require('./browser-utils.cjs');
const {
getVersion,
printVersion: _printVersion,
hasVersionFlag: _hasVersionFlag
} = require('./version.cjs');
const { BROWSER_TIMEOUT_MS } = require('./config/timeouts.cjs');
// SECURITY: Import security utilities
const {
validateFilePath,
validateOutputPath,
readSVGFileSafe,
sanitizeSVGContent,
writeFileSafe,
readJSONFileSafe,
validateRenameMapping,
SVGBBoxError,
ValidationError,
FileSystemError: _FileSystemError
} = require('./lib/security-utils.cjs');
const {
runCLI,
printSuccess: _printSuccess,
printError: _printError,
printInfo,
printWarning: _printWarning
} = require('./lib/cli-utils.cjs');
// -------- CLI parsing --------
function printHelp() {
console.log(`
╔════════════════════════════════════════════════════════════════════════════╗
║ sbb-extractor.cjs - SVG Object Extraction & Manipulation Toolkit ║
╚════════════════════════════════════════════════════════════════════════════╝
DESCRIPTION:
Versatile tool for listing, renaming, extracting, and exporting SVG objects
with visual bbox calculation and interactive HTML catalog.
USAGE:
node sbb-extractor.cjs input.svg <mode> [options]
═══════════════════════════════════════════════════════════════════════════════
MODE 1: LIST OBJECTS (--list)
Generate interactive HTML catalog with visual previews
node sbb-extractor.cjs input.svg --list \\
[--assign-ids] [--out-fixed fixed.svg] \\
[--out-html list.html] [--auto-open] [--json]
What it does:
• Scans for all objects (g, path, rect, circle, ellipse, polygon, etc.)
• Automatically detects sprite sheets (icon/sprite stacks)
• Computes visual bbox for each object
• Generates interactive HTML page with:
- Visual previews using computed bboxes
- Filterable table (regex, tag type, bbox area, groups)
- Rename UI with live validation
- JSON export for renaming workflow
Options:
--assign-ids Auto-assign IDs to elements without IDs
--out-fixed <file> Save SVG with auto-assigned IDs
--out-html <file> Specify HTML output path (default: input.objects.html)
--auto-open Open HTML in Chrome/Chromium automatically
--json Output JSON instead of human-readable format
Example:
node sbb-extractor.cjs sprites.svg --list --assign-ids
═══════════════════════════════════════════════════════════════════════════════
MODE 2: RENAME IDS (--rename)
Apply ID renaming from JSON mapping file
node sbb-extractor.cjs input.svg --rename mapping.json output.svg [--json]
What it does:
• Validates ID syntax (^[A-Za-z_][A-Za-z0-9_.:-]*$)
• Checks for collisions with existing IDs
• Updates element IDs
• Updates all references (href, xlink:href, url(#id))
• Reports applied/skipped mappings
JSON format (from HTML "Save JSON with renaming"):
{
"sourceSvgFile": "input.svg",
"createdAt": "2025-01-01T00:00:00.000Z",
"mappings": [
{ "from": "auto_id_path_3", "to": "icon_save" }
]
}
Also accepts:
• Array: [ { "from": "oldId", "to": "newId" } ]
• Object: { "oldId": "newId" }
Example:
node sbb-extractor.cjs sprites.svg --rename map.json renamed.svg
═══════════════════════════════════════════════════════════════════════════════
MODE 3: EXTRACT OBJECT (--extract)
Extract single object to standalone SVG
node sbb-extractor.cjs input.svg --extract objectId output.svg \\
[--margin N] [--include-context] [--json]
Two behaviors:
Default (pure cut-out):
• Only target object and ancestors
• Clean asset for reuse elsewhere
• No siblings, no overlays
With --include-context:
• All objects remain (preserves filters, overlays, context)
• ViewBox cropped to target bbox + margin
• Shows object in its environment
Options:
--margin <number> Add margin in SVG user units (default: 0)
--include-context Keep all objects, crop viewBox to target
--json Output JSON metadata
Example:
node sbb-extractor.cjs drawing.svg --extract logo logo.svg --margin 10
═══════════════════════════════════════════════════════════════════════════════
MODE 4: EXPORT ALL OBJECTS (--export-all)
Export each object as separate SVG file
node sbb-extractor.cjs input.svg --export-all out-dir \\
[--margin N] [--export-groups] [--json]
What it does:
• Exports: path, rect, circle, ellipse, polygon, polyline, text,
image, use, symbol
• Each object gets own SVG with:
- ViewBox = visual bbox + margin
- Ancestor chain (preserves transforms/groups)
- All <defs> (filters, patterns, gradients)
Options:
--margin <number> Add margin in SVG user units (default: 0)
--export-groups Also export each <g> as separate SVG
--json Output JSON list of exported files
Example:
node sbb-extractor.cjs sprites.svg --export-all ./sprites --margin 2
Perfect for sprite sheets! Extracts each sprite/icon automatically.
═══════════════════════════════════════════════════════════════════════════════
SPRITE SHEET DETECTION:
Automatically detects SVGs used as icon/sprite stacks in --list mode:
• Size uniformity (coefficient of variation < 0.3)
• Grid arrangement (rows × columns)
• Common naming patterns (icon_, sprite_, symbol_, glyph_)
• Minimum 3 child elements
When detected, displays helpful tip:
🎨 Sprite sheet detected!
Sprites: 6
Grid: 2 rows × 3 cols
💡 Tip: Use --export-all to extract each sprite
═══════════════════════════════════════════════════════════════════════════════
COMPLETE WORKFLOW:
1. List & browse objects:
node sbb-extractor.cjs sprites.svg --list --assign-ids
2. Open HTML, use filters, rename objects interactively
3. Save JSON mapping from HTML page
4. Apply renaming:
node sbb-extractor.cjs sprites.ids.svg --rename map.json renamed.svg
5. Extract individual objects or export all:
node sbb-extractor.cjs renamed.svg --export-all ./icons --margin 5
`);
}
function parseArgs(argv) {
const args = argv.slice(2);
// Check for --help
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
printHelp();
process.exit(0);
}
if (args.length < 2 && !(args.length === 2 && args[1] === '--list')) {
printHelp();
process.exit(1);
}
const positional = [];
const options = {
input: null,
mode: null, // 'list', 'extract', 'exportAll', 'rename'
extractId: null,
outSvg: null,
outDir: null,
margin: 0,
includeContext: false,
assignIds: false,
outFixed: null,
exportGroups: false,
json: false,
outHtml: null,
renameJson: null,
renameOut: null,
autoOpen: false // automatically open HTML in browser
};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a.startsWith('--')) {
const [key, val] = a.split('=');
const name = key.replace(/^--/, '');
const next = typeof val === 'undefined' ? args[i + 1] : val;
function useNext() {
if (typeof val === 'undefined') {
i++;
}
}
switch (name) {
case 'list':
options.mode = 'list';
break;
case 'assign-ids':
options.assignIds = true;
break;
case 'out-fixed':
options.outFixed = next;
useNext();
break;
case 'out-html':
options.outHtml = next;
useNext();
break;
case 'extract':
options.mode = 'extract';
options.extractId = next;
useNext();
break;
case 'export-all':
options.mode = 'exportAll';
options.outDir = next;
useNext();
break;
case 'margin':
options.margin = parseFloat(next);
if (!isFinite(options.margin) || options.margin < 0) {
options.margin = 0;
}
useNext();
break;
case 'include-context':
options.includeContext = true;
break;
case 'export-groups':
options.exportGroups = true;
break;
case 'json':
options.json = true;
break;
case 'auto-open':
options.autoOpen = true;
break;
case 'rename':
options.mode = 'rename';
options.renameJson = next;
useNext();
break;
default:
console.warn('Unknown option:', key);
}
} else {
positional.push(a);
}
}
if (!positional[0]) {
console.error('You must provide an input.svg file.');
process.exit(1);
}
options.input = positional[0];
// extract: need outSvg
if (options.mode === 'extract') {
if (!options.extractId) {
console.error('--extract requires an element id');
process.exit(1);
}
if (!positional[1]) {
console.error('--extract requires an output SVG path');
process.exit(1);
}
options.outSvg = positional[1];
}
// exportAll: need outDir
if (options.mode === 'exportAll') {
if (!options.outDir) {
console.error('--export-all requires an output directory');
process.exit(1);
}
}
// rename: need mapping json and output
if (options.mode === 'rename') {
if (!options.renameJson) {
console.error('--rename requires a mapping.json file');
process.exit(1);
}
if (!positional[1]) {
console.error('--rename requires an output SVG path');
process.exit(1);
}
options.renameOut = positional[1];
}
// list defaults
if (options.mode === 'list' && options.assignIds && !options.outFixed) {
options.outFixed = options.input.replace(/\.svg$/i, '') + '.ids.svg';
}
if (options.mode === 'list' && !options.outHtml) {
options.outHtml = options.input.replace(/\.svg$/i, '') + '.objects.html';
}
if (!options.mode) {
options.mode = 'list';
}
return options;
}
// -------- shared browser/page setup --------
async function withPageForSvg(inputPath, handler) {
// SECURITY: Validate and read SVG file safely
const safePath = validateFilePath(inputPath, {
requiredExtensions: ['.svg'],
mustExist: true
});
const svgContent = readSVGFileSafe(safePath);
const sanitizedSvg = sanitizeSVGContent(svgContent);
// SECURITY: Launch browser with security args and timeout
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
timeout: BROWSER_TIMEOUT_MS
});
try {
const page = await browser.newPage();
// SECURITY: Set browser timeout
page.setDefaultTimeout(BROWSER_TIMEOUT_MS);
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>SVG Tool</title>
</head>
<body>
${sanitizedSvg}
</body>
</html>`;
await page.setContent(html, { waitUntil: 'networkidle0' });
const libPath = path.resolve(__dirname, 'SvgVisualBBox.js');
if (!fs.existsSync(libPath)) {
throw new Error('SvgVisualBBox.js not found at: ' + libPath);
}
await page.addScriptTag({ path: libPath });
// Shared "initial import": normalize viewBox + width/height in memory.
await page.evaluate(async () => {
/* eslint-disable no-undef */
const SvgVisualBBox = window.SvgVisualBBox;
if (!SvgVisualBBox) {
throw new Error('SvgVisualBBox not found.');
}
const rootSvg = document.querySelector('svg');
if (!rootSvg) {
throw new Error('No <svg> found in document.');
}
// SECURITY: Wait for fonts with timeout
await SvgVisualBBox.waitForDocumentFonts(document, 8000);
/* eslint-enable no-undef */
const vbVal = rootSvg.viewBox && rootSvg.viewBox.baseVal;
if (!vbVal || !vbVal.width || !vbVal.height) {
const both = await SvgVisualBBox.getSvgElementVisibleAndFullBBoxes(rootSvg, {
coarseFactor: 3,
fineFactor: 24,
useLayoutScale: true
});
const full = both.full;
if (full && full.width > 0 && full.height > 0) {
rootSvg.setAttribute('viewBox', `${full.x} ${full.y} ${full.width} ${full.height}`);
if (!rootSvg.getAttribute('width')) {
rootSvg.setAttribute('width', String(full.width));
}
if (!rootSvg.getAttribute('height')) {
rootSvg.setAttribute('height', String(full.height));
}
}
} else {
const hasW = !!rootSvg.getAttribute('width');
const hasH = !!rootSvg.getAttribute('height');
const vb = rootSvg.viewBox.baseVal;
const aspect = vb.width > 0 && vb.height > 0 ? vb.width / vb.height : 1;
if (!hasW && !hasH) {
rootSvg.setAttribute('width', String(vb.width || 1000));
rootSvg.setAttribute('height', String(vb.height || 1000));
} else if (!hasW && hasH) {
const h = parseFloat(rootSvg.getAttribute('height'));
const w = isFinite(h) && h > 0 && aspect > 0 ? h * aspect : vb.width || 1000;
rootSvg.setAttribute('width', String(w));
} else if (hasW && !hasH) {
const w = parseFloat(rootSvg.getAttribute('width'));
const h = isFinite(w) && w > 0 && aspect > 0 ? w / aspect : vb.height || 1000;
rootSvg.setAttribute('height', String(h));
}
}
});
return await handler(page);
} finally {
// SECURITY: Ensure browser is always closed
if (browser) {
try {
await browser.close();
} catch {
// Force kill if close fails
if (browser.process()) {
browser.process().kill('SIGKILL');
}
}
}
}
}
// -------- LIST mode: data + HTML with filters & rename UI --------
async function listAndAssignIds(
inputPath,
assignIds,
outFixedPath,
outHtmlPath,
jsonMode,
autoOpen
) {
const result = await withPageForSvg(inputPath, async (page) => {
const evalResult = await page.evaluate(async (assignIds) => {
/* eslint-disable no-undef */
const SvgVisualBBox = window.SvgVisualBBox;
if (!SvgVisualBBox) {
throw new Error('SvgVisualBBox not found.');
}
const rootSvg = document.querySelector('svg');
if (!rootSvg) {
throw new Error('No <svg> found');
}
const serializer = new XMLSerializer();
// Sprite sheet detection function (runs in browser context)
function detectSpriteSheet(rootSvg) {
const children = Array.from(rootSvg.children).filter((el) => {
const tag = el.tagName.toLowerCase();
return (
tag !== 'defs' &&
tag !== 'style' &&
tag !== 'script' &&
tag !== 'title' &&
tag !== 'desc' &&
tag !== 'metadata'
);
});
if (children.length < 3) {
return { isSprite: false, sprites: [], grid: null, stats: null };
}
const sprites = [];
for (const child of children) {
const id = child.id || `auto_${child.tagName}_${sprites.length}`;
const bbox = child.getBBox ? child.getBBox() : null;
if (bbox && bbox.width > 0 && bbox.height > 0) {
sprites.push({
id,
tag: child.tagName.toLowerCase(),
x: bbox.x,
y: bbox.y,
width: bbox.width,
height: bbox.height,
hasId: !!child.id
});
}
}
if (sprites.length < 3) {
return { isSprite: false, sprites: [], grid: null, stats: null };
}
const widths = sprites.map((s) => s.width);
const heights = sprites.map((s) => s.height);
const areas = sprites.map((s) => s.width * s.height);
const avgWidth = widths.reduce((a, b) => a + b, 0) / widths.length;
const avgHeight = heights.reduce((a, b) => a + b, 0) / heights.length;
const avgArea = areas.reduce((a, b) => a + b, 0) / areas.length;
const widthStdDev = Math.sqrt(
widths.reduce((sum, w) => sum + Math.pow(w - avgWidth, 2), 0) / widths.length
);
const heightStdDev = Math.sqrt(
heights.reduce((sum, h) => sum + Math.pow(h - avgHeight, 2), 0) / heights.length
);
const areaStdDev = Math.sqrt(
areas.reduce((sum, a) => sum + Math.pow(a - avgArea, 2), 0) / areas.length
);
const widthCV = widthStdDev / avgWidth;
const heightCV = heightStdDev / avgHeight;
const areaCV = areaStdDev / avgArea;
const idPatterns = [
/^icon[-_]/i,
/^sprite[-_]/i,
/^symbol[-_]/i,
/^glyph[-_]/i,
/[-_]\d+$/,
/^\d+$/
];
const hasCommonPattern =
sprites.filter((s) => s.hasId && idPatterns.some((p) => p.test(s.id))).length /
sprites.length >
0.5;
const xPositions = [...new Set(sprites.map((s) => Math.round(s.x)))].sort((a, b) => a - b);
const yPositions = [...new Set(sprites.map((s) => Math.round(s.y)))].sort((a, b) => a - b);
const isGridArranged = xPositions.length >= 2 && yPositions.length >= 2;
const isSpriteSheet =
(widthCV < 0.3 && heightCV < 0.3) || areaCV < 0.3 || hasCommonPattern || isGridArranged;
return {
isSprite: isSpriteSheet,
sprites: sprites.map((s) => ({ id: s.id, tag: s.tag })),
grid: isGridArranged
? {
rows: yPositions.length,
cols: xPositions.length
}
: null,
stats: {
count: sprites.length,
avgSize: { width: avgWidth, height: avgHeight },
uniformity: {
widthCV: widthCV.toFixed(3),
heightCV: heightCV.toFixed(3),
areaCV: areaCV.toFixed(3)
},
hasCommonPattern,
isGridArranged
}
};
}
// Detect if this is a sprite sheet
const spriteInfo = detectSpriteSheet(rootSvg);
const selector = [
'g',
'path',
'rect',
'circle',
'ellipse',
'polygon',
'polyline',
'text',
'image',
'use',
'symbol'
].join(',');
const els = Array.from(rootSvg.querySelectorAll(selector));
const seenIds = new Set();
function ensureUniqueId(base) {
let id = base;
let counter = 1;
while (seenIds.has(id) || document.getElementById(id)) {
id = base + '_' + counter++;
}
seenIds.add(id);
return id;
}
for (const el of els) {
if (el.id) {
seenIds.add(el.id);
}
}
const info = [];
let changed = false;
for (const el of els) {
let id = el.id || null;
if (assignIds && !id) {
const base = 'auto_id_' + el.tagName.toLowerCase();
const newId = ensureUniqueId(base);
el.setAttribute('id', newId);
id = newId;
changed = true;
}
// Compute group ancestors (IDs of ancestor <g>)
const groupIds = [];
/** @type {HTMLElement | null} */
let parent = el.parentElement;
while (parent && parent !== /** @type {unknown} */ (rootSvg)) {
if (parent.tagName && parent.tagName.toLowerCase() === 'g' && parent.id) {
groupIds.push(parent.id);
}
parent = parent.parentElement;
}
// Compute visual bbox (may fail / be null)
let bbox = null;
let bboxError = null;
try {
const b = await SvgVisualBBox.getSvgElementVisualBBoxTwoPassAggressive(el, {
mode: 'unclipped',
coarseFactor: 3,
fineFactor: 24,
useLayoutScale: true,
fontTimeoutMs: 15000 // Longer timeout for font loading
});
if (b) {
bbox = { x: b.x, y: b.y, width: b.width, height: b.height };
} else {
// Check if it's a text element - likely font issue
const tagLower = el.tagName && el.tagName.toLowerCase();
if (tagLower === 'text') {
bboxError = 'No visible pixels (likely missing fonts)';
} else {
bboxError = 'No visible pixels detected';
}
}
} catch (err) {
bboxError = err.message || 'BBox measurement failed';
}
info.push({
tagName: el.tagName,
id,
bbox,
bboxError,
groups: groupIds
});
}
let fixedSvgString = null;
if (assignIds && changed) {
fixedSvgString = serializer.serializeToString(rootSvg);
}
// ═══════════════════════════════════════════════════════════════════════════════
// CRITICAL FIX #1: Remove viewBox/width/height/x/y from hidden container SVG
// ═══════════════════════════════════════════════════════════════════════════════
//
// WHY THIS IS NECESSARY:
// The hidden SVG container (which holds all element definitions for <use> references)
// MUST NOT have viewBox, width, height, x, or y attributes because they constrain
// the coordinate system and cause incorrect clipping of referenced elements.
//
// WHAT HAPPENS IF WE DON'T REMOVE THESE:
// 1. The viewBox creates a "viewport coordinate system" for the container
// 2. When <use href="#element-id" /> references an element, the browser tries to
// fit it within the container's viewBox
// 3. Elements with coordinates outside the container viewBox get clipped
// 4. This causes preview SVGs to show partial/empty content even though their
// individual viewBox is correct
//
// EXAMPLE OF THE BUG:
// - Container has viewBox="0 0 1037.227 2892.792"
// - Element rect1851 has bbox at x=42.34, y=725.29 (inside container viewBox) ✓
// - Element text8 has bbox at x=-455.64 (OUTSIDE container viewBox, negative!) ✗
// - Result: text8 preview appears empty because container viewBox clips it
//
// HOW WE TESTED THIS:
// 1. Generated HTML with container viewBox → text8, text9, rect1851 broken
// 2. Removed container viewBox → All previews showed correctly
// 3. Extracted objects to individual SVG files (--extract) → All worked perfectly
// (proving bbox calculations are correct, issue is HTML-specific)
//
// WHY THIS FIX IS CORRECT:
// According to SVG spec, a <use> element inherits the coordinate system from
// its context (the preview SVG), NOT from the element's original container.
// By removing the container's viewBox, we allow <use> to work purely with
// the preview SVG's viewBox, which is correctly sized to the element's bbox.
//
// COMPREHENSIVE TESTS PROVING THIS FIX:
// See tests/unit/html-preview-rendering.test.js
// - "Elements with negative coordinates get clipped when container has viewBox"
// → Proves faulty method (container with viewBox) clips elements
// - "Elements with negative coordinates render fully when container has NO viewBox"
// → Proves correct method (no viewBox) works
// - "EDGE CASE: Element far outside container viewBox (negative coordinates)"
// → Tests real bug from text8 at x=-455.64
// - "EDGE CASE: Element with coordinates in all quadrants"
// → Tests negative X, negative Y, positive X, positive Y
const clonedForMarkup = /** @type {Element} */ (rootSvg.cloneNode(true));
clonedForMarkup.removeAttribute('viewBox');
clonedForMarkup.removeAttribute('width');
clonedForMarkup.removeAttribute('height');
clonedForMarkup.removeAttribute('x');
clonedForMarkup.removeAttribute('y');
const rootSvgMarkup = serializer.serializeToString(clonedForMarkup);
// ═══════════════════════════════════════════════════════════════════════════════
// CRITICAL FIX #2: Collect parent group transforms for <use> elements
// ═══════════════════════════════════════════════════════════════════════════════
//
// ROOT CAUSE OF THE TRANSFORM BUG (discovered after extensive testing):
// ───────────────────────────────────────────────────────────────────────────────
// When using <use href="#element-id" />, SVG does NOT apply parent group transforms!
// This is a fundamental SVG specification behavior that MUST be handled explicitly.
//
// DETAILED EXPLANATION:
// In the original SVG document, elements inherit transforms from their parent groups:
//
// <g id="g37" transform="translate(-13.613145,-10.209854)">
// <text id="text8" transform="scale(0.86535508,1.155595)">Λοπ</text>
// </g>
//
// When the browser renders this, text8's FINAL transform matrix is:
// 1. Apply g37's translate(-13.613145,-10.209854)
// 2. Apply text8's scale(0.86535508,1.155595)
// 3. Render text content
//
// But when HTML preview creates:
// <svg viewBox="-455.64 1474.75 394.40 214.40">
// <use href="#text8" />
// </svg>
//
// The <use> element ONLY applies text8's LOCAL transform:
// ✓ scale(0.86535508,1.155595) from text8's transform attribute
// ✗ MISSING translate(-13.613145,-10.209854) from parent g37!
//
// RESULT: Preview is shifted/mispositioned by exactly the parent transform amount
//
// REAL-WORLD EXAMPLE FROM test_text_to_path_advanced.svg:
// ───────────────────────────────────────────────────────────────────────────────
// Elements that BROKE in HTML preview:
// - text8: Has parent g37 with translate(-13.613145,-10.209854)
// → Preview shifted 13.6 pixels left, 10.2 pixels up
// - text9: Has parent g37 with translate(-13.613145,-10.209854)
// → Preview shifted 13.6 pixels left, 10.2 pixels up
// - rect1851: Has parent g1 with translate(-1144.8563,517.64642)
// → Preview shifted 1144.8 pixels left, 517.6 pixels down (appeared empty!)
//
// Elements that WORKED in HTML preview:
// - text37: Direct child of root SVG, NO parent group
// → No parent transforms to miss, worked perfectly
// - text2: Has parent g6 with translate(0,0)
// → Parent transform is identity, no visible shift
//
// HOW WE DEBUGGED THIS:
// ───────────────────────────────────────────────────────────────────────────────
// 1. Initial hypothesis: bbox calculation wrong
// TEST: Extracted text8 to individual SVG file with --extract --margin 0
// RESULT: Extracted SVG rendered PERFECTLY in browser! ✓
// CONCLUSION: Bbox calculations are correct, bug is HTML-specific ✓
//
// 2. Second hypothesis: viewBox constraining coordinates
// TEST: Removed viewBox from hidden container SVG
// RESULT: Still broken! ✗
// CONCLUSION: Not the root cause
//
// 3. Third hypothesis: width/height conflicting with viewBox
// TEST: Removed width/height from preview SVGs
// RESULT: Still broken! ✗
// CONCLUSION: Not the root cause
//
// 4. Fourth hypothesis: <use> element not inheriting transforms
// COMPARISON: Analyzed working vs broken elements:
// - text37 (works): No parent group
// - text2 (works): Parent g6 has translate(0,0)
// - text8 (broken): Parent g37 has translate(-13.613145,-10.209854)
// - text9 (broken): Parent g37 has translate(-13.613145,-10.209854)
// PATTERN: All broken elements have non-identity parent transforms! ✓
// CONCLUSION: This is the root cause! ✓
//
// THE SOLUTION:
// ───────────────────────────────────────────────────────────────────────────────
// Wrap <use> in a <g> element with explicitly collected parent transforms:
//
// <svg viewBox="-455.64 1474.75 394.40 214.40">
// <g transform="translate(-13.613145,-10.209854)"> ← Parent transform
// <use href="#text8" /> ← Element with local scale transform
// </g>
// </svg>
//
// Now the transform chain is COMPLETE:
// 1. Apply wrapper <g>'s translate (parent transform from g37)
// 2. Apply text8's scale (local transform from text8)
// 3. Render text content
//
// This exactly matches the original SVG's transform chain! ✓
//
// VERIFICATION THAT THIS FIX WORKS:
// ───────────────────────────────────────────────────────────────────────────────
// After implementing this fix:
// - text8 preview: Renders perfectly, text fully visible ✓
// - text9 preview: Renders perfectly, text fully visible ✓
// - rect1851 preview: Renders perfectly, red oval fully visible ✓
// - All other elements: Still working correctly ✓
//
// User confirmation: "yes, it worked!"
//
// IMPLEMENTATION DETAILS:
// ───────────────────────────────────────────────────────────────────────────────
// We collect transforms by walking UP the DOM tree from each element to the root:
// 1. Start at element's parent
// 2. For each ancestor group until root SVG:
// a. Get transform attribute if present
// b. Prepend to list (unshift) to maintain parent→child order
// 3. Join all transforms with spaces
// 4. Store in parentTransforms[id] for use in HTML generation
//
// Example transform collection for text8:
// text8 → g37 (transform="translate(-13.613145,-10.209854)") → root SVG
// parentTransforms["text8"] = "translate(-13.613145,-10.209854)"
//
// Example transform collection for deeply nested element:
// elem → g3 (transform="rotate(45)") → g2 (transform="scale(2)") → g1 (transform="translate(10,20)") → root
// parentTransforms["elem"] = "translate(10,20) scale(2) rotate(45)"
// (Note: parent→child order is preserved!)
//
// WHY THIS APPROACH IS CORRECT:
// ───────────────────────────────────────────────────────────────────────────────
// SVG transform matrices multiply from RIGHT to LEFT (parent first, then child):
// final_matrix = child_matrix × parent_matrix
//
// When we write:
// <g transform="translate(10,20) scale(2) rotate(45)">
//
// The browser computes:
// matrix = rotate(45) × scale(2) × translate(10,20)
//
// By collecting parent→child order and letting the browser parse it,
// we get the exact same transform chain as the original SVG! ✓
//
// COMPREHENSIVE TESTS PROVING THIS FIX:
// See tests/unit/html-preview-rendering.test.js
// - "Element with parent translate transform renders incorrectly without wrapper"
// → Proves faulty method (<use> alone) is shifted by parent transform amount
// - "Element with multiple nested parent transforms requires all transforms"
// → Tests complex case: translate(100,200) scale(2,2) rotate(45) chain
// - "EDGE CASE: Element with no parent transforms (direct child of root)"
// → Tests text37 from test_text_to_path_advanced.svg (works without wrapper)
// - "EDGE CASE: Element with identity parent transform (translate(0,0))"
// → Tests text2 from test_text_to_path_advanced.svg (no-op transform)
// - "EDGE CASE: Large parent transform (rect1851 bug - shifted 1144px)"
// → Tests rect1851 real bug: translate(-1144.8563,517.64642) made it empty!
// - "REAL-WORLD REGRESSION TEST: text8, text9, rect1851"
// → Tests exact production bug with all three broken elements
// → User confirmation: "yes, it worked!"
const parentTransforms = {};
info.forEach((obj) => {
const el = rootSvg.getElementById(obj.id);
if (!el) {
return;
}
// Collect transforms from all ancestor groups (bottom-up, then reverse for correct order)
const transforms = [];
/** @type {Node | null} */
let node = el.parentNode;
while (node && node !== rootSvg) {
// Type guard: Check if node is an Element before accessing getAttribute
if (node.nodeType === Node.ELEMENT_NODE) {
const transform = /** @type {Element} */ (node).getAttribute('transform');
if (transform) {
transforms.unshift(transform); // Prepend to maintain parent→child order
}
}
node = node.parentNode;
}
if (transforms.length > 0) {
parentTransforms[obj.id] = transforms.join(' ');
}
});
/* eslint-enable no-undef */
return { info, fixedSvgString, rootSvgMarkup, parentTransforms, spriteInfo };
}, assignIds);
return evalResult;
});
// Build HTML listing file
const html = buildListHtml(
path.basename(inputPath),
result.rootSvgMarkup,
result.info,
result.parentTransforms
);
// SECURITY: Validate and write HTML file safely
const safeHtmlPath = validateOutputPath(outHtmlPath, {
requiredExtensions: ['.html']
});
writeFileSafe(safeHtmlPath, html, 'utf-8');
if (assignIds && result.fixedSvgString && outFixedPath) {
// SECURITY: Validate and write fixed SVG file safely
const safeFixedPath = validateOutputPath(outFixedPath, {
requiredExtensions: ['.svg']
});
writeFileSafe(safeFixedPath, result.fixedSvgString, 'utf-8');
}
// Count bbox failures
const totalObjects = result.info.length;
const failedObjects = result.info.filter((obj) => obj.bboxError).length;
const zeroSizeObjects = result.info.filter(
(obj) => obj.bbox && (obj.bbox.width === 0 || obj.bbox.height === 0)
).length;
if (jsonMode) {
const jsonOut = {
mode: 'list',
input: path.resolve(inputPath),
objects: result.info || [],
totalObjects,
bboxFailures: failedObjects,
zeroSizeObjects,
fixedSvgWritten: !!(assignIds && result.fixedSvgString && outFixedPath),
fixedSvgPath: assignIds && outFixedPath ? path.resolve(outFixedPath) : null,
htmlWritten: !!outHtmlPath,
htmlPath: outHtmlPath ? path.resolve(outHtmlPath) : null,
spriteInfo: result.spriteInfo
};
console.log(JSON.stringify(jsonOut, null, 2));
} else {
console.log(`✓ HTML listing written to: ${outHtmlPath}`);
if (assignIds && result.fixedSvgString && outFixedPath) {
console.log(`✓ Fixed SVG with assigned IDs saved to: ${outFixedPath}`);
console.log(' Rename IDs in that file manually if you prefer, or use the');
console.log(' HTML page to generate a JSON mapping and then use --rename.');
} else {
console.log('Tip: open the HTML file in your browser, use the filters to find');
console.log(' objects, and fill the "New ID name" column to generate a');
console.log(' JSON rename mapping.');
}
// Display sprite sheet detection info
if (result.spriteInfo && result.spriteInfo.isSprite) {
console.log('');
console.log('🎨 Sprite sheet detected!');
console.log(` Sprites: ${result.spriteInfo.stats.count}`);
if (result.spriteInfo.grid) {
console.log(
` Grid: ${result.spriteInfo.grid.rows} rows × ${result.spriteInfo.grid.cols} cols`
);
}
console.log(
` Avg size: ${result.spriteInfo.stats.avgSize.width.toFixed(1)} × ${result.spriteInfo.stats.avgSize.height.toFixed(1)}`
);
console.log(
` Uniformity: width CV=${result.spriteInfo.stats.uniformity.widthCV}, height CV=${result.spriteInfo.stats.uniformity.heightCV}`
);
console.log(' 💡 Tip: Use --export-all to extract each sprite as a separate SVG file');
}
console.log('');
console.log(`Objects found: ${totalObjects}`);
if (failedObjects > 0) {
console.log(
`⚠️ BBox measurement FAILED for ${failedObjects} object(s) - marked with ❌ in HTML`
);
}
if (zeroSizeObjects > 0) {
console.log(
`⚠️ ${zeroSizeObjects} object(s) have zero width/height - marked with ⚠️ in HTML`
);
}
// Auto-open HTML in Chrome/Chromium if requested
// CRITICAL: Must use Chrome/Chromium (other browsers have poor SVG support)
if (autoOpen) {
const absolutePath = path.resolve(outHtmlPath);
openInChrome(absolutePath)
.then((result) => {
if (result.success) {
console.log(`\n✓ Opened in Chrome: ${absolutePath}`);
} else {
console.log(`\n⚠️ ${result.error}`);
console.log(` Please open manually in Chrome/Chromium: ${absolutePath}`);
}
})
.catch((err) => {
console.log(`\n⚠️ Failed to auto-open: ${err.message}`);
console.log(` Please open manually in Chrome/Chromium: ${absolutePath}`);
});
}
}
}
function buildListHtml(titleName, rootSvgMarkup, objects, parentTransforms = {}) {
const safeTitle = String(titleName || 'SVG');
const rows = [];
objects.forEach((obj, index) => {
const rowIndex = index + 1;
const id = obj.id || '';
const tagName = obj.tagName || '';
const bbox = obj.bbox;
const bboxError = obj.bboxError;
const groups = Array.isArray(obj.groups) ? obj.groups : [];
const groupsStr = groups.join(',');
// If bbox measurement failed, show error instead of default
let previewCell;
let dataAttrs;
if (bboxError || !bbox) {
const errorMsg = bboxError || 'BBox is null';
previewCell = `
<div style="width:120px; height:120px; display:flex; align-items:center; justify-content:center; border:1px solid #f00; background:#ffe5e5; padding:8px; box-sizing:border-box;">
<div style="font-size:0.7rem; color:#b00020; text-align:center;">
❌ BBox Failed<br>
<span style="font-size:0.65rem;">${errorMsg.replace(/"/g, '"')}</span>
</div>
</div>`;
dataAttrs = `
data-x=""
data-y=""
data-w=""
data-h=""
data-bbox-error="${errorMsg.replace(/"/g, '"')}"`;
} else {
const x = isFinite(bbox.x) ? bbox.x : 0;
const y = isFinite(bbox.y) ? bbox.y : 0;
const w = isFinite(bbox.width) && bbox.width > 0 ? bbox.width : 0;
const h = isFinite(bbox.height) && bbox.height > 0 ? bbox.height : 0;
if (w === 0 || h === 0) {
previewCell = `
<div style="width:120px; height:120px; display:flex; align-items:center; justify-content:center; border:1px solid #f90; background:#fff3e5; padding:8px; box-sizing:border-box;">
<div style="font-size:0.7rem; color:#f60; text-align:center;">
⚠️ Zero Size<br>
<span style="font-size:0.65rem;">w=${w} h=${h}</span>
</div>
</div>`;
} else {
const viewBoxStr = `${x} ${y} ${w} ${h}`;
// Apply parent transforms if they exist (critical for elements with local transforms)
const parentTransform = parentTransforms[id] || '';
const useElement = id
? parentTransform
? `<g transform="${parentTransform}"><use href="#${id}" /></g>`
: `<use href="#${id}" />`
: '';
// ════════════════════════════════════════════════════════════════════════════════
// PREVIEW CELL WITH VISIBLE BBOX BORDER
// ════════════════════════════════════════════════════════════════════════════════
//
// CRITICAL REQUIREMENTS:
// 1. Border must be COMPLETELY EXTERNAL to SVG content (no overlap)
// 2. Border must be visible on both light and dark SVG content
// 3. Border must be exactly 1px wide (not thicker)
// 4. SVG must display at correct size with proper centering
//
// WHY THIS IS HARD:
// - CSS border/outline on SVG always overlaps the content (border draws half inside/half outside)
// - SVG with only viewBox (no width/height) collapses to 0x0 size
// - SVG coordinate system makes stroke-width scale incorrectly
// - display:none doesn't work in headless browsers (must use CSS class)
//
// WRONG APPROACHES (DON'T USE):
// ❌ outline on SVG - overlaps content on top/right, not bottom/left (asymmetric)
// ❌ border on SVG - always overlaps content by half the border width
// ❌ SVG <rect> with stroke - stroke-width in user units scales unpredictably
// ❌ SVG <rect> with vector-effect="non-scaling-stroke" - offset in user units is tiny
// ❌ box-shadow - creates solid line, can't achieve dashed pattern
// ❌ wrapper div with flex - collapses SVG to 0x0 size
// ❌ wrapper div with padding - padding blocks SVG rendering (blank output)
// ❌ rgba() alpha + opacity together - makes color too light (double transparency)
//
// CORRECT SOLUTION:
// 1. Wrapper <span> with display:inline-block + line-height:0
// - inline-block shrink-wraps to SVG size
// - line-height:0 removes extra spacing from inline element
// 2. Border on the wrapper span (NOT on SVG)
// - border draws completely outside the wrapper
// - wrapper tightly wraps the SVG, so border is just outside SVG
// 3. SVG with width="100%" height="100%"
// - gives SVG actual dimensions (not 0x0)
// - 100% fills the wrapper exactly
// - max-width/max-height constraints keep it ≤ 120px
// 4. Border: 1px dashed rgba(0,0,0,0.4)
// - dashed pattern for visibility
// - 40% opacity is subtle but visible on any background
// - pure black with alpha (NOT mixing alpha in rgba() with CSS opacity)
//
// ANTIALIASING NOTE:
// You may see slight "bleeding" of SVG colors over the border edge.
// This is normal browser antialiasing and NOT a bug - leave it alone!
//
previewCell = `
<div style="width:120px; height:120px; display:flex; align-items:center; justify-content:center; border:1px solid #ccc; background:#fdfdfd;">
<span style="display:inline-block; border:1px dashed rgba(0,0,0,0.4); line-height:0;">
<svg viewBox="${viewBoxStr}" width="100%" height="100%"
style="max-width:120px; max-height:120px; display:block;">
${useElement}
</svg>
</span>
</div>`;
}
dataAttrs = `
data-x="${x}"
data-y="${y}"
data-w="${w}"
data-h="${h}"`;
}
rows.push(
`
<tr
data-row-index="${rowIndex}"
data-id="${id.replace(/"/g, '"')}"
data-tag="${tagName.replace(/"/g, '"')}"
data-groups="${groupsStr.replace(/"/g, '"')}"
${dataAttrs}
>
<td class="row-index-cell">${rowIndex}</td>
<td style="white-space:nowrap;"><code>${id}</code></td>
<td><code><${tagName}></code></td>
<td>${previewCell}</td>
<td>
<label style="display:flex; flex-direction:column; gap:2px;">
<span style="display:flex; gap:4px; align-items:center;">
<inp