@emasoft/svg-matrix 1.0.19 → 1.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,627 @@
1
+ /**
2
+ * SVG Rendering Context - Tracks ALL SVG properties affecting rendered geometry
3
+ *
4
+ * This module provides a comprehensive representation of SVG rendering properties
5
+ * that affect the actual visible/filled area of an element. Every function that
6
+ * operates on SVG geometry MUST use this context to account for:
7
+ *
8
+ * 1. Fill properties (fill-rule, fill-opacity)
9
+ * 2. Stroke properties (width, linecap, linejoin, miterlimit, dasharray, dashoffset)
10
+ * 3. Markers (marker-start, marker-mid, marker-end)
11
+ * 4. Paint order (fill, stroke, markers rendering order)
12
+ * 5. Clipping (clip-path, clip-rule)
13
+ * 6. Masking (mask, mask-type)
14
+ * 7. Opacity (opacity, fill-opacity, stroke-opacity)
15
+ * 8. Transforms (transform, vector-effect)
16
+ * 9. Filters (filter)
17
+ *
18
+ * ## Critical Insight
19
+ *
20
+ * SVG boolean operations, collision detection, off-canvas detection, and path
21
+ * merging must ALL account for the RENDERED AREA, not just path geometry.
22
+ *
23
+ * A path with stroke-width: 50 extends 25 units beyond its geometric boundary.
24
+ * A path with markers has additional geometry at vertices.
25
+ * A path with evenodd fill-rule may have holes that don't participate in collisions.
26
+ *
27
+ * @module svg-rendering-context
28
+ */
29
+
30
+ import Decimal from 'decimal.js';
31
+ import { FillRule, pointInPolygonWithRule, offsetPolygon, strokeToFilledPolygon } from './svg-boolean-ops.js';
32
+ import * as PolygonClip from './polygon-clip.js';
33
+
34
+ Decimal.set({ precision: 80 });
35
+
36
+ const D = x => (x instanceof Decimal ? x : new Decimal(x));
37
+
38
+ /**
39
+ * Default SVG property values per SVG 1.1/2.0 specification
40
+ */
41
+ export const SVG_DEFAULTS = {
42
+ // Fill properties
43
+ fill: 'black',
44
+ 'fill-rule': 'nonzero',
45
+ 'fill-opacity': 1,
46
+
47
+ // Stroke properties
48
+ stroke: 'none',
49
+ 'stroke-width': 1,
50
+ 'stroke-linecap': 'butt', // butt | round | square
51
+ 'stroke-linejoin': 'miter', // miter | round | bevel
52
+ 'stroke-miterlimit': 4,
53
+ 'stroke-dasharray': 'none',
54
+ 'stroke-dashoffset': 0,
55
+ 'stroke-opacity': 1,
56
+
57
+ // Markers
58
+ 'marker-start': 'none',
59
+ 'marker-mid': 'none',
60
+ 'marker-end': 'none',
61
+
62
+ // Paint order (SVG 2)
63
+ 'paint-order': 'normal', // normal = fill stroke markers
64
+
65
+ // Clipping
66
+ 'clip-path': 'none',
67
+ 'clip-rule': 'nonzero',
68
+
69
+ // Masking
70
+ 'mask': 'none',
71
+ 'mask-type': 'luminance', // luminance | alpha
72
+
73
+ // Opacity
74
+ 'opacity': 1,
75
+
76
+ // Transform
77
+ 'transform': 'none',
78
+ 'vector-effect': 'none', // none | non-scaling-stroke
79
+
80
+ // Filter
81
+ 'filter': 'none'
82
+ };
83
+
84
+ /**
85
+ * Properties that affect the geometric extent of an element
86
+ */
87
+ export const GEOMETRY_AFFECTING_PROPERTIES = [
88
+ 'stroke-width',
89
+ 'stroke-linecap',
90
+ 'stroke-linejoin',
91
+ 'stroke-miterlimit',
92
+ 'marker-start',
93
+ 'marker-mid',
94
+ 'marker-end',
95
+ 'filter',
96
+ 'transform'
97
+ ];
98
+
99
+ /**
100
+ * Properties that affect what's considered "inside" a shape
101
+ */
102
+ export const FILL_AFFECTING_PROPERTIES = [
103
+ 'fill-rule',
104
+ 'clip-rule'
105
+ ];
106
+
107
+ /**
108
+ * SVG Rendering Context class
109
+ *
110
+ * Extracts and stores all rendering properties from an SVG element,
111
+ * providing methods to compute the actual rendered geometry.
112
+ */
113
+ export class SVGRenderingContext {
114
+ /**
115
+ * Create a rendering context from an SVG element
116
+ * @param {Object} element - SVG element (from svg-parser.js)
117
+ * @param {Object} inherited - Inherited properties from parent elements
118
+ * @param {Map} defsMap - Map of definitions (gradients, markers, clipPaths, etc.)
119
+ */
120
+ constructor(element, inherited = {}, defsMap = null) {
121
+ this.element = element;
122
+ this.defsMap = defsMap || new Map();
123
+
124
+ // Extract all properties with inheritance
125
+ this.properties = this._extractProperties(element, inherited);
126
+
127
+ // Parse stroke-dasharray into array
128
+ this.dashArray = this._parseDashArray(this.properties['stroke-dasharray']);
129
+ this.dashOffset = D(this.properties['stroke-dashoffset'] || 0);
130
+
131
+ // Parse marker references
132
+ this.markers = {
133
+ start: this._parseMarkerRef(this.properties['marker-start']),
134
+ mid: this._parseMarkerRef(this.properties['marker-mid']),
135
+ end: this._parseMarkerRef(this.properties['marker-end'])
136
+ };
137
+
138
+ // Determine if element has visible fill
139
+ this.hasFill = this.properties.fill !== 'none' &&
140
+ this.properties['fill-opacity'] > 0;
141
+
142
+ // Determine if element has visible stroke
143
+ this.hasStroke = this.properties.stroke !== 'none' &&
144
+ D(this.properties['stroke-width']).gt(0) &&
145
+ this.properties['stroke-opacity'] > 0;
146
+
147
+ // Determine if element has markers
148
+ this.hasMarkers = this.markers.start || this.markers.mid || this.markers.end;
149
+ }
150
+
151
+ /**
152
+ * Extract all rendering properties from element with inheritance
153
+ * @private
154
+ */
155
+ _extractProperties(element, inherited) {
156
+ const props = { ...SVG_DEFAULTS, ...inherited };
157
+
158
+ // Get attributes from element
159
+ if (element && element.getAttributeNames) {
160
+ for (const attr of element.getAttributeNames()) {
161
+ const value = element.getAttribute(attr);
162
+ if (value !== null && value !== undefined) {
163
+ props[attr] = value;
164
+ }
165
+ }
166
+ }
167
+
168
+ // Parse style attribute
169
+ if (props.style) {
170
+ const styleProps = this._parseStyleAttribute(props.style);
171
+ Object.assign(props, styleProps);
172
+ }
173
+
174
+ // Convert numeric properties
175
+ const numericProps = ['stroke-width', 'stroke-miterlimit', 'stroke-dashoffset',
176
+ 'opacity', 'fill-opacity', 'stroke-opacity'];
177
+ for (const prop of numericProps) {
178
+ if (typeof props[prop] === 'string') {
179
+ const parsed = parseFloat(props[prop]);
180
+ if (!isNaN(parsed)) {
181
+ props[prop] = parsed;
182
+ }
183
+ }
184
+ }
185
+
186
+ return props;
187
+ }
188
+
189
+ /**
190
+ * Parse CSS style attribute into property object
191
+ * @private
192
+ */
193
+ _parseStyleAttribute(style) {
194
+ const props = {};
195
+ if (!style) return props;
196
+
197
+ const declarations = style.split(';');
198
+ for (const decl of declarations) {
199
+ const [prop, value] = decl.split(':').map(s => s.trim());
200
+ if (prop && value) {
201
+ props[prop] = value;
202
+ }
203
+ }
204
+ return props;
205
+ }
206
+
207
+ /**
208
+ * Parse stroke-dasharray into array of Decimal values
209
+ * @private
210
+ */
211
+ _parseDashArray(dasharray) {
212
+ if (!dasharray || dasharray === 'none') return null;
213
+
214
+ const parts = dasharray.toString().split(/[\s,]+/).filter(s => s);
215
+ const values = parts.map(s => D(parseFloat(s)));
216
+
217
+ // Per SVG spec, if odd number of values, duplicate the array
218
+ if (values.length % 2 === 1) {
219
+ return [...values, ...values];
220
+ }
221
+ return values;
222
+ }
223
+
224
+ /**
225
+ * Parse marker reference URL
226
+ * @private
227
+ */
228
+ _parseMarkerRef(value) {
229
+ if (!value || value === 'none') return null;
230
+ const match = value.match(/url\(#?([^)]+)\)/);
231
+ return match ? match[1] : null;
232
+ }
233
+
234
+ /**
235
+ * Get the fill rule for this element
236
+ * @returns {string} 'nonzero' or 'evenodd'
237
+ */
238
+ get fillRule() {
239
+ return this.properties['fill-rule'] || 'nonzero';
240
+ }
241
+
242
+ /**
243
+ * Get the clip rule for this element
244
+ * @returns {string} 'nonzero' or 'evenodd'
245
+ */
246
+ get clipRule() {
247
+ return this.properties['clip-rule'] || 'nonzero';
248
+ }
249
+
250
+ /**
251
+ * Get stroke width as Decimal
252
+ * @returns {Decimal}
253
+ */
254
+ get strokeWidth() {
255
+ return D(this.properties['stroke-width'] || 0);
256
+ }
257
+
258
+ /**
259
+ * Get stroke linecap
260
+ * @returns {string} 'butt', 'round', or 'square'
261
+ */
262
+ get strokeLinecap() {
263
+ return this.properties['stroke-linecap'] || 'butt';
264
+ }
265
+
266
+ /**
267
+ * Get stroke linejoin
268
+ * @returns {string} 'miter', 'round', or 'bevel'
269
+ */
270
+ get strokeLinejoin() {
271
+ return this.properties['stroke-linejoin'] || 'miter';
272
+ }
273
+
274
+ /**
275
+ * Get stroke miterlimit
276
+ * @returns {Decimal}
277
+ */
278
+ get strokeMiterlimit() {
279
+ return D(this.properties['stroke-miterlimit'] || 4);
280
+ }
281
+
282
+ /**
283
+ * Calculate the maximum extent that stroke adds to geometry
284
+ *
285
+ * For a path at position (x, y), the stroke can extend up to:
286
+ * - strokeWidth/2 for normal edges
287
+ * - strokeWidth/2 * miterlimit for miter joins (worst case)
288
+ * - strokeWidth/2 for round/bevel joins
289
+ * - strokeWidth/2 + strokeWidth/2 for square linecaps
290
+ *
291
+ * @returns {Decimal} Maximum stroke extent beyond path geometry
292
+ */
293
+ getStrokeExtent() {
294
+ if (!this.hasStroke) return D(0);
295
+
296
+ const halfWidth = this.strokeWidth.div(2);
297
+ let extent = halfWidth;
298
+
299
+ // Miter joins can extend further
300
+ if (this.strokeLinejoin === 'miter') {
301
+ extent = halfWidth.times(this.strokeMiterlimit);
302
+ }
303
+
304
+ // Square linecaps extend by half stroke width beyond endpoints
305
+ if (this.strokeLinecap === 'square') {
306
+ const capExtent = halfWidth;
307
+ if (capExtent.gt(extent)) {
308
+ extent = capExtent;
309
+ }
310
+ }
311
+
312
+ return extent;
313
+ }
314
+
315
+ /**
316
+ * Expand a bounding box to account for stroke
317
+ *
318
+ * @param {Object} bbox - Bounding box {x, y, width, height} with Decimal values
319
+ * @returns {Object} Expanded bounding box
320
+ */
321
+ expandBBoxForStroke(bbox) {
322
+ if (!this.hasStroke) return bbox;
323
+
324
+ const extent = this.getStrokeExtent();
325
+
326
+ return {
327
+ x: bbox.x.minus(extent),
328
+ y: bbox.y.minus(extent),
329
+ width: bbox.width.plus(extent.times(2)),
330
+ height: bbox.height.plus(extent.times(2))
331
+ };
332
+ }
333
+
334
+ /**
335
+ * Expand a bounding box to account for markers
336
+ *
337
+ * This is an approximation - for exact results, marker geometry must be resolved.
338
+ *
339
+ * @param {Object} bbox - Bounding box {x, y, width, height} with Decimal values
340
+ * @param {Object} markerSizes - Optional {start, mid, end} marker sizes
341
+ * @returns {Object} Expanded bounding box
342
+ */
343
+ expandBBoxForMarkers(bbox, markerSizes = null) {
344
+ if (!this.hasMarkers) return bbox;
345
+
346
+ // If marker sizes provided, use them; otherwise estimate from marker definitions
347
+ let maxMarkerSize = D(0);
348
+
349
+ if (markerSizes) {
350
+ const sizes = [markerSizes.start, markerSizes.mid, markerSizes.end]
351
+ .filter(s => s)
352
+ .map(s => D(s));
353
+ if (sizes.length > 0) {
354
+ maxMarkerSize = Decimal.max(...sizes);
355
+ }
356
+ } else {
357
+ // Default marker size estimate based on stroke width
358
+ maxMarkerSize = this.strokeWidth.times(3);
359
+ }
360
+
361
+ if (maxMarkerSize.lte(0)) return bbox;
362
+
363
+ const extent = maxMarkerSize.div(2);
364
+
365
+ return {
366
+ x: bbox.x.minus(extent),
367
+ y: bbox.y.minus(extent),
368
+ width: bbox.width.plus(extent.times(2)),
369
+ height: bbox.height.plus(extent.times(2))
370
+ };
371
+ }
372
+
373
+ /**
374
+ * Expand a bounding box to account for filter effects
375
+ *
376
+ * SVG filters can extend the rendering area significantly (blur, drop-shadow, etc.)
377
+ *
378
+ * @param {Object} bbox - Bounding box {x, y, width, height} with Decimal values
379
+ * @param {Object} filterDef - Optional filter definition with primitive extents
380
+ * @returns {Object} Expanded bounding box
381
+ */
382
+ expandBBoxForFilter(bbox, filterDef = null) {
383
+ const filterRef = this.properties.filter;
384
+ if (!filterRef || filterRef === 'none') return bbox;
385
+
386
+ // Default filter region is 10% larger on each side (per SVG spec)
387
+ // filterUnits="objectBoundingBox" x="-10%" y="-10%" width="120%" height="120%"
388
+ let extentX = bbox.width.times('0.1');
389
+ let extentY = bbox.height.times('0.1');
390
+
391
+ // If filter definition provided with explicit bounds, use those
392
+ if (filterDef) {
393
+ if (filterDef.x !== undefined) extentX = D(filterDef.x).abs();
394
+ if (filterDef.y !== undefined) extentY = D(filterDef.y).abs();
395
+ }
396
+
397
+ return {
398
+ x: bbox.x.minus(extentX),
399
+ y: bbox.y.minus(extentY),
400
+ width: bbox.width.plus(extentX.times(2)),
401
+ height: bbox.height.plus(extentY.times(2))
402
+ };
403
+ }
404
+
405
+ /**
406
+ * Get the full rendered bounding box including all effects
407
+ *
408
+ * @param {Object} geometryBBox - Base geometry bounding box
409
+ * @param {Object} options - Options for marker/filter sizes
410
+ * @returns {Object} Full rendered bounding box
411
+ */
412
+ getRenderedBBox(geometryBBox, options = {}) {
413
+ let bbox = { ...geometryBBox };
414
+
415
+ // Expand for stroke
416
+ bbox = this.expandBBoxForStroke(bbox);
417
+
418
+ // Expand for markers
419
+ bbox = this.expandBBoxForMarkers(bbox, options.markerSizes);
420
+
421
+ // Expand for filter
422
+ bbox = this.expandBBoxForFilter(bbox, options.filterDef);
423
+
424
+ return bbox;
425
+ }
426
+
427
+ /**
428
+ * Convert path geometry to filled polygon accounting for stroke
429
+ *
430
+ * @param {Array} polygon - Path geometry as polygon vertices
431
+ * @returns {Array} Polygon(s) representing the full rendered area
432
+ */
433
+ getFilledArea(polygon) {
434
+ const areas = [];
435
+
436
+ // Add fill area (if filled)
437
+ if (this.hasFill) {
438
+ areas.push({
439
+ polygon: polygon,
440
+ fillRule: this.fillRule,
441
+ type: 'fill'
442
+ });
443
+ }
444
+
445
+ // Add stroke area (if stroked)
446
+ if (this.hasStroke) {
447
+ const strokePolygon = strokeToFilledPolygon(polygon, {
448
+ width: this.strokeWidth.toNumber(),
449
+ linecap: this.strokeLinecap,
450
+ linejoin: this.strokeLinejoin,
451
+ miterlimit: this.strokeMiterlimit.toNumber()
452
+ });
453
+
454
+ if (strokePolygon && strokePolygon.length > 0) {
455
+ areas.push({
456
+ polygon: strokePolygon,
457
+ fillRule: 'nonzero', // Stroke is always nonzero
458
+ type: 'stroke'
459
+ });
460
+ }
461
+ }
462
+
463
+ return areas;
464
+ }
465
+
466
+ /**
467
+ * Test if a point is inside the rendered area of this element
468
+ *
469
+ * @param {Object} point - Point {x, y} with Decimal values
470
+ * @param {Array} polygon - Element geometry as polygon
471
+ * @returns {boolean} True if point is inside rendered area
472
+ */
473
+ isPointInRenderedArea(point, polygon) {
474
+ // Check fill area
475
+ if (this.hasFill) {
476
+ const fillRule = this.fillRule === 'evenodd' ? FillRule.EVENODD : FillRule.NONZERO;
477
+ const inFill = pointInPolygonWithRule(point, polygon, fillRule);
478
+ if (inFill >= 0) return true;
479
+ }
480
+
481
+ // Check stroke area
482
+ if (this.hasStroke) {
483
+ const strokePolygon = strokeToFilledPolygon(polygon, {
484
+ width: this.strokeWidth.toNumber(),
485
+ linecap: this.strokeLinecap,
486
+ linejoin: this.strokeLinejoin,
487
+ miterlimit: this.strokeMiterlimit.toNumber()
488
+ });
489
+
490
+ if (strokePolygon && strokePolygon.length > 0) {
491
+ const inStroke = pointInPolygonWithRule(point, strokePolygon, FillRule.NONZERO);
492
+ if (inStroke >= 0) return true;
493
+ }
494
+ }
495
+
496
+ return false;
497
+ }
498
+
499
+ /**
500
+ * Check if two elements with contexts can be merged
501
+ *
502
+ * Elements can only be merged if their rendering properties are compatible.
503
+ *
504
+ * @param {SVGRenderingContext} other - Other element's context
505
+ * @returns {Object} {canMerge: boolean, reason: string}
506
+ */
507
+ canMergeWith(other) {
508
+ // Fill rules must match
509
+ if (this.fillRule !== other.fillRule) {
510
+ return { canMerge: false, reason: 'Different fill-rule' };
511
+ }
512
+
513
+ // Stroke properties must match if either has stroke
514
+ if (this.hasStroke || other.hasStroke) {
515
+ if (this.hasStroke !== other.hasStroke) {
516
+ return { canMerge: false, reason: 'Different stroke presence' };
517
+ }
518
+
519
+ if (!this.strokeWidth.eq(other.strokeWidth)) {
520
+ return { canMerge: false, reason: 'Different stroke-width' };
521
+ }
522
+
523
+ if (this.strokeLinecap !== other.strokeLinecap) {
524
+ return { canMerge: false, reason: 'Different stroke-linecap' };
525
+ }
526
+
527
+ if (this.strokeLinejoin !== other.strokeLinejoin) {
528
+ return { canMerge: false, reason: 'Different stroke-linejoin' };
529
+ }
530
+ }
531
+
532
+ // Neither can have markers (markers break continuity)
533
+ if (this.hasMarkers || other.hasMarkers) {
534
+ return { canMerge: false, reason: 'Has markers' };
535
+ }
536
+
537
+ // Neither can have clip-path or mask
538
+ if (this.properties['clip-path'] !== 'none' || other.properties['clip-path'] !== 'none') {
539
+ return { canMerge: false, reason: 'Has clip-path' };
540
+ }
541
+
542
+ if (this.properties.mask !== 'none' || other.properties.mask !== 'none') {
543
+ return { canMerge: false, reason: 'Has mask' };
544
+ }
545
+
546
+ // Neither can have filter
547
+ if (this.properties.filter !== 'none' || other.properties.filter !== 'none') {
548
+ return { canMerge: false, reason: 'Has filter' };
549
+ }
550
+
551
+ return { canMerge: true, reason: null };
552
+ }
553
+
554
+ /**
555
+ * Get a summary of this context for debugging
556
+ * @returns {Object}
557
+ */
558
+ toSummary() {
559
+ return {
560
+ hasFill: this.hasFill,
561
+ hasStroke: this.hasStroke,
562
+ hasMarkers: this.hasMarkers,
563
+ fillRule: this.fillRule,
564
+ strokeWidth: this.hasStroke ? this.strokeWidth.toNumber() : 0,
565
+ strokeExtent: this.getStrokeExtent().toNumber(),
566
+ linecap: this.strokeLinecap,
567
+ linejoin: this.strokeLinejoin,
568
+ clipPath: this.properties['clip-path'],
569
+ filter: this.properties.filter
570
+ };
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Create a rendering context from an SVG element
576
+ *
577
+ * @param {Object} element - SVG element
578
+ * @param {Object} inherited - Inherited properties
579
+ * @param {Map} defsMap - Definitions map
580
+ * @returns {SVGRenderingContext}
581
+ */
582
+ export function createRenderingContext(element, inherited = {}, defsMap = null) {
583
+ return new SVGRenderingContext(element, inherited, defsMap);
584
+ }
585
+
586
+ /**
587
+ * Get inherited properties from parent chain
588
+ *
589
+ * @param {Object} element - SVG element
590
+ * @returns {Object} Inherited properties
591
+ */
592
+ export function getInheritedProperties(element) {
593
+ const inherited = {};
594
+
595
+ // Inheritable properties per SVG spec
596
+ const inheritableProps = [
597
+ 'fill', 'fill-rule', 'fill-opacity',
598
+ 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin',
599
+ 'stroke-miterlimit', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity',
600
+ 'marker-start', 'marker-mid', 'marker-end',
601
+ 'clip-rule', 'opacity', 'font-family', 'font-size', 'font-style', 'font-weight'
602
+ ];
603
+
604
+ let current = element.parentNode;
605
+ while (current && current.tagName) {
606
+ for (const prop of inheritableProps) {
607
+ if (inherited[prop] === undefined) {
608
+ const value = current.getAttribute ? current.getAttribute(prop) : null;
609
+ if (value !== null && value !== undefined) {
610
+ inherited[prop] = value;
611
+ }
612
+ }
613
+ }
614
+ current = current.parentNode;
615
+ }
616
+
617
+ return inherited;
618
+ }
619
+
620
+ export default {
621
+ SVGRenderingContext,
622
+ SVG_DEFAULTS,
623
+ GEOMETRY_AFFECTING_PROPERTIES,
624
+ FILL_AFFECTING_PROPERTIES,
625
+ createRenderingContext,
626
+ getInheritedProperties
627
+ };