my-multiplayer-app-2 / tools /svg-to-colorrm.cjs
Jaimodiji's picture
Upload folder using huggingface_hub
d9eba27 verified
Raw
History Blame
65 kB
#!/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) === '<!--') {
const commentEnd = content.indexOf('-->', tagStart);
i = commentEnd === -1 ? len : commentEnd + 3;
continue;
}
if (content.slice(tagStart, tagStart + 9) === '<![CDATA[') {
const cdataEnd = content.indexOf(']]>', 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(`</(?:\\w+:)?${escapedName}>`, '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(/<image[^>]*(?: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(/<tspan[^>]*\s+x="([^"]+)"[^>]*\s+y="([^"]+)"/);
const tspanMatchAlt = elem.innerContent.match(/<tspan[^>]*\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('<tspan')) {
result.svgData = {
transform: elemTransformStr || null,
xmlSpace: xmlSpace || null,
fontSize: fontSize || null,
fontFamily: fontFamily || null,
fill: fill || null,
innerContent: elem.innerContent
};
}
return result;
}
function convertImage(elem, globalTransform, warnings) {
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;
const href = getElemAttr(elem, 'href') || getElemAttr(elem, 'xlink:href');
if (!href) return null;
// Skip images with zero dimensions
if (w === 0 || h === 0) {
warnings.push('Warning: Image with zero dimensions skipped');
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 topLeft = transformPoint(x, y, transform);
const bottomRight = transformPoint(x + w, y + h, transform);
if (href.startsWith('data:') && href.length > 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 <input.svg> [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 };