-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathSvgVisualBBox.js
More file actions
2775 lines (2542 loc) · 110 KB
/
SvgVisualBBox.js
File metadata and controls
2775 lines (2542 loc) · 110 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* SvgVisualBBox.js
*
* High-accuracy *visual* bounding boxes for SVG content using a two-pass
* rasterization strategy. Designed to handle:
* - complex text (CJK, Arabic, ligatures, RTL/LTR mixing, text-anchor)
* - <use>, <symbol>, <defs>, markers, gradients, patterns
* - stroke width, caps, joins, markers, vector-effect
* - filters, masks, clipPaths, compositing, images/bitmaps
*
* Approach
* --------
* 1. Clone the root <svg>, isolate the target element while keeping <defs>.
* 2. PASS 1: rasterize a large region at coarse resolution → rough bbox.
* 3. Expand rough bbox with a large safety margin.
* 4. PASS 2: rasterize only that region at high resolution → precise bbox.
*
* All bounding boxes are returned in the root <svg>'s user coordinate system
* (i.e. its viewBox units). That makes them directly comparable to all
* other SVG coordinates (paths, rects, etc).
*
* Security / CORS
* ---------------
* Reading back pixels from <canvas> requires that the SVG and all referenced
* images/fonts are same-origin or CORS-enabled. Otherwise the canvas is
* "tainted" and getImageData() will throw a SecurityError.
*
* Public API (namespace: SvgVisualBBox)
* -------------------------------------
* - waitForDocumentFonts(doc?, timeoutMs?)
* Waits for document fonts to load (CSS Font Loading API), with timeout.
*
* - getSvgElementVisualBBoxTwoPassAggressive(target, options?)
* High-accuracy visual bbox for a single SVG element.
*
* - getSvgElementsUnionVisualBBox(targets[], options?)
* Union bbox for multiple SVG elements in the same <svg>.
*
* - getSvgElementVisibleAndFullBBoxes(target, options?)
* Returns both:
* - visible: bbox clipped to viewBox / viewport
* - full: bbox ignoring viewBox clipping (whole drawing ROI)
*
* - getSvgRootViewBoxExpansionForFullDrawing(svgRootOrId, options?)
* For a root <svg> with a viewBox, computes how much padding you'd
* need to expand the viewBox so its visible area fully covers the
* drawing's full visual bbox.
*
* - showTrueBBoxBorder(target, options?)
* Visual debug helper: displays a dotted border around any SVG element's
* true visual bounding box. Auto-detects dark/light theme. Works with all
* SVG types. Returns {bbox, overlay, remove()} for cleanup.
*
* Usage
* -----
* <script src="SvgVisualBBox.js"></script>
*
* (async () => {
* const bbox = await SvgVisualBBox.getSvgElementVisualBBoxTwoPassAggressive('myTextId', {
* mode: 'clipped', // or 'unclipped'
* coarseFactor: 3,
* fineFactor: 24
* });
*
* console.log(bbox.x, bbox.y, bbox.width, bbox.height);
* })();
*
* // Multiple elements:
* const union = await SvgVisualBBox.getSvgElementsUnionVisualBBox(
* ['text1', 'text2', pathElement]
* );
*
* // Visible vs full (before viewBox clipping):
* const { visible, full } = await SvgVisualBBox.getSvgElementVisibleAndFullBBoxes('mySvg');
*
* // Compute how much to expand the viewBox to cover full drawing:
* const expansion = await SvgVisualBBox.getSvgRootViewBoxExpansionForFullDrawing('mySvg');
*/
(function (root, factory) {
// @ts-ignore - UMD pattern: define.amd check is intentional
if (typeof define === 'function' && define.amd) {
// AMD
// @ts-ignore - UMD pattern: AMD define signature
define([], factory);
} else if (typeof module === 'object' && module.exports) {
// CommonJS / Node
module.exports = factory();
} else {
// Browser global
// @ts-ignore - UMD pattern: dynamic property assignment to global
root.SvgVisualBBox = factory();
}
})(typeof self !== 'undefined' ? self : this, () => {
'use strict';
/**
* @typedef {{x: number, y: number, width: number, height: number}} BBox
* @typedef {{x: number, y: number, width: number, height: number, element: Element, svgRoot: SVGSVGElement}} BBoxResult
* @typedef {{x: number, y: number, width: number, height: number, svgRoot: SVGSVGElement, bboxes: BBoxResult[]}} UnionBBoxResult
* @typedef {{scale: number, scaleX?: undefined, scaleY?: undefined, offsetX: number, offsetY: number, uniform: true}|{scale?: undefined, scaleX: number, scaleY: number, offsetX: number, offsetY: number, uniform: false}} ScalingResult
* @typedef {{display: string, visibility: string, opacity: string}} VisibilityState
* @typedef {{[key: string]: VisibilityState}} VisibilityList
*/
// Debug flag - set to true to enable console logging
const DEBUG = false;
// CRITICAL FIX #2: Mutex for preventing race conditions
// WHY: Multiple simultaneous getBBox calls on same element can interfere
// IMPACT: Ensures operations complete atomically, preventing corrupted results
// Map: element -> promise (pending operation)
const elementLocks = new WeakMap();
/**
* INTERNAL: Check if an ID looks like an auto-generated ID
* @param {string} id
* @returns {boolean}
*/
function isAutoGeneratedId(id) {
return Boolean(id && /^auto_id_/.test(id));
}
/**
* INTERNAL: Get detailed information about an SVG element for error reporting
* @param {SVGElement} el
* @returns {string} Human-readable element description
*/
function getElementDescription(el) {
if (!el) {
return 'null/undefined element';
}
const parts = [];
// Tag name
const tag = el.tagName || el.nodeName || 'unknown';
parts.push(`<${tag}>`);
// ID if present
if (el.id) {
const idDesc = isAutoGeneratedId(el.id)
? `id="${el.id}" (AUTO-GENERATED - not in original SVG!)`
: `id="${el.id}"`;
parts.push(idDesc);
}
// Class if present
if (el.getAttribute && el.getAttribute('class')) {
parts.push(`class="${el.getAttribute('class')}"`);
}
// Position in DOM
const parent = el.parentNode;
if (parent && parent instanceof Element) {
const parentIdDesc = parent.id
? isAutoGeneratedId(parent.id)
? ` id="${parent.id}" (AUTO-GENERATED)`
: ` id="${parent.id}"`
: '';
parts.push(`(child of <${parent.tagName}>${parentIdDesc})`);
}
return parts.join(' ');
}
/**
* INTERNAL: Save debug SVG to global for error handling
* @param {SVGSVGElement} svgRoot
*/
function saveDebugSvgToGlobal(svgRoot) {
if (!svgRoot) {
return;
}
try {
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(svgRoot);
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '-')
.replace('T', '_')
.slice(0, -5);
const svgId = svgRoot.id || 'svg';
// Store in global for error handler to access
if (typeof window !== 'undefined') {
// @ts-ignore - Dynamic debug property added at runtime
window.__DEBUG_SVG_DATA__ = {
content: svgString,
filename: `${svgId}_debug_${timestamp}.svg`,
timestamp: timestamp
};
}
} catch (e) {
// CRITICAL FIX #4: Intentionally silent error (debug helper only)
// WHY: Saving debug data should never break the main operation
// IMPACT: Debug failures don't propagate to user-facing code
if (DEBUG && typeof console !== 'undefined' && console.warn) {
console.warn('[DEBUG] Failed to save debug SVG data:', e);
}
}
}
/**
* INTERNAL: Get auto-ID warning text if any IDs are auto-generated
* @param {SVGElement} el
* @param {SVGSVGElement} svgRoot
* @returns {string} Warning text or empty string
*/
function getAutoIdWarning(el, svgRoot) {
const hasAutoId = el && el.id && isAutoGeneratedId(el.id);
const parentHasAutoId =
el &&
el.parentNode &&
el.parentNode instanceof Element &&
el.parentNode.id &&
isAutoGeneratedId(el.parentNode.id);
const rootHasAutoId = svgRoot && svgRoot.id && isAutoGeneratedId(svgRoot.id);
if (!hasAutoId && !parentHasAutoId && !rootHasAutoId) {
return '';
}
// Save debug SVG to global for Node.js error handler to save
saveDebugSvgToGlobal(svgRoot);
const elementId = el && el.id ? el.id : '(none)';
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, -5);
const svgId = svgRoot && svgRoot.id ? svgRoot.id : 'svg';
const debugFilename = `${svgId}_debug_${timestamp}.svg`;
return (
'\n' +
'⚠️ AUTO-GENERATED ID WARNING:\n' +
' The IDs shown above were automatically assigned by sbb-extract.cjs.\n' +
' These IDs DO NOT EXIST in your original SVG file!\n' +
'\n' +
' 🔍 DEBUG SVG WILL BE AUTOMATICALLY SAVED:\n' +
` ${debugFilename}\n` +
'\n' +
' To find this element in your original SVG:\n' +
' 1. Open the debug SVG file (saved automatically in current directory)\n' +
` 2. Search for the ID "${elementId}" to locate the problematic element\n` +
" 3. Note the element's position, visual appearance, and attributes\n" +
' 4. Find the corresponding element in your original SVG using these details\n'
);
}
/**
* INTERNAL: Get detailed font information from an element
* @param {SVGElement} el
* @returns {string} Font family and detected fonts
*/
function getFontDescription(el) {
if (!el || typeof window === 'undefined' || !window.getComputedStyle) {
return 'unknown';
}
try {
const style = window.getComputedStyle(el);
const fontFamily = style.fontFamily || style.getPropertyValue('font-family');
return fontFamily || 'default';
} catch {
// CRITICAL FIX #4: Intentionally silent error (error reporting helper)
// WHY: Font detection failure shouldn't prevent error reporting
// IMPACT: Returns fallback string instead of throwing
return 'error detecting font';
}
}
/**
* Wait until the document's fonts are loaded (CSS Font Loading API),
* with a timeout so we don't hang forever if the network is flaky.
*
* @param {Document} [doc=document] The document whose fonts to wait for.
* @param {number} [timeoutMs=5000] Max time to wait (ms). If <=0, waits fully.
* @returns {Promise<void>}
*/
async function waitForDocumentFonts(doc, timeoutMs) {
if (!doc) {
doc = document;
}
if (typeof timeoutMs !== 'number' || !isFinite(timeoutMs)) {
// CRITICAL FIX #3: Default timeout to prevent indefinite hangs
// Also handle NaN since typeof NaN === 'number' but setTimeout(NaN) fires immediately
timeoutMs = 5000;
}
const fonts = doc.fonts;
if (!fonts || !fonts.ready) {
// CSS Font Loading API not supported; nothing we can do.
return;
}
const readyPromise = fonts.ready;
if (timeoutMs <= 0) {
await readyPromise;
return;
}
// CRITICAL FIX #3: Add timeout handler with clear error message
// WHY: Without timeout, font loading can hang indefinitely if network is slow/blocked
// IMPACT: Prevents browser tab freezes when fonts fail to load
await Promise.race([
readyPromise,
/** @type {Promise<void>} */ (
new Promise((resolve) =>
setTimeout(() => {
if (DEBUG && typeof console !== 'undefined' && console.warn) {
console.warn(
`[waitForDocumentFonts] Font loading timeout after ${timeoutMs}ms - proceeding anyway`
);
}
resolve(undefined);
}, timeoutMs)
)
)
]);
}
/**
* INTERNAL: Parse preserveAspectRatio attribute and calculate scaling parameters
* for converting between page pixel coordinates and SVG user coordinates.
*
* @param {SVGSVGElement} svgEl - The SVG root element
* @param {{x:number,y:number,width:number,height:number}} viewBox - The viewBox
* @param {DOMRect} renderedRect - The getBoundingClientRect() of the SVG element
* @returns {ScalingResult}
*/
function parsePreserveAspectRatio(svgEl, viewBox, renderedRect) {
const scaleX = viewBox.width / renderedRect.width;
const scaleY = viewBox.height / renderedRect.height;
// Get preserveAspectRatio attribute (default: "xMidYMid meet")
const aspectRatio = svgEl.getAttribute('preserveAspectRatio') || 'xMidYMid meet';
// Filter out "defer" keyword (only meaningful for <image> elements, not <svg>)
const parts = aspectRatio
.trim()
.split(/\s+/)
.filter((p) => p !== 'defer');
if (parts.length === 1 && parts[0] === 'none') {
// Non-uniform scaling - no offset needed
return { scaleX, scaleY, offsetX: 0, offsetY: 0, uniform: false };
}
const align = parts[0] || 'xMidYMid';
const meetOrSlice = parts[1] || 'meet';
// Determine uniform scale factor
let scale;
if (meetOrSlice === 'slice') {
// slice: covers viewport (SVG→page uses MAX) → page→user uses MIN
scale = Math.min(scaleX, scaleY);
} else {
// meet: fits inside viewport (SVG→page uses MIN) → page→user uses MAX
scale = Math.max(scaleX, scaleY);
}
// Calculate the offset due to alignment (letterboxing/pillarboxing)
// Scaled viewBox dimensions in page pixels
const scaledViewBoxWidth = viewBox.width / scale;
const scaledViewBoxHeight = viewBox.height / scale;
// Extra space in page pixels
const extraX = renderedRect.width - scaledViewBoxWidth;
const extraY = renderedRect.height - scaledViewBoxHeight;
let offsetX = 0;
let offsetY = 0;
// Horizontal alignment (xMin/xMid/xMax)
if (align.startsWith('xMid')) {
offsetX = extraX / 2;
} else if (align.startsWith('xMax')) {
offsetX = extraX;
}
// xMin: offsetX = 0 (already set)
// Vertical alignment (YMin/YMid/YMax)
if (align.includes('YMid')) {
offsetY = extraY / 2;
} else if (align.includes('YMax')) {
offsetY = extraY;
}
// YMin: offsetY = 0 (already set)
return { scale, offsetX, offsetY, uniform: true };
}
/**
* INTERNAL: Get a bounding box for an SVG element using ONLY getBoundingClientRect()
* and convert from page pixels to SVG user coordinates.
* Returns null if unable or if values are NaN/infinite.
*
* IMPORTANT: We do NOT use getBBox() because it:
* - Ignores strokes, filters, transforms, and dashed lines
* - Returns empty objects for some elements (paths, textPath)
* - Gives incorrect results for elements outside viewBox
*
* @param {SVGElement} el
* @returns {{x:number,y:number,width:number,height:number}|null}
*/
function safeGetBBox(el) {
try {
const clientRect = el.getBoundingClientRect();
const svgRoot = el.ownerSVGElement || (el instanceof SVGSVGElement ? el : null);
if (!svgRoot || !clientRect) {
return null;
}
if (clientRect.width === 0 && clientRect.height === 0) {
return null;
}
// Convert screen coordinates to SVG user coordinates
const rootRect = svgRoot.getBoundingClientRect();
const vb = svgRoot.viewBox && svgRoot.viewBox.baseVal;
if (!vb || vb.width <= 0 || vb.height <= 0) {
return null;
}
if (rootRect.width <= 0 || rootRect.height <= 0) {
return null;
}
// Parse preserveAspectRatio attribute
const scaling = parsePreserveAspectRatio(svgRoot, vb, rootRect);
let x, y, width, height;
if (scaling.uniform) {
// Uniform scaling (meet or slice) - subtract offset before scaling
const scale = scaling.scale;
x = vb.x + (clientRect.left - rootRect.left - scaling.offsetX) * scale;
y = vb.y + (clientRect.top - rootRect.top - scaling.offsetY) * scale;
width = clientRect.width * scale;
height = clientRect.height * scale;
} else {
// Non-uniform scaling (preserveAspectRatio="none") - no offset
x = vb.x + (clientRect.left - rootRect.left) * scaling.scaleX;
y = vb.y + (clientRect.top - rootRect.top) * scaling.scaleY;
width = clientRect.width * scaling.scaleX;
height = clientRect.height * scaling.scaleY;
}
if (!isFinite(x) || !isFinite(y)) {
return null;
}
if (!isFinite(width) || !isFinite(height)) {
return null;
}
return { x, y, width, height };
} catch {
// CRITICAL FIX #4: Intentionally silent error (fallback function)
// WHY: getBoundingClientRect can fail for detached/invalid elements
// IMPACT: Returns null to signal fallback to caller, doesn't crash
return null;
}
}
// NOTE: _normalizeTargetForText() was removed in v1.1.2 audit (2026-01-05)
// It was dead code - defined but never called. The function attempted to
// normalize textPath/tspan elements by returning their parent <text>,
// but this functionality is handled elsewhere in the codebase.
/**
* INTERNAL: Rasterize ONE element of ONE SVG into a canvas over a given
* ROI (region of interest) in SVG user units, at a given resolution
* (pixelsPerUnit), and return a visual bbox in user units.
*
* roi = { x, y, width, height } in svgRoot user units
* pixelsPerUnit = canvas pixels per 1 user unit (high = better accuracy)
*
* @param {SVGElement} el
* @param {SVGSVGElement} svgRoot
* @param {{x:number,y:number,width:number,height:number}} roi
* @param {number} pixelsPerUnit
* @returns {Promise<{x:number,y:number,width:number,height:number}|null>}
*/
async function rasterizeSvgElementToBBox(el, svgRoot, roi, pixelsPerUnit) {
// CRITICAL FIX #5: Input validation for robustness
// WHY: Invalid inputs can cause cryptic errors later in the pipeline
// IMPACT: Clear error messages help users identify problems early
if (!el) {
throw new Error('rasterizeSvgElementToBBox: element parameter is null or undefined');
}
if (!svgRoot) {
throw new Error('rasterizeSvgElementToBBox: svgRoot parameter is null or undefined');
}
if (!roi || typeof roi !== 'object') {
throw new Error(
'rasterizeSvgElementToBBox: roi parameter must be an object with x, y, width, height'
);
}
if (roi.width <= 0 || roi.height <= 0) {
return null; // Zero-size ROI is valid, just means no content
}
if (typeof pixelsPerUnit !== 'number' || pixelsPerUnit <= 0 || !isFinite(pixelsPerUnit)) {
throw new Error(
`rasterizeSvgElementToBBox: pixelsPerUnit must be a positive finite number, got: ${pixelsPerUnit}`
);
}
const vb = roi;
// ═══════════════════════════════════════════════════════════════════════════════
// CRITICAL FIX #1: Temp ID Assignment Timing
// ═══════════════════════════════════════════════════════════════════════════════
// PROBLEM: Elements without IDs were failing with "Element not found in cloned SVG"
//
// ❌ WRONG SOLUTION #1 - Assign temp ID AFTER cloning (original broken code):
// const clonedSvg = svgRoot.cloneNode(true); // Clone first
// if (!el.id) {
// el.id = '__temp_' + Math.random(); // Assign ID after
// }
// const cloneTarget = clonedSvg.getElementById(el.id); // Try to find → FAILS!
//
// WHY IT FAILS:
// - cloneNode() creates snapshot of DOM at moment of cloning
// - Element has NO ID when cloned → cloned element also has NO ID
// - Adding ID to original AFTER clone doesn't affect the clone
// - getElementById(el.id) searches clone for an ID that doesn't exist
// - Result: cloneTarget = null → "Element not found" error
//
// ❌ WRONG SOLUTION #2 - Don't remove temp ID from original:
// if (!el.id) {
// el.id = '__temp_' + Math.random();
// }
// const clonedSvg = svgRoot.cloneNode(true);
// const cloneTarget = clonedSvg.getElementById(el.id); // This works BUT...
// // Missing: el.removeAttribute('id')
//
// WHY IT FAILS:
// - Pollutes original DOM with temp IDs that never get cleaned up
// - Multiple calls accumulate temp IDs in the document
// - Breaks ID uniqueness assumptions in user's code
// - User's code might rely on elements not having IDs
// - Memory leak: temp IDs persist forever
//
// ✅ CORRECT SOLUTION - Assign BEFORE clone, remove AFTER clone:
// const hadId = !!el.id;
// let tmpId;
// if (!hadId) {
// tmpId = '__svg_visual_bbox_tmp_' + Math.random().toString(36).slice(2);
// el.id = tmpId; // Assign BEFORE clone
// }
// const clonedSvg = svgRoot.cloneNode(true); // Clone (includes temp ID)
// if (!hadId) {
// el.removeAttribute('id'); // Clean up original DOM
// }
// const idToFind = tmpId || el.id; // Use saved tmpId
// const cloneTarget = clonedSvg.getElementById(idToFind); // SUCCESS!
//
// WHY IT WORKS:
// 1. Element HAS ID when cloned → cloned element also HAS ID
// 2. We save tmpId BEFORE cleanup so we can use it later
// 3. Original DOM is restored to pristine state (no temp IDs left behind)
// 4. We can find cloned element by the ID it actually has
// 5. No pollution, no memory leaks, no side effects
const hadId = !!el.id;
let tmpId;
if (!hadId) {
tmpId = '__svg_visual_bbox_tmp_' + Math.random().toString(36).slice(2);
el.id = tmpId;
}
// Clone the root <svg> so we don't touch the real DOM
const clonedSvg = /** @type {SVGSVGElement} */ (svgRoot.cloneNode(true));
// Remove temp ID from original element immediately after cloning
// This keeps the original DOM tree clean and unchanged
if (!hadId) {
el.removeAttribute('id');
}
if (!clonedSvg.getAttribute('xmlns')) {
clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
}
// Map ROI user space → viewport
clonedSvg.setAttribute('viewBox', vb.x + ' ' + vb.y + ' ' + vb.width + ' ' + vb.height);
// Maximum canvas dimensions to avoid out-of-memory errors
// Modern browsers support up to 16384×16384
// Using higher limit ensures consistent actualPixelsPerUnit across elements (critical for alignment)
const MAX_CANVAS_DIMENSION = 16384;
// Calculate initial canvas dimensions
const requestedPixelWidth = Math.max(1, Math.round(vb.width * pixelsPerUnit));
const requestedPixelHeight = Math.max(1, Math.round(vb.height * pixelsPerUnit));
// Scale down uniformly if canvas would exceed maximum dimensions (preserves aspect ratio)
let actualPixelsPerUnit = pixelsPerUnit;
let pixelWidth = requestedPixelWidth;
let pixelHeight = requestedPixelHeight;
if (requestedPixelWidth > MAX_CANVAS_DIMENSION || requestedPixelHeight > MAX_CANVAS_DIMENSION) {
// Find which dimension exceeds the limit more
const scaleW = MAX_CANVAS_DIMENSION / requestedPixelWidth;
const scaleH = MAX_CANVAS_DIMENSION / requestedPixelHeight;
// Use the smaller scale factor to ensure both dimensions fit
const scale = Math.min(scaleW, scaleH);
// Apply uniform scaling to preserve aspect ratio
actualPixelsPerUnit = pixelsPerUnit * scale;
pixelWidth = Math.max(1, Math.round(vb.width * actualPixelsPerUnit));
pixelHeight = Math.max(1, Math.round(vb.height * actualPixelsPerUnit));
if (DEBUG && typeof console !== 'undefined' && console.warn) {
console.warn(
'[DEBUG rasterize] Canvas size exceeded maximum, scaling down uniformly:\n' +
` Requested: ${requestedPixelWidth}×${requestedPixelHeight}\n` +
` Scaled: ${pixelWidth}×${pixelHeight}\n` +
` Scale factor: ${scale.toFixed(3)} (${(scale * 100).toFixed(1)}%)\n` +
` Aspect ratio preserved: ${(vb.width / vb.height).toFixed(3)} → ${(pixelWidth / pixelHeight).toFixed(3)}`
);
}
}
clonedSvg.setAttribute('width', String(pixelWidth));
clonedSvg.setAttribute('height', String(pixelHeight));
// CRITICAL: Set preserveAspectRatio="none" to prevent unwanted scaling/letterboxing
// The viewBox and width/height are already calculated to match exactly
clonedSvg.setAttribute('preserveAspectRatio', 'none');
// CRITICAL: Remove x/y attributes from cloned SVG to prevent offset during canvas rendering
// The x/y attributes are for positioning when SVG is embedded as a child element,
// but they cause unwanted offsets when rendering to canvas via data URL
clonedSvg.removeAttribute('x');
clonedSvg.removeAttribute('y');
if (DEBUG && typeof console !== 'undefined' && console.log) {
console.log('[DEBUG rasterize] After removing x/y:', {
x: clonedSvg.getAttribute('x'),
y: clonedSvg.getAttribute('y'),
hasX: clonedSvg.hasAttribute('x'),
hasY: clonedSvg.hasAttribute('y')
});
}
// ═══════════════════════════════════════════════════════════════════════════════
// CRITICAL FIX #2 & #3: SVG Root Element + Temp ID Variable Usage
// ═══════════════════════════════════════════════════════════════════════════════
// PROBLEM #2: Couldn't measure SVG root element itself
// PROBLEM #3: After removing temp ID from original, el.id was empty when searching clone
//
// ❌ WRONG SOLUTION #1 - Always use getElementById (original broken code):
// const cloneTarget = clonedSvg.getElementById(el.id); // Fails for SVG root!
//
// WHY IT FAILS (Issue #2):
// - getElementById() searches DESCENDANTS only, not the element itself
// - clonedSvg.getElementById(id) searches INSIDE clonedSvg, not clonedSvg itself
// - If el === svgRoot, we're measuring the root SVG element
// - But getElementById can't find the root because it IS the root!
// - Analogy: You can't find yourself by looking inside yourself
// - Result: cloneTarget = null for SVG root elements
//
// ❌ WRONG SOLUTION #2 - Use el.id after cleanup (another bug):
// if (!hadId) {
// el.removeAttribute('id'); // Remove temp ID from original
// }
// const cloneTarget = clonedSvg.getElementById(el.id); // el.id is empty now!
//
// WHY IT FAILS (Issue #3):
// - We removed the temp ID from original element at line 496
// - Now el.id is empty string (no ID attribute)
// - getElementById("") searches for empty string → returns null
// - The temp ID still exists in CLONE, but we lost the value!
// - We're asking "find element with ID ''" → nonsensical query
//
// ❌ WRONG SOLUTION #3 - Re-read ID from original element:
// const cloneTarget = clonedSvg.getElementById(el.getAttribute('id'));
//
// WHY IT FAILS:
// - getAttribute('id') returns null if no ID (after we removed it)
// - getElementById(null) searches for string "null" → wrong!
// - Still doesn't solve SVG root issue (#2)
//
// ✅ CORRECT SOLUTION - Special case for root + use saved tmpId:
// let cloneTarget;
// if (el === svgRoot) {
// cloneTarget = clonedSvg; // Fix #2: Return root directly
// } else {
// const idToFind = tmpId || el.id; // Fix #3: Use SAVED tmpId
// cloneTarget = clonedSvg.getElementById(idToFind);
// }
//
// WHY IT WORKS:
// Fix #2: Detect when measuring SVG root and return cloned root directly
// - No need for getElementById when target IS the root
// - Direct reference: clonedSvg is exactly what we need
// Fix #3: Use tmpId variable saved BEFORE cleanup
// - tmpId was captured at line 498 before removeAttribute
// - It contains the exact temp ID that exists in the clone
// - For elements with original IDs: tmpId is undefined, use el.id (still intact)
// - For elements with temp IDs: use tmpId (saved value before cleanup)
let cloneTarget;
if (el === svgRoot) {
cloneTarget = clonedSvg;
} else {
// Use the saved ID (either original or temp) to find the cloned element
const idToFind = tmpId || el.id;
cloneTarget = clonedSvg.getElementById(idToFind);
}
if (!cloneTarget) {
const elementInfo = getElementDescription(el);
const tempIdUsed = tmpId
? `Temporary ID used: "${tmpId}"`
: `Element ID: ${el.id ? `"${el.id}"` : '(none)'}`;
const autoIdWarning = getAutoIdWarning(el, svgRoot);
throw new Error(
'❌ Cannot render SVG element: Element not found in cloned SVG\n' +
'\n' +
'ELEMENT DETAILS:\n' +
` ${elementInfo}\n` +
` ${tempIdUsed}\n` +
` SVG Root: ${svgRoot.id ? `id="${svgRoot.id}"` : '(no id)'}\n` +
autoIdWarning +
'\n' +
'This typically happens when:\n' +
' 1. The element IS the SVG root itself (not supported - query a child element instead)\n' +
' 2. The element was removed or modified during cloning\n' +
" 3. The element's ID conflicts with another element\n" +
'\n' +
'How to fix:\n' +
' • If querying the root <svg>, query a child element instead\n' +
" • Ensure the element has a unique 'id' attribute\n" +
' • Check that the element exists in the DOM before calling this function\n' +
" • Verify the element hasn't been dynamically removed"
);
}
// Keep:
// - target
// - its ancestors
// - its descendants
// - all <defs> (filters, markers, gradients, patterns, etc.)
// - sibling tspan/textPath elements (needed for proper text layout)
const allowed = new Set();
let node = /** @type {Node | null} */ (cloneTarget);
while (node) {
allowed.add(node);
if (node === clonedSvg) {
break;
}
node = node.parentNode;
}
// Add all descendants of the target
/** @description Recursively adds a node and all its child elements to the allowed set */
(function addDescendants(n) {
allowed.add(n);
const children = n.children;
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child) addDescendants(child);
}
})(cloneTarget);
// ═══════════════════════════════════════════════════════════════════════════════
// CRITICAL FIX #4: Cross-SVG Reference Resolution
// ═══════════════════════════════════════════════════════════════════════════════
// PROBLEM: <use href="#id"/> elements referencing elements in DIFFERENT SVG roots failed
//
// REAL-WORLD SCENARIO (HTML Preview Catalogs generated by sbb-extract.cjs --list):
//
// <!-- Hidden container with actual elements -->
// <div style="display:none">
// <svg id="root">
// <g id="g37" transform="translate(-13.5,-10.2)">
// <text id="text8" x="-50" y="100">Hello</text>
// </g>
// </svg>
// </div>
//
// <!-- Visible preview SVG using <use> to reference hidden element -->
// <svg id="preview" viewBox="-70 80 100 50">
// <g transform="translate(-13.5,-10.2)">
// <use href="#text8" /> <!-- References element in DIFFERENT SVG! -->
// </g>
// </svg>
//
// ❌ WRONG SOLUTION #1 - Only search within cloned SVG (original broken code):
// const refEl = clonedSvg.getElementById(refId);
// if (refEl) {
// allowed.add(refEl); // Add to whitelist
// }
// // If not found, do nothing (WRONG!)
//
// WHY IT FAILS:
// - We clone ONLY the preview SVG (id="preview")
// - The clone contains: <use href="#text8" />
// - But #text8 lives in DIFFERENT SVG (id="root" in hidden container)
// - clonedSvg.getElementById("text8") returns null
// - We don't add the referenced element to the clone
// - Browser tries to render <use href="#text8" /> but can't find #text8
// - Result: Blank rendering (use element renders nothing)
//
// ❌ WRONG SOLUTION #2 - Clone element WITH parent transforms:
// const originalRefEl = document.getElementById(refId);
// if (originalRefEl) {
// // Build transform chain from parent <g> elements
// const transforms = [];
// let parent = originalRefEl.parentNode;
// while (parent && parent.tagName !== 'svg') {
// if (parent.getAttribute('transform')) {
// transforms.push(parent.getAttribute('transform'));
// }
// parent = parent.parentNode;
// }
// // Wrap cloned element in <g> with transforms
// let wrapped = originalRefEl.cloneNode(true);
// for (const t of transforms) {
// const g = document.createElementNS('...', 'g');
// g.setAttribute('transform', t);
// g.appendChild(wrapped);
// wrapped = g;
// }
// defs.appendChild(wrapped);
// }
//
// WHY IT FAILS:
// - Original element has transform: translate(-13.5,-10.2) from parent <g id="g37">
// - We wrap cloned element with this transform
// - But preview SVG ALSO has <g transform="translate(-13.5,-10.2)">!
// - Transform gets applied TWICE: once from wrapped element, once from preview <g>
// - Result: Element appears at WRONG position (double transform)
// - Example: text at x=-50 should be at -63.5, but appears at -77 (double offset)
//
// ❌ WRONG SOLUTION #3 - Don't clone, just reference original:
// // Don't clone at all, assume browser will find it in original document
//
// WHY IT FAILS:
// - The cloned SVG is rendered in isolation (via data URL to canvas)
// - It's a SEPARATE document context from the original DOM
// - References can't reach across document boundaries
// - Browser can't find #text8 because it's in different document
//
// ✅ CORRECT SOLUTION - Clone referenced element to <defs>, no transform wrapper:
// let refEl = clonedSvg.getElementById(refId);
// if (!refEl) {
// const originalRefEl = document.getElementById(refId); // Search whole document
// if (originalRefEl) {
// let defs = clonedSvg.querySelector('defs');
// if (!defs) {
// defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
// clonedSvg.insertBefore(defs, clonedSvg.firstChild);
// }
// refEl = originalRefEl.cloneNode(true); // Clone WITHOUT transform wrapper
// defs.appendChild(refEl);
// allowed.add(defs);
// }
// }
//
// WHY IT WORKS:
// 1. Search cloned SVG first (handles normal same-SVG references)
// 2. If not found, search ENTIRE document with document.getElementById()
// 3. Clone the referenced element and add to <defs>
// 4. DON'T wrap with parent transforms (preview SVG already has them)
// 5. <use> element can now find its target in <defs>
// 6. Browser renders <use> correctly with transform from preview SVG
// 7. Transform applied ONCE (not doubled)
//
// KEY INSIGHT:
// - <use> elements instantiate a COPY of the target (like a function call)
// - The target just needs to EXIST in the document (in <defs> is fine)
// - Transforms should be on the <use> or its parents, NOT on the definition
// - This is why HTML preview has <g transform="..."><use /></g> structure
(function addReferencedElements(n) {
// Check for xlink:href or href attributes (textPath, use, image, etc.)
const xlinkHref = n.getAttribute && n.getAttribute('xlink:href');
const href = n.getAttribute && n.getAttribute('href');
for (const refAttr of [xlinkHref, href]) {
if (refAttr && refAttr.startsWith('#')) {
const refId = refAttr.substring(1);
let refEl = clonedSvg.getElementById(refId);
// Handle cross-SVG references (e.g., <use href="#id"/> referencing hidden container)
if (!refEl) {
// Element not in cloned SVG - try to find it in the original document
const originalRefEl = document.getElementById(refId);
if (originalRefEl) {
// Clone the referenced element and add it to a <defs> section in the cloned SVG
let defs = clonedSvg.querySelector('defs');
if (!defs) {
defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
clonedSvg.insertBefore(defs, clonedSvg.firstChild);
}
// TYPE SAFETY: cloneNode(true) returns Node, but we know it's an Element
// JSDoc type cast satisfies TypeScript without runtime overhead
// This is safe because getElementById always returns Element | null
refEl = /** @type {Element} */ (originalRefEl.cloneNode(true));
defs.appendChild(refEl);
allowed.add(defs);
}
}
if (refEl && !allowed.has(refEl)) {
allowed.add(refEl);
// Also add ancestors of referenced element (might be in <defs>)
let parent = refEl.parentNode;
while (parent && parent !== clonedSvg) {
allowed.add(parent);
parent = parent.parentNode;
}
}
}
}
// Check for url(#...) in style attributes (fill, stroke, filter, mask, etc.)
const style = n.getAttribute && n.getAttribute('style');
if (style) {
const urlRefs = style.match(/url\(#([^)]+)\)/g);
if (urlRefs) {
for (const urlRef of urlRefs) {
const refMatch = urlRef.match(/url\(#([^)]+)\)/);
if (!refMatch) continue;
const refId = refMatch[1];
if (!refId) continue;
const refEl = clonedSvg.getElementById(refId);
if (refEl && !allowed.has(refEl)) {
allowed.add(refEl);
let parent = refEl.parentNode;
while (parent && parent !== clonedSvg) {
allowed.add(parent);
parent = parent.parentNode;
}
}
}
}
}
// Recurse to children
const children = n.children;
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child) addReferencedElements(child);
}
})(cloneTarget);
// Special case: for tspan/textPath elements, keep all siblings
// because text layout depends on sibling positioning
const targetTag = cloneTarget.tagName && cloneTarget.tagName.toLowerCase();
if (targetTag === 'tspan' || targetTag === 'textpath') {
const parent = cloneTarget.parentNode;
if (parent) {
const siblings = Array.from(parent.children);
for (const sibling of siblings) {
const siblingTag = sibling.tagName && sibling.tagName.toLowerCase();
if (siblingTag === 'tspan' || siblingTag === 'textpath') {
allowed.add(sibling);
// Also add all descendants of sibling tspans
/** @description Recursively adds a node and all its child elements to the allowed set */
(function addDescendants(n) {
allowed.add(n);
const children = n.children;
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child) addDescendants(child);
}
})(sibling);
}
}
}
}
/** @description Recursively removes elements not in the allowed set, preserving defs and structure */
(function removeIrrelevant(rootNode) {
const children = Array.prototype.slice.call(rootNode.children);
for (let i = 0; i < children.length; i++) {
const child = children[i];
const tag = child.tagName && child.tagName.toLowerCase();
if (tag === 'defs') {
// Keep all defs intact
continue;
}
if (!allowed.has(child) && !child.contains(cloneTarget)) {
// REMOVE the element entirely - display='none' doesn't work reliably in SVG-as-image
child.parentNode.removeChild(child);
} else {
removeIrrelevant(child);
}
}
})(clonedSvg);
// Serialize SVG → Blob → Image
const xml = new XMLSerializer().serializeToString(clonedSvg);
if (DEBUG && typeof console !== 'undefined' && console.log) {
console.log('[DEBUG rasterize] Serialized SVG length:', xml.length);
console.log('[DEBUG rasterize] SVG contains textPath:', xml.includes('textPath'));