UNPKG

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
#!/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, '&quot;')}</span> </div> </div>`; dataAttrs = ` data-x="" data-y="" data-w="" data-h="" data-bbox-error="${errorMsg.replace(/"/g, '&quot;')}"`; } 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, '&quot;')}" data-tag="${tagName.replace(/"/g, '&quot;')}" data-groups="${groupsStr.replace(/"/g, '&quot;')}" ${dataAttrs} > <td class="row-index-cell">${rowIndex}</td> <td style="white-space:nowrap;"><code>${id}</code></td> <td><code>&lt;${tagName}&gt;</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