#!/usr/bin/env node /** * SVG to ColorRM Converter - Pro Version v3 * * FIXES in v3: * 1. Recursively processes elements inside groups * 2. Applies inherited transforms from parent groups * 3. Applies per-element transforms * 4. Disabled simplification to preserve hand-drawn curves * 5. Always preserves opacity values for highlighters * * PREVIOUS FIXES (v2): * 1. Preserves element order (z-order) from original SVG * 2. Detects fill+stroke path pairs and merges them * 3. Extracts color from fill paths, stroke-width from stroke paths * 4. Properly handles highlighter (low fill-opacity) * * Usage: node svg-to-colorrm.cjs input.svg [output.json] */ const fs = require('fs'); const path = require('path'); // ============================================ // CONFIGURATION // ============================================ const CONFIG = { simplifyTolerance: 0, // Disabled - preserve original path shapes maxPointsPerStroke: 5000, // Increased to allow more points highlighterOpacityThreshold: 0.5, bezierPointsPerPixel: 0.2, minBezierSegments: 8, maxBezierSegments: 100, base64WarnSize: 500000, defaultStrokeWidth: 2, defaultStroke: '#000000', defaultFill: 'transparent', skipSimplification: true // New flag to skip simplification entirely }; // ============================================ // UTILITY FUNCTIONS // ============================================ function perpendicularDistance(point, lineStart, lineEnd) { const dx = lineEnd.x - lineStart.x; const dy = lineEnd.y - lineStart.y; if (dx === 0 && dy === 0) { return Math.sqrt((point.x - lineStart.x) ** 2 + (point.y - lineStart.y) ** 2); } const t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) / (dx * dx + dy * dy); return Math.sqrt((point.x - (lineStart.x + t * dx)) ** 2 + (point.y - (lineStart.y + t * dy)) ** 2); } function simplifyPath(points, tolerance = CONFIG.simplifyTolerance) { if (points.length <= 2) return points; let maxDist = 0, maxIndex = 0; const first = points[0], last = points[points.length - 1]; for (let i = 1; i < points.length - 1; i++) { const dist = perpendicularDistance(points[i], first, last); if (dist > maxDist) { maxDist = dist; maxIndex = i; } } if (maxDist > tolerance) { const left = simplifyPath(points.slice(0, maxIndex + 1), tolerance); const right = simplifyPath(points.slice(maxIndex), tolerance); return left.slice(0, -1).concat(right); } return [first, last]; } function enforceMaxPoints(points, maxPoints = CONFIG.maxPointsPerStroke) { if (points.length <= maxPoints) return points; let tolerance = CONFIG.simplifyTolerance; let simplified = points; while (simplified.length > maxPoints && tolerance < 50) { tolerance *= 1.5; simplified = simplifyPath(points, tolerance); } if (simplified.length > maxPoints) { const step = simplified.length / maxPoints; const result = []; for (let i = 0; i < maxPoints; i++) result.push(simplified[Math.floor(i * step)]); result.push(simplified[simplified.length - 1]); return result; } return simplified; } // Compute bounding box of points function computeBounds(pts) { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const p of pts) { if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; } return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } // Check if a path is a closed rectangle (4 corners, axis-aligned-ish) function isClosedRectangle(pts) { if (pts.length < 4 || pts.length > 6) return false; // Allow 4-5 points (closed rect might have duplicate endpoint) // Get unique corners (remove duplicate close point if present) const corners = []; for (let i = 0; i < pts.length; i++) { const p = pts[i]; const isDup = corners.some(c => Math.abs(c.x - p.x) < 1 && Math.abs(c.y - p.y) < 1); if (!isDup) corners.push(p); } if (corners.length !== 4) return false; // Check if corners form axis-aligned rectangle const bounds = computeBounds(corners); const tolerance = 2; // pixels // Each corner should be at a corner of the bounding box let cornersAtBounds = 0; for (const c of corners) { const atLeft = Math.abs(c.x - bounds.x) < tolerance; const atRight = Math.abs(c.x - (bounds.x + bounds.width)) < tolerance; const atTop = Math.abs(c.y - bounds.y) < tolerance; const atBottom = Math.abs(c.y - (bounds.y + bounds.height)) < tolerance; if ((atLeft || atRight) && (atTop || atBottom)) cornersAtBounds++; } return cornersAtBounds >= 4; } // Determine border type from stroke-dasharray value function getBorderTypeFromDasharray(strokeDasharray) { if (!strokeDasharray || strokeDasharray === 'none') return 'solid'; const parts = strokeDasharray.split(/[\s,]+/).map(parseFloat).filter(n => !isNaN(n)); if (parts.length >= 2) { // Dotted: small gaps ratio > 1, Dashed: otherwise const ratio = parts[1] / parts[0]; return ratio > 1 ? 'dotted' : 'dashed'; } return parts.length === 1 ? 'dashed' : 'solid'; } // ============================================ // BEZIER INTERPOLATION // ============================================ function curveLength(x0, y0, x1, y1, x2, y2, x3, y3) { const chord = Math.sqrt((x3 - x0) ** 2 + (y3 - y0) ** 2); const poly = Math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2) + Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) + Math.sqrt((x3 - x2) ** 2 + (y3 - y2) ** 2); return (chord + poly) / 2; } function adaptiveSegments(length) { const segments = Math.ceil(length * CONFIG.bezierPointsPerPixel); return Math.max(CONFIG.minBezierSegments, Math.min(CONFIG.maxBezierSegments, segments)); } function cubicBezier(x0, y0, x1, y1, x2, y2, x3, y3) { const segments = adaptiveSegments(curveLength(x0, y0, x1, y1, x2, y2, x3, y3)); const pts = []; for (let i = 0; i <= segments; i++) { const t = i / segments, mt = 1 - t; pts.push({ x: mt*mt*mt*x0 + 3*mt*mt*t*x1 + 3*mt*t*t*x2 + t*t*t*x3, y: mt*mt*mt*y0 + 3*mt*mt*t*y1 + 3*mt*t*t*y2 + t*t*t*y3 }); } return pts; } function quadBezier(x0, y0, x1, y1, x2, y2) { const length = Math.sqrt((x2 - x0) ** 2 + (y2 - y0) ** 2); const segments = adaptiveSegments(length); const pts = []; for (let i = 0; i <= segments; i++) { const t = i / segments, mt = 1 - t; pts.push({ x: mt*mt*x0 + 2*mt*t*x1 + t*t*x2, y: mt*mt*y0 + 2*mt*t*y1 + t*t*y2 }); } return pts; } function arcPoints(x0, y0, rx, ry, rotation, largeArc, sweep, x1, y1) { if (rx === 0 || ry === 0) return [{ x: x0, y: y0 }, { x: x1, y: y1 }]; const phi = rotation * Math.PI / 180; const cosPhi = Math.cos(phi), sinPhi = Math.sin(phi); const dx = (x0 - x1) / 2, dy = (y0 - y1) / 2; const x1p = cosPhi * dx + sinPhi * dy, y1p = -sinPhi * dx + cosPhi * dy; let rxSq = rx * rx, rySq = ry * ry; const x1pSq = x1p * x1p, y1pSq = y1p * y1p; const lambda = x1pSq / rxSq + y1pSq / rySq; if (lambda > 1) { rx *= Math.sqrt(lambda); ry *= Math.sqrt(lambda); rxSq = rx*rx; rySq = ry*ry; } let sq = (rxSq*rySq - rxSq*y1pSq - rySq*x1pSq) / (rxSq*y1pSq + rySq*x1pSq); sq = sq < 0 ? 0 : sq; const coef = (largeArc !== sweep ? 1 : -1) * Math.sqrt(sq); const cxp = coef * rx * y1p / ry, cyp = coef * -ry * x1p / rx; const mx = (x0 + x1) / 2, my = (y0 + y1) / 2; const cx = cosPhi * cxp - sinPhi * cyp + mx, cy = sinPhi * cxp + cosPhi * cyp + my; const ux = (x1p - cxp) / rx, uy = (y1p - cyp) / ry; const n = Math.sqrt(ux*ux + uy*uy); let theta = (uy < 0 ? -1 : 1) * Math.acos(ux / n); const vx = (-x1p - cxp) / rx, vy = (-y1p - cyp) / ry; const nn = Math.sqrt((ux*ux + uy*uy) * (vx*vx + vy*vy)); let dTheta = (ux*vy - uy*vx < 0 ? -1 : 1) * Math.acos((ux*vx + uy*vy) / nn); if (!sweep && dTheta > 0) dTheta -= 2 * Math.PI; if (sweep && dTheta < 0) dTheta += 2 * Math.PI; const arcLen = Math.abs(dTheta) * Math.max(rx, ry); const segments = adaptiveSegments(arcLen); const pts = []; for (let i = 0; i <= segments; i++) { const t = i / segments, angle = theta + t * dTheta; const xr = rx * Math.cos(angle), yr = ry * Math.sin(angle); pts.push({ x: cosPhi * xr - sinPhi * yr + cx, y: sinPhi * xr + cosPhi * yr + cy }); } return pts; } // ============================================ // PATH PARSER // ============================================ function parsePathD(d) { if (!d) return []; const points = []; let currentX = 0, currentY = 0, startX = 0, startY = 0; let lastControlX = 0, lastControlY = 0, lastCommand = ''; const tokens = d.match(/[a-zA-Z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g) || []; let i = 0; const getNum = () => i < tokens.length ? (parseFloat(tokens[i++]) || 0) : 0; const addPt = (x, y) => { if (points.length === 0 || points[points.length-1].x !== x || points[points.length-1].y !== y) points.push({ x, y }); }; while (i < tokens.length) { let cmd = tokens[i]; if (/[a-zA-Z]/.test(cmd)) { i++; lastCommand = cmd; } else { cmd = lastCommand; if (cmd === 'M') cmd = 'L'; if (cmd === 'm') cmd = 'l'; } const rel = cmd === cmd.toLowerCase(), C = cmd.toUpperCase(); switch (C) { case 'M': { let x = getNum(), y = getNum(); if (rel) { x += currentX; y += currentY; } currentX = x; currentY = y; startX = x; startY = y; addPt(x, y); break; } case 'L': { let x = getNum(), y = getNum(); if (rel) { x += currentX; y += currentY; } currentX = x; currentY = y; addPt(x, y); break; } case 'H': { let x = getNum(); if (rel) x += currentX; currentX = x; addPt(currentX, currentY); break; } case 'V': { let y = getNum(); if (rel) y += currentY; currentY = y; addPt(currentX, currentY); break; } case 'C': { let x1 = getNum(), y1 = getNum(), x2 = getNum(), y2 = getNum(), x = getNum(), y = getNum(); if (rel) { x1 += currentX; y1 += currentY; x2 += currentX; y2 += currentY; x += currentX; y += currentY; } cubicBezier(currentX, currentY, x1, y1, x2, y2, x, y).slice(1).forEach(p => addPt(p.x, p.y)); lastControlX = x2; lastControlY = y2; currentX = x; currentY = y; break; } case 'S': { let x1 = currentX*2 - lastControlX, y1 = currentY*2 - lastControlY; let x2 = getNum(), y2 = getNum(), x = getNum(), y = getNum(); if (rel) { x2 += currentX; y2 += currentY; x += currentX; y += currentY; } if (!'CScs'.includes(lastCommand)) { x1 = currentX; y1 = currentY; } cubicBezier(currentX, currentY, x1, y1, x2, y2, x, y).slice(1).forEach(p => addPt(p.x, p.y)); lastControlX = x2; lastControlY = y2; currentX = x; currentY = y; break; } case 'Q': { let x1 = getNum(), y1 = getNum(), x = getNum(), y = getNum(); if (rel) { x1 += currentX; y1 += currentY; x += currentX; y += currentY; } quadBezier(currentX, currentY, x1, y1, x, y).slice(1).forEach(p => addPt(p.x, p.y)); lastControlX = x1; lastControlY = y1; currentX = x; currentY = y; break; } case 'T': { let x1 = currentX*2 - lastControlX, y1 = currentY*2 - lastControlY; let x = getNum(), y = getNum(); if (rel) { x += currentX; y += currentY; } if (!'QTqt'.includes(lastCommand)) { x1 = currentX; y1 = currentY; } quadBezier(currentX, currentY, x1, y1, x, y).slice(1).forEach(p => addPt(p.x, p.y)); lastControlX = x1; lastControlY = y1; currentX = x; currentY = y; break; } case 'A': { const rxV = Math.abs(getNum()), ryV = Math.abs(getNum()), rot = getNum(); const la = !!getNum(), sw = !!getNum(); let x = getNum(), y = getNum(); if (rel) { x += currentX; y += currentY; } arcPoints(currentX, currentY, rxV, ryV, rot, la, sw, x, y).slice(1).forEach(p => addPt(p.x, p.y)); currentX = x; currentY = y; break; } case 'Z': { if (currentX !== startX || currentY !== startY) addPt(startX, startY); currentX = startX; currentY = startY; break; } default: i++; } lastCommand = cmd; } return points; } // ============================================ // TRANSFORM MATRIX // ============================================ function identityMatrix() { return [1, 0, 0, 1, 0, 0]; } function multiplyMatrix(a, b) { return [ a[0]*b[0] + a[2]*b[1], a[1]*b[0] + a[3]*b[1], a[0]*b[2] + a[2]*b[3], a[1]*b[2] + a[3]*b[3], a[0]*b[4] + a[2]*b[5] + a[4], a[1]*b[4] + a[3]*b[5] + a[5] ]; } function parseTransform(transform) { if (!transform) return identityMatrix(); let matrix = identityMatrix(); const transforms = transform.match(/(\w+)\s*\([^)]+\)/g) || []; for (const t of transforms) { const match = t.match(/(\w+)\s*\(([^)]+)\)/); if (!match) continue; const type = match[1], values = match[2].split(/[\s,]+/).map(parseFloat); let m; switch (type) { case 'matrix': if (values.length >= 6) m = values.slice(0, 6); break; case 'translate': m = [1, 0, 0, 1, values[0] || 0, values[1] || 0]; break; case 'scale': const sx = values[0] || 1, sy = values[1] !== undefined ? values[1] : sx; m = [sx, 0, 0, sy, 0, 0]; break; case 'rotate': const angle = (values[0] || 0) * Math.PI / 180; const cos = Math.cos(angle), sin = Math.sin(angle); if (values.length >= 3) { const cx = values[1], cy = values[2]; m = [cos, sin, -sin, cos, cx - cos*cx + sin*cy, cy - sin*cx - cos*cy]; } else m = [cos, sin, -sin, cos, 0, 0]; break; case 'skewX': m = [1, 0, Math.tan((values[0]||0)*Math.PI/180), 1, 0, 0]; break; case 'skewY': m = [1, Math.tan((values[0]||0)*Math.PI/180), 0, 1, 0, 0]; break; } if (m) matrix = multiplyMatrix(matrix, m); } return matrix; } function transformPoint(x, y, m) { return { x: m[0]*x + m[2]*y + m[4], y: m[1]*x + m[3]*y + m[5] }; } function getMatrixScale(m) { return Math.sqrt(Math.abs(m[0]*m[3] - m[1]*m[2])); } // ============================================ // COLOR PARSING // ============================================ function parseColor(color) { if (!color || color === 'none' || color === 'transparent') return null; if (color === 'currentColor') return '#000000'; if (color.startsWith('#')) { if (color.length === 4) return '#' + color[1]+color[1] + color[2]+color[2] + color[3]+color[3]; return color; } const rgb = color.match(/rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); if (rgb) return '#' + parseInt(rgb[1]).toString(16).padStart(2,'0') + parseInt(rgb[2]).toString(16).padStart(2,'0') + parseInt(rgb[3]).toString(16).padStart(2,'0'); const named = { black:'#000000', white:'#ffffff', red:'#ff0000', green:'#008000', blue:'#0000ff', yellow:'#ffff00', cyan:'#00ffff', magenta:'#ff00ff', orange:'#ffa500', purple:'#800080' }; return named[color.toLowerCase()] || '#000000'; } function parseOpacity(value) { if (value === undefined || value === null || value === '') return 1; const num = parseFloat(value); return isNaN(num) ? 1 : Math.max(0, Math.min(1, num)); } // ============================================ // ATTRIBUTE EXTRACTION // ============================================ const NS = '(?:\\w+:)?'; function getAttr(element, attr) { const patterns = [new RegExp(`\\s${attr}="([^"]*)"`, 'i'), new RegExp(`\\s\\w+:${attr}="([^"]*)"`, 'i')]; for (const p of patterns) { const m = element.match(p); if (m) return m[1]; } return null; } function parseStyle(style) { if (!style) return {}; const result = {}; style.split(';').forEach(r => { const [p, v] = r.split(':').map(s => s.trim()); if (p && v) result[p] = v; }); return result; } // ============================================ // SIMPLE XML ELEMENT PARSER (no regex for structure) // ============================================ /** * Parse SVG content into a tree structure * This is more robust than regex-based parsing */ function parseXMLElements(content) { const elements = []; let i = 0; const len = content.length; while (i < len) { // Skip until we find a tag const tagStart = content.indexOf('<', i); if (tagStart === -1) break; // Skip comments and CDATA if (content.slice(tagStart, tagStart + 4) === '', tagStart); i = commentEnd === -1 ? len : commentEnd + 3; continue; } if (content.slice(tagStart, tagStart + 9) === '', tagStart); i = cdataEnd === -1 ? len : cdataEnd + 3; continue; } // Skip closing tags if (content[tagStart + 1] === '/') { const closeEnd = content.indexOf('>', tagStart); i = closeEnd === -1 ? len : closeEnd + 1; continue; } // Skip processing instructions and doctype if (content[tagStart + 1] === '?' || content[tagStart + 1] === '!') { const piEnd = content.indexOf('>', tagStart); i = piEnd === -1 ? len : piEnd + 1; continue; } // Find tag name end let j = tagStart + 1; while (j < len && /[a-zA-Z0-9:_-]/.test(content[j])) j++; const tagName = content.slice(tagStart + 1, j); if (!tagName) { i = tagStart + 1; continue; } // Find end of opening tag (either /> or >) let inQuote = false; let quoteChar = ''; let k = j; while (k < len) { const ch = content[k]; if (inQuote) { if (ch === quoteChar) inQuote = false; } else { if (ch === '"' || ch === "'") { inQuote = true; quoteChar = ch; } else if (ch === '>') { break; } } k++; } if (k >= len) break; const isSelfClosing = content[k - 1] === '/'; const attrsStr = content.slice(j, isSelfClosing ? k - 1 : k).trim(); const tagEnd = k + 1; // Parse attributes const attrs = parseAttributes(attrsStr); let innerContent = ''; let elementEnd = tagEnd; // If not self-closing, find the closing tag if (!isSelfClosing) { const closeTag = findClosingTag(content, tagEnd, tagName); if (closeTag.end !== -1) { innerContent = content.slice(tagEnd, closeTag.start); elementEnd = closeTag.end; } else { elementEnd = tagEnd; } } elements.push({ position: tagStart, tagName: tagName.replace(/^\w+:/, '').toLowerCase(), rawTagName: tagName, attrs, attrsStr, innerContent, selfClosing: isSelfClosing, end: elementEnd }); i = elementEnd; } return elements; } /** * Parse attribute string into object */ function parseAttributes(attrsStr) { const attrs = {}; const attrPattern = /([a-zA-Z0-9:_-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g; let match; while ((match = attrPattern.exec(attrsStr)) !== null) { attrs[match[1]] = match[2] !== undefined ? match[2] : match[3]; } return attrs; } /** * Find the matching closing tag, handling nesting * Handles namespaced tags (e.g., svg:g) by matching both with and without namespace */ function findClosingTag(content, start, tagName) { // Strip namespace from tagName for pattern matching const baseName = tagName.replace(/^\w+:/, ''); // Match both namespaced and non-namespaced versions const escapedName = baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const openPattern = new RegExp(`<(?:\\w+:)?${escapedName}(?:\\s|>)`, 'gi'); const closePattern = new RegExp(``, 'gi'); let depth = 1; let pos = start; while (depth > 0 && pos < content.length) { openPattern.lastIndex = pos; closePattern.lastIndex = pos; const openMatch = openPattern.exec(content); const closeMatch = closePattern.exec(content); if (!closeMatch) { // No closing tag found - try to find end of content return { start: -1, end: -1 }; } // Check if there's an opening tag before the closing tag if (openMatch && openMatch.index < closeMatch.index) { // Check if it's not a self-closing tag const tagEnd = content.indexOf('>', openMatch.index); if (tagEnd !== -1 && content[tagEnd - 1] !== '/') { depth++; } pos = openMatch.index + openMatch[0].length; } else { depth--; if (depth === 0) { return { start: closeMatch.index, end: closeMatch.index + closeMatch[0].length }; } pos = closeMatch.index + closeMatch[0].length; } } return { start: -1, end: -1 }; } // ============================================ // ELEMENT COLLECTION (using proper parser) // ============================================ // Global mask definitions storage let maskDefinitions = {}; function collectAllElementsRecursive(content, parentTransform = identityMatrix(), depth = 0, parentMaskId = null) { const elements = []; const parsedElements = parseXMLElements(content); const drawableElements = ['path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'text', 'image']; for (const elem of parsedElements) { const tagLower = elem.tagName; if (tagLower === 'g') { // It's a group - parse transform and recurse const groupTransformStr = elem.attrs.transform || null; const groupMatrix = parseTransform(groupTransformStr); const combinedTransform = multiplyMatrix(parentTransform, groupMatrix); // Also inherit style attributes const groupOpacity = elem.attrs.opacity; // Check for mask attribute on this group let maskId = parentMaskId; const maskAttr = elem.attrs.mask; if (maskAttr) { // Extract mask ID from url(#mask_id) const maskMatch = maskAttr.match(/url\(#([^)]+)\)/); if (maskMatch) { maskId = maskMatch[1]; } } // Recursively process group contents with mask inheritance const childElements = collectAllElementsRecursive(elem.innerContent, combinedTransform, depth + 1, maskId); // Apply group opacity to children if set if (groupOpacity !== undefined) { childElements.forEach(child => { if (child.groupOpacity === undefined) { child.groupOpacity = parseFloat(groupOpacity); } else { child.groupOpacity *= parseFloat(groupOpacity); } }); } elements.push(...childElements); } else if (tagLower === 'mask') { // Store mask definition - extract image data from inside const maskId = elem.attrs.id; if (maskId) { // Find image inside the mask const maskContent = elem.innerContent; const imageMatch = maskContent.match(/]*(?:xlink:)?href="([^"]+)"[^>]*>/); if (imageMatch) { // Store the mask image data maskDefinitions[maskId] = { imageData: imageMatch[1], type: 'luminance' // SVG masks default to luminance }; } } // Don't add mask content as drawable elements continue; } else if (tagLower === 'clippath') { // Skip clippath content continue; } else if (tagLower === 'defs') { // Process defs - this will capture masks inside const childElements = collectAllElementsRecursive(elem.innerContent, parentTransform, depth + 1, parentMaskId); // Don't add defs content to drawable elements continue; } else if (drawableElements.includes(tagLower)) { // It's a drawable element const elemData = { position: elem.position, tag: tagLower, fullMatch: content.slice(elem.position, elem.end), attrs: elem.attrsStr, attrsObj: elem.attrs, innerContent: elem.innerContent, inheritedTransform: parentTransform }; // Add mask reference if this element is inside a masked group if (parentMaskId) { elemData.maskId = parentMaskId; } elements.push(elemData); } } return elements; } function collectAllElements(content) { // Reset mask definitions for each conversion maskDefinitions = {}; console.log('Using recursive element collection with transform inheritance...'); return collectAllElementsRecursive(content); } // ============================================ // PATH PAIR DETECTION & MERGING // ============================================ function detectPathPairs(elements) { const merged = []; const used = new Set(); // Helper to get attribute from attrsObj or fallback to getAttr const getElemAttr = (elem, attr) => { if (elem.attrsObj && elem.attrsObj[attr] !== undefined) { return elem.attrsObj[attr]; } return getAttr(`<${elem.tag} ${elem.attrs}>`, attr); }; for (let i = 0; i < elements.length; i++) { if (used.has(i)) continue; const elem = elements[i]; if (elem.tag === 'path') { const d = getElemAttr(elem, 'd'); const fill = getElemAttr(elem, 'fill'); const stroke = getElemAttr(elem, 'stroke'); const fillOpacity = getElemAttr(elem, 'fill-opacity'); const strokeWidth = getElemAttr(elem, 'stroke-width'); const styleStr = getElemAttr(elem, 'style'); const style = parseStyle(styleStr); // Check if this is a fill-only path (potential first of pair) const isFillPath = fill && fill !== 'none' && (!stroke || stroke === 'none'); if (isFillPath && i + 1 < elements.length) { const nextElem = elements[i + 1]; if (nextElem.tag === 'path') { const nextD = getElemAttr(nextElem, 'd'); const nextFill = getElemAttr(nextElem, 'fill'); const nextStroke = getElemAttr(nextElem, 'stroke'); const nextStrokeWidth = getElemAttr(nextElem, 'stroke-width'); const nextStrokeOpacity = getElemAttr(nextElem, 'stroke-opacity'); const nextStrokeDasharray = getElemAttr(nextElem, 'stroke-dasharray'); // Check if next path is stroke-only with same d const isStrokePath = (!nextFill || nextFill === 'none') && nextStroke && nextStroke !== 'none'; if (isStrokePath && d === nextD) { const fillOp = parseFloat(fillOpacity || style['fill-opacity'] || '1'); const strokeOp = parseFloat(nextStrokeOpacity || '1'); // CASE 1: Visible fill WITH solid stroke (like filled box with border) // DON'T merge - keep both as separate elements (a fill shape + border) if (fillOp >= 0.01 && strokeOp >= 0.99) { // Mark both as used but create a SHAPE instead of stroke used.add(i); used.add(i + 1); // Create a filled shape with border merged.push({ ...elem, isPair: true, isFillWithBorder: true, // New flag for shapes with fill + border mergedFillColor: fill, mergedFillOpacity: fillOp, mergedStrokeColor: nextStroke, mergedStrokeWidth: nextStrokeWidth || strokeWidth || '2', mergedStrokeOpacity: strokeOp, mergedStrokeDasharray: nextStrokeDasharray }); continue; } // CASE 2: Invisible fill (fillOp < 0.01) - eraser/highlighter pattern // Use stroke color and stroke-opacity if (fillOp < 0.01) { used.add(i); used.add(i + 1); merged.push({ ...elem, isPair: true, mergedColor: nextStroke, mergedStrokeWidth: nextStrokeWidth || strokeWidth || '2', mergedOpacity: strokeOp, // Use stroke-opacity from companion path originalStrokeColor: nextStroke, isEraser: fillOp < 0.01 && strokeOp >= 1 }); continue; } // CASE 3: Semi-transparent fill with semi-transparent stroke (highlighter) // Merge into single highlighter stroke used.add(i); used.add(i + 1); merged.push({ ...elem, isPair: true, mergedColor: fill, mergedStrokeWidth: nextStrokeWidth || strokeWidth || '2', mergedOpacity: fillOp, originalStrokeColor: nextStroke }); continue; } } } } // Not a pair, add as-is merged.push(elem); used.add(i); } return merged; } // ============================================ // ELEMENT CONVERTERS // ============================================ let itemIdCounter = Date.now(); function generateId() { return itemIdCounter++; } // Helper to get attribute from element (prefers parsed attrsObj, falls back to regex) function getElemAttr(elem, attr) { if (elem.attrsObj && elem.attrsObj[attr] !== undefined) { return elem.attrsObj[attr]; } return getAttr(`<${elem.tag} ${elem.attrs}>`, attr); } function convertPath(elem, globalTransform) { const d = getElemAttr(elem, 'd'); if (!d) return null; let pts = parsePathD(d); if (pts.length < 2) return null; // Combine inherited transform (from groups) with element's own transform const elemTransformStr = getElemAttr(elem, 'transform'); const elemTransform = parseTransform(elemTransformStr); const inheritedTransform = elem.inheritedTransform || identityMatrix(); const transform = multiplyMatrix(multiplyMatrix(globalTransform, inheritedTransform), elemTransform); // Apply group opacity if inherited let groupOpacityMultiplier = elem.groupOpacity !== undefined ? elem.groupOpacity : 1; pts = pts.map(p => transformPoint(p.x, p.y, transform)); // Skip simplification if configured if (!CONFIG.skipSimplification && CONFIG.simplifyTolerance > 0) { pts = simplifyPath(pts); } pts = enforceMaxPoints(pts); if (pts.length < 2) return null; // Handle merged pair - fillWithBorder case (visible fill + solid stroke) if (elem.isPair && elem.isFillWithBorder) { const fillOpacity = elem.mergedFillOpacity * groupOpacityMultiplier; const strokeOpacity = elem.mergedStrokeOpacity * groupOpacityMultiplier; const strokeWidth = parseFloat(elem.mergedStrokeWidth) * getMatrixScale(transform); const borderType = getBorderTypeFromDasharray(elem.mergedStrokeDasharray); // Try to detect if this is a rectangle-like shape (closed path with 4 corners) const isRectLike = isClosedRectangle(pts); if (isRectLike) { // Convert to rectangle shape const bounds = computeBounds(pts); return { id: generateId(), lastMod: Date.now(), tool: 'shape', shapeType: 'rectangle', x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, borderColor: parseColor(elem.mergedStrokeColor) || CONFIG.defaultStroke, fillColor: parseColor(elem.mergedFillColor) || '#ffffff', fillOpacity: fillOpacity, borderOpacity: strokeOpacity, borderSize: strokeWidth, borderType: borderType, deleted: false }; } else { // Create a polygon shape for non-rectangular paths const bounds = computeBounds(pts); const normalizedPts = pts.map(p => ({ x: bounds.width > 0 ? (p.x - bounds.x) / bounds.width : 0, y: bounds.height > 0 ? (p.y - bounds.y) / bounds.height : 0 })); return { id: generateId(), lastMod: Date.now(), tool: 'shape', shapeType: 'polygon', x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, pts: normalizedPts, borderColor: parseColor(elem.mergedStrokeColor) || CONFIG.defaultStroke, fillColor: parseColor(elem.mergedFillColor) || '#ffffff', fillOpacity: fillOpacity, borderOpacity: strokeOpacity, borderSize: strokeWidth, borderType: borderType, deleted: false }; } } // Handle merged pair - highlighter/eraser case if (elem.isPair) { const opacity = elem.mergedOpacity * groupOpacityMultiplier; const tool = opacity < CONFIG.highlighterOpacityThreshold ? 'highlighter' : 'pen'; const strokeWidth = parseFloat(elem.mergedStrokeWidth) * getMatrixScale(transform); return { id: generateId(), lastMod: Date.now(), tool, pts, color: parseColor(elem.mergedColor) || CONFIG.defaultStroke, size: strokeWidth, opacity: opacity, // Always include opacity lineCap: 'round', lineJoin: 'round', deleted: false }; } // Single path const fill = getElemAttr(elem, 'fill'); const stroke = getElemAttr(elem, 'stroke'); const strokeWidth = getElemAttr(elem, 'stroke-width'); const fillOpacity = getElemAttr(elem, 'fill-opacity'); const strokeOpacity = getElemAttr(elem, 'stroke-opacity'); const opacity = getElemAttr(elem, 'opacity'); const lineCap = getElemAttr(elem, 'stroke-linecap'); const lineJoin = getElemAttr(elem, 'stroke-linejoin'); const strokeDasharray = getElemAttr(elem, 'stroke-dasharray'); // Check if this is a stroke-only path with a dash pattern (dashed/dotted border) // If so, and it looks like a closed rectangle, convert to a shape instead of stroke const isStrokeOnly = (!fill || fill === 'none') && stroke && stroke !== 'none'; const hasDashPattern = strokeDasharray && strokeDasharray !== 'none'; if (isStrokeOnly && hasDashPattern && isClosedRectangle(pts)) { // Convert to rectangle shape with dashed border and no fill const bounds = computeBounds(pts); const borderType = getBorderTypeFromDasharray(strokeDasharray); const strokeOp = parseFloat(strokeOpacity || opacity || '1') * groupOpacityMultiplier; const sw = (parseFloat(strokeWidth) || CONFIG.defaultStrokeWidth) * getMatrixScale(transform); return { id: generateId(), lastMod: Date.now(), tool: 'shape', shapeType: 'rectangle', x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, borderColor: parseColor(stroke) || CONFIG.defaultStroke, fillColor: 'transparent', fillOpacity: 0, borderOpacity: strokeOp, borderSize: sw, borderType: borderType, deleted: false }; } // Check if stroke-only with dash pattern but not rectangular - make polygon if (isStrokeOnly && hasDashPattern && pts.length >= 3) { const bounds = computeBounds(pts); const normalizedPts = pts.map(p => ({ x: bounds.width > 0 ? (p.x - bounds.x) / bounds.width : 0, y: bounds.height > 0 ? (p.y - bounds.y) / bounds.height : 0 })); const borderType = getBorderTypeFromDasharray(strokeDasharray); const strokeOp = parseFloat(strokeOpacity || opacity || '1') * groupOpacityMultiplier; const sw = (parseFloat(strokeWidth) || CONFIG.defaultStrokeWidth) * getMatrixScale(transform); return { id: generateId(), lastMod: Date.now(), tool: 'shape', shapeType: 'polygon', x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, pts: normalizedPts, borderColor: parseColor(stroke) || CONFIG.defaultStroke, fillColor: 'transparent', fillOpacity: 0, borderOpacity: strokeOp, borderSize: sw, borderType: borderType, deleted: false }; } const effectiveOpacity = parseOpacity(opacity) * parseOpacity(fillOpacity) * groupOpacityMultiplier; const tool = effectiveOpacity < CONFIG.highlighterOpacityThreshold ? 'highlighter' : 'pen'; const color = parseColor(stroke) || parseColor(fill) || CONFIG.defaultStroke; const sw = (parseFloat(strokeWidth) || CONFIG.defaultStrokeWidth) * getMatrixScale(transform); return { id: generateId(), lastMod: Date.now(), tool, pts, color, size: sw, opacity: effectiveOpacity, // Always include opacity lineCap: lineCap || 'round', lineJoin: lineJoin || 'round', deleted: false }; } function convertRect(elem, globalTransform) { const x = parseFloat(getElemAttr(elem, 'x')) || 0; const y = parseFloat(getElemAttr(elem, 'y')) || 0; const w = parseFloat(getElemAttr(elem, 'width')) || 0; const h = parseFloat(getElemAttr(elem, 'height')) || 0; if (w === 0 || h === 0) return null; // Combine inherited transform with element's own transform const elemTransformStr = getElemAttr(elem, 'transform'); const elemTransform = parseTransform(elemTransformStr); const inheritedTransform = elem.inheritedTransform || identityMatrix(); const transform = multiplyMatrix(multiplyMatrix(globalTransform, inheritedTransform), elemTransform); const stroke = getElemAttr(elem, 'stroke'); const fill = getElemAttr(elem, 'fill'); const strokeWidth = getElemAttr(elem, 'stroke-width'); const fillOpacity = getElemAttr(elem, 'fill-opacity'); const strokeOpacity = getElemAttr(elem, 'stroke-opacity'); const strokeDasharray = getElemAttr(elem, 'stroke-dasharray'); // Determine border type from stroke-dasharray let borderType = 'solid'; if (strokeDasharray && strokeDasharray !== 'none') { const parts = strokeDasharray.split(/[\s,]+/).map(parseFloat); if (parts.length >= 2) { // Dotted: small gaps, Dashed: larger gaps const ratio = parts[1] / parts[0]; borderType = ratio > 1 ? 'dotted' : 'dashed'; } } const corners = [ transformPoint(x, y, transform), transformPoint(x + w, y, transform), transformPoint(x + w, y + h, transform), transformPoint(x, y + h, transform) ]; const xs = corners.map(c => c.x), ys = corners.map(c => c.y); const minX = Math.min(...xs), maxX = Math.max(...xs); const minY = Math.min(...ys), maxY = Math.max(...ys); const result = { id: generateId(), lastMod: Date.now(), tool: 'shape', shapeType: 'rectangle', x: minX, y: minY, w: maxX - minX, h: maxY - minY, border: parseColor(stroke) || CONFIG.defaultStroke, fill: parseColor(fill) || CONFIG.defaultFill, width: (parseFloat(strokeWidth) || CONFIG.defaultStrokeWidth) * getMatrixScale(transform), rotation: 0, deleted: false }; // Add separate opacities if (fillOpacity !== undefined && parseFloat(fillOpacity) < 1) { result.fillOpacity = parseFloat(fillOpacity); } if (strokeOpacity !== undefined && parseFloat(strokeOpacity) < 1) { result.borderOpacity = parseFloat(strokeOpacity); } if (borderType !== 'solid') { result.borderType = borderType; } return result; } function convertCircle(elem, globalTransform) { const cx = parseFloat(getElemAttr(elem, 'cx')) || 0; const cy = parseFloat(getElemAttr(elem, 'cy')) || 0; const r = parseFloat(getElemAttr(elem, 'r')) || 0; if (r === 0) return null; // Combine inherited transform with element's own transform const elemTransformStr = getElemAttr(elem, 'transform'); const elemTransform = parseTransform(elemTransformStr); const inheritedTransform = elem.inheritedTransform || identityMatrix(); const transform = multiplyMatrix(multiplyMatrix(globalTransform, inheritedTransform), elemTransform); const stroke = getElemAttr(elem, 'stroke'); const fill = getElemAttr(elem, 'fill'); const strokeWidth = getElemAttr(elem, 'stroke-width'); const fillOpacity = getElemAttr(elem, 'fill-opacity'); const strokeOpacity = getElemAttr(elem, 'stroke-opacity'); const strokeDasharray = getElemAttr(elem, 'stroke-dasharray'); let borderType = 'solid'; if (strokeDasharray && strokeDasharray !== 'none') { const parts = strokeDasharray.split(/[\s,]+/).map(parseFloat); if (parts.length >= 2) { borderType = parts[1] / parts[0] > 1 ? 'dotted' : 'dashed'; } } const center = transformPoint(cx, cy, transform); const scaledR = r * getMatrixScale(transform); const result = { id: generateId(), lastMod: Date.now(), tool: 'shape', shapeType: 'circle', x: center.x - scaledR, y: center.y - scaledR, w: scaledR * 2, h: scaledR * 2, border: parseColor(stroke) || CONFIG.defaultStroke, fill: parseColor(fill) || CONFIG.defaultFill, width: (parseFloat(strokeWidth) || CONFIG.defaultStrokeWidth) * getMatrixScale(transform), rotation: 0, deleted: false }; if (fillOpacity !== undefined && parseFloat(fillOpacity) < 1) { result.fillOpacity = parseFloat(fillOpacity); } if (strokeOpacity !== undefined && parseFloat(strokeOpacity) < 1) { result.borderOpacity = parseFloat(strokeOpacity); } if (borderType !== 'solid') { result.borderType = borderType; } return result; } function convertEllipse(elem, globalTransform) { const cx = parseFloat(getElemAttr(elem, 'cx')) || 0; const cy = parseFloat(getElemAttr(elem, 'cy')) || 0; const rx = parseFloat(getElemAttr(elem, 'rx')) || 0; const ry = parseFloat(getElemAttr(elem, 'ry')) || 0; if (rx === 0 || ry === 0) return null; // Combine inherited transform with element's own transform const elemTransformStr = getElemAttr(elem, 'transform'); const elemTransform = parseTransform(elemTransformStr); const inheritedTransform = elem.inheritedTransform || identityMatrix(); const transform = multiplyMatrix(multiplyMatrix(globalTransform, inheritedTransform), elemTransform); const stroke = getElemAttr(elem, 'stroke'); const fill = getElemAttr(elem, 'fill'); const strokeWidth = getElemAttr(elem, 'stroke-width'); const fillOpacity = getElemAttr(elem, 'fill-opacity'); const strokeOpacity = getElemAttr(elem, 'stroke-opacity'); const strokeDasharray = getElemAttr(elem, 'stroke-dasharray'); let borderType = 'solid'; if (strokeDasharray && strokeDasharray !== 'none') { const parts = strokeDasharray.split(/[\s,]+/).map(parseFloat); if (parts.length >= 2) { borderType = parts[1] / parts[0] > 1 ? 'dotted' : 'dashed'; } } const center = transformPoint(cx, cy, transform); const scale = getMatrixScale(transform); const result = { id: generateId(), lastMod: Date.now(), tool: 'shape', shapeType: 'ellipse', x: center.x - rx * scale, y: center.y - ry * scale, w: rx * 2 * scale, h: ry * 2 * scale, border: parseColor(stroke) || CONFIG.defaultStroke, fill: parseColor(fill) || CONFIG.defaultFill, width: (parseFloat(strokeWidth) || CONFIG.defaultStrokeWidth) * scale, rotation: 0, deleted: false }; if (fillOpacity !== undefined && parseFloat(fillOpacity) < 1) { result.fillOpacity = parseFloat(fillOpacity); } if (strokeOpacity !== undefined && parseFloat(strokeOpacity) < 1) { result.borderOpacity = parseFloat(strokeOpacity); } if (borderType !== 'solid') { result.borderType = borderType; } return result; } function convertLine(elem, globalTransform) { const x1 = parseFloat(getElemAttr(elem, 'x1')) || 0; const y1 = parseFloat(getElemAttr(elem, 'y1')) || 0; const x2 = parseFloat(getElemAttr(elem, 'x2')) || 0; const y2 = parseFloat(getElemAttr(elem, 'y2')) || 0; // Combine inherited transform with element's own transform const elemTransformStr = getElemAttr(elem, 'transform'); const elemTransform = parseTransform(elemTransformStr); const inheritedTransform = elem.inheritedTransform || identityMatrix(); const transform = multiplyMatrix(multiplyMatrix(globalTransform, inheritedTransform), elemTransform); const stroke = getElemAttr(elem, 'stroke'); const strokeWidth = getElemAttr(elem, 'stroke-width'); return { id: generateId(), lastMod: Date.now(), tool: 'pen', pts: [transformPoint(x1, y1, transform), transformPoint(x2, y2, transform)], color: parseColor(stroke) || CONFIG.defaultStroke, size: (parseFloat(strokeWidth) || CONFIG.defaultStrokeWidth) * getMatrixScale(transform), lineCap: 'round', lineJoin: 'round', deleted: false }; } function convertPolyline(elem, globalTransform, close = false) { const pointsStr = getElemAttr(elem, 'points') || ''; const nums = pointsStr.match(/[-+]?(?:\d+\.?\d*|\.\d+)/g) || []; if (nums.length < 4) return null; // Combine inherited transform with element's own transform const elemTransformStr = getElemAttr(elem, 'transform'); const elemTransform = parseTransform(elemTransformStr); const inheritedTransform = elem.inheritedTransform || identityMatrix(); const transform = multiplyMatrix(multiplyMatrix(globalTransform, inheritedTransform), elemTransform); let pts = []; for (let i = 0; i < nums.length - 1; i += 2) { pts.push(transformPoint(parseFloat(nums[i]), parseFloat(nums[i + 1]), transform)); } if (close && pts.length > 0 && (pts[0].x !== pts[pts.length-1].x || pts[0].y !== pts[pts.length-1].y)) { pts.push({ x: pts[0].x, y: pts[0].y }); } if (!CONFIG.skipSimplification && CONFIG.simplifyTolerance > 0) { pts = simplifyPath(pts); } pts = enforceMaxPoints(pts); if (pts.length < 2) return null; const stroke = getElemAttr(elem, 'stroke'); const strokeWidth = getElemAttr(elem, 'stroke-width'); return { id: generateId(), lastMod: Date.now(), tool: 'pen', pts, color: parseColor(stroke) || CONFIG.defaultStroke, size: (parseFloat(strokeWidth) || CONFIG.defaultStrokeWidth) * getMatrixScale(transform), lineCap: 'round', lineJoin: 'round', deleted: false }; } // Convert polygon to a shape (closed polygon) function convertPolygonShape(elem, globalTransform) { const pointsStr = getElemAttr(elem, 'points') || ''; const nums = pointsStr.match(/[-+]?(?:\d+\.?\d*|\.\d+)/g) || []; if (nums.length < 6) return null; // Need at least 3 points for a polygon // Combine inherited transform with element's own transform const elemTransformStr = getElemAttr(elem, 'transform'); const elemTransform = parseTransform(elemTransformStr); const inheritedTransform = elem.inheritedTransform || identityMatrix(); const transform = multiplyMatrix(multiplyMatrix(globalTransform, inheritedTransform), elemTransform); // Parse and transform points let pts = []; for (let i = 0; i < nums.length - 1; i += 2) { pts.push(transformPoint(parseFloat(nums[i]), parseFloat(nums[i + 1]), transform)); } // Calculate bounding box const xs = pts.map(p => p.x), ys = pts.map(p => p.y); const minX = Math.min(...xs), maxX = Math.max(...xs); const minY = Math.min(...ys), maxY = Math.max(...ys); const stroke = getElemAttr(elem, 'stroke'); const fill = getElemAttr(elem, 'fill'); const strokeWidth = getElemAttr(elem, 'stroke-width'); const fillOpacity = getElemAttr(elem, 'fill-opacity'); const strokeOpacity = getElemAttr(elem, 'stroke-opacity'); const strokeDasharray = getElemAttr(elem, 'stroke-dasharray'); const opacity = getElemAttr(elem, 'opacity'); let borderType = 'solid'; if (strokeDasharray && strokeDasharray !== 'none') { const parts = strokeDasharray.split(/[\s,]+/).map(parseFloat); if (parts.length >= 2) { borderType = parts[1] / parts[0] > 1 ? 'dotted' : 'dashed'; } } // Store normalized points (relative to bounding box) const normalizedPts = pts.map(p => ({ x: (p.x - minX) / (maxX - minX || 1), y: (p.y - minY) / (maxY - minY || 1) })); const result = { id: generateId(), lastMod: Date.now(), tool: 'shape', shapeType: 'polygon', x: minX, y: minY, w: maxX - minX, h: maxY - minY, pts: normalizedPts, // Store normalized points for custom polygon border: parseColor(stroke) || CONFIG.defaultStroke, fill: parseColor(fill) || CONFIG.defaultFill, width: (parseFloat(strokeWidth) || CONFIG.defaultStrokeWidth) * getMatrixScale(transform), rotation: 0, deleted: false }; // Apply group opacity if inherited const groupOpacityMultiplier = elem.groupOpacity !== undefined ? elem.groupOpacity : 1; const effectiveOpacity = parseOpacity(opacity) * groupOpacityMultiplier; if (effectiveOpacity < 1) { result.opacity = effectiveOpacity; } if (fillOpacity !== undefined && parseFloat(fillOpacity) < 1) { result.fillOpacity = parseFloat(fillOpacity); } if (strokeOpacity !== undefined && parseFloat(strokeOpacity) < 1) { result.borderOpacity = parseFloat(strokeOpacity); } if (borderType !== 'solid') { result.borderType = borderType; } return result; } function convertText(elem, globalTransform) { // Get text content - strip tags but preserve text const text = elem.innerContent.replace(/<[^>]*>/g, '').trim(); if (!text) return null; // Try to get position from tspan first, then fall back to text element let x = 0, y = 0; // Parse tspan for position - tspan often has the actual x/y const tspanMatch = elem.innerContent.match(/]*\s+x="([^"]+)"[^>]*\s+y="([^"]+)"/); const tspanMatchAlt = elem.innerContent.match(/]*\s+y="([^"]+)"[^>]*\s+x="([^"]+)"/); if (tspanMatch) { // x might be multiple values (kerning) - take the first const xVals = tspanMatch[1].split(/\s+/); x = parseFloat(xVals[0]) || 0; y = parseFloat(tspanMatch[2]) || 0; } else if (tspanMatchAlt) { const xVals = tspanMatchAlt[2].split(/\s+/); x = parseFloat(xVals[0]) || 0; y = parseFloat(tspanMatchAlt[1]) || 0; } else { // Fall back to text element attributes x = parseFloat(getElemAttr(elem, 'x')) || 0; y = parseFloat(getElemAttr(elem, 'y')) || 0; } // Combine inherited transform with element's own transform const elemTransformStr = getElemAttr(elem, 'transform'); const elemTransform = parseTransform(elemTransformStr); const inheritedTransform = elem.inheritedTransform || identityMatrix(); const transform = multiplyMatrix(multiplyMatrix(globalTransform, inheritedTransform), elemTransform); const fill = getElemAttr(elem, 'fill'); const fontSize = getElemAttr(elem, 'font-size'); const fontFamily = getElemAttr(elem, 'font-family'); const pos = transformPoint(x, y, transform); const size = (parseFloat(fontSize) || 16) * getMatrixScale(transform); const result = { id: generateId(), lastMod: Date.now(), tool: 'text', text, x: pos.x, y: pos.y, size, color: parseColor(fill) || CONFIG.defaultStroke, rotation: 0, w: text.length * size * 0.6, h: size * 1.2, deleted: false }; // Store font family if present if (fontFamily) { result.fontFamily = fontFamily; } // Store original SVG structure for precise roundtrip // This preserves kerning, transforms, tspans, etc. const xmlSpace = getElemAttr(elem, 'xml:space'); if (elemTransformStr || elem.innerContent.includes(' CONFIG.base64WarnSize) { warnings.push(`Warning: Large embedded image (${Math.round(href.length / 1024)}KB)`); } // Get opacity (may be inherited from group) const opacity = getElemAttr(elem, 'opacity'); const groupOpacity = elem.groupOpacity !== undefined ? elem.groupOpacity : 1; const effectiveOpacity = parseOpacity(opacity) * groupOpacity; // Check for mask (warn if present since we can't fully support it) const mask = getElemAttr(elem, 'mask'); if (mask) { warnings.push(`Warning: Image has mask "${mask}" which may not render correctly`); } const result = { id: generateId(), lastMod: Date.now(), tool: 'image', x: topLeft.x, y: topLeft.y, w: Math.abs(bottomRight.x - topLeft.x), h: Math.abs(bottomRight.y - topLeft.y), src: href, rotation: 0, deleted: false }; // Add opacity if not fully opaque if (effectiveOpacity < 1) { result.opacity = effectiveOpacity; } // Add mask data if this image has a mask applied if (elem.maskId && maskDefinitions[elem.maskId]) { result.mask = { id: elem.maskId, type: maskDefinitions[elem.maskId].type, src: maskDefinitions[elem.maskId].imageData }; } return result; } // ============================================ // MAIN CONVERTER // ============================================ function convertSVG(svgContent) { const history = []; const warnings = []; // Get SVG attributes const svgMatch = svgContent.match(/<(?:\w+:)?svg\s+([^>]*)>/i); if (!svgMatch) return { metadata: { error: 'No SVG element found' }, history: [] }; const svgAttrs = svgMatch[1]; const viewBoxMatch = svgAttrs.match(/viewBox="([^"]*)"/); let viewBox = null, viewBoxOffset = { x: 0, y: 0 }; if (viewBoxMatch) { const parts = viewBoxMatch[1].split(/[\s,]+/).map(parseFloat); viewBox = { x: parts[0], y: parts[1], w: parts[2], h: parts[3] }; viewBoxOffset = { x: -parts[0], y: -parts[1] }; } const widthMatch = svgAttrs.match(/width="([^"]*)"/); const heightMatch = svgAttrs.match(/height="([^"]*)"/); const svgWidth = parseFloat(widthMatch?.[1]) || viewBox?.w || 800; const svgHeight = parseFloat(heightMatch?.[1]) || viewBox?.h || 600; // Initial transform for viewBox offset let transform = identityMatrix(); if (viewBoxOffset.x !== 0 || viewBoxOffset.y !== 0) { transform = [1, 0, 0, 1, viewBoxOffset.x, viewBoxOffset.y]; } // Extract SVG content const svgContentMatch = svgContent.match(/<(?:\w+:)?svg[^>]*>([\s\S]*)<\/(?:\w+:)?svg>/i); if (!svgContentMatch) return { metadata: { error: 'Could not extract SVG content' }, history: [] }; // Keep defs - we now process mask/clippath content to include all images const cleanedContent = svgContentMatch[1]; // Collect all elements in order console.log('Collecting elements in document order...'); const elements = collectAllElements(cleanedContent); console.log(`Found ${elements.length} elements`); // Detect and merge path pairs console.log('Detecting fill+stroke path pairs...'); const mergedElements = detectPathPairs(elements); console.log(`After merging: ${mergedElements.length} elements (${elements.length - mergedElements.length} pairs merged)`); // Convert each element console.log('Converting elements...'); for (const elem of mergedElements) { try { let item = null; switch (elem.tag) { case 'path': item = convertPath(elem, transform); break; case 'rect': item = convertRect(elem, transform); break; case 'circle': item = convertCircle(elem, transform); break; case 'ellipse': item = convertEllipse(elem, transform); break; case 'line': item = convertLine(elem, transform); break; case 'polyline': item = convertPolyline(elem, transform, false); break; case 'polygon': item = convertPolygonShape(elem, transform); break; case 'text': item = convertText(elem, transform); break; case 'image': item = convertImage(elem, transform, warnings); break; } if (item) history.push(item); } catch (e) { warnings.push(`${elem.tag} conversion error: ${e.message}`); } } // Statistics const stats = { pen: 0, highlighter: 0, shape: 0, text: 0, image: 0 }; history.forEach(item => { if (stats[item.tool] !== undefined) stats[item.tool]++; }); console.log('Conversion statistics:'); for (const [type, count] of Object.entries(stats)) { if (count > 0) console.log(` ${type}: ${count} elements`); } if (warnings.length > 0) { console.log('\nWarnings:'); warnings.forEach(w => console.log(` ${w}`)); } // Post-process: Detect background images // Images that cover the full document are likely backgrounds const TOLERANCE = 0.05; // 5% tolerance for size matching const docWidth = svgWidth; const docHeight = svgHeight; for (const item of history) { if (item.tool === 'image') { const widthRatio = item.w / docWidth; const heightRatio = item.h / docHeight; const xNearZero = Math.abs(item.x) < docWidth * TOLERANCE; const yNearZero = Math.abs(item.y) < docHeight * TOLERANCE; const widthMatch = widthRatio > (1 - TOLERANCE) && widthRatio < (1 + TOLERANCE); const heightMatch = heightRatio > (1 - TOLERANCE) && heightRatio < (1 + TOLERANCE); if (xNearZero && yNearZero && widthMatch && heightMatch) { item.isBackground = true; } } } // Count backgrounds const backgroundCount = history.filter(i => i.isBackground).length; if (backgroundCount > 0) { console.log(`Detected ${backgroundCount} background image(s)`); } return { metadata: { version: 2, // New format version sourceType: 'svg', width: svgWidth, height: svgHeight, viewBox, elementCount: history.length, statistics: stats, backgroundCount }, history }; } // ============================================ // CLI // ============================================ function main() { const args = process.argv.slice(2); if (args.length === 0) { console.log(` SVG to ColorRM Converter - Pro Version v3 ========================================== Usage: node svg-to-colorrm.cjs [output.json] FIXES in v3: ✓ Recursively processes elements inside groups ✓ Applies inherited transforms from parent groups ✓ Applies per-element transforms ✓ Disabled simplification to preserve hand-drawn curves ✓ Always preserves opacity values for highlighters PREVIOUS (v2): ✓ Preserves element order (z-order) ✓ Detects fill+stroke path pairs and merges them ✓ Uses fill color, stroke width from pairs ✓ Properly maps low opacity to highlighter tool `); process.exit(0); } const inputFile = args[0]; const outputFile = args[1] || inputFile.replace(/\.svg$/i, '.colorrm.json'); if (!fs.existsSync(inputFile)) { console.error(`Error: Input file not found: ${inputFile}`); process.exit(1); } console.log(`Reading: ${inputFile}`); const svgContent = fs.readFileSync(inputFile, 'utf-8'); console.log(`File size: ${Math.round(svgContent.length / 1024)}KB`); console.log('\nConverting SVG to ColorRM format...'); const result = convertSVG(svgContent); console.log(`\nConversion complete!`); console.log(` Total elements: ${result.history.length}`); console.log(` Canvas size: ${result.metadata.width} x ${result.metadata.height}`); console.log(`\nWriting: ${outputFile}`); fs.writeFileSync(outputFile, JSON.stringify(result, null, 2)); const outputSize = fs.statSync(outputFile).size; console.log(`Output size: ${Math.round(outputSize / 1024)}KB`); console.log('\nDone!'); } if (require.main === module) main(); module.exports = { convertSVG, parsePathD, parseTransform, transformPoint, parseColor, simplifyPath, CONFIG };