Jaimodiji commited on
Commit
d9eba27
·
verified ·
1 Parent(s): 773ac4d

Upload folder using huggingface_hub

Browse files
.claude/settings.local.json CHANGED
@@ -1,7 +1,11 @@
1
  {
2
  "permissions": {
3
  "allow": [
4
- "Bash(npx wrangler deploy:*)"
 
 
 
 
5
  ]
6
  }
7
  }
 
1
  {
2
  "permissions": {
3
  "allow": [
4
+ "Bash(node tools/colorrm-to-svg.cjs:*)",
5
+ "Bash(node -e:*)",
6
+ "Bash(node check_masks.cjs:*)",
7
+ "Bash(node tools/svg-to-colorrm.cjs:*)",
8
+ "Bash(python3 -c:*)"
9
  ]
10
  }
11
  }
DroidSans-Bold.svg ADDED
check_images.cjs ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+ const content = fs.readFileSync('test_page_1.svg', 'utf8');
3
+
4
+ // Find all image elements
5
+ const imagePattern = /<image[^>]*>/g;
6
+ let match;
7
+ let idx = 0;
8
+ while ((match = imagePattern.exec(content)) !== null) {
9
+ const elem = match[0];
10
+ const id = elem.match(/id="([^"]*)"/)?.[1] || 'no-id';
11
+ const w = elem.match(/width="([^"]*)"/)?.[1] || '?';
12
+ const h = elem.match(/height="([^"]*)"/)?.[1] || '?';
13
+ const href = elem.match(/href="([^"]{0,50})/)?.[1] || '?';
14
+ console.log(idx++, id, w+'x'+h, href.slice(0,40)+'...');
15
+ }
check_structure.cjs ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require('fs');
2
+ const content = fs.readFileSync('test_page_1.svg', 'utf8');
3
+
4
+ // Find defs section
5
+ const defsMatch = content.match(/<defs>([\s\S]*?)<\/defs>/);
6
+ if (defsMatch) {
7
+ const defsContent = defsMatch[1];
8
+ const imagesInDefs = (defsContent.match(/<image/g) || []).length;
9
+ console.log('Images inside <defs>:', imagesInDefs);
10
+ }
11
+
12
+ // Find images outside defs
13
+ const withoutDefs = content.replace(/<defs>[\s\S]*?<\/defs>/g, '');
14
+ const imagesOutside = (withoutDefs.match(/<image/g) || []).length;
15
+ console.log('Images outside <defs>:', imagesOutside);
16
+
17
+ // List all image IDs
18
+ const imagePattern = /<image[^>]*id="([^"]*)"[^>]*/g;
19
+ let m;
20
+ console.log('\nAll images:');
21
+ while ((m = imagePattern.exec(content)) !== null) {
22
+ const inDefs = defsMatch && defsMatch[0].includes(m[0]);
23
+ console.log(' -', m[1], inDefs ? '(in defs)' : '(outside)');
24
+ }
compare_svg.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Compare original SVG with roundtrip SVG"""
3
+
4
+ import re
5
+ from xml.etree import ElementTree as ET
6
+ from collections import defaultdict
7
+
8
+ def parse_svg(path):
9
+ """Parse SVG and extract element details"""
10
+ with open(path, 'r') as f:
11
+ content = f.read()
12
+
13
+ # Parse XML
14
+ try:
15
+ root = ET.fromstring(content)
16
+ except:
17
+ # Fallback to regex if XML parsing fails
18
+ return parse_svg_regex(content)
19
+
20
+ ns = {'svg': 'http://www.w3.org/2000/svg', 'xlink': 'http://www.w3.org/1999/xlink'}
21
+
22
+ elements = defaultdict(list)
23
+
24
+ def process_elem(elem, depth=0):
25
+ tag = elem.tag.split('}')[-1] # Remove namespace
26
+ attrs = dict(elem.attrib)
27
+
28
+ if tag in ['path', 'rect', 'circle', 'ellipse', 'polygon', 'polyline', 'line', 'image', 'text']:
29
+ info = {'tag': tag, 'depth': depth}
30
+
31
+ # Key attributes
32
+ for attr in ['fill', 'stroke', 'stroke-width', 'stroke-dasharray',
33
+ 'fill-opacity', 'stroke-opacity', 'opacity', 'transform']:
34
+ if attr in attrs:
35
+ info[attr] = attrs[attr][:30] if len(attrs.get(attr, '')) > 30 else attrs.get(attr)
36
+
37
+ # Special handling
38
+ if 'd' in attrs:
39
+ info['d_len'] = len(attrs['d'])
40
+ info['d_cmds'] = len(re.findall(r'[MLHVCSQTAZ]', attrs['d'], re.I))
41
+ if 'points' in attrs:
42
+ info['points_count'] = len(attrs['points'].split())
43
+ if '{http://www.w3.org/1999/xlink}href' in attrs:
44
+ href = attrs['{http://www.w3.org/1999/xlink}href']
45
+ info['href'] = href[:40] + '...' if len(href) > 40 else href
46
+ if 'href' in attrs:
47
+ href = attrs['href']
48
+ info['href'] = href[:40] + '...' if len(href) > 40 else href
49
+
50
+ elements[tag].append(info)
51
+
52
+ for child in elem:
53
+ process_elem(child, depth + 1)
54
+
55
+ process_elem(root)
56
+ return elements
57
+
58
+ def parse_svg_regex(content):
59
+ """Fallback regex parser"""
60
+ elements = defaultdict(list)
61
+
62
+ for tag in ['path', 'rect', 'circle', 'ellipse', 'polygon', 'polyline', 'line', 'image']:
63
+ pattern = rf'<{tag}\s+([^>]*)/?>'
64
+ for match in re.finditer(pattern, content, re.I):
65
+ attrs_str = match.group(1)
66
+ info = {'tag': tag}
67
+
68
+ for attr in ['fill', 'stroke', 'stroke-width', 'stroke-dasharray',
69
+ 'fill-opacity', 'stroke-opacity', 'opacity']:
70
+ m = re.search(rf'{attr}="([^"]*)"', attrs_str)
71
+ if m:
72
+ val = m.group(1)
73
+ info[attr] = val[:30] if len(val) > 30 else val
74
+
75
+ if tag == 'path':
76
+ m = re.search(r'd="([^"]*)"', attrs_str)
77
+ if m:
78
+ info['d_len'] = len(m.group(1))
79
+ info['d_cmds'] = len(re.findall(r'[MLHVCSQTAZ]', m.group(1), re.I))
80
+
81
+ if tag == 'polygon':
82
+ m = re.search(r'points="([^"]*)"', attrs_str)
83
+ if m:
84
+ info['points_count'] = len(m.group(1).split())
85
+
86
+ elements[tag].append(info)
87
+
88
+ return elements
89
+
90
+ def compare(orig_path, roundtrip_path):
91
+ print(f"Original: {orig_path}")
92
+ print(f"Roundtrip: {roundtrip_path}")
93
+ print("=" * 60)
94
+
95
+ orig = parse_svg(orig_path)
96
+ rt = parse_svg(roundtrip_path)
97
+
98
+ all_tags = set(orig.keys()) | set(rt.keys())
99
+
100
+ print("\n### ELEMENT COUNTS ###")
101
+ print(f"{'Tag':<12} {'Original':>10} {'Roundtrip':>10} {'Diff':>10}")
102
+ print("-" * 44)
103
+ for tag in sorted(all_tags):
104
+ o_count = len(orig.get(tag, []))
105
+ r_count = len(rt.get(tag, []))
106
+ diff = r_count - o_count
107
+ diff_str = f"+{diff}" if diff > 0 else str(diff)
108
+ print(f"{tag:<12} {o_count:>10} {r_count:>10} {diff_str:>10}")
109
+
110
+ print("\n### ORIGINAL ELEMENTS ###")
111
+ for tag in sorted(orig.keys()):
112
+ print(f"\n[{tag.upper()}] ({len(orig[tag])} elements)")
113
+ for i, elem in enumerate(orig[tag][:5]): # Show first 5
114
+ attrs = {k: v for k, v in elem.items() if k != 'tag' and k != 'depth'}
115
+ print(f" {i}: {attrs}")
116
+ if len(orig[tag]) > 5:
117
+ print(f" ... and {len(orig[tag]) - 5} more")
118
+
119
+ print("\n### ROUNDTRIP ELEMENTS ###")
120
+ for tag in sorted(rt.keys()):
121
+ print(f"\n[{tag.upper()}] ({len(rt[tag])} elements)")
122
+ for i, elem in enumerate(rt[tag][:5]): # Show first 5
123
+ attrs = {k: v for k, v in elem.items() if k != 'tag' and k != 'depth'}
124
+ print(f" {i}: {attrs}")
125
+ if len(rt[tag]) > 5:
126
+ print(f" ... and {len(rt[tag]) - 5} more")
127
+
128
+ # Attribute comparison
129
+ print("\n### ATTRIBUTE ANALYSIS ###")
130
+
131
+ # Check fills
132
+ orig_fills = [e.get('fill') for t in orig.values() for e in t if e.get('fill')]
133
+ rt_fills = [e.get('fill') for t in rt.values() for e in t if e.get('fill')]
134
+ print(f"\nFills - Original: {len(orig_fills)}, Roundtrip: {len(rt_fills)}")
135
+ print(f" Orig unique: {set(orig_fills)}")
136
+ print(f" RT unique: {set(rt_fills)}")
137
+
138
+ # Check strokes
139
+ orig_strokes = [e.get('stroke') for t in orig.values() for e in t if e.get('stroke')]
140
+ rt_strokes = [e.get('stroke') for t in rt.values() for e in t if e.get('stroke')]
141
+ print(f"\nStrokes - Original: {len(orig_strokes)}, Roundtrip: {len(rt_strokes)}")
142
+
143
+ # Check dash arrays
144
+ orig_dash = [e.get('stroke-dasharray') for t in orig.values() for e in t if e.get('stroke-dasharray')]
145
+ rt_dash = [e.get('stroke-dasharray') for t in rt.values() for e in t if e.get('stroke-dasharray')]
146
+ print(f"\nDash arrays - Original: {len(orig_dash)}, Roundtrip: {len(rt_dash)}")
147
+ if orig_dash:
148
+ print(f" Orig: {orig_dash[:3]}...")
149
+ if rt_dash:
150
+ print(f" RT: {rt_dash[:3]}...")
151
+
152
+ if __name__ == '__main__':
153
+ compare('test_page_1.svg', 'test_page_1.roundtrip.svg')
docs/COLORRM_DATA_FORMAT_V2.md ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ColorRM Data Format v2
2
+
3
+ This document describes the ColorRM JSON data format version 2, used for storing and rendering drawings.
4
+
5
+ ## Format Overview
6
+
7
+ ```json
8
+ {
9
+ "metadata": { ... },
10
+ "history": [ ... ]
11
+ }
12
+ ```
13
+
14
+ ## Metadata
15
+
16
+ ```json
17
+ {
18
+ "version": 2,
19
+ "sourceType": "svg",
20
+ "width": 800,
21
+ "height": 600,
22
+ "viewBox": { "x": 0, "y": 0, "w": 800, "h": 600 },
23
+ "elementCount": 42,
24
+ "statistics": {
25
+ "pen": 10,
26
+ "highlighter": 5,
27
+ "shape": 8,
28
+ "text": 15,
29
+ "image": 4
30
+ },
31
+ "backgroundCount": 1
32
+ }
33
+ ```
34
+
35
+ | Field | Type | Description |
36
+ |-------|------|-------------|
37
+ | `version` | number | Format version (2 for this spec) |
38
+ | `sourceType` | string | Source format ("svg", "native") |
39
+ | `width` | number | Document width in pixels |
40
+ | `height` | number | Document height in pixels |
41
+ | `viewBox` | object | SVG viewBox (if from SVG source) |
42
+ | `elementCount` | number | Total number of elements |
43
+ | `statistics` | object | Count per tool type |
44
+ | `backgroundCount` | number | Number of detected background images |
45
+
46
+ ## History Elements
47
+
48
+ The `history` array contains drawing elements in z-order (first = bottom, last = top).
49
+
50
+ ### Common Properties (all elements)
51
+
52
+ | Property | Type | Required | Description |
53
+ |----------|------|----------|-------------|
54
+ | `id` | string | Yes | Unique element ID |
55
+ | `lastMod` | number | Yes | Last modification timestamp |
56
+ | `tool` | string | Yes | Tool type (see below) |
57
+ | `deleted` | boolean | No | If true, element is hidden |
58
+
59
+ ### Tool Types
60
+
61
+ ---
62
+
63
+ ## 1. Pen (`tool: "pen"`)
64
+
65
+ Freehand strokes with solid color.
66
+
67
+ ```json
68
+ {
69
+ "id": "abc123",
70
+ "lastMod": 1704067200000,
71
+ "tool": "pen",
72
+ "pts": [
73
+ { "x": 100, "y": 200 },
74
+ { "x": 105, "y": 202 },
75
+ { "x": 110, "y": 205 }
76
+ ],
77
+ "color": "#000000",
78
+ "size": 2,
79
+ "opacity": 1,
80
+ "lineCap": "round",
81
+ "lineJoin": "round",
82
+ "rotation": 0,
83
+ "deleted": false
84
+ }
85
+ ```
86
+
87
+ | Property | Type | Default | Description |
88
+ |----------|------|---------|-------------|
89
+ | `pts` | array | Required | Array of {x, y} points |
90
+ | `color` | string | "#000000" | Stroke color (hex) |
91
+ | `size` | number | 2 | Stroke width in pixels |
92
+ | `opacity` | number | 1 | Opacity (0-1) |
93
+ | `lineCap` | string | "round" | Line cap style |
94
+ | `lineJoin` | string | "round" | Line join style |
95
+
96
+ ---
97
+
98
+ ## 2. Highlighter (`tool: "highlighter"`)
99
+
100
+ Semi-transparent strokes, typically wider.
101
+
102
+ ```json
103
+ {
104
+ "id": "def456",
105
+ "tool": "highlighter",
106
+ "pts": [...],
107
+ "color": "#FFFF00",
108
+ "size": 20,
109
+ "opacity": 0.4
110
+ }
111
+ ```
112
+
113
+ Same properties as `pen`, but typically:
114
+ - Larger `size` (10-40)
115
+ - Lower `opacity` (0.2-0.5)
116
+
117
+ ---
118
+
119
+ ## 3. Shape (`tool: "shape"`)
120
+
121
+ Geometric shapes with fill and border.
122
+
123
+ ```json
124
+ {
125
+ "id": "ghi789",
126
+ "tool": "shape",
127
+ "shapeType": "rectangle",
128
+ "x": 50,
129
+ "y": 100,
130
+ "w": 200,
131
+ "h": 150,
132
+ "fillColor": "#FFCC00",
133
+ "borderColor": "#000000",
134
+ "fillOpacity": 0.5,
135
+ "borderOpacity": 1,
136
+ "borderSize": 2,
137
+ "borderType": "solid",
138
+ "rotation": 0
139
+ }
140
+ ```
141
+
142
+ | Property | Type | Default | Description |
143
+ |----------|------|---------|-------------|
144
+ | `shapeType` | string | "rectangle" | Shape type (see below) |
145
+ | `x`, `y` | number | 0 | Position |
146
+ | `w`, `h` | number | 100 | Dimensions |
147
+ | `fillColor` | string | "transparent" | Fill color |
148
+ | `borderColor` | string | "#000000" | Border color |
149
+ | `fillOpacity` | number | 1 | Fill opacity |
150
+ | `borderOpacity` | number | 1 | Border opacity |
151
+ | `borderSize` | number | 2 | Border width |
152
+ | `borderType` | string | "solid" | Border style |
153
+ | `rotation` | number | 0 | Rotation in degrees |
154
+
155
+ ### Shape Types
156
+ - `rectangle` - Rectangular shape
157
+ - `ellipse` - Ellipse/oval
158
+ - `circle` - Perfect circle
159
+ - `triangle` - Triangle
160
+ - `arrow` - Arrow shape
161
+ - `line` - Straight line
162
+ - `polygon` - Custom polygon (uses `pts`)
163
+
164
+ ### Border Types
165
+ - `solid` - Solid line
166
+ - `dashed` - Dashed line (stroke-dasharray: "10,5")
167
+ - `dotted` - Dotted line (stroke-dasharray: "2,3")
168
+
169
+ ### Polygon Shape
170
+
171
+ When `shapeType: "polygon"`, uses normalized points:
172
+
173
+ ```json
174
+ {
175
+ "shapeType": "polygon",
176
+ "x": 100,
177
+ "y": 100,
178
+ "w": 200,
179
+ "h": 150,
180
+ "pts": [
181
+ { "x": 0.5, "y": 0 },
182
+ { "x": 1, "y": 1 },
183
+ { "x": 0, "y": 1 }
184
+ ]
185
+ }
186
+ ```
187
+
188
+ Points are normalized (0-1) relative to bounding box.
189
+
190
+ ---
191
+
192
+ ## 4. Text (`tool: "text"`)
193
+
194
+ Text elements with optional SVG data for precise rendering.
195
+
196
+ ```json
197
+ {
198
+ "id": "jkl012",
199
+ "tool": "text",
200
+ "text": "Hello World",
201
+ "x": 100,
202
+ "y": 200,
203
+ "size": 16,
204
+ "color": "#000000",
205
+ "fontFamily": "Calibri",
206
+ "w": 120,
207
+ "h": 20,
208
+ "rotation": 0,
209
+ "svgData": {
210
+ "transform": "matrix(1,0,0,1,0,792)",
211
+ "xmlSpace": "preserve",
212
+ "fontSize": "11.04",
213
+ "fontFamily": "Calibri",
214
+ "fill": "#000000",
215
+ "innerContent": "<tspan y=\"-745\" x=\"54 61 67\">Hi</tspan>"
216
+ }
217
+ }
218
+ ```
219
+
220
+ | Property | Type | Default | Description |
221
+ |----------|------|---------|-------------|
222
+ | `text` | string | "" | Plain text content |
223
+ | `x`, `y` | number | 0 | Position (transformed) |
224
+ | `size` | number | 16 | Font size |
225
+ | `color` | string | "#000000" | Text color |
226
+ | `fontFamily` | string | "sans-serif" | Font family |
227
+ | `w`, `h` | number | auto | Bounding box |
228
+ | `svgData` | object | null | Original SVG for precise rendering |
229
+
230
+ ### svgData Object (v2 feature)
231
+
232
+ For imported SVGs, preserves original formatting:
233
+
234
+ | Property | Description |
235
+ |----------|-------------|
236
+ | `transform` | SVG transform attribute |
237
+ | `xmlSpace` | xml:space attribute |
238
+ | `fontSize` | Original font-size |
239
+ | `fontFamily` | Original font-family |
240
+ | `fill` | Original fill color |
241
+ | `innerContent` | Original tspan/text content |
242
+
243
+ **Rendering Priority:**
244
+ 1. If `svgData` exists, render using original SVG structure
245
+ 2. Otherwise, render using simplified properties
246
+
247
+ ---
248
+
249
+ ## 5. Image (`tool: "image"`)
250
+
251
+ Embedded or referenced images with optional masking.
252
+
253
+ ```json
254
+ {
255
+ "id": "mno345",
256
+ "tool": "image",
257
+ "x": 0,
258
+ "y": 0,
259
+ "w": 800,
260
+ "h": 600,
261
+ "src": "data:image/jpeg;base64,...",
262
+ "rotation": 0,
263
+ "opacity": 1,
264
+ "isBackground": true,
265
+ "mask": {
266
+ "id": "mask_2",
267
+ "type": "luminance",
268
+ "src": "data:image/png;base64,..."
269
+ }
270
+ }
271
+ ```
272
+
273
+ | Property | Type | Default | Description |
274
+ |----------|------|---------|-------------|
275
+ | `x`, `y` | number | 0 | Position |
276
+ | `w`, `h` | number | 100 | Dimensions |
277
+ | `src` | string | Required | Image source (data URL or URL) |
278
+ | `rotation` | number | 0 | Rotation in degrees |
279
+ | `opacity` | number | 1 | Opacity |
280
+ | `isBackground` | boolean | false | True if full-document size |
281
+ | `mask` | object | null | Mask definition |
282
+
283
+ ### Mask Object (v2 feature)
284
+
285
+ ```json
286
+ {
287
+ "id": "mask_2",
288
+ "type": "luminance",
289
+ "src": "data:image/png;base64,..."
290
+ }
291
+ ```
292
+
293
+ | Property | Description |
294
+ |----------|-------------|
295
+ | `id` | Original mask ID from SVG |
296
+ | `type` | Mask type ("luminance" or "alpha") |
297
+ | `src` | Mask image (grayscale for luminance) |
298
+
299
+ **Luminance Mask Rendering:**
300
+ - White pixels = fully visible
301
+ - Black pixels = fully transparent
302
+ - Gray pixels = partial transparency
303
+
304
+ ---
305
+
306
+ ## 6. Group (`tool: "group"`)
307
+
308
+ Container for grouping elements.
309
+
310
+ ```json
311
+ {
312
+ "id": "pqr678",
313
+ "tool": "group",
314
+ "children": ["abc123", "def456"],
315
+ "x": 50,
316
+ "y": 50,
317
+ "w": 300,
318
+ "h": 200,
319
+ "rotation": 0,
320
+ "opacity": 1
321
+ }
322
+ ```
323
+
324
+ | Property | Type | Description |
325
+ |----------|------|-------------|
326
+ | `children` | array | Array of element IDs or inline elements |
327
+ | `x`, `y`, `w`, `h` | number | Bounding box |
328
+
329
+ ---
330
+
331
+ ## Backwards Compatibility
332
+
333
+ ### Reading Old Data (v1)
334
+
335
+ - If `metadata.version` is missing, assume v1
336
+ - v1 data lacks: `svgData`, `mask`, `isBackground`
337
+ - Renderer should handle missing properties with defaults
338
+
339
+ ### Version Detection
340
+
341
+ ```javascript
342
+ function getVersion(data) {
343
+ return data.metadata?.version || 1;
344
+ }
345
+
346
+ function isV2(data) {
347
+ return getVersion(data) >= 2;
348
+ }
349
+ ```
350
+
351
+ ---
352
+
353
+ ## Rendering Order
354
+
355
+ 1. **Background images** (`isBackground: true`) - render first
356
+ 2. **Regular elements** - render in history order (z-index)
357
+ 3. **Masked images** - apply mask during render
358
+
359
+ ---
360
+
361
+ ## Canvas Rendering Guidelines
362
+
363
+ ### Images with Masks
364
+
365
+ ```javascript
366
+ function renderMaskedImage(ctx, item) {
367
+ if (!item.mask) {
368
+ // Simple image render
369
+ ctx.drawImage(img, item.x, item.y, item.w, item.h);
370
+ return;
371
+ }
372
+
373
+ // Create offscreen canvas for masking
374
+ const offscreen = new OffscreenCanvas(item.w, item.h);
375
+ const offCtx = offscreen.getContext('2d');
376
+
377
+ // Draw content image
378
+ offCtx.drawImage(contentImg, 0, 0, item.w, item.h);
379
+
380
+ // Apply luminance mask using globalCompositeOperation
381
+ offCtx.globalCompositeOperation = 'destination-in';
382
+ offCtx.drawImage(maskImg, 0, 0, item.w, item.h);
383
+
384
+ // Draw result to main canvas
385
+ ctx.drawImage(offscreen, item.x, item.y);
386
+ }
387
+ ```
388
+
389
+ ### Text with svgData
390
+
391
+ For precise text rendering (imported SVGs):
392
+
393
+ ```javascript
394
+ function renderText(ctx, item) {
395
+ if (item.svgData) {
396
+ // Use SVG rendering for precise positioning
397
+ // Create inline SVG and render to canvas
398
+ const svgStr = `<svg>...</svg>`;
399
+ // Use canvg or similar library
400
+ } else {
401
+ // Simple canvas text
402
+ ctx.font = `${item.size}px ${item.fontFamily}`;
403
+ ctx.fillStyle = item.color;
404
+ ctx.fillText(item.text, item.x, item.y);
405
+ }
406
+ }
407
+ ```
408
+
409
+ ---
410
+
411
+ ## Migration Notes
412
+
413
+ ### From v1 to v2
414
+
415
+ No migration needed - v2 is backwards compatible. New properties are optional.
416
+
417
+ ### For New Imports
418
+
419
+ SVG imports will generate v2 data with:
420
+ - `svgData` for text elements with complex formatting
421
+ - `mask` for masked images
422
+ - `isBackground` for full-size images
public/color_rm.html CHANGED
@@ -812,7 +812,7 @@
812
  </div>
813
  </div>
814
 
815
- <input type="file" id="fileIn" accept="image/*,.pdf" style="display:none" multiple>
816
  <input type="file" id="splitViewFileIn" accept=".pdf" style="display:none">
817
 
818
  <!-- PDF Library Target Selection Modal -->
 
812
  </div>
813
  </div>
814
 
815
+ <input type="file" id="fileIn" accept="image/*,.pdf,.svg" style="display:none" multiple>
816
  <input type="file" id="splitViewFileIn" accept=".pdf" style="display:none">
817
 
818
  <!-- PDF Library Target Selection Modal -->
public/scripts/ColorRmApp.js CHANGED
@@ -6,6 +6,7 @@ import { ColorRmUI } from './modules/ColorRmUI.js';
6
  import { ColorRmSession } from './modules/ColorRmSession.js';
7
  import { ColorRmExport } from './modules/ColorRmExport.js';
8
  import { PerformanceManager } from './modules/ColorRmPerformance.js';
 
9
 
10
  export class ColorRmApp {
11
  constructor(config = {}) {
@@ -979,6 +980,9 @@ Object.assign(ColorRmApp.prototype, ColorRmUI);
979
  Object.assign(ColorRmApp.prototype, ColorRmSession);
980
  Object.assign(ColorRmApp.prototype, ColorRmExport);
981
 
 
 
 
982
  // Ensure the app instance has access to export methods for other modules
983
  ColorRmApp.prototype.sanitizeFilename = ColorRmExport.sanitizeFilename;
984
 
 
6
  import { ColorRmSession } from './modules/ColorRmSession.js';
7
  import { ColorRmExport } from './modules/ColorRmExport.js';
8
  import { PerformanceManager } from './modules/ColorRmPerformance.js';
9
+ import { ColorRmSvgImporter } from './modules/ColorRmSvgImporter.js';
10
 
11
  export class ColorRmApp {
12
  constructor(config = {}) {
 
980
  Object.assign(ColorRmApp.prototype, ColorRmSession);
981
  Object.assign(ColorRmApp.prototype, ColorRmExport);
982
 
983
+ // Make SVG importer available for importing SVG files
984
+ ColorRmApp.prototype.svgImporter = ColorRmSvgImporter;
985
+
986
  // Ensure the app instance has access to export methods for other modules
987
  ColorRmApp.prototype.sanitizeFilename = ColorRmExport.sanitizeFilename;
988
 
public/scripts/modules/ColorRmRenderer.js CHANGED
@@ -578,7 +578,13 @@ export const ColorRmRenderer = {
578
  renderObject(ctx, st, dx, dy) {
579
  if (!st) return; // Safety check
580
  ctx.save();
581
- if(st.rotation && st.tool!=='pen') {
 
 
 
 
 
 
582
  const cx = st.x + st.w/2 + dx;
583
  const cy = st.y + st.h/2 + dy;
584
  ctx.translate(cx, cy);
@@ -604,14 +610,63 @@ export const ColorRmRenderer = {
604
  return;
605
  }
606
 
 
 
 
 
 
 
 
607
  if(st.tool === 'text') {
608
- ctx.fillStyle = st.color;
609
- ctx.font = `${st.size}px sans-serif`;
610
- ctx.textBaseline = 'top';
611
- ctx.fillText(st.text, st.x, st.y);
 
 
 
 
 
 
612
  } else if(st.tool === 'shape') {
613
- ctx.strokeStyle = st.border; ctx.lineWidth = st.width;
614
- if(st.fill!=='transparent') { ctx.fillStyle=st.fill; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
615
  ctx.beginPath();
616
  const {x,y,w,h} = st;
617
  const cx = x + w/2, cy = y + h/2;
@@ -664,6 +719,14 @@ export const ColorRmRenderer = {
664
  else if(st.shapeType==='octagon') {
665
  this._drawPolygon(ctx, cx, cy, Math.min(rx, ry), 8);
666
  }
 
 
 
 
 
 
 
 
667
 
668
  if(st.fill!=='transparent' && !['line','arrow'].includes(st.shapeType)) ctx.fill();
669
  ctx.stroke();
@@ -671,10 +734,29 @@ export const ColorRmRenderer = {
671
  ctx.beginPath(); ctx.strokeStyle = '#f472b6'; ctx.setLineDash([2,2]); ctx.lineWidth=1;
672
  ctx.moveTo(x,y); ctx.lineTo(x+w, y+h); ctx.stroke();
673
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
674
  } else {
675
- // Safety check for points
676
  if (st.pts && st.pts.length > 0) {
677
- ctx.lineCap='round'; ctx.lineJoin='round'; ctx.lineWidth=st.size;
 
 
 
678
  ctx.strokeStyle = st.tool==='eraser' ? '#000' : st.color;
679
  if(st.tool==='eraser') ctx.globalCompositeOperation='destination-out';
680
  ctx.beginPath();
@@ -841,6 +923,121 @@ export const ColorRmRenderer = {
841
  return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
842
  },
843
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
844
  rgbToLab(r,g,b) {
845
  let r_=r/255, g_=g/255, b_=b/255;
846
  r_ = r_>0.04045 ? Math.pow((r_+0.055)/1.055, 2.4) : r_/12.92;
 
578
  renderObject(ctx, st, dx, dy) {
579
  if (!st) return; // Safety check
580
  ctx.save();
581
+
582
+ // Apply global opacity if set
583
+ if (st.opacity !== undefined && st.opacity < 1) {
584
+ ctx.globalAlpha = st.opacity;
585
+ }
586
+
587
+ if(st.rotation && st.tool!=='pen' && st.tool!=='highlighter') {
588
  const cx = st.x + st.w/2 + dx;
589
  const cy = st.y + st.h/2 + dy;
590
  ctx.translate(cx, cy);
 
610
  return;
611
  }
612
 
613
+ // Handle image objects (v2 feature)
614
+ if(st.tool === 'image') {
615
+ this._renderImage(ctx, st);
616
+ ctx.restore();
617
+ return;
618
+ }
619
+
620
  if(st.tool === 'text') {
621
+ // Check for svgData (v2 feature) for precise rendering
622
+ if (st.svgData && st.svgData.innerContent) {
623
+ this._renderSvgText(ctx, st);
624
+ } else {
625
+ // Fallback to simple canvas text
626
+ ctx.fillStyle = st.color || '#000000';
627
+ ctx.font = `${st.size || 16}px ${st.fontFamily || 'sans-serif'}`;
628
+ ctx.textBaseline = 'top';
629
+ ctx.fillText(st.text, st.x, st.y);
630
+ }
631
  } else if(st.tool === 'shape') {
632
+ // Handle border/stroke with separate opacity
633
+ const borderColor = st.border || '#000000';
634
+ if (st.borderOpacity !== undefined && st.borderOpacity < 1 && borderColor.startsWith('#')) {
635
+ const r = parseInt(borderColor.slice(1, 3), 16);
636
+ const g = parseInt(borderColor.slice(3, 5), 16);
637
+ const b = parseInt(borderColor.slice(5, 7), 16);
638
+ ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${st.borderOpacity})`;
639
+ } else {
640
+ ctx.strokeStyle = borderColor;
641
+ }
642
+ ctx.lineWidth = st.width;
643
+
644
+ // Handle border/stroke dash pattern
645
+ if (st.borderType === 'dashed') {
646
+ ctx.setLineDash([st.width * 4, st.width * 2]);
647
+ } else if (st.borderType === 'dotted') {
648
+ ctx.setLineDash([st.width, st.width * 2]);
649
+ } else {
650
+ ctx.setLineDash([]);
651
+ }
652
+
653
+ // Handle fill with separate opacity
654
+ if(st.fill!=='transparent') {
655
+ if (st.fillOpacity !== undefined && st.fillOpacity < 1) {
656
+ // Apply fill opacity by modifying the fill color
657
+ const fillColor = st.fill;
658
+ if (fillColor.startsWith('#')) {
659
+ const r = parseInt(fillColor.slice(1, 3), 16);
660
+ const g = parseInt(fillColor.slice(3, 5), 16);
661
+ const b = parseInt(fillColor.slice(5, 7), 16);
662
+ ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${st.fillOpacity})`;
663
+ } else {
664
+ ctx.fillStyle = fillColor;
665
+ }
666
+ } else {
667
+ ctx.fillStyle = st.fill;
668
+ }
669
+ }
670
  ctx.beginPath();
671
  const {x,y,w,h} = st;
672
  const cx = x + w/2, cy = y + h/2;
 
719
  else if(st.shapeType==='octagon') {
720
  this._drawPolygon(ctx, cx, cy, Math.min(rx, ry), 8);
721
  }
722
+ else if(st.shapeType==='polygon' && st.pts && st.pts.length >= 3) {
723
+ // Custom polygon with normalized points
724
+ ctx.moveTo(x + st.pts[0].x * w, y + st.pts[0].y * h);
725
+ for(let i = 1; i < st.pts.length; i++) {
726
+ ctx.lineTo(x + st.pts[i].x * w, y + st.pts[i].y * h);
727
+ }
728
+ ctx.closePath();
729
+ }
730
 
731
  if(st.fill!=='transparent' && !['line','arrow'].includes(st.shapeType)) ctx.fill();
732
  ctx.stroke();
 
734
  ctx.beginPath(); ctx.strokeStyle = '#f472b6'; ctx.setLineDash([2,2]); ctx.lineWidth=1;
735
  ctx.moveTo(x,y); ctx.lineTo(x+w, y+h); ctx.stroke();
736
  }
737
+ } else if(st.tool === 'highlighter') {
738
+ // Highlighter: semi-transparent wide stroke
739
+ if (st.pts && st.pts.length > 0) {
740
+ ctx.lineCap = st.lineCap || 'round';
741
+ ctx.lineJoin = st.lineJoin || 'round';
742
+ ctx.lineWidth = st.size || 20;
743
+ ctx.strokeStyle = st.color || '#ffff00';
744
+ // Highlighter gets additional opacity if not already set via globalAlpha
745
+ if (st.opacity === undefined) {
746
+ ctx.globalAlpha = 0.4;
747
+ }
748
+ ctx.beginPath();
749
+ ctx.moveTo(st.pts[0].x, st.pts[0].y);
750
+ for(let i=1; i<st.pts.length; i++) ctx.lineTo(st.pts[i].x, st.pts[i].y);
751
+ ctx.stroke();
752
+ }
753
  } else {
754
+ // Pen, eraser, and other stroke-based tools
755
  if (st.pts && st.pts.length > 0) {
756
+ // Use custom lineCap/lineJoin if provided, otherwise default to round
757
+ ctx.lineCap = st.lineCap || 'round';
758
+ ctx.lineJoin = st.lineJoin || 'round';
759
+ ctx.lineWidth = st.size;
760
  ctx.strokeStyle = st.tool==='eraser' ? '#000' : st.color;
761
  if(st.tool==='eraser') ctx.globalCompositeOperation='destination-out';
762
  ctx.beginPath();
 
923
  return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
924
  },
925
 
926
+ // Image cache for loaded images
927
+ _imageCache: new Map(),
928
+
929
+ // Helper to render images (v2 feature)
930
+ _renderImage(ctx, st) {
931
+ const cacheKey = st.src;
932
+ let img = this._imageCache.get(cacheKey);
933
+
934
+ if (!img) {
935
+ // Load image asynchronously
936
+ img = new Image();
937
+ img.onload = () => {
938
+ this._imageCache.set(cacheKey, img);
939
+ img._loaded = true;
940
+ // Trigger re-render
941
+ this.invalidateCache();
942
+ };
943
+ img.onerror = () => {
944
+ console.warn('Failed to load image:', st.src?.substring(0, 50));
945
+ };
946
+ img.src = st.src;
947
+ this._imageCache.set(cacheKey, img);
948
+ return; // Don't render until loaded
949
+ }
950
+
951
+ if (!img._loaded) return; // Still loading
952
+
953
+ // Check if image has a mask (v2 feature)
954
+ if (st.mask && st.mask.src) {
955
+ this._renderMaskedImage(ctx, st, img);
956
+ } else {
957
+ // Simple image render
958
+ ctx.drawImage(img, st.x, st.y, st.w, st.h);
959
+ }
960
+ },
961
+
962
+ // Helper to render masked images (v2 feature)
963
+ _renderMaskedImage(ctx, st, contentImg) {
964
+ const maskCacheKey = st.mask.src;
965
+ let maskImg = this._imageCache.get(maskCacheKey);
966
+
967
+ if (!maskImg) {
968
+ maskImg = new Image();
969
+ maskImg.onload = () => {
970
+ this._imageCache.set(maskCacheKey, maskImg);
971
+ maskImg._loaded = true;
972
+ this.invalidateCache();
973
+ };
974
+ maskImg.src = st.mask.src;
975
+ this._imageCache.set(maskCacheKey, maskImg);
976
+ return;
977
+ }
978
+
979
+ if (!maskImg._loaded) return;
980
+
981
+ // Create offscreen canvas for masking
982
+ const offscreen = document.createElement('canvas');
983
+ offscreen.width = st.w;
984
+ offscreen.height = st.h;
985
+ const offCtx = offscreen.getContext('2d');
986
+
987
+ // Draw content image
988
+ offCtx.drawImage(contentImg, 0, 0, st.w, st.h);
989
+
990
+ // Apply luminance mask using globalCompositeOperation
991
+ // In luminance mask: white = visible, black = transparent
992
+ offCtx.globalCompositeOperation = 'destination-in';
993
+ offCtx.drawImage(maskImg, 0, 0, st.w, st.h);
994
+
995
+ // Draw result to main canvas
996
+ ctx.drawImage(offscreen, st.x, st.y);
997
+ },
998
+
999
+ // Helper to render text with SVG data (v2 feature)
1000
+ _renderSvgText(ctx, st) {
1001
+ // For now, fall back to simple text rendering
1002
+ // Full SVG text rendering would require parsing transforms and tspans
1003
+ // This is a simplified version that handles common cases
1004
+
1005
+ const svg = st.svgData;
1006
+ ctx.fillStyle = svg.fill || st.color || '#000000';
1007
+ ctx.font = `${svg.fontSize || st.size || 16}px ${svg.fontFamily || st.fontFamily || 'sans-serif'}`;
1008
+ ctx.textBaseline = 'top';
1009
+
1010
+ // If we have a matrix transform, try to apply it
1011
+ if (svg.transform && svg.transform.startsWith('matrix(')) {
1012
+ const match = svg.transform.match(/matrix\(([^)]+)\)/);
1013
+ if (match) {
1014
+ const values = match[1].split(/[\s,]+/).map(parseFloat);
1015
+ if (values.length >= 6) {
1016
+ // Save current transform
1017
+ ctx.save();
1018
+ // Apply matrix transform
1019
+ ctx.transform(values[0], values[1], values[2], values[3], values[4], values[5]);
1020
+
1021
+ // Extract text from innerContent (strip tspan tags)
1022
+ const text = svg.innerContent.replace(/<[^>]*>/g, '').trim();
1023
+
1024
+ // Get y position from tspan
1025
+ const yMatch = svg.innerContent.match(/y="([^"]+)"/);
1026
+ const xMatch = svg.innerContent.match(/x="([^"]+)"/);
1027
+ const y = yMatch ? parseFloat(yMatch[1]) : 0;
1028
+ const x = xMatch ? parseFloat(xMatch[1].split(/\s+/)[0]) : 0;
1029
+
1030
+ ctx.fillText(text, x, y);
1031
+ ctx.restore();
1032
+ return;
1033
+ }
1034
+ }
1035
+ }
1036
+
1037
+ // Fallback: use computed position
1038
+ ctx.fillText(st.text, st.x, st.y);
1039
+ },
1040
+
1041
  rgbToLab(r,g,b) {
1042
  let r_=r/255, g_=g/255, b_=b/255;
1043
  r_ = r_>0.04045 ? Math.pow((r_+0.055)/1.055, 2.4) : r_/12.92;
public/scripts/modules/ColorRmSession.js CHANGED
@@ -1027,6 +1027,50 @@ export const ColorRmSession = {
1027
  console.error(e);
1028
  this.ui.showToast("Failed to load PDF");
1029
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1030
  } else {
1031
  const pageId = this._generatePageId('user');
1032
  const pageObj = {
 
1027
  console.error(e);
1028
  this.ui.showToast("Failed to load PDF");
1029
  }
1030
+ } else if (f.type === 'image/svg+xml' || f.name?.toLowerCase().endsWith('.svg')) {
1031
+ // SVG import - convert to ColorRM format
1032
+ try {
1033
+ const svgContent = await f.text();
1034
+ const result = await this.svgImporter.importSvg(svgContent);
1035
+
1036
+ if (result.metadata.error) {
1037
+ this.ui.showToast(`SVG import error: ${result.metadata.error}`);
1038
+ } else {
1039
+ // Create a blank canvas as the background based on SVG dimensions
1040
+ const svgWidth = result.metadata.width || 800;
1041
+ const svgHeight = result.metadata.height || 600;
1042
+
1043
+ const cvs = document.createElement('canvas');
1044
+ cvs.width = svgWidth;
1045
+ cvs.height = svgHeight;
1046
+ const ctx = cvs.getContext('2d');
1047
+ ctx.fillStyle = '#ffffff';
1048
+ ctx.fillRect(0, 0, svgWidth, svgHeight);
1049
+
1050
+ const blob = await new Promise(r => cvs.toBlob(r, 'image/png'));
1051
+
1052
+ const pageId = this._generatePageId('user');
1053
+ const pageObj = {
1054
+ id: `${this.state.sessionId}_${idx}`,
1055
+ sessionId: this.state.sessionId,
1056
+ pageIndex: idx,
1057
+ pageId: pageId,
1058
+ blob: blob,
1059
+ history: result.history,
1060
+ svgMetadata: result.metadata
1061
+ };
1062
+ await this.dbPut('pages', pageObj);
1063
+ this.state.images.push(pageObj);
1064
+ if (this.state.images.length === 1) await this.loadPage(0, false);
1065
+ if (this.state.activeSideTab === 'pages') this.renderPageSidebar();
1066
+ idx++;
1067
+
1068
+ console.log(`SVG imported: ${result.metadata.elementCount} elements`);
1069
+ }
1070
+ } catch (e) {
1071
+ console.error('SVG import error:', e);
1072
+ this.ui.showToast("Failed to import SVG");
1073
+ }
1074
  } else {
1075
  const pageId = this._generatePageId('user');
1076
  const pageObj = {
public/scripts/modules/ColorRmSvgImporter.js ADDED
@@ -0,0 +1,759 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ColorRmSvgImporter - Browser-compatible SVG to ColorRM converter
3
+ *
4
+ * Converts SVG files to ColorRM format for import into the drawing app.
5
+ * Supports: paths, shapes, text, images, masks, transforms
6
+ *
7
+ * Usage:
8
+ * import { ColorRmSvgImporter } from './modules/ColorRmSvgImporter.js';
9
+ * const result = await ColorRmSvgImporter.importSvg(svgString);
10
+ * // result = { metadata: {...}, history: [...] }
11
+ */
12
+
13
+ export const ColorRmSvgImporter = {
14
+ // Configuration
15
+ CONFIG: {
16
+ defaultStroke: '#000000',
17
+ defaultFill: 'transparent',
18
+ base64WarnSize: 500000
19
+ },
20
+
21
+ // Counter for unique IDs
22
+ _idCounter: 0,
23
+
24
+ // Mask definitions storage
25
+ _maskDefinitions: {},
26
+
27
+ /**
28
+ * Generate unique ID
29
+ */
30
+ generateId() {
31
+ return `svg_${Date.now()}_${++this._idCounter}`;
32
+ },
33
+
34
+ /**
35
+ * Parse color string to hex
36
+ */
37
+ parseColor(colorStr) {
38
+ if (!colorStr || colorStr === 'none') return null;
39
+ if (colorStr.startsWith('#')) return colorStr;
40
+ if (colorStr.startsWith('rgb')) {
41
+ const match = colorStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
42
+ if (match) {
43
+ const r = parseInt(match[1]).toString(16).padStart(2, '0');
44
+ const g = parseInt(match[2]).toString(16).padStart(2, '0');
45
+ const b = parseInt(match[3]).toString(16).padStart(2, '0');
46
+ return `#${r}${g}${b}`;
47
+ }
48
+ }
49
+ // Named colors
50
+ const colors = {
51
+ black: '#000000', white: '#ffffff', red: '#ff0000',
52
+ green: '#00ff00', blue: '#0000ff', yellow: '#ffff00',
53
+ orange: '#ffa500', purple: '#800080', pink: '#ffc0cb'
54
+ };
55
+ return colors[colorStr.toLowerCase()] || colorStr;
56
+ },
57
+
58
+ /**
59
+ * Parse opacity value
60
+ */
61
+ parseOpacity(val) {
62
+ if (val === undefined || val === null || val === '') return 1;
63
+ const num = parseFloat(val);
64
+ return isNaN(num) ? 1 : Math.max(0, Math.min(1, num));
65
+ },
66
+
67
+ /**
68
+ * Parse transform attribute to matrix
69
+ */
70
+ parseTransform(transformStr) {
71
+ if (!transformStr) return [1, 0, 0, 1, 0, 0];
72
+
73
+ let matrix = [1, 0, 0, 1, 0, 0];
74
+ const transforms = transformStr.match(/(\w+)\(([^)]+)\)/g) || [];
75
+
76
+ for (const t of transforms) {
77
+ const match = t.match(/(\w+)\(([^)]+)\)/);
78
+ if (!match) continue;
79
+
80
+ const type = match[1];
81
+ const values = match[2].split(/[\s,]+/).map(parseFloat);
82
+
83
+ let m;
84
+ switch (type) {
85
+ case 'matrix':
86
+ m = values.length >= 6 ? values.slice(0, 6) : [1, 0, 0, 1, 0, 0];
87
+ break;
88
+ case 'translate':
89
+ m = [1, 0, 0, 1, values[0] || 0, values[1] || 0];
90
+ break;
91
+ case 'scale':
92
+ const sx = values[0] || 1;
93
+ const sy = values.length > 1 ? values[1] : sx;
94
+ m = [sx, 0, 0, sy, 0, 0];
95
+ break;
96
+ case 'rotate':
97
+ const angle = (values[0] || 0) * Math.PI / 180;
98
+ const cos = Math.cos(angle);
99
+ const sin = Math.sin(angle);
100
+ if (values.length === 3) {
101
+ const cx = values[1], cy = values[2];
102
+ m = [cos, sin, -sin, cos, cx - cx * cos + cy * sin, cy - cx * sin - cy * cos];
103
+ } else {
104
+ m = [cos, sin, -sin, cos, 0, 0];
105
+ }
106
+ break;
107
+ default:
108
+ continue;
109
+ }
110
+ matrix = this.multiplyMatrix(matrix, m);
111
+ }
112
+
113
+ return matrix;
114
+ },
115
+
116
+ /**
117
+ * Multiply two transformation matrices
118
+ */
119
+ multiplyMatrix(a, b) {
120
+ return [
121
+ a[0] * b[0] + a[2] * b[1],
122
+ a[1] * b[0] + a[3] * b[1],
123
+ a[0] * b[2] + a[2] * b[3],
124
+ a[1] * b[2] + a[3] * b[3],
125
+ a[0] * b[4] + a[2] * b[5] + a[4],
126
+ a[1] * b[4] + a[3] * b[5] + a[5]
127
+ ];
128
+ },
129
+
130
+ /**
131
+ * Transform a point using a matrix
132
+ */
133
+ transformPoint(x, y, matrix) {
134
+ return {
135
+ x: matrix[0] * x + matrix[2] * y + matrix[4],
136
+ y: matrix[1] * x + matrix[3] * y + matrix[5]
137
+ };
138
+ },
139
+
140
+ /**
141
+ * Get scale factor from matrix
142
+ */
143
+ getMatrixScale(matrix) {
144
+ return Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1]);
145
+ },
146
+
147
+ /**
148
+ * Parse SVG path d attribute to points
149
+ */
150
+ parsePathD(d) {
151
+ if (!d) return [];
152
+
153
+ const points = [];
154
+ let x = 0, y = 0;
155
+ let startX = 0, startY = 0;
156
+
157
+ const commands = d.match(/[MmLlHhVvCcSsQqTtAaZz][^MmLlHhVvCcSsQqTtAaZz]*/g) || [];
158
+
159
+ for (const cmd of commands) {
160
+ const type = cmd[0];
161
+ const args = cmd.slice(1).trim().split(/[\s,]+/).map(parseFloat).filter(n => !isNaN(n));
162
+
163
+ switch (type) {
164
+ case 'M':
165
+ x = args[0]; y = args[1];
166
+ startX = x; startY = y;
167
+ points.push({ x, y });
168
+ for (let i = 2; i < args.length; i += 2) {
169
+ x = args[i]; y = args[i + 1];
170
+ points.push({ x, y });
171
+ }
172
+ break;
173
+ case 'm':
174
+ x += args[0]; y += args[1];
175
+ startX = x; startY = y;
176
+ points.push({ x, y });
177
+ for (let i = 2; i < args.length; i += 2) {
178
+ x += args[i]; y += args[i + 1];
179
+ points.push({ x, y });
180
+ }
181
+ break;
182
+ case 'L':
183
+ for (let i = 0; i < args.length; i += 2) {
184
+ x = args[i]; y = args[i + 1];
185
+ points.push({ x, y });
186
+ }
187
+ break;
188
+ case 'l':
189
+ for (let i = 0; i < args.length; i += 2) {
190
+ x += args[i]; y += args[i + 1];
191
+ points.push({ x, y });
192
+ }
193
+ break;
194
+ case 'H':
195
+ for (const arg of args) {
196
+ x = arg;
197
+ points.push({ x, y });
198
+ }
199
+ break;
200
+ case 'h':
201
+ for (const arg of args) {
202
+ x += arg;
203
+ points.push({ x, y });
204
+ }
205
+ break;
206
+ case 'V':
207
+ for (const arg of args) {
208
+ y = arg;
209
+ points.push({ x, y });
210
+ }
211
+ break;
212
+ case 'v':
213
+ for (const arg of args) {
214
+ y += arg;
215
+ points.push({ x, y });
216
+ }
217
+ break;
218
+ case 'C':
219
+ for (let i = 0; i < args.length; i += 6) {
220
+ // Cubic bezier - sample points
221
+ const x1 = args[i], y1 = args[i + 1];
222
+ const x2 = args[i + 2], y2 = args[i + 3];
223
+ const x3 = args[i + 4], y3 = args[i + 5];
224
+ for (let t = 0.25; t <= 1; t += 0.25) {
225
+ const px = Math.pow(1 - t, 3) * x + 3 * Math.pow(1 - t, 2) * t * x1 +
226
+ 3 * (1 - t) * t * t * x2 + t * t * t * x3;
227
+ const py = Math.pow(1 - t, 3) * y + 3 * Math.pow(1 - t, 2) * t * y1 +
228
+ 3 * (1 - t) * t * t * y2 + t * t * t * y3;
229
+ points.push({ x: px, y: py });
230
+ }
231
+ x = x3; y = y3;
232
+ }
233
+ break;
234
+ case 'c':
235
+ for (let i = 0; i < args.length; i += 6) {
236
+ const x1 = x + args[i], y1 = y + args[i + 1];
237
+ const x2 = x + args[i + 2], y2 = y + args[i + 3];
238
+ const x3 = x + args[i + 4], y3 = y + args[i + 5];
239
+ for (let t = 0.25; t <= 1; t += 0.25) {
240
+ const px = Math.pow(1 - t, 3) * x + 3 * Math.pow(1 - t, 2) * t * x1 +
241
+ 3 * (1 - t) * t * t * x2 + t * t * t * x3;
242
+ const py = Math.pow(1 - t, 3) * y + 3 * Math.pow(1 - t, 2) * t * y1 +
243
+ 3 * (1 - t) * t * t * y2 + t * t * t * y3;
244
+ points.push({ x: px, y: py });
245
+ }
246
+ x = x3; y = y3;
247
+ }
248
+ break;
249
+ case 'Q':
250
+ for (let i = 0; i < args.length; i += 4) {
251
+ const x1 = args[i], y1 = args[i + 1];
252
+ const x2 = args[i + 2], y2 = args[i + 3];
253
+ for (let t = 0.33; t <= 1; t += 0.33) {
254
+ const px = Math.pow(1 - t, 2) * x + 2 * (1 - t) * t * x1 + t * t * x2;
255
+ const py = Math.pow(1 - t, 2) * y + 2 * (1 - t) * t * y1 + t * t * y2;
256
+ points.push({ x: px, y: py });
257
+ }
258
+ x = x2; y = y2;
259
+ }
260
+ break;
261
+ case 'q':
262
+ for (let i = 0; i < args.length; i += 4) {
263
+ const x1 = x + args[i], y1 = y + args[i + 1];
264
+ const x2 = x + args[i + 2], y2 = y + args[i + 3];
265
+ for (let t = 0.33; t <= 1; t += 0.33) {
266
+ const px = Math.pow(1 - t, 2) * x + 2 * (1 - t) * t * x1 + t * t * x2;
267
+ const py = Math.pow(1 - t, 2) * y + 2 * (1 - t) * t * y1 + t * t * y2;
268
+ points.push({ x: px, y: py });
269
+ }
270
+ x = x2; y = y2;
271
+ }
272
+ break;
273
+ case 'Z':
274
+ case 'z':
275
+ if (startX !== x || startY !== y) {
276
+ points.push({ x: startX, y: startY });
277
+ }
278
+ x = startX; y = startY;
279
+ break;
280
+ }
281
+ }
282
+
283
+ return points;
284
+ },
285
+
286
+ /**
287
+ * Main import function
288
+ * @param {string} svgContent - SVG string content
289
+ * @returns {Promise<{metadata: Object, history: Array}>}
290
+ */
291
+ async importSvg(svgContent) {
292
+ this._idCounter = 0;
293
+ this._maskDefinitions = {};
294
+
295
+ const history = [];
296
+ const warnings = [];
297
+
298
+ // Parse SVG
299
+ const parser = new DOMParser();
300
+ const doc = parser.parseFromString(svgContent, 'image/svg+xml');
301
+ const svg = doc.querySelector('svg');
302
+
303
+ if (!svg) {
304
+ return { metadata: { error: 'No SVG element found' }, history: [] };
305
+ }
306
+
307
+ // Get dimensions
308
+ const viewBox = svg.getAttribute('viewBox');
309
+ let svgWidth = parseFloat(svg.getAttribute('width')) || 800;
310
+ let svgHeight = parseFloat(svg.getAttribute('height')) || 600;
311
+ let viewBoxData = null;
312
+
313
+ if (viewBox) {
314
+ const parts = viewBox.split(/[\s,]+/).map(parseFloat);
315
+ viewBoxData = { x: parts[0], y: parts[1], w: parts[2], h: parts[3] };
316
+ svgWidth = svgWidth || viewBoxData.w;
317
+ svgHeight = svgHeight || viewBoxData.h;
318
+ }
319
+
320
+ // First pass: collect mask definitions
321
+ this._collectMasks(svg);
322
+
323
+ // Second pass: collect all elements
324
+ const elements = this._collectElements(svg, [1, 0, 0, 1, 0, 0], null);
325
+
326
+ // Convert elements
327
+ for (const elem of elements) {
328
+ try {
329
+ const item = this._convertElement(elem, svgWidth, svgHeight);
330
+ if (item) history.push(item);
331
+ } catch (e) {
332
+ warnings.push(`${elem.tagName} conversion error: ${e.message}`);
333
+ }
334
+ }
335
+
336
+ // Detect background images
337
+ const TOLERANCE = 0.05;
338
+ for (const item of history) {
339
+ if (item.tool === 'image') {
340
+ const widthRatio = item.w / svgWidth;
341
+ const heightRatio = item.h / svgHeight;
342
+ const xNearZero = Math.abs(item.x) < svgWidth * TOLERANCE;
343
+ const yNearZero = Math.abs(item.y) < svgHeight * TOLERANCE;
344
+ const widthMatch = widthRatio > (1 - TOLERANCE) && widthRatio < (1 + TOLERANCE);
345
+ const heightMatch = heightRatio > (1 - TOLERANCE) && heightRatio < (1 + TOLERANCE);
346
+
347
+ if (xNearZero && yNearZero && widthMatch && heightMatch) {
348
+ item.isBackground = true;
349
+ }
350
+ }
351
+ }
352
+
353
+ // Statistics
354
+ const stats = { pen: 0, highlighter: 0, shape: 0, text: 0, image: 0 };
355
+ history.forEach(item => { if (stats[item.tool] !== undefined) stats[item.tool]++; });
356
+
357
+ const backgroundCount = history.filter(i => i.isBackground).length;
358
+
359
+ return {
360
+ metadata: {
361
+ version: 2,
362
+ sourceType: 'svg',
363
+ width: svgWidth,
364
+ height: svgHeight,
365
+ viewBox: viewBoxData,
366
+ elementCount: history.length,
367
+ statistics: stats,
368
+ backgroundCount
369
+ },
370
+ history
371
+ };
372
+ },
373
+
374
+ /**
375
+ * Collect mask definitions from SVG
376
+ */
377
+ _collectMasks(svg) {
378
+ const masks = svg.querySelectorAll('mask');
379
+ for (const mask of masks) {
380
+ const id = mask.getAttribute('id');
381
+ if (!id) continue;
382
+
383
+ const image = mask.querySelector('image');
384
+ if (image) {
385
+ const href = image.getAttribute('href') || image.getAttribute('xlink:href');
386
+ if (href) {
387
+ this._maskDefinitions[id] = {
388
+ imageData: href,
389
+ type: 'luminance'
390
+ };
391
+ }
392
+ }
393
+ }
394
+ },
395
+
396
+ /**
397
+ * Recursively collect elements from SVG
398
+ */
399
+ _collectElements(parent, transform, maskId) {
400
+ const elements = [];
401
+ const drawableTags = ['path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'text', 'image'];
402
+
403
+ for (const child of parent.children) {
404
+ const tagName = child.tagName.toLowerCase();
405
+
406
+ // Skip defs, mask, clippath, style, etc.
407
+ if (['defs', 'mask', 'clippath', 'style', 'script', 'title', 'desc'].includes(tagName)) {
408
+ continue;
409
+ }
410
+
411
+ // Parse element transform
412
+ const elemTransform = this.parseTransform(child.getAttribute('transform'));
413
+ const combinedTransform = this.multiplyMatrix(transform, elemTransform);
414
+
415
+ // Check for mask on groups
416
+ let currentMaskId = maskId;
417
+ const maskAttr = child.getAttribute('mask');
418
+ if (maskAttr) {
419
+ const match = maskAttr.match(/url\(#([^)]+)\)/);
420
+ if (match) currentMaskId = match[1];
421
+ }
422
+
423
+ if (tagName === 'g' || tagName === 'svg') {
424
+ // Recurse into groups
425
+ const childElements = this._collectElements(child, combinedTransform, currentMaskId);
426
+ elements.push(...childElements);
427
+ } else if (drawableTags.includes(tagName)) {
428
+ elements.push({
429
+ element: child,
430
+ tagName,
431
+ transform: combinedTransform,
432
+ maskId: currentMaskId
433
+ });
434
+ }
435
+ }
436
+
437
+ return elements;
438
+ },
439
+
440
+ /**
441
+ * Convert a single element to ColorRM format
442
+ */
443
+ _convertElement(elemData, docWidth, docHeight) {
444
+ const { element, tagName, transform, maskId } = elemData;
445
+
446
+ switch (tagName) {
447
+ case 'path':
448
+ return this._convertPath(element, transform);
449
+ case 'rect':
450
+ return this._convertRect(element, transform);
451
+ case 'circle':
452
+ case 'ellipse':
453
+ return this._convertEllipse(element, transform);
454
+ case 'line':
455
+ return this._convertLine(element, transform);
456
+ case 'polyline':
457
+ case 'polygon':
458
+ return this._convertPolyline(element, transform, tagName === 'polygon');
459
+ case 'text':
460
+ return this._convertText(element, transform);
461
+ case 'image':
462
+ return this._convertImage(element, transform, maskId);
463
+ default:
464
+ return null;
465
+ }
466
+ },
467
+
468
+ /**
469
+ * Get attribute with style fallback
470
+ */
471
+ _getAttr(element, name) {
472
+ let val = element.getAttribute(name);
473
+ if (val) return val;
474
+
475
+ // Check style attribute
476
+ const style = element.getAttribute('style');
477
+ if (style) {
478
+ const match = style.match(new RegExp(`${name}:\\s*([^;]+)`));
479
+ if (match) return match[1].trim();
480
+ }
481
+
482
+ return null;
483
+ },
484
+
485
+ /**
486
+ * Convert path element
487
+ */
488
+ _convertPath(element, transform) {
489
+ const d = element.getAttribute('d');
490
+ if (!d) return null;
491
+
492
+ const pts = this.parsePathD(d);
493
+ if (pts.length < 2) return null;
494
+
495
+ // Transform points
496
+ const transformedPts = pts.map(p => this.transformPoint(p.x, p.y, transform));
497
+
498
+ const stroke = this._getAttr(element, 'stroke');
499
+ const fill = this._getAttr(element, 'fill');
500
+ const strokeWidth = parseFloat(this._getAttr(element, 'stroke-width')) || 1;
501
+ const opacity = this.parseOpacity(this._getAttr(element, 'opacity'));
502
+ const strokeOpacity = this.parseOpacity(this._getAttr(element, 'stroke-opacity'));
503
+
504
+ // Determine tool type
505
+ let tool = 'pen';
506
+ const effectiveOpacity = opacity * strokeOpacity;
507
+ if (effectiveOpacity < 0.6 && effectiveOpacity > 0.1) {
508
+ tool = 'highlighter';
509
+ }
510
+
511
+ return {
512
+ id: this.generateId(),
513
+ lastMod: Date.now(),
514
+ tool,
515
+ pts: transformedPts,
516
+ color: this.parseColor(stroke) || this.CONFIG.defaultStroke,
517
+ size: strokeWidth * this.getMatrixScale(transform),
518
+ opacity: effectiveOpacity < 1 ? effectiveOpacity : undefined,
519
+ lineCap: this._getAttr(element, 'stroke-linecap') || 'round',
520
+ lineJoin: this._getAttr(element, 'stroke-linejoin') || 'round',
521
+ deleted: false
522
+ };
523
+ },
524
+
525
+ /**
526
+ * Convert rect element
527
+ */
528
+ _convertRect(element, transform) {
529
+ const x = parseFloat(element.getAttribute('x')) || 0;
530
+ const y = parseFloat(element.getAttribute('y')) || 0;
531
+ const w = parseFloat(element.getAttribute('width')) || 0;
532
+ const h = parseFloat(element.getAttribute('height')) || 0;
533
+
534
+ if (w === 0 || h === 0) return null;
535
+
536
+ const topLeft = this.transformPoint(x, y, transform);
537
+ const bottomRight = this.transformPoint(x + w, y + h, transform);
538
+
539
+ return {
540
+ id: this.generateId(),
541
+ lastMod: Date.now(),
542
+ tool: 'shape',
543
+ shapeType: 'rectangle',
544
+ x: topLeft.x,
545
+ y: topLeft.y,
546
+ w: bottomRight.x - topLeft.x,
547
+ h: bottomRight.y - topLeft.y,
548
+ fill: this.parseColor(this._getAttr(element, 'fill')) || 'transparent',
549
+ border: this.parseColor(this._getAttr(element, 'stroke')) || '#000000',
550
+ width: parseFloat(this._getAttr(element, 'stroke-width')) || 1,
551
+ fillOpacity: this.parseOpacity(this._getAttr(element, 'fill-opacity')),
552
+ borderOpacity: this.parseOpacity(this._getAttr(element, 'stroke-opacity')),
553
+ deleted: false
554
+ };
555
+ },
556
+
557
+ /**
558
+ * Convert circle/ellipse element
559
+ */
560
+ _convertEllipse(element, transform) {
561
+ let cx, cy, rx, ry;
562
+
563
+ if (element.tagName.toLowerCase() === 'circle') {
564
+ cx = parseFloat(element.getAttribute('cx')) || 0;
565
+ cy = parseFloat(element.getAttribute('cy')) || 0;
566
+ rx = ry = parseFloat(element.getAttribute('r')) || 0;
567
+ } else {
568
+ cx = parseFloat(element.getAttribute('cx')) || 0;
569
+ cy = parseFloat(element.getAttribute('cy')) || 0;
570
+ rx = parseFloat(element.getAttribute('rx')) || 0;
571
+ ry = parseFloat(element.getAttribute('ry')) || 0;
572
+ }
573
+
574
+ if (rx === 0 || ry === 0) return null;
575
+
576
+ const topLeft = this.transformPoint(cx - rx, cy - ry, transform);
577
+ const bottomRight = this.transformPoint(cx + rx, cy + ry, transform);
578
+
579
+ return {
580
+ id: this.generateId(),
581
+ lastMod: Date.now(),
582
+ tool: 'shape',
583
+ shapeType: 'circle',
584
+ x: topLeft.x,
585
+ y: topLeft.y,
586
+ w: bottomRight.x - topLeft.x,
587
+ h: bottomRight.y - topLeft.y,
588
+ fill: this.parseColor(this._getAttr(element, 'fill')) || 'transparent',
589
+ border: this.parseColor(this._getAttr(element, 'stroke')) || '#000000',
590
+ width: parseFloat(this._getAttr(element, 'stroke-width')) || 1,
591
+ deleted: false
592
+ };
593
+ },
594
+
595
+ /**
596
+ * Convert line element
597
+ */
598
+ _convertLine(element, transform) {
599
+ const x1 = parseFloat(element.getAttribute('x1')) || 0;
600
+ const y1 = parseFloat(element.getAttribute('y1')) || 0;
601
+ const x2 = parseFloat(element.getAttribute('x2')) || 0;
602
+ const y2 = parseFloat(element.getAttribute('y2')) || 0;
603
+
604
+ const p1 = this.transformPoint(x1, y1, transform);
605
+ const p2 = this.transformPoint(x2, y2, transform);
606
+
607
+ return {
608
+ id: this.generateId(),
609
+ lastMod: Date.now(),
610
+ tool: 'pen',
611
+ pts: [p1, p2],
612
+ color: this.parseColor(this._getAttr(element, 'stroke')) || this.CONFIG.defaultStroke,
613
+ size: parseFloat(this._getAttr(element, 'stroke-width')) || 1,
614
+ deleted: false
615
+ };
616
+ },
617
+
618
+ /**
619
+ * Convert polyline/polygon element
620
+ */
621
+ _convertPolyline(element, transform, isClosed) {
622
+ const pointsStr = element.getAttribute('points');
623
+ if (!pointsStr) return null;
624
+
625
+ const coords = pointsStr.trim().split(/[\s,]+/).map(parseFloat);
626
+ const pts = [];
627
+ for (let i = 0; i < coords.length - 1; i += 2) {
628
+ const p = this.transformPoint(coords[i], coords[i + 1], transform);
629
+ pts.push(p);
630
+ }
631
+
632
+ if (pts.length < 2) return null;
633
+
634
+ if (isClosed && (pts[0].x !== pts[pts.length - 1].x || pts[0].y !== pts[pts.length - 1].y)) {
635
+ pts.push({ ...pts[0] });
636
+ }
637
+
638
+ return {
639
+ id: this.generateId(),
640
+ lastMod: Date.now(),
641
+ tool: 'pen',
642
+ pts,
643
+ color: this.parseColor(this._getAttr(element, 'stroke')) || this.CONFIG.defaultStroke,
644
+ size: parseFloat(this._getAttr(element, 'stroke-width')) || 1,
645
+ deleted: false
646
+ };
647
+ },
648
+
649
+ /**
650
+ * Convert text element
651
+ */
652
+ _convertText(element, transform) {
653
+ const text = element.textContent.trim();
654
+ if (!text) return null;
655
+
656
+ // Get position from tspan or text element
657
+ let x = 0, y = 0;
658
+ const tspan = element.querySelector('tspan');
659
+ if (tspan) {
660
+ const tspanX = tspan.getAttribute('x');
661
+ const tspanY = tspan.getAttribute('y');
662
+ x = parseFloat(tspanX?.split(/\s+/)[0]) || 0;
663
+ y = parseFloat(tspanY) || 0;
664
+ } else {
665
+ x = parseFloat(element.getAttribute('x')) || 0;
666
+ y = parseFloat(element.getAttribute('y')) || 0;
667
+ }
668
+
669
+ const pos = this.transformPoint(x, y, transform);
670
+ const fontSize = parseFloat(this._getAttr(element, 'font-size')) || 16;
671
+ const fontFamily = this._getAttr(element, 'font-family');
672
+ const fill = this._getAttr(element, 'fill');
673
+ const elemTransform = element.getAttribute('transform');
674
+
675
+ const result = {
676
+ id: this.generateId(),
677
+ lastMod: Date.now(),
678
+ tool: 'text',
679
+ text,
680
+ x: pos.x,
681
+ y: pos.y,
682
+ size: fontSize * this.getMatrixScale(transform),
683
+ color: this.parseColor(fill) || this.CONFIG.defaultStroke,
684
+ fontFamily: fontFamily || 'sans-serif',
685
+ w: text.length * fontSize * 0.6,
686
+ h: fontSize * 1.2,
687
+ deleted: false
688
+ };
689
+
690
+ // Store original SVG data for precise rendering
691
+ if (elemTransform || element.innerHTML.includes('<tspan')) {
692
+ result.svgData = {
693
+ transform: elemTransform,
694
+ xmlSpace: element.getAttribute('xml:space'),
695
+ fontSize: this._getAttr(element, 'font-size'),
696
+ fontFamily: fontFamily,
697
+ fill: fill,
698
+ innerContent: element.innerHTML
699
+ };
700
+ }
701
+
702
+ return result;
703
+ },
704
+
705
+ /**
706
+ * Convert image element
707
+ */
708
+ _convertImage(element, transform, maskId) {
709
+ const x = parseFloat(element.getAttribute('x')) || 0;
710
+ const y = parseFloat(element.getAttribute('y')) || 0;
711
+ const w = parseFloat(element.getAttribute('width')) || 0;
712
+ const h = parseFloat(element.getAttribute('height')) || 0;
713
+ const href = element.getAttribute('href') || element.getAttribute('xlink:href');
714
+
715
+ if (!href || w === 0 || h === 0) return null;
716
+
717
+ const topLeft = this.transformPoint(x, y, transform);
718
+ const bottomRight = this.transformPoint(x + w, y + h, transform);
719
+
720
+ const result = {
721
+ id: this.generateId(),
722
+ lastMod: Date.now(),
723
+ tool: 'image',
724
+ x: topLeft.x,
725
+ y: topLeft.y,
726
+ w: Math.abs(bottomRight.x - topLeft.x),
727
+ h: Math.abs(bottomRight.y - topLeft.y),
728
+ src: href,
729
+ rotation: 0,
730
+ deleted: false
731
+ };
732
+
733
+ // Add mask data if present
734
+ if (maskId && this._maskDefinitions[maskId]) {
735
+ result.mask = {
736
+ id: maskId,
737
+ type: this._maskDefinitions[maskId].type,
738
+ src: this._maskDefinitions[maskId].imageData
739
+ };
740
+ }
741
+
742
+ return result;
743
+ },
744
+
745
+ /**
746
+ * Import SVG from File object
747
+ * @param {File} file - SVG file
748
+ * @returns {Promise<{metadata: Object, history: Array}>}
749
+ */
750
+ async importSvgFile(file) {
751
+ const content = await file.text();
752
+ return this.importSvg(content);
753
+ }
754
+ };
755
+
756
+ // Make available globally for non-module usage
757
+ if (typeof window !== 'undefined') {
758
+ window.ColorRmSvgImporter = ColorRmSvgImporter;
759
+ }
test_page_1.colorrm.json ADDED
The diff for this file is too large to render. See raw diff
 
test_page_1.colorrm_v3.json ADDED
The diff for this file is too large to render. See raw diff
 
test_page_1.colorrm_v3b.json ADDED
The diff for this file is too large to render. See raw diff
 
test_page_1.colorrm_v4.json ADDED
The diff for this file is too large to render. See raw diff
 
test_page_1.final.svg ADDED
test_page_1.json ADDED
The diff for this file is too large to render. See raw diff
 
test_page_1.roundtrip.svg ADDED
test_page_1.roundtrip_v3.svg ADDED
test_page_1.roundtrip_v3b.svg ADDED
test_page_1.roundtrip_v4.svg ADDED
test_page_1.svg ADDED
tools/colorrm-to-svg.cjs ADDED
@@ -0,0 +1,540 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ColorRM to SVG Converter - Pro Version
4
+ *
5
+ * Converts ColorRM JSON format back to SVG for round-trip testing.
6
+ * Handles all ColorRM properties including:
7
+ * - opacity (global alpha)
8
+ * - lineCap, lineJoin
9
+ * - highlighter tool
10
+ * - shapes, text, images, groups
11
+ *
12
+ * Usage: node colorrm-to-svg.cjs input.json [output.svg]
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ // Convert a color object or string to SVG color
19
+ function toSvgColor(color) {
20
+ if (!color) return 'black';
21
+ if (typeof color === 'string') return color;
22
+ if (color.r !== undefined) {
23
+ const a = color.a !== undefined ? color.a : 1;
24
+ if (a < 1) {
25
+ return `rgba(${color.r},${color.g},${color.b},${a})`;
26
+ }
27
+ return `rgb(${color.r},${color.g},${color.b})`;
28
+ }
29
+ return 'black';
30
+ }
31
+
32
+ // Build opacity attribute string
33
+ function opacityAttr(item) {
34
+ if (item.opacity !== undefined && item.opacity < 1) {
35
+ return ` opacity="${item.opacity.toFixed(4)}"`;
36
+ }
37
+ return '';
38
+ }
39
+
40
+ // Build stroke properties string
41
+ function strokeProps(item, defaultCap = 'round', defaultJoin = 'round') {
42
+ const lineCap = item.lineCap || defaultCap;
43
+ const lineJoin = item.lineJoin || defaultJoin;
44
+ return ` stroke-linecap="${lineCap}" stroke-linejoin="${lineJoin}"`;
45
+ }
46
+
47
+ // Convert pen stroke to SVG path
48
+ function penToSvg(item) {
49
+ if (!item.pts || item.pts.length === 0) return '';
50
+
51
+ const color = toSvgColor(item.color);
52
+ const strokeWidth = item.size || 2;
53
+
54
+ // Build path d attribute
55
+ let d = `M ${item.pts[0].x.toFixed(2)} ${item.pts[0].y.toFixed(2)}`;
56
+ for (let i = 1; i < item.pts.length; i++) {
57
+ d += ` L ${item.pts[i].x.toFixed(2)} ${item.pts[i].y.toFixed(2)}`;
58
+ }
59
+
60
+ const transform = item.rotation ? ` transform="rotate(${item.rotation})"` : '';
61
+ const opacity = opacityAttr(item);
62
+ const strokeAttrs = strokeProps(item);
63
+
64
+ return ` <path d="${d}" stroke="${color}" stroke-width="${strokeWidth}" fill="none"${strokeAttrs}${opacity}${transform}/>`;
65
+ }
66
+
67
+ // Convert highlighter stroke to SVG path with opacity
68
+ function highlighterToSvg(item) {
69
+ if (!item.pts || item.pts.length === 0) return '';
70
+
71
+ const color = toSvgColor(item.color);
72
+ const strokeWidth = item.size || 20;
73
+
74
+ let d = `M ${item.pts[0].x.toFixed(2)} ${item.pts[0].y.toFixed(2)}`;
75
+ for (let i = 1; i < item.pts.length; i++) {
76
+ d += ` L ${item.pts[i].x.toFixed(2)} ${item.pts[i].y.toFixed(2)}`;
77
+ }
78
+
79
+ const transform = item.rotation ? ` transform="rotate(${item.rotation})"` : '';
80
+ const strokeAttrs = strokeProps(item);
81
+
82
+ // Highlighter uses its own opacity or defaults to 0.4
83
+ const effectiveOpacity = item.opacity !== undefined ? item.opacity : 0.4;
84
+
85
+ return ` <path d="${d}" stroke="${color}" stroke-width="${strokeWidth}" fill="none"${strokeAttrs} opacity="${effectiveOpacity.toFixed(4)}"${transform}/>`;
86
+ }
87
+
88
+ // Convert shape to SVG element
89
+ function shapeToSvg(item) {
90
+ const x = item.x || 0;
91
+ const y = item.y || 0;
92
+ const w = item.width || item.w || 100;
93
+ const h = item.height || item.h || 100;
94
+ const strokeWidth = item.borderSize || item.width || 2;
95
+ const rotation = item.rotation || 0;
96
+
97
+ // Handle fill with separate opacity (support both fillColor and fill)
98
+ let fill = 'none';
99
+ const fillColorVal = item.fillColor || item.fill;
100
+ if (fillColorVal && fillColorVal !== 'transparent') {
101
+ if (item.fillOpacity !== undefined && item.fillOpacity < 1) {
102
+ // Apply fill opacity using rgba
103
+ const fillColor = fillColorVal;
104
+ if (fillColor.startsWith('#')) {
105
+ const r = parseInt(fillColor.slice(1, 3), 16);
106
+ const g = parseInt(fillColor.slice(3, 5), 16);
107
+ const b = parseInt(fillColor.slice(5, 7), 16);
108
+ fill = `rgba(${r},${g},${b},${item.fillOpacity.toFixed(4)})`;
109
+ } else {
110
+ fill = toSvgColor(fillColor);
111
+ }
112
+ } else {
113
+ fill = toSvgColor(fillColorVal);
114
+ }
115
+ }
116
+
117
+ // Handle border with separate opacity (support both borderColor and border)
118
+ const borderColorVal = item.borderColor || item.border || '#000000';
119
+ let border = toSvgColor(borderColorVal);
120
+ if (item.borderOpacity !== undefined && item.borderOpacity < 1) {
121
+ if (borderColorVal && borderColorVal.startsWith('#')) {
122
+ const r = parseInt(borderColorVal.slice(1, 3), 16);
123
+ const g = parseInt(borderColorVal.slice(3, 5), 16);
124
+ const b = parseInt(borderColorVal.slice(5, 7), 16);
125
+ border = `rgba(${r},${g},${b},${item.borderOpacity.toFixed(4)})`;
126
+ }
127
+ }
128
+
129
+ // Handle border dash pattern
130
+ let strokeDasharray = '';
131
+ if (item.borderType === 'dashed') {
132
+ strokeDasharray = ` stroke-dasharray="${strokeWidth * 4},${strokeWidth * 2}"`;
133
+ } else if (item.borderType === 'dotted') {
134
+ strokeDasharray = ` stroke-dasharray="${strokeWidth},${strokeWidth * 2}"`;
135
+ }
136
+
137
+ const opacity = opacityAttr(item);
138
+
139
+ // Calculate center for rotation
140
+ const cx = x + w / 2;
141
+ const cy = y + h / 2;
142
+ const transform = rotation ? ` transform="rotate(${rotation} ${cx} ${cy})"` : '';
143
+
144
+ switch (item.shapeType) {
145
+ case 'rectangle':
146
+ case 'roundedRect':
147
+ return ` <rect x="${x}" y="${y}" width="${w}" height="${h}" stroke="${border}" fill="${fill}" stroke-width="${strokeWidth}"${strokeDasharray}${opacity}${transform}/>`;
148
+
149
+ case 'ellipse':
150
+ case 'circle':
151
+ const rx = w / 2;
152
+ const ry = h / 2;
153
+ return ` <ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" stroke="${border}" fill="${fill}" stroke-width="${strokeWidth}"${strokeDasharray}${opacity}${transform}/>`;
154
+
155
+ case 'triangle':
156
+ const p1 = `${cx},${y}`;
157
+ const p2 = `${x},${y + h}`;
158
+ const p3 = `${x + w},${y + h}`;
159
+ return ` <polygon points="${p1} ${p2} ${p3}" stroke="${border}" fill="${fill}" stroke-width="${strokeWidth}"${strokeDasharray}${opacity}${transform}/>`;
160
+
161
+ case 'diamond':
162
+ const d1 = `${cx},${y}`;
163
+ const d2 = `${x + w},${cy}`;
164
+ const d3 = `${cx},${y + h}`;
165
+ const d4 = `${x},${cy}`;
166
+ return ` <polygon points="${d1} ${d2} ${d3} ${d4}" stroke="${border}" fill="${fill}" stroke-width="${strokeWidth}"${strokeDasharray}${opacity}${transform}/>`;
167
+
168
+ case 'star':
169
+ // 5-pointed star
170
+ const starPoints = [];
171
+ const outerR = Math.min(w, h) / 2;
172
+ const innerR = outerR * 0.4;
173
+ for (let i = 0; i < 10; i++) {
174
+ const angle = (i * 36 - 90) * Math.PI / 180;
175
+ const r = i % 2 === 0 ? outerR : innerR;
176
+ starPoints.push(`${(cx + r * Math.cos(angle)).toFixed(2)},${(cy + r * Math.sin(angle)).toFixed(2)}`);
177
+ }
178
+ return ` <polygon points="${starPoints.join(' ')}" stroke="${border}" fill="${fill}" stroke-width="${strokeWidth}"${strokeDasharray}${opacity}${transform}/>`;
179
+
180
+ case 'hexagon':
181
+ const hexPoints = [];
182
+ const hexR = Math.min(w, h) / 2;
183
+ for (let i = 0; i < 6; i++) {
184
+ const angle = (i * 60 - 30) * Math.PI / 180;
185
+ hexPoints.push(`${(cx + hexR * Math.cos(angle)).toFixed(2)},${(cy + hexR * Math.sin(angle)).toFixed(2)}`);
186
+ }
187
+ return ` <polygon points="${hexPoints.join(' ')}" stroke="${border}" fill="${fill}" stroke-width="${strokeWidth}"${strokeDasharray}${opacity}${transform}/>`;
188
+
189
+ case 'pentagon':
190
+ const pentPoints = [];
191
+ const pentR = Math.min(w, h) / 2;
192
+ for (let i = 0; i < 5; i++) {
193
+ const angle = (i * 72 - 90) * Math.PI / 180;
194
+ pentPoints.push(`${(cx + pentR * Math.cos(angle)).toFixed(2)},${(cy + pentR * Math.sin(angle)).toFixed(2)}`);
195
+ }
196
+ return ` <polygon points="${pentPoints.join(' ')}" stroke="${border}" fill="${fill}" stroke-width="${strokeWidth}"${strokeDasharray}${opacity}${transform}/>`;
197
+
198
+ case 'octagon':
199
+ const octPoints = [];
200
+ const octR = Math.min(w, h) / 2;
201
+ for (let i = 0; i < 8; i++) {
202
+ const angle = (i * 45 - 22.5) * Math.PI / 180;
203
+ octPoints.push(`${(cx + octR * Math.cos(angle)).toFixed(2)},${(cy + octR * Math.sin(angle)).toFixed(2)}`);
204
+ }
205
+ return ` <polygon points="${octPoints.join(' ')}" stroke="${border}" fill="${fill}" stroke-width="${strokeWidth}"${strokeDasharray}${opacity}${transform}/>`;
206
+
207
+ case 'polygon':
208
+ // Custom polygon with normalized points
209
+ if (item.pts && item.pts.length >= 3) {
210
+ const polyPoints = item.pts.map(p =>
211
+ `${(x + p.x * w).toFixed(2)},${(y + p.y * h).toFixed(2)}`
212
+ );
213
+ return ` <polygon points="${polyPoints.join(' ')}" stroke="${border}" fill="${fill}" stroke-width="${strokeWidth}"${strokeDasharray}${opacity}${transform}/>`;
214
+ }
215
+ // Fallback to rectangle if no points
216
+ return ` <rect x="${x}" y="${y}" width="${w}" height="${h}" stroke="${border}" fill="${fill}" stroke-width="${strokeWidth}"${strokeDasharray}${opacity}${transform}/>`;
217
+
218
+ case 'arrow':
219
+ // Arrow pointing right
220
+ const arrowPath = `M ${x} ${y + h*0.3} L ${x + w*0.6} ${y + h*0.3} L ${x + w*0.6} ${y} L ${x + w} ${y + h/2} L ${x + w*0.6} ${y + h} L ${x + w*0.6} ${y + h*0.7} L ${x} ${y + h*0.7} Z`;
221
+ return ` <path d="${arrowPath}" stroke="${border}" fill="${fill}" stroke-width="${strokeWidth}"${strokeDasharray}${opacity}${transform}/>`;
222
+
223
+ case 'line':
224
+ return ` <line x1="${x}" y1="${y}" x2="${x + w}" y2="${y + h}" stroke="${border}" stroke-width="${strokeWidth}"${strokeDasharray}${opacity}${transform}/>`;
225
+
226
+ default:
227
+ // Default to rectangle
228
+ return ` <rect x="${x}" y="${y}" width="${w}" height="${h}" stroke="${border}" fill="${fill}" stroke-width="${strokeWidth}"${strokeDasharray}${opacity}${transform}/>`;
229
+ }
230
+ }
231
+
232
+ // Convert text to SVG text element
233
+ function textToSvg(item) {
234
+ // If we have original SVG data, use it for precise reproduction
235
+ if (item.svgData) {
236
+ const svg = item.svgData;
237
+ let attrs = '';
238
+ if (svg.xmlSpace) attrs += ` xml:space="${svg.xmlSpace}"`;
239
+ if (svg.transform) attrs += ` transform="${svg.transform}"`;
240
+ if (svg.fontSize) attrs += ` font-size="${svg.fontSize}"`;
241
+ if (svg.fontFamily) attrs += ` font-family="${svg.fontFamily}"`;
242
+ if (svg.fill) attrs += ` fill="${svg.fill}"`;
243
+
244
+ return ` <text${attrs}>${svg.innerContent}</text>`;
245
+ }
246
+
247
+ // Fallback to simple text rendering
248
+ const x = item.x || 0;
249
+ const y = item.y || 0;
250
+ const fontSize = item.size || 16;
251
+ const color = toSvgColor(item.color);
252
+ const text = item.text || '';
253
+ const rotation = item.rotation || 0;
254
+ const opacity = opacityAttr(item);
255
+ const fontFamily = item.fontFamily || 'sans-serif';
256
+
257
+ const transform = rotation ? ` transform="rotate(${rotation} ${x} ${y})"` : '';
258
+
259
+ // Escape XML special characters
260
+ const escapedText = text
261
+ .replace(/&/g, '&amp;')
262
+ .replace(/</g, '&lt;')
263
+ .replace(/>/g, '&gt;')
264
+ .replace(/"/g, '&quot;')
265
+ .replace(/'/g, '&apos;');
266
+
267
+ // Handle multi-line text
268
+ const lines = escapedText.split('\n');
269
+ if (lines.length === 1) {
270
+ return ` <text x="${x}" y="${y + fontSize}" font-size="${fontSize}" font-family="${fontFamily}" fill="${color}"${opacity}${transform}>${escapedText}</text>`;
271
+ }
272
+
273
+ // Multi-line text using tspan
274
+ let svgText = ` <text x="${x}" y="${y + fontSize}" font-size="${fontSize}" font-family="${fontFamily}" fill="${color}"${opacity}${transform}>`;
275
+ lines.forEach((line, i) => {
276
+ if (i === 0) {
277
+ svgText += `<tspan>${line}</tspan>`;
278
+ } else {
279
+ svgText += `<tspan x="${x}" dy="${fontSize * 1.2}">${line}</tspan>`;
280
+ }
281
+ });
282
+ svgText += '</text>';
283
+ return svgText;
284
+ }
285
+
286
+ // Convert image to SVG image element
287
+ // Returns { svg: string, maskDef: string|null, maskId: string|null }
288
+ function imageToSvg(item) {
289
+ const x = item.x || 0;
290
+ const y = item.y || 0;
291
+ const w = item.w || 100;
292
+ const h = item.h || 100;
293
+ const src = item.src || '';
294
+ const rotation = item.rotation || 0;
295
+ const opacity = opacityAttr(item);
296
+
297
+ const cx = x + w / 2;
298
+ const cy = y + h / 2;
299
+ const transform = rotation ? ` transform="rotate(${rotation} ${cx} ${cy})"` : '';
300
+
301
+ let imageSvg = ` <image x="${x}" y="${y}" width="${w}" height="${h}" href="${src}"${opacity}${transform}/>`;
302
+
303
+ // If this image has a mask, return mask info for defs
304
+ if (item.mask && item.mask.src) {
305
+ const maskId = item.mask.id || `mask_${item.id}`;
306
+ // Mask definition - image inside mask element
307
+ const maskDef = ` <mask id="${maskId}">
308
+ <image x="${x}" y="${y}" width="${w}" height="${h}" href="${item.mask.src}"/>
309
+ </mask>`;
310
+ // Wrap the image in a group with the mask
311
+ imageSvg = ` <g mask="url(#${maskId})">
312
+ <image x="${x}" y="${y}" width="${w}" height="${h}" href="${src}"${opacity}${transform}/>
313
+ </g>`;
314
+ return { svg: imageSvg, maskDef, maskId };
315
+ }
316
+
317
+ return { svg: imageSvg, maskDef: null, maskId: null };
318
+ }
319
+
320
+ // Convert group to SVG group element
321
+ // Note: Returns string only - mask defs from children won't be captured here
322
+ // (Groups with masked images would need more complex handling)
323
+ function groupToSvg(item, allItems) {
324
+ if (!item.children || item.children.length === 0) return '';
325
+
326
+ const rotation = item.rotation || 0;
327
+ const x = item.x || 0;
328
+ const y = item.y || 0;
329
+ const w = item.w || 0;
330
+ const h = item.h || 0;
331
+ const opacity = opacityAttr(item);
332
+
333
+ const cx = x + w / 2;
334
+ const cy = y + h / 2;
335
+ const transform = rotation ? ` transform="rotate(${rotation} ${cx} ${cy})"` : '';
336
+
337
+ let content = '';
338
+ for (const child of item.children) {
339
+ // Children can be inline objects or IDs referencing allItems
340
+ let childItem = child;
341
+ if (typeof child === 'string' || typeof child === 'number') {
342
+ childItem = allItems.find(it => it.id === child);
343
+ }
344
+ if (childItem && !childItem.deleted) {
345
+ const result = itemToSvg(childItem, allItems);
346
+ content += '\n' + result.svg;
347
+ }
348
+ }
349
+
350
+ return ` <g id="${item.id}"${opacity}${transform}>${content}\n </g>`;
351
+ }
352
+
353
+ // Convert any item to SVG
354
+ // Returns { svg: string, maskDef: string|null } for items that may have masks
355
+ function itemToSvg(item, allItems) {
356
+ if (!item || item.deleted) return { svg: '', maskDef: null };
357
+
358
+ switch (item.tool) {
359
+ case 'pen':
360
+ return { svg: penToSvg(item), maskDef: null };
361
+ case 'highlighter':
362
+ return { svg: highlighterToSvg(item), maskDef: null };
363
+ case 'shape':
364
+ return { svg: shapeToSvg(item), maskDef: null };
365
+ case 'text':
366
+ return { svg: textToSvg(item), maskDef: null };
367
+ case 'image':
368
+ return imageToSvg(item); // Already returns { svg, maskDef, maskId }
369
+ case 'group':
370
+ return { svg: groupToSvg(item, allItems), maskDef: null };
371
+ default:
372
+ // Try to handle as pen if it has pts
373
+ if (item.pts) return { svg: penToSvg(item), maskDef: null };
374
+ return { svg: '', maskDef: null };
375
+ }
376
+ }
377
+
378
+ // Main conversion function
379
+ function convertToSvg(colorrmData) {
380
+ const metadata = colorrmData.metadata || {};
381
+ const history = colorrmData.history || [];
382
+
383
+ // Get canvas dimensions
384
+ const width = metadata.width || 1920;
385
+ const height = metadata.height || 1080;
386
+ const title = metadata.title || 'ColorRM Export';
387
+
388
+ // Collect mask definitions and SVG content
389
+ const maskDefs = [];
390
+ const svgElements = [];
391
+
392
+ // Track which items are children of groups (to avoid double-rendering)
393
+ const groupChildren = new Set();
394
+ for (const item of history) {
395
+ if (item.tool === 'group' && item.children) {
396
+ for (const child of item.children) {
397
+ // Handle both inline objects and ID references
398
+ if (typeof child === 'object' && child.id) {
399
+ groupChildren.add(child.id);
400
+ } else {
401
+ groupChildren.add(child);
402
+ }
403
+ }
404
+ }
405
+ }
406
+
407
+ // Convert each item (skip group children as they're rendered inside groups)
408
+ for (const item of history) {
409
+ if (item.deleted) continue;
410
+ if (groupChildren.has(item.id)) continue; // Skip, will be rendered in group
411
+
412
+ const result = itemToSvg(item, history);
413
+ if (result.svg) {
414
+ svgElements.push(result.svg);
415
+ }
416
+ if (result.maskDef) {
417
+ maskDefs.push(result.maskDef);
418
+ }
419
+ }
420
+
421
+ // Build final SVG with defs section if we have masks
422
+ let svgContent = `<?xml version="1.0" encoding="UTF-8"?>
423
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
424
+ width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
425
+ <title>${title}</title>
426
+ <desc>Exported from ColorRM - Pro Version</desc>
427
+ `;
428
+
429
+ // Add defs section if we have mask definitions
430
+ if (maskDefs.length > 0) {
431
+ svgContent += ` <defs>\n${maskDefs.join('\n')}\n </defs>\n`;
432
+ }
433
+
434
+ // Add all SVG elements
435
+ svgContent += svgElements.join('\n') + '\n';
436
+
437
+ svgContent += '</svg>';
438
+ return svgContent;
439
+ }
440
+
441
+ // CLI interface
442
+ function main() {
443
+ const args = process.argv.slice(2);
444
+
445
+ if (args.length === 0) {
446
+ console.log(`
447
+ ColorRM to SVG Converter - Pro Version
448
+ ======================================
449
+
450
+ Usage:
451
+ node colorrm-to-svg.cjs input.json [output.svg]
452
+
453
+ Converts ColorRM JSON format back to SVG.
454
+ If no output file is specified, uses input filename with .roundtrip.svg extension.
455
+
456
+ Supported Features:
457
+ ✓ Pen strokes with lineCap/lineJoin
458
+ ✓ Highlighter strokes with opacity
459
+ ✓ All shape types (rectangle, ellipse, triangle, etc.)
460
+ ✓ Text elements
461
+ ✓ Images (including base64)
462
+ ✓ Groups with nested items
463
+ ✓ Opacity/transparency
464
+ ✓ Rotation transforms
465
+ `);
466
+ process.exit(0);
467
+ }
468
+
469
+ const inputFile = args[0];
470
+ const outputFile = args[1] || inputFile.replace(/\.(json|colorrm\.json)$/i, '') + '.roundtrip.svg';
471
+
472
+ // Read input file
473
+ console.log(`Reading: ${inputFile}`);
474
+ let content;
475
+ try {
476
+ content = fs.readFileSync(inputFile, 'utf-8');
477
+ } catch (err) {
478
+ console.error(`Error reading file: ${err.message}`);
479
+ process.exit(1);
480
+ }
481
+
482
+ // Parse JSON
483
+ let colorrmData;
484
+ try {
485
+ colorrmData = JSON.parse(content);
486
+ } catch (err) {
487
+ console.error(`Error parsing JSON: ${err.message}`);
488
+ process.exit(1);
489
+ }
490
+
491
+ // Convert
492
+ console.log('Converting ColorRM to SVG format...');
493
+ const history = colorrmData.history || [];
494
+
495
+ // Count elements by type
496
+ const counts = {};
497
+ for (const item of history) {
498
+ if (!item.deleted) {
499
+ const tool = item.tool || 'unknown';
500
+ counts[tool] = (counts[tool] || 0) + 1;
501
+ }
502
+ }
503
+
504
+ console.log('Element statistics:');
505
+ for (const [tool, count] of Object.entries(counts)) {
506
+ console.log(` ${tool}: ${count} elements`);
507
+ }
508
+
509
+ const svgContent = convertToSvg(colorrmData);
510
+
511
+ // Write output
512
+ console.log(`\nWriting: ${outputFile}`);
513
+ try {
514
+ fs.writeFileSync(outputFile, svgContent, 'utf-8');
515
+ } catch (err) {
516
+ console.error(`Error writing file: ${err.message}`);
517
+ process.exit(1);
518
+ }
519
+
520
+ const outputSize = fs.statSync(outputFile).size;
521
+ console.log(`Output size: ${Math.round(outputSize / 1024)}KB`);
522
+ console.log('Done!');
523
+ }
524
+
525
+ // Run if called directly
526
+ if (require.main === module) {
527
+ main();
528
+ }
529
+
530
+ // Export for programmatic use
531
+ module.exports = {
532
+ convertToSvg,
533
+ itemToSvg,
534
+ penToSvg,
535
+ highlighterToSvg,
536
+ shapeToSvg,
537
+ textToSvg,
538
+ imageToSvg,
539
+ groupToSvg
540
+ };
tools/svg-deep-analysis.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ SVG Deep Analysis Tool
4
+
5
+ Deeper analysis of the original SVG structure to understand the exact pattern
6
+ """
7
+
8
+ import re
9
+ import sys
10
+ from collections import defaultdict
11
+
12
+ def extract_paths_with_details(svg_content, limit=50):
13
+ """Extract detailed path information"""
14
+ # More robust pattern for paths
15
+ path_pattern = r'<(?:\w+:)?path\s+([^>]*)/?>'
16
+
17
+ paths = []
18
+ for i, match in enumerate(re.finditer(path_pattern, svg_content, re.IGNORECASE)):
19
+ if i >= limit:
20
+ break
21
+
22
+ attrs_str = match.group(1)
23
+
24
+ # Parse all attributes
25
+ attrs = {}
26
+ for attr_match in re.finditer(r'(\w+(?:-\w+)*)="([^"]*)"', attrs_str):
27
+ attrs[attr_match.group(1)] = attr_match.group(2)
28
+
29
+ # Get d length
30
+ d = attrs.get('d', '')
31
+ attrs['_d_length'] = len(d)
32
+ attrs['_d_preview'] = d[:100] + '...' if len(d) > 100 else d
33
+
34
+ paths.append({
35
+ 'index': i,
36
+ 'position': match.start(),
37
+ 'attrs': attrs
38
+ })
39
+
40
+ return paths
41
+
42
+ def analyze_path_patterns(paths):
43
+ """Analyze patterns in path definitions"""
44
+ patterns = []
45
+
46
+ for i in range(0, min(len(paths), 20), 2):
47
+ if i + 1 < len(paths):
48
+ p1 = paths[i]['attrs']
49
+ p2 = paths[i + 1]['attrs']
50
+
51
+ same_d = p1.get('d', '') == paths[i+1]['attrs'].get('d', '') if '_d_length' not in p1 else p1.get('_d_length') == p2.get('_d_length')
52
+
53
+ pattern = {
54
+ 'pair': f"{i} & {i+1}",
55
+ 'same_path_data': same_d,
56
+ 'path1': {
57
+ 'fill': p1.get('fill'),
58
+ 'stroke': p1.get('stroke'),
59
+ 'opacity': p1.get('opacity'),
60
+ 'fill-opacity': p1.get('fill-opacity'),
61
+ 'stroke-width': p1.get('stroke-width')
62
+ },
63
+ 'path2': {
64
+ 'fill': p2.get('fill'),
65
+ 'stroke': p2.get('stroke'),
66
+ 'opacity': p2.get('opacity'),
67
+ 'fill-opacity': p2.get('fill-opacity'),
68
+ 'stroke-width': p2.get('stroke-width')
69
+ }
70
+ }
71
+ patterns.append(pattern)
72
+
73
+ return patterns
74
+
75
+ def find_group_structure(svg_content):
76
+ """Analyze group nesting structure"""
77
+ # Find all groups and their attributes
78
+ group_pattern = r'<(?:\w+:)?g\s+([^>]*)>'
79
+
80
+ groups = []
81
+ for match in re.finditer(group_pattern, svg_content, re.IGNORECASE):
82
+ attrs_str = match.group(1)
83
+ attrs = {}
84
+ for attr_match in re.finditer(r'(\w+(?:-\w+)*)="([^"]*)"', attrs_str):
85
+ attrs[attr_match.group(1)] = attr_match.group(2)
86
+
87
+ groups.append({
88
+ 'position': match.start(),
89
+ 'attrs': attrs
90
+ })
91
+
92
+ return groups
93
+
94
+ def main():
95
+ if len(sys.argv) < 2:
96
+ print("Usage: python svg-deep-analysis.py <file.svg>")
97
+ sys.exit(1)
98
+
99
+ filepath = sys.argv[1]
100
+ with open(filepath, 'r') as f:
101
+ content = f.read()
102
+
103
+ print(f"\n{'='*70}")
104
+ print(f"DEEP SVG ANALYSIS: {filepath}")
105
+ print('='*70)
106
+
107
+ # Analyze groups
108
+ groups = find_group_structure(content)
109
+ print(f"\n📁 GROUPS ({len(groups)}):")
110
+ for i, g in enumerate(groups[:10]):
111
+ print(f" Group {i}: {g['attrs']}")
112
+
113
+ # Analyze paths in detail
114
+ paths = extract_paths_with_details(content, limit=30)
115
+
116
+ print(f"\n📝 FIRST 30 PATHS DETAILS:")
117
+ for p in paths[:30]:
118
+ attrs = p['attrs']
119
+ print(f"\n Path {p['index']}:")
120
+ print(f" fill: {attrs.get('fill', 'NONE')}")
121
+ print(f" stroke: {attrs.get('stroke', 'NONE')}")
122
+ print(f" stroke-width: {attrs.get('stroke-width', 'NONE')}")
123
+ print(f" opacity: {attrs.get('opacity', 'NONE')}")
124
+ print(f" fill-opacity: {attrs.get('fill-opacity', 'NONE')}")
125
+ print(f" d length: {attrs.get('_d_length')}")
126
+
127
+ # Analyze patterns
128
+ patterns = analyze_path_patterns(paths)
129
+ print(f"\n🔍 PATH PAIR PATTERNS (checking if paths come in fill+stroke pairs):")
130
+ for p in patterns:
131
+ print(f"\n {p['pair']}:")
132
+ print(f" Same path data: {p['same_path_data']}")
133
+ print(f" Path 1: fill={p['path1']['fill']}, stroke={p['path1']['stroke']}, opacity={p['path1']['opacity']}")
134
+ print(f" Path 2: fill={p['path2']['fill']}, stroke={p['path2']['stroke']}, sw={p['path2']['stroke-width']}")
135
+
136
+ # Look for image elements
137
+ image_pattern = r'<(?:\w+:)?image\s+([^>]*)/?>'
138
+ images = list(re.finditer(image_pattern, content, re.IGNORECASE))
139
+ print(f"\n🖼️ IMAGES ({len(images)}):")
140
+ for i, img in enumerate(images):
141
+ attrs_str = img.group(1)
142
+ # Extract key attrs
143
+ x = re.search(r'x="([^"]*)"', attrs_str)
144
+ y = re.search(r'y="([^"]*)"', attrs_str)
145
+ w = re.search(r'width="([^"]*)"', attrs_str)
146
+ h = re.search(r'height="([^"]*)"', attrs_str)
147
+ mask = re.search(r'mask="([^"]*)"', attrs_str)
148
+
149
+ print(f" Image {i} at position {img.start()}:")
150
+ print(f" x={x.group(1) if x else 'N/A'}, y={y.group(1) if y else 'N/A'}")
151
+ print(f" w={w.group(1) if w else 'N/A'}, h={h.group(1) if h else 'N/A'}")
152
+ print(f" mask={mask.group(1) if mask else 'NONE'}")
153
+
154
+ print("\n" + "="*70)
155
+ print("DIAGNOSIS SUMMARY")
156
+ print("="*70)
157
+
158
+ print("""
159
+ Based on this analysis, the SVG structure is:
160
+
161
+ 1. PATHS COME IN PAIRS:
162
+ - First path: Colored FILL with low opacity (e.g., fill=#ff7d2e opacity=.03)
163
+ This creates the "highlighter" effect
164
+ - Second path: Same geometry with STROKE only (stroke=#000000 stroke-width=2)
165
+ This creates a thin outline
166
+
167
+ 2. ISSUE WITH CONVERTER:
168
+ - We're treating filled paths and stroked paths separately
169
+ - We should recognize these as a SINGLE stroke with:
170
+ * color from the fill
171
+ * stroke-width from the companion stroke path (or default to 2)
172
+ * opacity from the fill-opacity or opacity
173
+
174
+ 3. IMAGES:
175
+ - Images appear EARLY in the document (as backgrounds)
176
+ - Our converter outputs them LAST (covering content)
177
+
178
+ FIX NEEDED:
179
+ - Detect fill+stroke path pairs (same d attribute)
180
+ - Merge them into single ColorRM item
181
+ - Preserve element order (images first = background)
182
+ """)
183
+
184
+ if __name__ == '__main__':
185
+ main()
tools/svg-diagnosis.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ SVG Diagnosis Tool
4
+
5
+ Analyzes SVG files to understand structure, layering, and potential issues.
6
+ """
7
+
8
+ import re
9
+ import sys
10
+ import json
11
+ from collections import defaultdict
12
+
13
+ def parse_viewbox(svg_content):
14
+ """Extract viewBox dimensions"""
15
+ match = re.search(r'viewBox="([^"]*)"', svg_content)
16
+ if match:
17
+ parts = match.group(1).split()
18
+ if len(parts) >= 4:
19
+ return {
20
+ 'minX': float(parts[0]),
21
+ 'minY': float(parts[1]),
22
+ 'width': float(parts[2]),
23
+ 'height': float(parts[3])
24
+ }
25
+ return None
26
+
27
+ def parse_dimensions(svg_content):
28
+ """Extract width/height"""
29
+ width_match = re.search(r'width="([^"]*)"', svg_content)
30
+ height_match = re.search(r'height="([^"]*)"', svg_content)
31
+ return {
32
+ 'width': width_match.group(1) if width_match else None,
33
+ 'height': height_match.group(1) if height_match else None
34
+ }
35
+
36
+ def find_elements_with_order(svg_content):
37
+ """Find all elements in order of appearance (z-order)"""
38
+ # Pattern matches opening tags of drawable elements
39
+ pattern = r'<((?:\w+:)?(?:path|rect|circle|ellipse|line|polyline|polygon|text|image|g))\s+([^>]*)(?:>|/>)'
40
+
41
+ elements = []
42
+ for i, match in enumerate(re.finditer(pattern, svg_content, re.IGNORECASE)):
43
+ tag = match.group(1)
44
+ attrs = match.group(2)
45
+
46
+ # Extract key attributes
47
+ elem_info = {
48
+ 'order': i,
49
+ 'tag': tag,
50
+ 'position': match.start()
51
+ }
52
+
53
+ # Get fill
54
+ fill_match = re.search(r'fill="([^"]*)"', attrs)
55
+ if fill_match:
56
+ elem_info['fill'] = fill_match.group(1)
57
+
58
+ # Get stroke
59
+ stroke_match = re.search(r'stroke="([^"]*)"', attrs)
60
+ if stroke_match:
61
+ elem_info['stroke'] = stroke_match.group(1)
62
+
63
+ # Get stroke-width
64
+ sw_match = re.search(r'stroke-width="([^"]*)"', attrs)
65
+ if sw_match:
66
+ elem_info['stroke-width'] = sw_match.group(1)
67
+
68
+ # Get opacity
69
+ opacity_match = re.search(r'opacity="([^"]*)"', attrs)
70
+ if opacity_match:
71
+ elem_info['opacity'] = opacity_match.group(1)
72
+
73
+ # Get fill-opacity
74
+ fill_opacity_match = re.search(r'fill-opacity="([^"]*)"', attrs)
75
+ if fill_opacity_match:
76
+ elem_info['fill-opacity'] = fill_opacity_match.group(1)
77
+
78
+ # Get id
79
+ id_match = re.search(r'id="([^"]*)"', attrs)
80
+ if id_match:
81
+ elem_info['id'] = id_match.group(1)
82
+
83
+ # Check for mask/clip
84
+ if 'mask=' in attrs:
85
+ elem_info['has_mask'] = True
86
+ if 'clip-path=' in attrs:
87
+ elem_info['has_clip'] = True
88
+
89
+ # Get d attribute length for paths
90
+ if 'path' in tag.lower():
91
+ d_match = re.search(r'd="([^"]*)"', attrs)
92
+ if d_match:
93
+ elem_info['path_length'] = len(d_match.group(1))
94
+ # Count path commands
95
+ commands = len(re.findall(r'[MLHVCSQTAZ]', d_match.group(1), re.IGNORECASE))
96
+ elem_info['path_commands'] = commands
97
+
98
+ elements.append(elem_info)
99
+
100
+ return elements
101
+
102
+ def analyze_stroke_widths(elements, viewbox):
103
+ """Analyze stroke widths relative to canvas size"""
104
+ stroke_analysis = []
105
+ canvas_size = max(viewbox['width'], viewbox['height']) if viewbox else 1000
106
+
107
+ for elem in elements:
108
+ if 'stroke-width' in elem:
109
+ sw = elem['stroke-width']
110
+ try:
111
+ sw_val = float(sw)
112
+ relative = (sw_val / canvas_size) * 100
113
+ stroke_analysis.append({
114
+ 'tag': elem['tag'],
115
+ 'order': elem['order'],
116
+ 'stroke-width': sw_val,
117
+ 'relative_percent': round(relative, 2),
118
+ 'assessment': 'VERY THICK' if relative > 1 else 'THICK' if relative > 0.5 else 'NORMAL'
119
+ })
120
+ except ValueError:
121
+ pass
122
+
123
+ return stroke_analysis
124
+
125
+ def find_potential_background_elements(elements):
126
+ """Find elements that might be acting as backgrounds (solid fills, early in order)"""
127
+ backgrounds = []
128
+
129
+ for elem in elements[:10]: # Check first 10 elements
130
+ fill = elem.get('fill', '')
131
+ stroke = elem.get('stroke', '')
132
+
133
+ # Large filled rect early in document could be background
134
+ if 'rect' in elem['tag'].lower():
135
+ if fill and fill != 'none' and fill != 'transparent':
136
+ backgrounds.append({
137
+ 'order': elem['order'],
138
+ 'tag': elem['tag'],
139
+ 'fill': fill,
140
+ 'issue': 'Filled rect early in document - may cover content'
141
+ })
142
+
143
+ # Image early in document
144
+ if 'image' in elem['tag'].lower():
145
+ backgrounds.append({
146
+ 'order': elem['order'],
147
+ 'tag': elem['tag'],
148
+ 'issue': 'Image early in document - may cover content if large'
149
+ })
150
+
151
+ return backgrounds
152
+
153
+ def analyze_layering(elements):
154
+ """Analyze element layering/z-order"""
155
+ layer_stats = defaultdict(list)
156
+
157
+ for elem in elements:
158
+ tag_type = elem['tag'].split(':')[-1].lower() # Remove namespace
159
+ layer_stats[tag_type].append(elem['order'])
160
+
161
+ report = {}
162
+ for tag, orders in layer_stats.items():
163
+ report[tag] = {
164
+ 'count': len(orders),
165
+ 'first_appearance': min(orders),
166
+ 'last_appearance': max(orders),
167
+ 'range': f"positions {min(orders)}-{max(orders)}"
168
+ }
169
+
170
+ return report
171
+
172
+ def check_namespace_issues(svg_content):
173
+ """Check for namespace prefixes that might cause rendering issues"""
174
+ namespaces = re.findall(r'xmlns:(\w+)="([^"]*)"', svg_content)
175
+ prefixed_elements = re.findall(r'<(\w+):', svg_content)
176
+
177
+ return {
178
+ 'declared_namespaces': namespaces,
179
+ 'prefixed_element_count': len(prefixed_elements),
180
+ 'unique_prefixes': list(set(prefixed_elements))
181
+ }
182
+
183
+ def compare_two_svgs(file1, file2):
184
+ """Compare two SVG files"""
185
+ with open(file1, 'r') as f:
186
+ content1 = f.read()
187
+ with open(file2, 'r') as f:
188
+ content2 = f.read()
189
+
190
+ elements1 = find_elements_with_order(content1)
191
+ elements2 = find_elements_with_order(content2)
192
+
193
+ viewbox1 = parse_viewbox(content1)
194
+ viewbox2 = parse_viewbox(content2)
195
+
196
+ comparison = {
197
+ 'file1': {
198
+ 'name': file1,
199
+ 'size': len(content1),
200
+ 'element_count': len(elements1),
201
+ 'viewbox': viewbox1
202
+ },
203
+ 'file2': {
204
+ 'name': file2,
205
+ 'size': len(content2),
206
+ 'element_count': len(elements2),
207
+ 'viewbox': viewbox2
208
+ }
209
+ }
210
+
211
+ # Compare stroke widths
212
+ strokes1 = analyze_stroke_widths(elements1, viewbox1)
213
+ strokes2 = analyze_stroke_widths(elements2, viewbox2)
214
+
215
+ comparison['stroke_width_comparison'] = {
216
+ 'file1_thick_strokes': [s for s in strokes1 if s['assessment'] in ['THICK', 'VERY THICK']],
217
+ 'file2_thick_strokes': [s for s in strokes2 if s['assessment'] in ['THICK', 'VERY THICK']]
218
+ }
219
+
220
+ # Compare layering
221
+ comparison['layering'] = {
222
+ 'file1': analyze_layering(elements1),
223
+ 'file2': analyze_layering(elements2)
224
+ }
225
+
226
+ return comparison
227
+
228
+ def analyze_svg(filepath):
229
+ """Main analysis function"""
230
+ print(f"\n{'='*60}")
231
+ print(f"SVG DIAGNOSIS: {filepath}")
232
+ print('='*60)
233
+
234
+ with open(filepath, 'r') as f:
235
+ content = f.read()
236
+
237
+ print(f"\n📊 FILE SIZE: {len(content):,} bytes ({len(content)//1024} KB)")
238
+
239
+ # Dimensions
240
+ dims = parse_dimensions(content)
241
+ viewbox = parse_viewbox(content)
242
+ print(f"\n📐 DIMENSIONS:")
243
+ print(f" Width: {dims['width']}")
244
+ print(f" Height: {dims['height']}")
245
+ if viewbox:
246
+ print(f" ViewBox: {viewbox['minX']} {viewbox['minY']} {viewbox['width']} {viewbox['height']}")
247
+
248
+ # Namespace issues
249
+ ns_info = check_namespace_issues(content)
250
+ if ns_info['prefixed_element_count'] > 0:
251
+ print(f"\n⚠️ NAMESPACE PREFIXES DETECTED:")
252
+ print(f" Prefixed elements: {ns_info['prefixed_element_count']}")
253
+ print(f" Prefixes used: {ns_info['unique_prefixes']}")
254
+ print(f" Note: Some browsers may not render prefixed elements correctly!")
255
+
256
+ # Elements
257
+ elements = find_elements_with_order(content)
258
+ print(f"\n📦 ELEMENTS FOUND: {len(elements)}")
259
+
260
+ # Layering analysis
261
+ layering = analyze_layering(elements)
262
+ print(f"\n🔢 ELEMENT LAYERING (z-order):")
263
+ for tag, info in sorted(layering.items(), key=lambda x: x[1]['first_appearance']):
264
+ print(f" {tag}: {info['count']} elements, {info['range']}")
265
+
266
+ # Background issues
267
+ bg_issues = find_potential_background_elements(elements)
268
+ if bg_issues:
269
+ print(f"\n⚠️ POTENTIAL BACKGROUND/LAYERING ISSUES:")
270
+ for issue in bg_issues:
271
+ print(f" Order {issue['order']}: {issue['tag']} - {issue['issue']}")
272
+ if 'fill' in issue:
273
+ print(f" Fill: {issue['fill']}")
274
+
275
+ # Stroke width analysis
276
+ stroke_analysis = analyze_stroke_widths(elements, viewbox)
277
+ thick_strokes = [s for s in stroke_analysis if s['assessment'] in ['THICK', 'VERY THICK']]
278
+ if thick_strokes:
279
+ print(f"\n⚠️ THICK STROKE WIDTHS DETECTED ({len(thick_strokes)} elements):")
280
+ for s in thick_strokes[:10]: # Show first 10
281
+ print(f" Order {s['order']}: {s['tag']} - width={s['stroke-width']} ({s['relative_percent']}% of canvas)")
282
+ if len(thick_strokes) > 10:
283
+ print(f" ... and {len(thick_strokes) - 10} more")
284
+
285
+ # Sample elements
286
+ print(f"\n📋 FIRST 10 ELEMENTS (in z-order):")
287
+ for elem in elements[:10]:
288
+ parts = [f"Order {elem['order']}: <{elem['tag']}>"]
289
+ if 'fill' in elem:
290
+ parts.append(f"fill={elem['fill']}")
291
+ if 'stroke' in elem:
292
+ parts.append(f"stroke={elem['stroke']}")
293
+ if 'stroke-width' in elem:
294
+ parts.append(f"sw={elem['stroke-width']}")
295
+ if 'opacity' in elem:
296
+ parts.append(f"opacity={elem['opacity']}")
297
+ if 'path_length' in elem:
298
+ parts.append(f"d_len={elem['path_length']}")
299
+ print(f" {' | '.join(parts)}")
300
+
301
+ # Last 5 elements (top of z-order)
302
+ if len(elements) > 10:
303
+ print(f"\n📋 LAST 5 ELEMENTS (top of z-order):")
304
+ for elem in elements[-5:]:
305
+ parts = [f"Order {elem['order']}: <{elem['tag']}>"]
306
+ if 'fill' in elem:
307
+ parts.append(f"fill={elem['fill']}")
308
+ if 'stroke' in elem:
309
+ parts.append(f"stroke={elem['stroke']}")
310
+ if 'stroke-width' in elem:
311
+ parts.append(f"sw={elem['stroke-width']}")
312
+ print(f" {' | '.join(parts)}")
313
+
314
+ return {
315
+ 'filepath': filepath,
316
+ 'size': len(content),
317
+ 'dimensions': dims,
318
+ 'viewbox': viewbox,
319
+ 'element_count': len(elements),
320
+ 'namespaces': ns_info,
321
+ 'layering': layering,
322
+ 'thick_strokes': thick_strokes,
323
+ 'background_issues': bg_issues
324
+ }
325
+
326
+ def main():
327
+ if len(sys.argv) < 2:
328
+ print("""
329
+ SVG Diagnosis Tool
330
+ ==================
331
+
332
+ Usage:
333
+ python svg-diagnosis.py <file.svg> # Analyze single SVG
334
+ python svg-diagnosis.py <file1.svg> <file2.svg> # Compare two SVGs
335
+
336
+ Diagnoses:
337
+ - Stroke width issues (too thick)
338
+ - Layering/z-order problems (background covering content)
339
+ - Namespace prefix issues
340
+ - ViewBox problems
341
+ """)
342
+ sys.exit(0)
343
+
344
+ if len(sys.argv) == 2:
345
+ analyze_svg(sys.argv[1])
346
+ elif len(sys.argv) >= 3:
347
+ print("\n" + "="*60)
348
+ print("COMPARING TWO SVG FILES")
349
+ print("="*60)
350
+
351
+ analyze_svg(sys.argv[1])
352
+ analyze_svg(sys.argv[2])
353
+
354
+ comparison = compare_two_svgs(sys.argv[1], sys.argv[2])
355
+
356
+ print("\n" + "="*60)
357
+ print("COMPARISON SUMMARY")
358
+ print("="*60)
359
+ print(f"\nFile 1: {comparison['file1']['element_count']} elements, {comparison['file1']['size']//1024}KB")
360
+ print(f"File 2: {comparison['file2']['element_count']} elements, {comparison['file2']['size']//1024}KB")
361
+
362
+ thick1 = comparison['stroke_width_comparison']['file1_thick_strokes']
363
+ thick2 = comparison['stroke_width_comparison']['file2_thick_strokes']
364
+ print(f"\nThick strokes: File1={len(thick1)}, File2={len(thick2)}")
365
+
366
+ if __name__ == '__main__':
367
+ main()
tools/svg-group-analysis.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Deep SVG Group Analysis - Find what's being missed
4
+ """
5
+
6
+ import re
7
+ import sys
8
+
9
+ def analyze_groups(svg_content):
10
+ """Analyze group structure and transforms"""
11
+
12
+ # Find all groups
13
+ group_pattern = r'<(?:\w+:)?g\s+([^>]*)>'
14
+ groups = []
15
+
16
+ for match in re.finditer(group_pattern, svg_content, re.IGNORECASE):
17
+ attrs = match.group(1)
18
+ pos = match.start()
19
+
20
+ # Extract transform
21
+ transform_match = re.search(r'transform="([^"]*)"', attrs)
22
+ transform = transform_match.group(1) if transform_match else None
23
+
24
+ groups.append({
25
+ 'position': pos,
26
+ 'attrs': attrs,
27
+ 'transform': transform
28
+ })
29
+
30
+ return groups
31
+
32
+ def find_elements_in_groups(svg_content):
33
+ """Find which elements are inside groups vs top-level"""
34
+
35
+ # Count total elements
36
+ all_paths = len(re.findall(r'<(?:\w+:)?path\s+', svg_content, re.IGNORECASE))
37
+ all_images = len(re.findall(r'<(?:\w+:)?image\s+', svg_content, re.IGNORECASE))
38
+
39
+ # Remove group content and count remaining
40
+ no_groups = re.sub(r'<(?:\w+:)?g[^>]*>[\s\S]*?</(?:\w+:)?g>', '', svg_content)
41
+ top_level_paths = len(re.findall(r'<(?:\w+:)?path\s+', no_groups, re.IGNORECASE))
42
+ top_level_images = len(re.findall(r'<(?:\w+:)?image\s+', no_groups, re.IGNORECASE))
43
+
44
+ return {
45
+ 'total_paths': all_paths,
46
+ 'paths_in_groups': all_paths - top_level_paths,
47
+ 'top_level_paths': top_level_paths,
48
+ 'total_images': all_images,
49
+ 'images_in_groups': all_images - top_level_images,
50
+ 'top_level_images': top_level_images
51
+ }
52
+
53
+ def find_bounding_boxes(svg_content):
54
+ """Find coordinate ranges to detect if lower half is missing"""
55
+
56
+ # Extract all Y coordinates from paths
57
+ y_coords = []
58
+
59
+ # From path d attributes - look for M, L, V commands with Y values
60
+ path_pattern = r'd="([^"]*)"'
61
+ for match in re.finditer(path_pattern, svg_content):
62
+ d = match.group(1)
63
+ # Extract numbers after M, L commands (every second number is Y)
64
+ coords = re.findall(r'[ML]\s*([-\d.]+)\s+([-\d.]+)', d, re.IGNORECASE)
65
+ for x, y in coords:
66
+ try:
67
+ y_coords.append(float(y))
68
+ except:
69
+ pass
70
+
71
+ if y_coords:
72
+ return {
73
+ 'min_y': min(y_coords),
74
+ 'max_y': max(y_coords),
75
+ 'y_range': max(y_coords) - min(y_coords)
76
+ }
77
+ return None
78
+
79
+ def analyze_nested_groups(svg_content):
80
+ """Find deeply nested groups"""
81
+
82
+ # Find group opening/closing tags
83
+ opens = [(m.start(), 'open') for m in re.finditer(r'<(?:\w+:)?g\s+[^>]*>', svg_content, re.IGNORECASE)]
84
+ closes = [(m.start(), 'close') for m in re.finditer(r'</(?:\w+:)?g>', svg_content, re.IGNORECASE)]
85
+
86
+ all_tags = sorted(opens + closes, key=lambda x: x[0])
87
+
88
+ max_depth = 0
89
+ current_depth = 0
90
+ depth_counts = {}
91
+
92
+ for pos, tag_type in all_tags:
93
+ if tag_type == 'open':
94
+ current_depth += 1
95
+ max_depth = max(max_depth, current_depth)
96
+ depth_counts[current_depth] = depth_counts.get(current_depth, 0) + 1
97
+ else:
98
+ current_depth -= 1
99
+
100
+ return {
101
+ 'max_nesting_depth': max_depth,
102
+ 'groups_at_each_depth': depth_counts
103
+ }
104
+
105
+ def check_transform_types(svg_content):
106
+ """Check what transform types are used"""
107
+
108
+ transforms = re.findall(r'transform="([^"]*)"', svg_content)
109
+
110
+ types = {
111
+ 'matrix': 0,
112
+ 'translate': 0,
113
+ 'scale': 0,
114
+ 'rotate': 0,
115
+ 'skew': 0
116
+ }
117
+
118
+ for t in transforms:
119
+ if 'matrix' in t:
120
+ types['matrix'] += 1
121
+ if 'translate' in t:
122
+ types['translate'] += 1
123
+ if 'scale' in t:
124
+ types['scale'] += 1
125
+ if 'rotate' in t:
126
+ types['rotate'] += 1
127
+ if 'skew' in t:
128
+ types['skew'] += 1
129
+
130
+ return types
131
+
132
+ def main():
133
+ if len(sys.argv) < 2:
134
+ print("Usage: python svg-group-analysis.py <file.svg>")
135
+ sys.exit(1)
136
+
137
+ filepath = sys.argv[1]
138
+ with open(filepath, 'r') as f:
139
+ content = f.read()
140
+
141
+ print(f"\n{'='*60}")
142
+ print(f"SVG GROUP ANALYSIS: {filepath}")
143
+ print('='*60)
144
+
145
+ # Basic counts
146
+ elem_counts = find_elements_in_groups(content)
147
+ print(f"\n📊 ELEMENT DISTRIBUTION:")
148
+ print(f" Total paths: {elem_counts['total_paths']}")
149
+ print(f" - In groups: {elem_counts['paths_in_groups']}")
150
+ print(f" - Top level: {elem_counts['top_level_paths']}")
151
+ print(f" Total images: {elem_counts['total_images']}")
152
+ print(f" - In groups: {elem_counts['images_in_groups']}")
153
+ print(f" - Top level: {elem_counts['top_level_images']}")
154
+
155
+ # Group nesting
156
+ nesting = analyze_nested_groups(content)
157
+ print(f"\n📁 GROUP NESTING:")
158
+ print(f" Max depth: {nesting['max_nesting_depth']}")
159
+ print(f" Groups at each depth: {nesting['groups_at_each_depth']}")
160
+
161
+ # Transforms
162
+ transforms = check_transform_types(content)
163
+ print(f"\n🔄 TRANSFORMS USED:")
164
+ for t, count in transforms.items():
165
+ if count > 0:
166
+ print(f" {t}: {count}")
167
+
168
+ # Groups with transforms
169
+ groups = analyze_groups(content)
170
+ groups_with_transforms = [g for g in groups if g['transform']]
171
+ print(f"\n🔧 GROUPS WITH TRANSFORMS: {len(groups_with_transforms)}")
172
+ for g in groups_with_transforms[:5]:
173
+ print(f" transform: {g['transform'][:60]}...")
174
+
175
+ # Bounding box
176
+ bbox = find_bounding_boxes(content)
177
+ if bbox:
178
+ print(f"\n📐 Y-COORDINATE RANGE:")
179
+ print(f" Min Y: {bbox['min_y']:.2f}")
180
+ print(f" Max Y: {bbox['max_y']:.2f}")
181
+ print(f" Range: {bbox['y_range']:.2f}")
182
+
183
+ # ViewBox
184
+ viewbox_match = re.search(r'viewBox="([^"]*)"', content)
185
+ if viewbox_match:
186
+ vb = viewbox_match.group(1).split()
187
+ if len(vb) >= 4:
188
+ vb_height = float(vb[3])
189
+ print(f" ViewBox height: {vb_height}")
190
+ if bbox:
191
+ coverage = (bbox['max_y'] - bbox['min_y']) / vb_height * 100
192
+ print(f" Content covers: {coverage:.1f}% of viewBox height")
193
+
194
+ if __name__ == '__main__':
195
+ main()
tools/svg-to-colorrm.cjs ADDED
@@ -0,0 +1,1640 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SVG to ColorRM Converter - Pro Version v3
4
+ *
5
+ * FIXES in v3:
6
+ * 1. Recursively processes elements inside groups
7
+ * 2. Applies inherited transforms from parent groups
8
+ * 3. Applies per-element transforms
9
+ * 4. Disabled simplification to preserve hand-drawn curves
10
+ * 5. Always preserves opacity values for highlighters
11
+ *
12
+ * PREVIOUS FIXES (v2):
13
+ * 1. Preserves element order (z-order) from original SVG
14
+ * 2. Detects fill+stroke path pairs and merges them
15
+ * 3. Extracts color from fill paths, stroke-width from stroke paths
16
+ * 4. Properly handles highlighter (low fill-opacity)
17
+ *
18
+ * Usage: node svg-to-colorrm.cjs input.svg [output.json]
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ // ============================================
25
+ // CONFIGURATION
26
+ // ============================================
27
+
28
+ const CONFIG = {
29
+ simplifyTolerance: 0, // Disabled - preserve original path shapes
30
+ maxPointsPerStroke: 5000, // Increased to allow more points
31
+ highlighterOpacityThreshold: 0.5,
32
+ bezierPointsPerPixel: 0.2,
33
+ minBezierSegments: 8,
34
+ maxBezierSegments: 100,
35
+ base64WarnSize: 500000,
36
+ defaultStrokeWidth: 2,
37
+ defaultStroke: '#000000',
38
+ defaultFill: 'transparent',
39
+ skipSimplification: true // New flag to skip simplification entirely
40
+ };
41
+
42
+ // ============================================
43
+ // UTILITY FUNCTIONS
44
+ // ============================================
45
+
46
+ function perpendicularDistance(point, lineStart, lineEnd) {
47
+ const dx = lineEnd.x - lineStart.x;
48
+ const dy = lineEnd.y - lineStart.y;
49
+ if (dx === 0 && dy === 0) {
50
+ return Math.sqrt((point.x - lineStart.x) ** 2 + (point.y - lineStart.y) ** 2);
51
+ }
52
+ const t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) / (dx * dx + dy * dy);
53
+ return Math.sqrt((point.x - (lineStart.x + t * dx)) ** 2 + (point.y - (lineStart.y + t * dy)) ** 2);
54
+ }
55
+
56
+ function simplifyPath(points, tolerance = CONFIG.simplifyTolerance) {
57
+ if (points.length <= 2) return points;
58
+ let maxDist = 0, maxIndex = 0;
59
+ const first = points[0], last = points[points.length - 1];
60
+ for (let i = 1; i < points.length - 1; i++) {
61
+ const dist = perpendicularDistance(points[i], first, last);
62
+ if (dist > maxDist) { maxDist = dist; maxIndex = i; }
63
+ }
64
+ if (maxDist > tolerance) {
65
+ const left = simplifyPath(points.slice(0, maxIndex + 1), tolerance);
66
+ const right = simplifyPath(points.slice(maxIndex), tolerance);
67
+ return left.slice(0, -1).concat(right);
68
+ }
69
+ return [first, last];
70
+ }
71
+
72
+ function enforceMaxPoints(points, maxPoints = CONFIG.maxPointsPerStroke) {
73
+ if (points.length <= maxPoints) return points;
74
+ let tolerance = CONFIG.simplifyTolerance;
75
+ let simplified = points;
76
+ while (simplified.length > maxPoints && tolerance < 50) {
77
+ tolerance *= 1.5;
78
+ simplified = simplifyPath(points, tolerance);
79
+ }
80
+ if (simplified.length > maxPoints) {
81
+ const step = simplified.length / maxPoints;
82
+ const result = [];
83
+ for (let i = 0; i < maxPoints; i++) result.push(simplified[Math.floor(i * step)]);
84
+ result.push(simplified[simplified.length - 1]);
85
+ return result;
86
+ }
87
+ return simplified;
88
+ }
89
+
90
+ // Compute bounding box of points
91
+ function computeBounds(pts) {
92
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
93
+ for (const p of pts) {
94
+ if (p.x < minX) minX = p.x;
95
+ if (p.y < minY) minY = p.y;
96
+ if (p.x > maxX) maxX = p.x;
97
+ if (p.y > maxY) maxY = p.y;
98
+ }
99
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
100
+ }
101
+
102
+ // Check if a path is a closed rectangle (4 corners, axis-aligned-ish)
103
+ function isClosedRectangle(pts) {
104
+ if (pts.length < 4 || pts.length > 6) return false; // Allow 4-5 points (closed rect might have duplicate endpoint)
105
+
106
+ // Get unique corners (remove duplicate close point if present)
107
+ const corners = [];
108
+ for (let i = 0; i < pts.length; i++) {
109
+ const p = pts[i];
110
+ const isDup = corners.some(c => Math.abs(c.x - p.x) < 1 && Math.abs(c.y - p.y) < 1);
111
+ if (!isDup) corners.push(p);
112
+ }
113
+
114
+ if (corners.length !== 4) return false;
115
+
116
+ // Check if corners form axis-aligned rectangle
117
+ const bounds = computeBounds(corners);
118
+ const tolerance = 2; // pixels
119
+
120
+ // Each corner should be at a corner of the bounding box
121
+ let cornersAtBounds = 0;
122
+ for (const c of corners) {
123
+ const atLeft = Math.abs(c.x - bounds.x) < tolerance;
124
+ const atRight = Math.abs(c.x - (bounds.x + bounds.width)) < tolerance;
125
+ const atTop = Math.abs(c.y - bounds.y) < tolerance;
126
+ const atBottom = Math.abs(c.y - (bounds.y + bounds.height)) < tolerance;
127
+
128
+ if ((atLeft || atRight) && (atTop || atBottom)) cornersAtBounds++;
129
+ }
130
+
131
+ return cornersAtBounds >= 4;
132
+ }
133
+
134
+ // Determine border type from stroke-dasharray value
135
+ function getBorderTypeFromDasharray(strokeDasharray) {
136
+ if (!strokeDasharray || strokeDasharray === 'none') return 'solid';
137
+ const parts = strokeDasharray.split(/[\s,]+/).map(parseFloat).filter(n => !isNaN(n));
138
+ if (parts.length >= 2) {
139
+ // Dotted: small gaps ratio > 1, Dashed: otherwise
140
+ const ratio = parts[1] / parts[0];
141
+ return ratio > 1 ? 'dotted' : 'dashed';
142
+ }
143
+ return parts.length === 1 ? 'dashed' : 'solid';
144
+ }
145
+
146
+ // ============================================
147
+ // BEZIER INTERPOLATION
148
+ // ============================================
149
+
150
+ function curveLength(x0, y0, x1, y1, x2, y2, x3, y3) {
151
+ const chord = Math.sqrt((x3 - x0) ** 2 + (y3 - y0) ** 2);
152
+ const poly = Math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2) +
153
+ Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) +
154
+ Math.sqrt((x3 - x2) ** 2 + (y3 - y2) ** 2);
155
+ return (chord + poly) / 2;
156
+ }
157
+
158
+ function adaptiveSegments(length) {
159
+ const segments = Math.ceil(length * CONFIG.bezierPointsPerPixel);
160
+ return Math.max(CONFIG.minBezierSegments, Math.min(CONFIG.maxBezierSegments, segments));
161
+ }
162
+
163
+ function cubicBezier(x0, y0, x1, y1, x2, y2, x3, y3) {
164
+ const segments = adaptiveSegments(curveLength(x0, y0, x1, y1, x2, y2, x3, y3));
165
+ const pts = [];
166
+ for (let i = 0; i <= segments; i++) {
167
+ const t = i / segments, mt = 1 - t;
168
+ pts.push({
169
+ x: mt*mt*mt*x0 + 3*mt*mt*t*x1 + 3*mt*t*t*x2 + t*t*t*x3,
170
+ y: mt*mt*mt*y0 + 3*mt*mt*t*y1 + 3*mt*t*t*y2 + t*t*t*y3
171
+ });
172
+ }
173
+ return pts;
174
+ }
175
+
176
+ function quadBezier(x0, y0, x1, y1, x2, y2) {
177
+ const length = Math.sqrt((x2 - x0) ** 2 + (y2 - y0) ** 2);
178
+ const segments = adaptiveSegments(length);
179
+ const pts = [];
180
+ for (let i = 0; i <= segments; i++) {
181
+ const t = i / segments, mt = 1 - t;
182
+ pts.push({ x: mt*mt*x0 + 2*mt*t*x1 + t*t*x2, y: mt*mt*y0 + 2*mt*t*y1 + t*t*y2 });
183
+ }
184
+ return pts;
185
+ }
186
+
187
+ function arcPoints(x0, y0, rx, ry, rotation, largeArc, sweep, x1, y1) {
188
+ if (rx === 0 || ry === 0) return [{ x: x0, y: y0 }, { x: x1, y: y1 }];
189
+ const phi = rotation * Math.PI / 180;
190
+ const cosPhi = Math.cos(phi), sinPhi = Math.sin(phi);
191
+ const dx = (x0 - x1) / 2, dy = (y0 - y1) / 2;
192
+ const x1p = cosPhi * dx + sinPhi * dy, y1p = -sinPhi * dx + cosPhi * dy;
193
+ let rxSq = rx * rx, rySq = ry * ry;
194
+ const x1pSq = x1p * x1p, y1pSq = y1p * y1p;
195
+ const lambda = x1pSq / rxSq + y1pSq / rySq;
196
+ if (lambda > 1) { rx *= Math.sqrt(lambda); ry *= Math.sqrt(lambda); rxSq = rx*rx; rySq = ry*ry; }
197
+ let sq = (rxSq*rySq - rxSq*y1pSq - rySq*x1pSq) / (rxSq*y1pSq + rySq*x1pSq);
198
+ sq = sq < 0 ? 0 : sq;
199
+ const coef = (largeArc !== sweep ? 1 : -1) * Math.sqrt(sq);
200
+ const cxp = coef * rx * y1p / ry, cyp = coef * -ry * x1p / rx;
201
+ const mx = (x0 + x1) / 2, my = (y0 + y1) / 2;
202
+ const cx = cosPhi * cxp - sinPhi * cyp + mx, cy = sinPhi * cxp + cosPhi * cyp + my;
203
+ const ux = (x1p - cxp) / rx, uy = (y1p - cyp) / ry;
204
+ const n = Math.sqrt(ux*ux + uy*uy);
205
+ let theta = (uy < 0 ? -1 : 1) * Math.acos(ux / n);
206
+ const vx = (-x1p - cxp) / rx, vy = (-y1p - cyp) / ry;
207
+ const nn = Math.sqrt((ux*ux + uy*uy) * (vx*vx + vy*vy));
208
+ let dTheta = (ux*vy - uy*vx < 0 ? -1 : 1) * Math.acos((ux*vx + uy*vy) / nn);
209
+ if (!sweep && dTheta > 0) dTheta -= 2 * Math.PI;
210
+ if (sweep && dTheta < 0) dTheta += 2 * Math.PI;
211
+ const arcLen = Math.abs(dTheta) * Math.max(rx, ry);
212
+ const segments = adaptiveSegments(arcLen);
213
+ const pts = [];
214
+ for (let i = 0; i <= segments; i++) {
215
+ const t = i / segments, angle = theta + t * dTheta;
216
+ const xr = rx * Math.cos(angle), yr = ry * Math.sin(angle);
217
+ pts.push({ x: cosPhi * xr - sinPhi * yr + cx, y: sinPhi * xr + cosPhi * yr + cy });
218
+ }
219
+ return pts;
220
+ }
221
+
222
+ // ============================================
223
+ // PATH PARSER
224
+ // ============================================
225
+
226
+ function parsePathD(d) {
227
+ if (!d) return [];
228
+ const points = [];
229
+ let currentX = 0, currentY = 0, startX = 0, startY = 0;
230
+ let lastControlX = 0, lastControlY = 0, lastCommand = '';
231
+ const tokens = d.match(/[a-zA-Z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g) || [];
232
+ let i = 0;
233
+ const getNum = () => i < tokens.length ? (parseFloat(tokens[i++]) || 0) : 0;
234
+ const addPt = (x, y) => {
235
+ if (points.length === 0 || points[points.length-1].x !== x || points[points.length-1].y !== y)
236
+ points.push({ x, y });
237
+ };
238
+ while (i < tokens.length) {
239
+ let cmd = tokens[i];
240
+ if (/[a-zA-Z]/.test(cmd)) { i++; lastCommand = cmd; }
241
+ else { cmd = lastCommand; if (cmd === 'M') cmd = 'L'; if (cmd === 'm') cmd = 'l'; }
242
+ const rel = cmd === cmd.toLowerCase(), C = cmd.toUpperCase();
243
+ switch (C) {
244
+ case 'M': { let x = getNum(), y = getNum(); if (rel) { x += currentX; y += currentY; }
245
+ currentX = x; currentY = y; startX = x; startY = y; addPt(x, y); break; }
246
+ case 'L': { let x = getNum(), y = getNum(); if (rel) { x += currentX; y += currentY; }
247
+ currentX = x; currentY = y; addPt(x, y); break; }
248
+ case 'H': { let x = getNum(); if (rel) x += currentX; currentX = x; addPt(currentX, currentY); break; }
249
+ case 'V': { let y = getNum(); if (rel) y += currentY; currentY = y; addPt(currentX, currentY); break; }
250
+ case 'C': { let x1 = getNum(), y1 = getNum(), x2 = getNum(), y2 = getNum(), x = getNum(), y = getNum();
251
+ if (rel) { x1 += currentX; y1 += currentY; x2 += currentX; y2 += currentY; x += currentX; y += currentY; }
252
+ cubicBezier(currentX, currentY, x1, y1, x2, y2, x, y).slice(1).forEach(p => addPt(p.x, p.y));
253
+ lastControlX = x2; lastControlY = y2; currentX = x; currentY = y; break; }
254
+ case 'S': { let x1 = currentX*2 - lastControlX, y1 = currentY*2 - lastControlY;
255
+ let x2 = getNum(), y2 = getNum(), x = getNum(), y = getNum();
256
+ if (rel) { x2 += currentX; y2 += currentY; x += currentX; y += currentY; }
257
+ if (!'CScs'.includes(lastCommand)) { x1 = currentX; y1 = currentY; }
258
+ cubicBezier(currentX, currentY, x1, y1, x2, y2, x, y).slice(1).forEach(p => addPt(p.x, p.y));
259
+ lastControlX = x2; lastControlY = y2; currentX = x; currentY = y; break; }
260
+ case 'Q': { let x1 = getNum(), y1 = getNum(), x = getNum(), y = getNum();
261
+ if (rel) { x1 += currentX; y1 += currentY; x += currentX; y += currentY; }
262
+ quadBezier(currentX, currentY, x1, y1, x, y).slice(1).forEach(p => addPt(p.x, p.y));
263
+ lastControlX = x1; lastControlY = y1; currentX = x; currentY = y; break; }
264
+ case 'T': { let x1 = currentX*2 - lastControlX, y1 = currentY*2 - lastControlY;
265
+ let x = getNum(), y = getNum(); if (rel) { x += currentX; y += currentY; }
266
+ if (!'QTqt'.includes(lastCommand)) { x1 = currentX; y1 = currentY; }
267
+ quadBezier(currentX, currentY, x1, y1, x, y).slice(1).forEach(p => addPt(p.x, p.y));
268
+ lastControlX = x1; lastControlY = y1; currentX = x; currentY = y; break; }
269
+ case 'A': { const rxV = Math.abs(getNum()), ryV = Math.abs(getNum()), rot = getNum();
270
+ const la = !!getNum(), sw = !!getNum(); let x = getNum(), y = getNum();
271
+ if (rel) { x += currentX; y += currentY; }
272
+ arcPoints(currentX, currentY, rxV, ryV, rot, la, sw, x, y).slice(1).forEach(p => addPt(p.x, p.y));
273
+ currentX = x; currentY = y; break; }
274
+ case 'Z': { if (currentX !== startX || currentY !== startY) addPt(startX, startY);
275
+ currentX = startX; currentY = startY; break; }
276
+ default: i++;
277
+ }
278
+ lastCommand = cmd;
279
+ }
280
+ return points;
281
+ }
282
+
283
+ // ============================================
284
+ // TRANSFORM MATRIX
285
+ // ============================================
286
+
287
+ function identityMatrix() { return [1, 0, 0, 1, 0, 0]; }
288
+
289
+ function multiplyMatrix(a, b) {
290
+ return [
291
+ a[0]*b[0] + a[2]*b[1], a[1]*b[0] + a[3]*b[1],
292
+ a[0]*b[2] + a[2]*b[3], a[1]*b[2] + a[3]*b[3],
293
+ a[0]*b[4] + a[2]*b[5] + a[4], a[1]*b[4] + a[3]*b[5] + a[5]
294
+ ];
295
+ }
296
+
297
+ function parseTransform(transform) {
298
+ if (!transform) return identityMatrix();
299
+ let matrix = identityMatrix();
300
+ const transforms = transform.match(/(\w+)\s*\([^)]+\)/g) || [];
301
+ for (const t of transforms) {
302
+ const match = t.match(/(\w+)\s*\(([^)]+)\)/);
303
+ if (!match) continue;
304
+ const type = match[1], values = match[2].split(/[\s,]+/).map(parseFloat);
305
+ let m;
306
+ switch (type) {
307
+ case 'matrix': if (values.length >= 6) m = values.slice(0, 6); break;
308
+ case 'translate': m = [1, 0, 0, 1, values[0] || 0, values[1] || 0]; break;
309
+ case 'scale': const sx = values[0] || 1, sy = values[1] !== undefined ? values[1] : sx;
310
+ m = [sx, 0, 0, sy, 0, 0]; break;
311
+ case 'rotate': const angle = (values[0] || 0) * Math.PI / 180;
312
+ const cos = Math.cos(angle), sin = Math.sin(angle);
313
+ if (values.length >= 3) { const cx = values[1], cy = values[2];
314
+ m = [cos, sin, -sin, cos, cx - cos*cx + sin*cy, cy - sin*cx - cos*cy];
315
+ } else m = [cos, sin, -sin, cos, 0, 0]; break;
316
+ case 'skewX': m = [1, 0, Math.tan((values[0]||0)*Math.PI/180), 1, 0, 0]; break;
317
+ case 'skewY': m = [1, Math.tan((values[0]||0)*Math.PI/180), 0, 1, 0, 0]; break;
318
+ }
319
+ if (m) matrix = multiplyMatrix(matrix, m);
320
+ }
321
+ return matrix;
322
+ }
323
+
324
+ function transformPoint(x, y, m) { return { x: m[0]*x + m[2]*y + m[4], y: m[1]*x + m[3]*y + m[5] }; }
325
+ function getMatrixScale(m) { return Math.sqrt(Math.abs(m[0]*m[3] - m[1]*m[2])); }
326
+
327
+ // ============================================
328
+ // COLOR PARSING
329
+ // ============================================
330
+
331
+ function parseColor(color) {
332
+ if (!color || color === 'none' || color === 'transparent') return null;
333
+ if (color === 'currentColor') return '#000000';
334
+ if (color.startsWith('#')) {
335
+ if (color.length === 4) return '#' + color[1]+color[1] + color[2]+color[2] + color[3]+color[3];
336
+ return color;
337
+ }
338
+ const rgb = color.match(/rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
339
+ if (rgb) return '#' + parseInt(rgb[1]).toString(16).padStart(2,'0') +
340
+ parseInt(rgb[2]).toString(16).padStart(2,'0') + parseInt(rgb[3]).toString(16).padStart(2,'0');
341
+ const named = { black:'#000000', white:'#ffffff', red:'#ff0000', green:'#008000', blue:'#0000ff',
342
+ yellow:'#ffff00', cyan:'#00ffff', magenta:'#ff00ff', orange:'#ffa500', purple:'#800080' };
343
+ return named[color.toLowerCase()] || '#000000';
344
+ }
345
+
346
+ function parseOpacity(value) {
347
+ if (value === undefined || value === null || value === '') return 1;
348
+ const num = parseFloat(value);
349
+ return isNaN(num) ? 1 : Math.max(0, Math.min(1, num));
350
+ }
351
+
352
+ // ============================================
353
+ // ATTRIBUTE EXTRACTION
354
+ // ============================================
355
+
356
+ const NS = '(?:\\w+:)?';
357
+
358
+ function getAttr(element, attr) {
359
+ const patterns = [new RegExp(`\\s${attr}="([^"]*)"`, 'i'), new RegExp(`\\s\\w+:${attr}="([^"]*)"`, 'i')];
360
+ for (const p of patterns) { const m = element.match(p); if (m) return m[1]; }
361
+ return null;
362
+ }
363
+
364
+ function parseStyle(style) {
365
+ if (!style) return {};
366
+ const result = {};
367
+ style.split(';').forEach(r => { const [p, v] = r.split(':').map(s => s.trim()); if (p && v) result[p] = v; });
368
+ return result;
369
+ }
370
+
371
+ // ============================================
372
+ // SIMPLE XML ELEMENT PARSER (no regex for structure)
373
+ // ============================================
374
+
375
+ /**
376
+ * Parse SVG content into a tree structure
377
+ * This is more robust than regex-based parsing
378
+ */
379
+ function parseXMLElements(content) {
380
+ const elements = [];
381
+ let i = 0;
382
+ const len = content.length;
383
+
384
+ while (i < len) {
385
+ // Skip until we find a tag
386
+ const tagStart = content.indexOf('<', i);
387
+ if (tagStart === -1) break;
388
+
389
+ // Skip comments and CDATA
390
+ if (content.slice(tagStart, tagStart + 4) === '<!--') {
391
+ const commentEnd = content.indexOf('-->', tagStart);
392
+ i = commentEnd === -1 ? len : commentEnd + 3;
393
+ continue;
394
+ }
395
+ if (content.slice(tagStart, tagStart + 9) === '<![CDATA[') {
396
+ const cdataEnd = content.indexOf(']]>', tagStart);
397
+ i = cdataEnd === -1 ? len : cdataEnd + 3;
398
+ continue;
399
+ }
400
+
401
+ // Skip closing tags
402
+ if (content[tagStart + 1] === '/') {
403
+ const closeEnd = content.indexOf('>', tagStart);
404
+ i = closeEnd === -1 ? len : closeEnd + 1;
405
+ continue;
406
+ }
407
+
408
+ // Skip processing instructions and doctype
409
+ if (content[tagStart + 1] === '?' || content[tagStart + 1] === '!') {
410
+ const piEnd = content.indexOf('>', tagStart);
411
+ i = piEnd === -1 ? len : piEnd + 1;
412
+ continue;
413
+ }
414
+
415
+ // Find tag name end
416
+ let j = tagStart + 1;
417
+ while (j < len && /[a-zA-Z0-9:_-]/.test(content[j])) j++;
418
+ const tagName = content.slice(tagStart + 1, j);
419
+
420
+ if (!tagName) {
421
+ i = tagStart + 1;
422
+ continue;
423
+ }
424
+
425
+ // Find end of opening tag (either /> or >)
426
+ let inQuote = false;
427
+ let quoteChar = '';
428
+ let k = j;
429
+ while (k < len) {
430
+ const ch = content[k];
431
+ if (inQuote) {
432
+ if (ch === quoteChar) inQuote = false;
433
+ } else {
434
+ if (ch === '"' || ch === "'") {
435
+ inQuote = true;
436
+ quoteChar = ch;
437
+ } else if (ch === '>') {
438
+ break;
439
+ }
440
+ }
441
+ k++;
442
+ }
443
+
444
+ if (k >= len) break;
445
+
446
+ const isSelfClosing = content[k - 1] === '/';
447
+ const attrsStr = content.slice(j, isSelfClosing ? k - 1 : k).trim();
448
+ const tagEnd = k + 1;
449
+
450
+ // Parse attributes
451
+ const attrs = parseAttributes(attrsStr);
452
+
453
+ let innerContent = '';
454
+ let elementEnd = tagEnd;
455
+
456
+ // If not self-closing, find the closing tag
457
+ if (!isSelfClosing) {
458
+ const closeTag = findClosingTag(content, tagEnd, tagName);
459
+ if (closeTag.end !== -1) {
460
+ innerContent = content.slice(tagEnd, closeTag.start);
461
+ elementEnd = closeTag.end;
462
+ } else {
463
+ elementEnd = tagEnd;
464
+ }
465
+ }
466
+
467
+ elements.push({
468
+ position: tagStart,
469
+ tagName: tagName.replace(/^\w+:/, '').toLowerCase(),
470
+ rawTagName: tagName,
471
+ attrs,
472
+ attrsStr,
473
+ innerContent,
474
+ selfClosing: isSelfClosing,
475
+ end: elementEnd
476
+ });
477
+
478
+ i = elementEnd;
479
+ }
480
+
481
+ return elements;
482
+ }
483
+
484
+ /**
485
+ * Parse attribute string into object
486
+ */
487
+ function parseAttributes(attrsStr) {
488
+ const attrs = {};
489
+ const attrPattern = /([a-zA-Z0-9:_-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
490
+ let match;
491
+ while ((match = attrPattern.exec(attrsStr)) !== null) {
492
+ attrs[match[1]] = match[2] !== undefined ? match[2] : match[3];
493
+ }
494
+ return attrs;
495
+ }
496
+
497
+ /**
498
+ * Find the matching closing tag, handling nesting
499
+ * Handles namespaced tags (e.g., svg:g) by matching both with and without namespace
500
+ */
501
+ function findClosingTag(content, start, tagName) {
502
+ // Strip namespace from tagName for pattern matching
503
+ const baseName = tagName.replace(/^\w+:/, '');
504
+
505
+ // Match both namespaced and non-namespaced versions
506
+ const escapedName = baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
507
+ const openPattern = new RegExp(`<(?:\\w+:)?${escapedName}(?:\\s|>)`, 'gi');
508
+ const closePattern = new RegExp(`</(?:\\w+:)?${escapedName}>`, 'gi');
509
+
510
+ let depth = 1;
511
+ let pos = start;
512
+
513
+ while (depth > 0 && pos < content.length) {
514
+ openPattern.lastIndex = pos;
515
+ closePattern.lastIndex = pos;
516
+
517
+ const openMatch = openPattern.exec(content);
518
+ const closeMatch = closePattern.exec(content);
519
+
520
+ if (!closeMatch) {
521
+ // No closing tag found - try to find end of content
522
+ return { start: -1, end: -1 };
523
+ }
524
+
525
+ // Check if there's an opening tag before the closing tag
526
+ if (openMatch && openMatch.index < closeMatch.index) {
527
+ // Check if it's not a self-closing tag
528
+ const tagEnd = content.indexOf('>', openMatch.index);
529
+ if (tagEnd !== -1 && content[tagEnd - 1] !== '/') {
530
+ depth++;
531
+ }
532
+ pos = openMatch.index + openMatch[0].length;
533
+ } else {
534
+ depth--;
535
+ if (depth === 0) {
536
+ return { start: closeMatch.index, end: closeMatch.index + closeMatch[0].length };
537
+ }
538
+ pos = closeMatch.index + closeMatch[0].length;
539
+ }
540
+ }
541
+
542
+ return { start: -1, end: -1 };
543
+ }
544
+
545
+ // ============================================
546
+ // ELEMENT COLLECTION (using proper parser)
547
+ // ============================================
548
+
549
+ // Global mask definitions storage
550
+ let maskDefinitions = {};
551
+
552
+ function collectAllElementsRecursive(content, parentTransform = identityMatrix(), depth = 0, parentMaskId = null) {
553
+ const elements = [];
554
+ const parsedElements = parseXMLElements(content);
555
+
556
+ const drawableElements = ['path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'text', 'image'];
557
+
558
+ for (const elem of parsedElements) {
559
+ const tagLower = elem.tagName;
560
+
561
+ if (tagLower === 'g') {
562
+ // It's a group - parse transform and recurse
563
+ const groupTransformStr = elem.attrs.transform || null;
564
+ const groupMatrix = parseTransform(groupTransformStr);
565
+ const combinedTransform = multiplyMatrix(parentTransform, groupMatrix);
566
+
567
+ // Also inherit style attributes
568
+ const groupOpacity = elem.attrs.opacity;
569
+
570
+ // Check for mask attribute on this group
571
+ let maskId = parentMaskId;
572
+ const maskAttr = elem.attrs.mask;
573
+ if (maskAttr) {
574
+ // Extract mask ID from url(#mask_id)
575
+ const maskMatch = maskAttr.match(/url\(#([^)]+)\)/);
576
+ if (maskMatch) {
577
+ maskId = maskMatch[1];
578
+ }
579
+ }
580
+
581
+ // Recursively process group contents with mask inheritance
582
+ const childElements = collectAllElementsRecursive(elem.innerContent, combinedTransform, depth + 1, maskId);
583
+
584
+ // Apply group opacity to children if set
585
+ if (groupOpacity !== undefined) {
586
+ childElements.forEach(child => {
587
+ if (child.groupOpacity === undefined) {
588
+ child.groupOpacity = parseFloat(groupOpacity);
589
+ } else {
590
+ child.groupOpacity *= parseFloat(groupOpacity);
591
+ }
592
+ });
593
+ }
594
+
595
+ elements.push(...childElements);
596
+
597
+ } else if (tagLower === 'mask') {
598
+ // Store mask definition - extract image data from inside
599
+ const maskId = elem.attrs.id;
600
+ if (maskId) {
601
+ // Find image inside the mask
602
+ const maskContent = elem.innerContent;
603
+ const imageMatch = maskContent.match(/<image[^>]*(?:xlink:)?href="([^"]+)"[^>]*>/);
604
+ if (imageMatch) {
605
+ // Store the mask image data
606
+ maskDefinitions[maskId] = {
607
+ imageData: imageMatch[1],
608
+ type: 'luminance' // SVG masks default to luminance
609
+ };
610
+ }
611
+ }
612
+ // Don't add mask content as drawable elements
613
+ continue;
614
+
615
+ } else if (tagLower === 'clippath') {
616
+ // Skip clippath content
617
+ continue;
618
+
619
+ } else if (tagLower === 'defs') {
620
+ // Process defs - this will capture masks inside
621
+ const childElements = collectAllElementsRecursive(elem.innerContent, parentTransform, depth + 1, parentMaskId);
622
+ // Don't add defs content to drawable elements
623
+ continue;
624
+
625
+ } else if (drawableElements.includes(tagLower)) {
626
+ // It's a drawable element
627
+ const elemData = {
628
+ position: elem.position,
629
+ tag: tagLower,
630
+ fullMatch: content.slice(elem.position, elem.end),
631
+ attrs: elem.attrsStr,
632
+ attrsObj: elem.attrs,
633
+ innerContent: elem.innerContent,
634
+ inheritedTransform: parentTransform
635
+ };
636
+
637
+ // Add mask reference if this element is inside a masked group
638
+ if (parentMaskId) {
639
+ elemData.maskId = parentMaskId;
640
+ }
641
+
642
+ elements.push(elemData);
643
+ }
644
+ }
645
+
646
+ return elements;
647
+ }
648
+
649
+ function collectAllElements(content) {
650
+ // Reset mask definitions for each conversion
651
+ maskDefinitions = {};
652
+ console.log('Using recursive element collection with transform inheritance...');
653
+ return collectAllElementsRecursive(content);
654
+ }
655
+
656
+ // ============================================
657
+ // PATH PAIR DETECTION & MERGING
658
+ // ============================================
659
+
660
+ function detectPathPairs(elements) {
661
+ const merged = [];
662
+ const used = new Set();
663
+
664
+ // Helper to get attribute from attrsObj or fallback to getAttr
665
+ const getElemAttr = (elem, attr) => {
666
+ if (elem.attrsObj && elem.attrsObj[attr] !== undefined) {
667
+ return elem.attrsObj[attr];
668
+ }
669
+ return getAttr(`<${elem.tag} ${elem.attrs}>`, attr);
670
+ };
671
+
672
+ for (let i = 0; i < elements.length; i++) {
673
+ if (used.has(i)) continue;
674
+
675
+ const elem = elements[i];
676
+
677
+ if (elem.tag === 'path') {
678
+ const d = getElemAttr(elem, 'd');
679
+ const fill = getElemAttr(elem, 'fill');
680
+ const stroke = getElemAttr(elem, 'stroke');
681
+ const fillOpacity = getElemAttr(elem, 'fill-opacity');
682
+ const strokeWidth = getElemAttr(elem, 'stroke-width');
683
+ const styleStr = getElemAttr(elem, 'style');
684
+ const style = parseStyle(styleStr);
685
+
686
+ // Check if this is a fill-only path (potential first of pair)
687
+ const isFillPath = fill && fill !== 'none' && (!stroke || stroke === 'none');
688
+
689
+ if (isFillPath && i + 1 < elements.length) {
690
+ const nextElem = elements[i + 1];
691
+ if (nextElem.tag === 'path') {
692
+ const nextD = getElemAttr(nextElem, 'd');
693
+ const nextFill = getElemAttr(nextElem, 'fill');
694
+ const nextStroke = getElemAttr(nextElem, 'stroke');
695
+ const nextStrokeWidth = getElemAttr(nextElem, 'stroke-width');
696
+ const nextStrokeOpacity = getElemAttr(nextElem, 'stroke-opacity');
697
+ const nextStrokeDasharray = getElemAttr(nextElem, 'stroke-dasharray');
698
+
699
+ // Check if next path is stroke-only with same d
700
+ const isStrokePath = (!nextFill || nextFill === 'none') && nextStroke && nextStroke !== 'none';
701
+
702
+ if (isStrokePath && d === nextD) {
703
+ const fillOp = parseFloat(fillOpacity || style['fill-opacity'] || '1');
704
+ const strokeOp = parseFloat(nextStrokeOpacity || '1');
705
+
706
+ // CASE 1: Visible fill WITH solid stroke (like filled box with border)
707
+ // DON'T merge - keep both as separate elements (a fill shape + border)
708
+ if (fillOp >= 0.01 && strokeOp >= 0.99) {
709
+ // Mark both as used but create a SHAPE instead of stroke
710
+ used.add(i);
711
+ used.add(i + 1);
712
+
713
+ // Create a filled shape with border
714
+ merged.push({
715
+ ...elem,
716
+ isPair: true,
717
+ isFillWithBorder: true, // New flag for shapes with fill + border
718
+ mergedFillColor: fill,
719
+ mergedFillOpacity: fillOp,
720
+ mergedStrokeColor: nextStroke,
721
+ mergedStrokeWidth: nextStrokeWidth || strokeWidth || '2',
722
+ mergedStrokeOpacity: strokeOp,
723
+ mergedStrokeDasharray: nextStrokeDasharray
724
+ });
725
+ continue;
726
+ }
727
+
728
+ // CASE 2: Invisible fill (fillOp < 0.01) - eraser/highlighter pattern
729
+ // Use stroke color and stroke-opacity
730
+ if (fillOp < 0.01) {
731
+ used.add(i);
732
+ used.add(i + 1);
733
+ merged.push({
734
+ ...elem,
735
+ isPair: true,
736
+ mergedColor: nextStroke,
737
+ mergedStrokeWidth: nextStrokeWidth || strokeWidth || '2',
738
+ mergedOpacity: strokeOp, // Use stroke-opacity from companion path
739
+ originalStrokeColor: nextStroke,
740
+ isEraser: fillOp < 0.01 && strokeOp >= 1
741
+ });
742
+ continue;
743
+ }
744
+
745
+ // CASE 3: Semi-transparent fill with semi-transparent stroke (highlighter)
746
+ // Merge into single highlighter stroke
747
+ used.add(i);
748
+ used.add(i + 1);
749
+ merged.push({
750
+ ...elem,
751
+ isPair: true,
752
+ mergedColor: fill,
753
+ mergedStrokeWidth: nextStrokeWidth || strokeWidth || '2',
754
+ mergedOpacity: fillOp,
755
+ originalStrokeColor: nextStroke
756
+ });
757
+ continue;
758
+ }
759
+ }
760
+ }
761
+ }
762
+
763
+ // Not a pair, add as-is
764
+ merged.push(elem);
765
+ used.add(i);
766
+ }
767
+
768
+ return merged;
769
+ }
770
+
771
+ // ============================================
772
+ // ELEMENT CONVERTERS
773
+ // ============================================
774
+
775
+ let itemIdCounter = Date.now();
776
+ function generateId() { return itemIdCounter++; }
777
+
778
+ // Helper to get attribute from element (prefers parsed attrsObj, falls back to regex)
779
+ function getElemAttr(elem, attr) {
780
+ if (elem.attrsObj && elem.attrsObj[attr] !== undefined) {
781
+ return elem.attrsObj[attr];
782
+ }
783
+ return getAttr(`<${elem.tag} ${elem.attrs}>`, attr);
784
+ }
785
+
786
+ function convertPath(elem, globalTransform) {
787
+ const d = getElemAttr(elem, 'd');
788
+ if (!d) return null;
789
+
790
+ let pts = parsePathD(d);
791
+ if (pts.length < 2) return null;
792
+
793
+ // Combine inherited transform (from groups) with element's own transform
794
+ const elemTransformStr = getElemAttr(elem, 'transform');
795
+ const elemTransform = parseTransform(elemTransformStr);
796
+ const inheritedTransform = elem.inheritedTransform || identityMatrix();
797
+ const transform = multiplyMatrix(multiplyMatrix(globalTransform, inheritedTransform), elemTransform);
798
+
799
+ // Apply group opacity if inherited
800
+ let groupOpacityMultiplier = elem.groupOpacity !== undefined ? elem.groupOpacity : 1;
801
+
802
+ pts = pts.map(p => transformPoint(p.x, p.y, transform));
803
+
804
+ // Skip simplification if configured
805
+ if (!CONFIG.skipSimplification && CONFIG.simplifyTolerance > 0) {
806
+ pts = simplifyPath(pts);
807
+ }
808
+ pts = enforceMaxPoints(pts);
809
+ if (pts.length < 2) return null;
810
+
811
+ // Handle merged pair - fillWithBorder case (visible fill + solid stroke)
812
+ if (elem.isPair && elem.isFillWithBorder) {
813
+ const fillOpacity = elem.mergedFillOpacity * groupOpacityMultiplier;
814
+ const strokeOpacity = elem.mergedStrokeOpacity * groupOpacityMultiplier;
815
+ const strokeWidth = parseFloat(elem.mergedStrokeWidth) * getMatrixScale(transform);
816
+ const borderType = getBorderTypeFromDasharray(elem.mergedStrokeDasharray);
817
+
818
+ // Try to detect if this is a rectangle-like shape (closed path with 4 corners)
819
+ const isRectLike = isClosedRectangle(pts);
820
+
821
+ if (isRectLike) {
822
+ // Convert to rectangle shape
823
+ const bounds = computeBounds(pts);
824
+ return {
825
+ id: generateId(),
826
+ lastMod: Date.now(),
827
+ tool: 'shape',
828
+ shapeType: 'rectangle',
829
+ x: bounds.x,
830
+ y: bounds.y,
831
+ width: bounds.width,
832
+ height: bounds.height,
833
+ borderColor: parseColor(elem.mergedStrokeColor) || CONFIG.defaultStroke,
834
+ fillColor: parseColor(elem.mergedFillColor) || '#ffffff',
835
+ fillOpacity: fillOpacity,
836
+ borderOpacity: strokeOpacity,
837
+ borderSize: strokeWidth,
838
+ borderType: borderType,
839
+ deleted: false
840
+ };
841
+ } else {
842
+ // Create a polygon shape for non-rectangular paths
843
+ const bounds = computeBounds(pts);
844
+ const normalizedPts = pts.map(p => ({
845
+ x: bounds.width > 0 ? (p.x - bounds.x) / bounds.width : 0,
846
+ y: bounds.height > 0 ? (p.y - bounds.y) / bounds.height : 0
847
+ }));
848
+
849
+ return {
850
+ id: generateId(),
851
+ lastMod: Date.now(),
852
+ tool: 'shape',
853
+ shapeType: 'polygon',
854
+ x: bounds.x,
855
+ y: bounds.y,
856
+ width: bounds.width,
857
+ height: bounds.height,
858
+ pts: normalizedPts,
859
+ borderColor: parseColor(elem.mergedStrokeColor) || CONFIG.defaultStroke,
860
+ fillColor: parseColor(elem.mergedFillColor) || '#ffffff',
861
+ fillOpacity: fillOpacity,
862
+ borderOpacity: strokeOpacity,
863
+ borderSize: strokeWidth,
864
+ borderType: borderType,
865
+ deleted: false
866
+ };
867
+ }
868
+ }
869
+
870
+ // Handle merged pair - highlighter/eraser case
871
+ if (elem.isPair) {
872
+ const opacity = elem.mergedOpacity * groupOpacityMultiplier;
873
+ const tool = opacity < CONFIG.highlighterOpacityThreshold ? 'highlighter' : 'pen';
874
+ const strokeWidth = parseFloat(elem.mergedStrokeWidth) * getMatrixScale(transform);
875
+
876
+ return {
877
+ id: generateId(),
878
+ lastMod: Date.now(),
879
+ tool,
880
+ pts,
881
+ color: parseColor(elem.mergedColor) || CONFIG.defaultStroke,
882
+ size: strokeWidth,
883
+ opacity: opacity, // Always include opacity
884
+ lineCap: 'round',
885
+ lineJoin: 'round',
886
+ deleted: false
887
+ };
888
+ }
889
+
890
+ // Single path
891
+ const fill = getElemAttr(elem, 'fill');
892
+ const stroke = getElemAttr(elem, 'stroke');
893
+ const strokeWidth = getElemAttr(elem, 'stroke-width');
894
+ const fillOpacity = getElemAttr(elem, 'fill-opacity');
895
+ const strokeOpacity = getElemAttr(elem, 'stroke-opacity');
896
+ const opacity = getElemAttr(elem, 'opacity');
897
+ const lineCap = getElemAttr(elem, 'stroke-linecap');
898
+ const lineJoin = getElemAttr(elem, 'stroke-linejoin');
899
+ const strokeDasharray = getElemAttr(elem, 'stroke-dasharray');
900
+
901
+ // Check if this is a stroke-only path with a dash pattern (dashed/dotted border)
902
+ // If so, and it looks like a closed rectangle, convert to a shape instead of stroke
903
+ const isStrokeOnly = (!fill || fill === 'none') && stroke && stroke !== 'none';
904
+ const hasDashPattern = strokeDasharray && strokeDasharray !== 'none';
905
+
906
+ if (isStrokeOnly && hasDashPattern && isClosedRectangle(pts)) {
907
+ // Convert to rectangle shape with dashed border and no fill
908
+ const bounds = computeBounds(pts);
909
+ const borderType = getBorderTypeFromDasharray(strokeDasharray);
910
+ const strokeOp = parseFloat(strokeOpacity || opacity || '1') * groupOpacityMultiplier;
911
+ const sw = (parseFloat(strokeWidth) || CONFIG.defaultStrokeWidth) * getMatrixScale(transform);
912
+
913
+ return {
914
+ id: generateId(),
915
+ lastMod: Date.now(),
916
+ tool: 'shape',
917
+ shapeType: 'rectangle',
918
+ x: bounds.x,
919
+ y: bounds.y,
920
+ width: bounds.width,
921
+ height: bounds.height,
922
+ borderColor: parseColor(stroke) || CONFIG.defaultStroke,
923
+ fillColor: 'transparent',
924
+ fillOpacity: 0,
925
+ borderOpacity: strokeOp,
926
+ borderSize: sw,
927
+ borderType: borderType,
928
+ deleted: false
929
+ };
930
+ }
931
+
932
+ // Check if stroke-only with dash pattern but not rectangular - make polygon
933
+ if (isStrokeOnly && hasDashPattern && pts.length >= 3) {
934
+ const bounds = computeBounds(pts);
935
+ const normalizedPts = pts.map(p => ({
936
+ x: bounds.width > 0 ? (p.x - bounds.x) / bounds.width : 0,
937
+ y: bounds.height > 0 ? (p.y - bounds.y) / bounds.height : 0
938
+ }));
939
+ const borderType = getBorderTypeFromDasharray(strokeDasharray);
940
+ const strokeOp = parseFloat(strokeOpacity || opacity || '1') * groupOpacityMultiplier;
941
+ const sw = (parseFloat(strokeWidth) || CONFIG.defaultStrokeWidth) * getMatrixScale(transform);
942
+
943
+ return {
944
+ id: generateId(),
945
+ lastMod: Date.now(),
946
+ tool: 'shape',
947
+ shapeType: 'polygon',
948
+ x: bounds.x,
949
+ y: bounds.y,
950
+ width: bounds.width,
951
+ height: bounds.height,
952
+ pts: normalizedPts,
953
+ borderColor: parseColor(stroke) || CONFIG.defaultStroke,
954
+ fillColor: 'transparent',
955
+ fillOpacity: 0,
956
+ borderOpacity: strokeOp,
957
+ borderSize: sw,
958
+ borderType: borderType,
959
+ deleted: false
960
+ };
961
+ }
962
+
963
+ const effectiveOpacity = parseOpacity(opacity) * parseOpacity(fillOpacity) * groupOpacityMultiplier;
964
+ const tool = effectiveOpacity < CONFIG.highlighterOpacityThreshold ? 'highlighter' : 'pen';
965
+ const color = parseColor(stroke) || parseColor(fill) || CONFIG.defaultStroke;
966
+ const sw = (parseFloat(strokeWidth) || CONFIG.defaultStrokeWidth) * getMatrixScale(transform);
967
+
968
+ return {
969
+ id: generateId(),
970
+ lastMod: Date.now(),
971
+ tool,
972
+ pts,
973
+ color,
974
+ size: sw,
975
+ opacity: effectiveOpacity, // Always include opacity
976
+ lineCap: lineCap || 'round',
977
+ lineJoin: lineJoin || 'round',
978
+ deleted: false
979
+ };
980
+ }
981
+
982
+ function convertRect(elem, globalTransform) {
983
+ const x = parseFloat(getElemAttr(elem, 'x')) || 0;
984
+ const y = parseFloat(getElemAttr(elem, 'y')) || 0;
985
+ const w = parseFloat(getElemAttr(elem, 'width')) || 0;
986
+ const h = parseFloat(getElemAttr(elem, 'height')) || 0;
987
+ if (w === 0 || h === 0) return null;
988
+
989
+ // Combine inherited transform with element's own transform
990
+ const elemTransformStr = getElemAttr(elem, 'transform');
991
+ const elemTransform = parseTransform(elemTransformStr);
992
+ const inheritedTransform = elem.inheritedTransform || identityMatrix();
993
+ const transform = multiplyMatrix(multiplyMatrix(globalTransform, inheritedTransform), elemTransform);
994
+
995
+ const stroke = getElemAttr(elem, 'stroke');
996
+ const fill = getElemAttr(elem, 'fill');
997
+ const strokeWidth = getElemAttr(elem, 'stroke-width');
998
+ const fillOpacity = getElemAttr(elem, 'fill-opacity');
999
+ const strokeOpacity = getElemAttr(elem, 'stroke-opacity');
1000
+ const strokeDasharray = getElemAttr(elem, 'stroke-dasharray');
1001
+
1002
+ // Determine border type from stroke-dasharray
1003
+ let borderType = 'solid';
1004
+ if (strokeDasharray && strokeDasharray !== 'none') {
1005
+ const parts = strokeDasharray.split(/[\s,]+/).map(parseFloat);
1006
+ if (parts.length >= 2) {
1007
+ // Dotted: small gaps, Dashed: larger gaps
1008
+ const ratio = parts[1] / parts[0];
1009
+ borderType = ratio > 1 ? 'dotted' : 'dashed';
1010
+ }
1011
+ }
1012
+
1013
+ const corners = [
1014
+ transformPoint(x, y, transform),
1015
+ transformPoint(x + w, y, transform),
1016
+ transformPoint(x + w, y + h, transform),
1017
+ transformPoint(x, y + h, transform)
1018
+ ];
1019
+ const xs = corners.map(c => c.x), ys = corners.map(c => c.y);
1020
+ const minX = Math.min(...xs), maxX = Math.max(...xs);
1021
+ const minY = Math.min(...ys), maxY = Math.max(...ys);
1022
+
1023
+ const result = {
1024
+ id: generateId(),
1025
+ lastMod: Date.now(),
1026
+ tool: 'shape',
1027
+ shapeType: 'rectangle',
1028
+ x: minX, y: minY, w: maxX - minX, h: maxY - minY,
1029
+ border: parseColor(stroke) || CONFIG.defaultStroke,
1030
+ fill: parseColor(fill) || CONFIG.defaultFill,
1031
+ width: (parseFloat(strokeWidth) || CONFIG.defaultStrokeWidth) * getMatrixScale(transform),
1032
+ rotation: 0,
1033
+ deleted: false
1034
+ };
1035
+
1036
+ // Add separate opacities
1037
+ if (fillOpacity !== undefined && parseFloat(fillOpacity) < 1) {
1038
+ result.fillOpacity = parseFloat(fillOpacity);
1039
+ }
1040
+ if (strokeOpacity !== undefined && parseFloat(strokeOpacity) < 1) {
1041
+ result.borderOpacity = parseFloat(strokeOpacity);
1042
+ }
1043
+ if (borderType !== 'solid') {
1044
+ result.borderType = borderType;
1045
+ }
1046
+
1047
+ return result;
1048
+ }
1049
+
1050
+ function convertCircle(elem, globalTransform) {
1051
+ const cx = parseFloat(getElemAttr(elem, 'cx')) || 0;
1052
+ const cy = parseFloat(getElemAttr(elem, 'cy')) || 0;
1053
+ const r = parseFloat(getElemAttr(elem, 'r')) || 0;
1054
+ if (r === 0) return null;
1055
+
1056
+ // Combine inherited transform with element's own transform
1057
+ const elemTransformStr = getElemAttr(elem, 'transform');
1058
+ const elemTransform = parseTransform(elemTransformStr);
1059
+ const inheritedTransform = elem.inheritedTransform || identityMatrix();
1060
+ const transform = multiplyMatrix(multiplyMatrix(globalTransform, inheritedTransform), elemTransform);
1061
+
1062
+ const stroke = getElemAttr(elem, 'stroke');
1063
+ const fill = getElemAttr(elem, 'fill');
1064
+ const strokeWidth = getElemAttr(elem, 'stroke-width');
1065
+ const fillOpacity = getElemAttr(elem, 'fill-opacity');
1066
+ const strokeOpacity = getElemAttr(elem, 'stroke-opacity');
1067
+ const strokeDasharray = getElemAttr(elem, 'stroke-dasharray');
1068
+
1069
+ let borderType = 'solid';
1070
+ if (strokeDasharray && strokeDasharray !== 'none') {
1071
+ const parts = strokeDasharray.split(/[\s,]+/).map(parseFloat);
1072
+ if (parts.length >= 2) {
1073
+ borderType = parts[1] / parts[0] > 1 ? 'dotted' : 'dashed';
1074
+ }
1075
+ }
1076
+
1077
+ const center = transformPoint(cx, cy, transform);
1078
+ const scaledR = r * getMatrixScale(transform);
1079
+
1080
+ const result = {
1081
+ id: generateId(),
1082
+ lastMod: Date.now(),
1083
+ tool: 'shape',
1084
+ shapeType: 'circle',
1085
+ x: center.x - scaledR, y: center.y - scaledR, w: scaledR * 2, h: scaledR * 2,
1086
+ border: parseColor(stroke) || CONFIG.defaultStroke,
1087
+ fill: parseColor(fill) || CONFIG.defaultFill,
1088
+ width: (parseFloat(strokeWidth) || CONFIG.defaultStrokeWidth) * getMatrixScale(transform),
1089
+ rotation: 0,
1090
+ deleted: false
1091
+ };
1092
+
1093
+ if (fillOpacity !== undefined && parseFloat(fillOpacity) < 1) {
1094
+ result.fillOpacity = parseFloat(fillOpacity);
1095
+ }
1096
+ if (strokeOpacity !== undefined && parseFloat(strokeOpacity) < 1) {
1097
+ result.borderOpacity = parseFloat(strokeOpacity);
1098
+ }
1099
+ if (borderType !== 'solid') {
1100
+ result.borderType = borderType;
1101
+ }
1102
+
1103
+ return result;
1104
+ }
1105
+
1106
+ function convertEllipse(elem, globalTransform) {
1107
+ const cx = parseFloat(getElemAttr(elem, 'cx')) || 0;
1108
+ const cy = parseFloat(getElemAttr(elem, 'cy')) || 0;
1109
+ const rx = parseFloat(getElemAttr(elem, 'rx')) || 0;
1110
+ const ry = parseFloat(getElemAttr(elem, 'ry')) || 0;
1111
+ if (rx === 0 || ry === 0) return null;
1112
+
1113
+ // Combine inherited transform with element's own transform
1114
+ const elemTransformStr = getElemAttr(elem, 'transform');
1115
+ const elemTransform = parseTransform(elemTransformStr);
1116
+ const inheritedTransform = elem.inheritedTransform || identityMatrix();
1117
+ const transform = multiplyMatrix(multiplyMatrix(globalTransform, inheritedTransform), elemTransform);
1118
+
1119
+ const stroke = getElemAttr(elem, 'stroke');
1120
+ const fill = getElemAttr(elem, 'fill');
1121
+ const strokeWidth = getElemAttr(elem, 'stroke-width');
1122
+ const fillOpacity = getElemAttr(elem, 'fill-opacity');
1123
+ const strokeOpacity = getElemAttr(elem, 'stroke-opacity');
1124
+ const strokeDasharray = getElemAttr(elem, 'stroke-dasharray');
1125
+
1126
+ let borderType = 'solid';
1127
+ if (strokeDasharray && strokeDasharray !== 'none') {
1128
+ const parts = strokeDasharray.split(/[\s,]+/).map(parseFloat);
1129
+ if (parts.length >= 2) {
1130
+ borderType = parts[1] / parts[0] > 1 ? 'dotted' : 'dashed';
1131
+ }
1132
+ }
1133
+
1134
+ const center = transformPoint(cx, cy, transform);
1135
+ const scale = getMatrixScale(transform);
1136
+
1137
+ const result = {
1138
+ id: generateId(),
1139
+ lastMod: Date.now(),
1140
+ tool: 'shape',
1141
+ shapeType: 'ellipse',
1142
+ x: center.x - rx * scale, y: center.y - ry * scale, w: rx * 2 * scale, h: ry * 2 * scale,
1143
+ border: parseColor(stroke) || CONFIG.defaultStroke,
1144
+ fill: parseColor(fill) || CONFIG.defaultFill,
1145
+ width: (parseFloat(strokeWidth) || CONFIG.defaultStrokeWidth) * scale,
1146
+ rotation: 0,
1147
+ deleted: false
1148
+ };
1149
+
1150
+ if (fillOpacity !== undefined && parseFloat(fillOpacity) < 1) {
1151
+ result.fillOpacity = parseFloat(fillOpacity);
1152
+ }
1153
+ if (strokeOpacity !== undefined && parseFloat(strokeOpacity) < 1) {
1154
+ result.borderOpacity = parseFloat(strokeOpacity);
1155
+ }
1156
+ if (borderType !== 'solid') {
1157
+ result.borderType = borderType;
1158
+ }
1159
+
1160
+ return result;
1161
+ }
1162
+
1163
+ function convertLine(elem, globalTransform) {
1164
+ const x1 = parseFloat(getElemAttr(elem, 'x1')) || 0;
1165
+ const y1 = parseFloat(getElemAttr(elem, 'y1')) || 0;
1166
+ const x2 = parseFloat(getElemAttr(elem, 'x2')) || 0;
1167
+ const y2 = parseFloat(getElemAttr(elem, 'y2')) || 0;
1168
+
1169
+ // Combine inherited transform with element's own transform
1170
+ const elemTransformStr = getElemAttr(elem, 'transform');
1171
+ const elemTransform = parseTransform(elemTransformStr);
1172
+ const inheritedTransform = elem.inheritedTransform || identityMatrix();
1173
+ const transform = multiplyMatrix(multiplyMatrix(globalTransform, inheritedTransform), elemTransform);
1174
+
1175
+ const stroke = getElemAttr(elem, 'stroke');
1176
+ const strokeWidth = getElemAttr(elem, 'stroke-width');
1177
+
1178
+ return {
1179
+ id: generateId(),
1180
+ lastMod: Date.now(),
1181
+ tool: 'pen',
1182
+ pts: [transformPoint(x1, y1, transform), transformPoint(x2, y2, transform)],
1183
+ color: parseColor(stroke) || CONFIG.defaultStroke,
1184
+ size: (parseFloat(strokeWidth) || CONFIG.defaultStrokeWidth) * getMatrixScale(transform),
1185
+ lineCap: 'round',
1186
+ lineJoin: 'round',
1187
+ deleted: false
1188
+ };
1189
+ }
1190
+
1191
+ function convertPolyline(elem, globalTransform, close = false) {
1192
+ const pointsStr = getElemAttr(elem, 'points') || '';
1193
+ const nums = pointsStr.match(/[-+]?(?:\d+\.?\d*|\.\d+)/g) || [];
1194
+ if (nums.length < 4) return null;
1195
+
1196
+ // Combine inherited transform with element's own transform
1197
+ const elemTransformStr = getElemAttr(elem, 'transform');
1198
+ const elemTransform = parseTransform(elemTransformStr);
1199
+ const inheritedTransform = elem.inheritedTransform || identityMatrix();
1200
+ const transform = multiplyMatrix(multiplyMatrix(globalTransform, inheritedTransform), elemTransform);
1201
+
1202
+ let pts = [];
1203
+ for (let i = 0; i < nums.length - 1; i += 2) {
1204
+ pts.push(transformPoint(parseFloat(nums[i]), parseFloat(nums[i + 1]), transform));
1205
+ }
1206
+ if (close && pts.length > 0 && (pts[0].x !== pts[pts.length-1].x || pts[0].y !== pts[pts.length-1].y)) {
1207
+ pts.push({ x: pts[0].x, y: pts[0].y });
1208
+ }
1209
+
1210
+ if (!CONFIG.skipSimplification && CONFIG.simplifyTolerance > 0) {
1211
+ pts = simplifyPath(pts);
1212
+ }
1213
+ pts = enforceMaxPoints(pts);
1214
+ if (pts.length < 2) return null;
1215
+
1216
+ const stroke = getElemAttr(elem, 'stroke');
1217
+ const strokeWidth = getElemAttr(elem, 'stroke-width');
1218
+
1219
+ return {
1220
+ id: generateId(),
1221
+ lastMod: Date.now(),
1222
+ tool: 'pen',
1223
+ pts,
1224
+ color: parseColor(stroke) || CONFIG.defaultStroke,
1225
+ size: (parseFloat(strokeWidth) || CONFIG.defaultStrokeWidth) * getMatrixScale(transform),
1226
+ lineCap: 'round',
1227
+ lineJoin: 'round',
1228
+ deleted: false
1229
+ };
1230
+ }
1231
+
1232
+ // Convert polygon to a shape (closed polygon)
1233
+ function convertPolygonShape(elem, globalTransform) {
1234
+ const pointsStr = getElemAttr(elem, 'points') || '';
1235
+ const nums = pointsStr.match(/[-+]?(?:\d+\.?\d*|\.\d+)/g) || [];
1236
+ if (nums.length < 6) return null; // Need at least 3 points for a polygon
1237
+
1238
+ // Combine inherited transform with element's own transform
1239
+ const elemTransformStr = getElemAttr(elem, 'transform');
1240
+ const elemTransform = parseTransform(elemTransformStr);
1241
+ const inheritedTransform = elem.inheritedTransform || identityMatrix();
1242
+ const transform = multiplyMatrix(multiplyMatrix(globalTransform, inheritedTransform), elemTransform);
1243
+
1244
+ // Parse and transform points
1245
+ let pts = [];
1246
+ for (let i = 0; i < nums.length - 1; i += 2) {
1247
+ pts.push(transformPoint(parseFloat(nums[i]), parseFloat(nums[i + 1]), transform));
1248
+ }
1249
+
1250
+ // Calculate bounding box
1251
+ const xs = pts.map(p => p.x), ys = pts.map(p => p.y);
1252
+ const minX = Math.min(...xs), maxX = Math.max(...xs);
1253
+ const minY = Math.min(...ys), maxY = Math.max(...ys);
1254
+
1255
+ const stroke = getElemAttr(elem, 'stroke');
1256
+ const fill = getElemAttr(elem, 'fill');
1257
+ const strokeWidth = getElemAttr(elem, 'stroke-width');
1258
+ const fillOpacity = getElemAttr(elem, 'fill-opacity');
1259
+ const strokeOpacity = getElemAttr(elem, 'stroke-opacity');
1260
+ const strokeDasharray = getElemAttr(elem, 'stroke-dasharray');
1261
+ const opacity = getElemAttr(elem, 'opacity');
1262
+
1263
+ let borderType = 'solid';
1264
+ if (strokeDasharray && strokeDasharray !== 'none') {
1265
+ const parts = strokeDasharray.split(/[\s,]+/).map(parseFloat);
1266
+ if (parts.length >= 2) {
1267
+ borderType = parts[1] / parts[0] > 1 ? 'dotted' : 'dashed';
1268
+ }
1269
+ }
1270
+
1271
+ // Store normalized points (relative to bounding box)
1272
+ const normalizedPts = pts.map(p => ({
1273
+ x: (p.x - minX) / (maxX - minX || 1),
1274
+ y: (p.y - minY) / (maxY - minY || 1)
1275
+ }));
1276
+
1277
+ const result = {
1278
+ id: generateId(),
1279
+ lastMod: Date.now(),
1280
+ tool: 'shape',
1281
+ shapeType: 'polygon',
1282
+ x: minX, y: minY, w: maxX - minX, h: maxY - minY,
1283
+ pts: normalizedPts, // Store normalized points for custom polygon
1284
+ border: parseColor(stroke) || CONFIG.defaultStroke,
1285
+ fill: parseColor(fill) || CONFIG.defaultFill,
1286
+ width: (parseFloat(strokeWidth) || CONFIG.defaultStrokeWidth) * getMatrixScale(transform),
1287
+ rotation: 0,
1288
+ deleted: false
1289
+ };
1290
+
1291
+ // Apply group opacity if inherited
1292
+ const groupOpacityMultiplier = elem.groupOpacity !== undefined ? elem.groupOpacity : 1;
1293
+ const effectiveOpacity = parseOpacity(opacity) * groupOpacityMultiplier;
1294
+ if (effectiveOpacity < 1) {
1295
+ result.opacity = effectiveOpacity;
1296
+ }
1297
+
1298
+ if (fillOpacity !== undefined && parseFloat(fillOpacity) < 1) {
1299
+ result.fillOpacity = parseFloat(fillOpacity);
1300
+ }
1301
+ if (strokeOpacity !== undefined && parseFloat(strokeOpacity) < 1) {
1302
+ result.borderOpacity = parseFloat(strokeOpacity);
1303
+ }
1304
+ if (borderType !== 'solid') {
1305
+ result.borderType = borderType;
1306
+ }
1307
+
1308
+ return result;
1309
+ }
1310
+
1311
+ function convertText(elem, globalTransform) {
1312
+ // Get text content - strip tags but preserve text
1313
+ const text = elem.innerContent.replace(/<[^>]*>/g, '').trim();
1314
+ if (!text) return null;
1315
+
1316
+ // Try to get position from tspan first, then fall back to text element
1317
+ let x = 0, y = 0;
1318
+
1319
+ // Parse tspan for position - tspan often has the actual x/y
1320
+ const tspanMatch = elem.innerContent.match(/<tspan[^>]*\s+x="([^"]+)"[^>]*\s+y="([^"]+)"/);
1321
+ const tspanMatchAlt = elem.innerContent.match(/<tspan[^>]*\s+y="([^"]+)"[^>]*\s+x="([^"]+)"/);
1322
+
1323
+ if (tspanMatch) {
1324
+ // x might be multiple values (kerning) - take the first
1325
+ const xVals = tspanMatch[1].split(/\s+/);
1326
+ x = parseFloat(xVals[0]) || 0;
1327
+ y = parseFloat(tspanMatch[2]) || 0;
1328
+ } else if (tspanMatchAlt) {
1329
+ const xVals = tspanMatchAlt[2].split(/\s+/);
1330
+ x = parseFloat(xVals[0]) || 0;
1331
+ y = parseFloat(tspanMatchAlt[1]) || 0;
1332
+ } else {
1333
+ // Fall back to text element attributes
1334
+ x = parseFloat(getElemAttr(elem, 'x')) || 0;
1335
+ y = parseFloat(getElemAttr(elem, 'y')) || 0;
1336
+ }
1337
+
1338
+ // Combine inherited transform with element's own transform
1339
+ const elemTransformStr = getElemAttr(elem, 'transform');
1340
+ const elemTransform = parseTransform(elemTransformStr);
1341
+ const inheritedTransform = elem.inheritedTransform || identityMatrix();
1342
+ const transform = multiplyMatrix(multiplyMatrix(globalTransform, inheritedTransform), elemTransform);
1343
+
1344
+ const fill = getElemAttr(elem, 'fill');
1345
+ const fontSize = getElemAttr(elem, 'font-size');
1346
+ const fontFamily = getElemAttr(elem, 'font-family');
1347
+ const pos = transformPoint(x, y, transform);
1348
+ const size = (parseFloat(fontSize) || 16) * getMatrixScale(transform);
1349
+
1350
+ const result = {
1351
+ id: generateId(),
1352
+ lastMod: Date.now(),
1353
+ tool: 'text',
1354
+ text,
1355
+ x: pos.x, y: pos.y,
1356
+ size,
1357
+ color: parseColor(fill) || CONFIG.defaultStroke,
1358
+ rotation: 0,
1359
+ w: text.length * size * 0.6,
1360
+ h: size * 1.2,
1361
+ deleted: false
1362
+ };
1363
+
1364
+ // Store font family if present
1365
+ if (fontFamily) {
1366
+ result.fontFamily = fontFamily;
1367
+ }
1368
+
1369
+ // Store original SVG structure for precise roundtrip
1370
+ // This preserves kerning, transforms, tspans, etc.
1371
+ const xmlSpace = getElemAttr(elem, 'xml:space');
1372
+ if (elemTransformStr || elem.innerContent.includes('<tspan')) {
1373
+ result.svgData = {
1374
+ transform: elemTransformStr || null,
1375
+ xmlSpace: xmlSpace || null,
1376
+ fontSize: fontSize || null,
1377
+ fontFamily: fontFamily || null,
1378
+ fill: fill || null,
1379
+ innerContent: elem.innerContent
1380
+ };
1381
+ }
1382
+
1383
+ return result;
1384
+ }
1385
+
1386
+ function convertImage(elem, globalTransform, warnings) {
1387
+ const x = parseFloat(getElemAttr(elem, 'x')) || 0;
1388
+ const y = parseFloat(getElemAttr(elem, 'y')) || 0;
1389
+ const w = parseFloat(getElemAttr(elem, 'width')) || 0;
1390
+ const h = parseFloat(getElemAttr(elem, 'height')) || 0;
1391
+ const href = getElemAttr(elem, 'href') || getElemAttr(elem, 'xlink:href');
1392
+
1393
+ if (!href) return null;
1394
+
1395
+ // Skip images with zero dimensions
1396
+ if (w === 0 || h === 0) {
1397
+ warnings.push('Warning: Image with zero dimensions skipped');
1398
+ return null;
1399
+ }
1400
+
1401
+ // Combine inherited transform with element's own transform
1402
+ const elemTransformStr = getElemAttr(elem, 'transform');
1403
+ const elemTransform = parseTransform(elemTransformStr);
1404
+ const inheritedTransform = elem.inheritedTransform || identityMatrix();
1405
+ const transform = multiplyMatrix(multiplyMatrix(globalTransform, inheritedTransform), elemTransform);
1406
+
1407
+ const topLeft = transformPoint(x, y, transform);
1408
+ const bottomRight = transformPoint(x + w, y + h, transform);
1409
+
1410
+ if (href.startsWith('data:') && href.length > CONFIG.base64WarnSize) {
1411
+ warnings.push(`Warning: Large embedded image (${Math.round(href.length / 1024)}KB)`);
1412
+ }
1413
+
1414
+ // Get opacity (may be inherited from group)
1415
+ const opacity = getElemAttr(elem, 'opacity');
1416
+ const groupOpacity = elem.groupOpacity !== undefined ? elem.groupOpacity : 1;
1417
+ const effectiveOpacity = parseOpacity(opacity) * groupOpacity;
1418
+
1419
+ // Check for mask (warn if present since we can't fully support it)
1420
+ const mask = getElemAttr(elem, 'mask');
1421
+ if (mask) {
1422
+ warnings.push(`Warning: Image has mask "${mask}" which may not render correctly`);
1423
+ }
1424
+
1425
+ const result = {
1426
+ id: generateId(),
1427
+ lastMod: Date.now(),
1428
+ tool: 'image',
1429
+ x: topLeft.x, y: topLeft.y,
1430
+ w: Math.abs(bottomRight.x - topLeft.x),
1431
+ h: Math.abs(bottomRight.y - topLeft.y),
1432
+ src: href,
1433
+ rotation: 0,
1434
+ deleted: false
1435
+ };
1436
+
1437
+ // Add opacity if not fully opaque
1438
+ if (effectiveOpacity < 1) {
1439
+ result.opacity = effectiveOpacity;
1440
+ }
1441
+
1442
+ // Add mask data if this image has a mask applied
1443
+ if (elem.maskId && maskDefinitions[elem.maskId]) {
1444
+ result.mask = {
1445
+ id: elem.maskId,
1446
+ type: maskDefinitions[elem.maskId].type,
1447
+ src: maskDefinitions[elem.maskId].imageData
1448
+ };
1449
+ }
1450
+
1451
+ return result;
1452
+ }
1453
+
1454
+ // ============================================
1455
+ // MAIN CONVERTER
1456
+ // ============================================
1457
+
1458
+ function convertSVG(svgContent) {
1459
+ const history = [];
1460
+ const warnings = [];
1461
+
1462
+ // Get SVG attributes
1463
+ const svgMatch = svgContent.match(/<(?:\w+:)?svg\s+([^>]*)>/i);
1464
+ if (!svgMatch) return { metadata: { error: 'No SVG element found' }, history: [] };
1465
+
1466
+ const svgAttrs = svgMatch[1];
1467
+ const viewBoxMatch = svgAttrs.match(/viewBox="([^"]*)"/);
1468
+ let viewBox = null, viewBoxOffset = { x: 0, y: 0 };
1469
+
1470
+ if (viewBoxMatch) {
1471
+ const parts = viewBoxMatch[1].split(/[\s,]+/).map(parseFloat);
1472
+ viewBox = { x: parts[0], y: parts[1], w: parts[2], h: parts[3] };
1473
+ viewBoxOffset = { x: -parts[0], y: -parts[1] };
1474
+ }
1475
+
1476
+ const widthMatch = svgAttrs.match(/width="([^"]*)"/);
1477
+ const heightMatch = svgAttrs.match(/height="([^"]*)"/);
1478
+ const svgWidth = parseFloat(widthMatch?.[1]) || viewBox?.w || 800;
1479
+ const svgHeight = parseFloat(heightMatch?.[1]) || viewBox?.h || 600;
1480
+
1481
+ // Initial transform for viewBox offset
1482
+ let transform = identityMatrix();
1483
+ if (viewBoxOffset.x !== 0 || viewBoxOffset.y !== 0) {
1484
+ transform = [1, 0, 0, 1, viewBoxOffset.x, viewBoxOffset.y];
1485
+ }
1486
+
1487
+ // Extract SVG content
1488
+ const svgContentMatch = svgContent.match(/<(?:\w+:)?svg[^>]*>([\s\S]*)<\/(?:\w+:)?svg>/i);
1489
+ if (!svgContentMatch) return { metadata: { error: 'Could not extract SVG content' }, history: [] };
1490
+
1491
+ // Keep defs - we now process mask/clippath content to include all images
1492
+ const cleanedContent = svgContentMatch[1];
1493
+
1494
+ // Collect all elements in order
1495
+ console.log('Collecting elements in document order...');
1496
+ const elements = collectAllElements(cleanedContent);
1497
+ console.log(`Found ${elements.length} elements`);
1498
+
1499
+ // Detect and merge path pairs
1500
+ console.log('Detecting fill+stroke path pairs...');
1501
+ const mergedElements = detectPathPairs(elements);
1502
+ console.log(`After merging: ${mergedElements.length} elements (${elements.length - mergedElements.length} pairs merged)`);
1503
+
1504
+ // Convert each element
1505
+ console.log('Converting elements...');
1506
+ for (const elem of mergedElements) {
1507
+ try {
1508
+ let item = null;
1509
+ switch (elem.tag) {
1510
+ case 'path': item = convertPath(elem, transform); break;
1511
+ case 'rect': item = convertRect(elem, transform); break;
1512
+ case 'circle': item = convertCircle(elem, transform); break;
1513
+ case 'ellipse': item = convertEllipse(elem, transform); break;
1514
+ case 'line': item = convertLine(elem, transform); break;
1515
+ case 'polyline': item = convertPolyline(elem, transform, false); break;
1516
+ case 'polygon': item = convertPolygonShape(elem, transform); break;
1517
+ case 'text': item = convertText(elem, transform); break;
1518
+ case 'image': item = convertImage(elem, transform, warnings); break;
1519
+ }
1520
+ if (item) history.push(item);
1521
+ } catch (e) {
1522
+ warnings.push(`${elem.tag} conversion error: ${e.message}`);
1523
+ }
1524
+ }
1525
+
1526
+ // Statistics
1527
+ const stats = { pen: 0, highlighter: 0, shape: 0, text: 0, image: 0 };
1528
+ history.forEach(item => { if (stats[item.tool] !== undefined) stats[item.tool]++; });
1529
+
1530
+ console.log('Conversion statistics:');
1531
+ for (const [type, count] of Object.entries(stats)) {
1532
+ if (count > 0) console.log(` ${type}: ${count} elements`);
1533
+ }
1534
+
1535
+ if (warnings.length > 0) {
1536
+ console.log('\nWarnings:');
1537
+ warnings.forEach(w => console.log(` ${w}`));
1538
+ }
1539
+
1540
+ // Post-process: Detect background images
1541
+ // Images that cover the full document are likely backgrounds
1542
+ const TOLERANCE = 0.05; // 5% tolerance for size matching
1543
+ const docWidth = svgWidth;
1544
+ const docHeight = svgHeight;
1545
+
1546
+ for (const item of history) {
1547
+ if (item.tool === 'image') {
1548
+ const widthRatio = item.w / docWidth;
1549
+ const heightRatio = item.h / docHeight;
1550
+ const xNearZero = Math.abs(item.x) < docWidth * TOLERANCE;
1551
+ const yNearZero = Math.abs(item.y) < docHeight * TOLERANCE;
1552
+ const widthMatch = widthRatio > (1 - TOLERANCE) && widthRatio < (1 + TOLERANCE);
1553
+ const heightMatch = heightRatio > (1 - TOLERANCE) && heightRatio < (1 + TOLERANCE);
1554
+
1555
+ if (xNearZero && yNearZero && widthMatch && heightMatch) {
1556
+ item.isBackground = true;
1557
+ }
1558
+ }
1559
+ }
1560
+
1561
+ // Count backgrounds
1562
+ const backgroundCount = history.filter(i => i.isBackground).length;
1563
+ if (backgroundCount > 0) {
1564
+ console.log(`Detected ${backgroundCount} background image(s)`);
1565
+ }
1566
+
1567
+ return {
1568
+ metadata: {
1569
+ version: 2, // New format version
1570
+ sourceType: 'svg',
1571
+ width: svgWidth,
1572
+ height: svgHeight,
1573
+ viewBox,
1574
+ elementCount: history.length,
1575
+ statistics: stats,
1576
+ backgroundCount
1577
+ },
1578
+ history
1579
+ };
1580
+ }
1581
+
1582
+ // ============================================
1583
+ // CLI
1584
+ // ============================================
1585
+
1586
+ function main() {
1587
+ const args = process.argv.slice(2);
1588
+ if (args.length === 0) {
1589
+ console.log(`
1590
+ SVG to ColorRM Converter - Pro Version v3
1591
+ ==========================================
1592
+
1593
+ Usage: node svg-to-colorrm.cjs <input.svg> [output.json]
1594
+
1595
+ FIXES in v3:
1596
+ ✓ Recursively processes elements inside groups
1597
+ ✓ Applies inherited transforms from parent groups
1598
+ ✓ Applies per-element transforms
1599
+ ✓ Disabled simplification to preserve hand-drawn curves
1600
+ ✓ Always preserves opacity values for highlighters
1601
+
1602
+ PREVIOUS (v2):
1603
+ ✓ Preserves element order (z-order)
1604
+ ✓ Detects fill+stroke path pairs and merges them
1605
+ ✓ Uses fill color, stroke width from pairs
1606
+ ✓ Properly maps low opacity to highlighter tool
1607
+ `);
1608
+ process.exit(0);
1609
+ }
1610
+
1611
+ const inputFile = args[0];
1612
+ const outputFile = args[1] || inputFile.replace(/\.svg$/i, '.colorrm.json');
1613
+
1614
+ if (!fs.existsSync(inputFile)) {
1615
+ console.error(`Error: Input file not found: ${inputFile}`);
1616
+ process.exit(1);
1617
+ }
1618
+
1619
+ console.log(`Reading: ${inputFile}`);
1620
+ const svgContent = fs.readFileSync(inputFile, 'utf-8');
1621
+ console.log(`File size: ${Math.round(svgContent.length / 1024)}KB`);
1622
+
1623
+ console.log('\nConverting SVG to ColorRM format...');
1624
+ const result = convertSVG(svgContent);
1625
+
1626
+ console.log(`\nConversion complete!`);
1627
+ console.log(` Total elements: ${result.history.length}`);
1628
+ console.log(` Canvas size: ${result.metadata.width} x ${result.metadata.height}`);
1629
+
1630
+ console.log(`\nWriting: ${outputFile}`);
1631
+ fs.writeFileSync(outputFile, JSON.stringify(result, null, 2));
1632
+
1633
+ const outputSize = fs.statSync(outputFile).size;
1634
+ console.log(`Output size: ${Math.round(outputSize / 1024)}KB`);
1635
+ console.log('\nDone!');
1636
+ }
1637
+
1638
+ if (require.main === module) main();
1639
+
1640
+ module.exports = { convertSVG, parsePathD, parseTransform, transformPoint, parseColor, simplifyPath, CONFIG };
v2.final.colorrm.json ADDED
The diff for this file is too large to render. See raw diff
 
v2.final.roundtrip.svg ADDED
v2.final.svg ADDED
v2.json ADDED
The diff for this file is too large to render. See raw diff
 
v2.roundtrip2.svg ADDED
v2.roundtrip3.svg ADDED
v2.roundtrip4.svg ADDED
v2.roundtrip5.svg ADDED
v2.roundtrip6.svg ADDED
v2.roundtrip7.svg ADDED
v2.svg ADDED
v2.test.colorrm.json ADDED
The diff for this file is too large to render. See raw diff
 
v2.test2.colorrm.json ADDED
The diff for this file is too large to render. See raw diff
 
v2.test3.colorrm.json ADDED
The diff for this file is too large to render. See raw diff
 
v2.test4.colorrm.json ADDED
The diff for this file is too large to render. See raw diff
 
v2.test5.colorrm.json ADDED
The diff for this file is too large to render. See raw diff
 
v2.test6.colorrm.json ADDED
The diff for this file is too large to render. See raw diff
 
v2.test7.colorrm.json ADDED
The diff for this file is too large to render. See raw diff
 
x.py ADDED
@@ -0,0 +1 @@
 
 
1
+ import xml.etree.ElementTree as E, collections as C; print(C.Counter(e.tag.split('}')[-1] for e in E.parse('test_page_1.svg').iter()))