@miliastry/quasar 1.0.0

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.
Files changed (172) hide show
  1. package/LICENSE +119 -0
  2. package/README.md +45 -0
  3. package/dist/Visuals/lyne.css +1027 -0
  4. package/dist/Visuals/osu.css +257 -0
  5. package/dist/index.d.mts +4563 -0
  6. package/dist/index.d.ts +4563 -0
  7. package/dist/index.js +11291 -0
  8. package/dist/index.mjs +11197 -0
  9. package/package.json +51 -0
  10. package/src/Analysis/Contracts/AnalysisReport.ts +26 -0
  11. package/src/Analysis/Contracts/Contribution.ts +76 -0
  12. package/src/Analysis/Contracts/Pass.ts +76 -0
  13. package/src/Analysis/Contracts/PipelineContext.ts +48 -0
  14. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +292 -0
  15. package/src/Analysis/Passes/Analysis/MergeableColorAnalyzer.ts +112 -0
  16. package/src/Analysis/Passes/Analysis/RainbowAnalyzer.ts +233 -0
  17. package/src/Analysis/Passes/Analysis/WaveAnalyzer.ts +211 -0
  18. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +135 -0
  19. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +84 -0
  20. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +99 -0
  21. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +119 -0
  22. package/src/Analysis/Passes/Decision/DefaultDecision.ts +139 -0
  23. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +179 -0
  24. package/src/Analysis/Passes/Transform/CollapseGradientTransform.ts +176 -0
  25. package/src/Analysis/Passes/Transform/MergeColorsTransform.ts +126 -0
  26. package/src/Analysis/Passes/Transform/RainbowCollapseTransform.ts +83 -0
  27. package/src/Analysis/Passes/Transform/WaveCollapseTransform.ts +88 -0
  28. package/src/Analysis/Passes/Utility/CharacterCountAnalyzer.ts +45 -0
  29. package/src/Analysis/Pipeline/Pipeline.ts +133 -0
  30. package/src/Analysis/Pipeline/PipelineBuilder.ts +55 -0
  31. package/src/Analysis/Pipeline/PipelineStage.ts +19 -0
  32. package/src/Analysis/Utils/color-utils.ts +132 -0
  33. package/src/Analysis/__tests__/Integration.test.ts +162 -0
  34. package/src/Analysis/__tests__/Pipeline.test.ts +133 -0
  35. package/src/Analysis/index.ts +52 -0
  36. package/src/BBCode/BBCodeDocumentModel.ts +175 -0
  37. package/src/BBCode/BBCodeToGreenNode.ts +755 -0
  38. package/src/BBCode/Parser.ts +384 -0
  39. package/src/BBCode/index.ts +12 -0
  40. package/src/Collab/positions.ts +91 -0
  41. package/src/Commands/Command.ts +44 -0
  42. package/src/Commands/CommandRegistry.ts +78 -0
  43. package/src/Commands/DeleteNode.ts +20 -0
  44. package/src/Commands/InsertText.ts +21 -0
  45. package/src/Commands/SplitMerge.ts +28 -0
  46. package/src/Commands/WrapInTag.ts +21 -0
  47. package/src/Commands/index.ts +6 -0
  48. package/src/Diff/TreeDiffer.ts +264 -0
  49. package/src/Diff/__tests__/TreeDiffer.test.ts +65 -0
  50. package/src/Diff/index.ts +2 -0
  51. package/src/Events/EventBus.ts +160 -0
  52. package/src/Events/index.ts +2 -0
  53. package/src/Formatter/Formatter.ts +54 -0
  54. package/src/Formatter/index.ts +2 -0
  55. package/src/HTML/HTMLDocumentModel.ts +35 -0
  56. package/src/HTML/HTMLToGreenNode.ts +290 -0
  57. package/src/Incremental/ChangeTracker.ts +105 -0
  58. package/src/Incremental/IncrementalParser.ts +591 -0
  59. package/src/Incremental/__tests__/IncrementalParser.test.ts +164 -0
  60. package/src/Incremental/index.ts +4 -0
  61. package/src/Lexer/BBCodeLexer.ts +382 -0
  62. package/src/Lexer/Lexer.ts +181 -0
  63. package/src/Lexer/index.ts +10 -0
  64. package/src/Linter/Linter.ts +193 -0
  65. package/src/Linter/index.ts +2 -0
  66. package/src/Markdown/MarkdownAST.ts +112 -0
  67. package/src/Markdown/MarkdownDocumentModel.ts +55 -0
  68. package/src/Markdown/MarkdownLexer.ts +203 -0
  69. package/src/Markdown/MarkdownParser.ts +455 -0
  70. package/src/Markdown/MarkdownToGreenNode.ts +153 -0
  71. package/src/Model/DocumentModel.ts +694 -0
  72. package/src/Model/NodeFactory.ts +117 -0
  73. package/src/Model/TagRegistry.ts +495 -0
  74. package/src/Model/index.ts +5 -0
  75. package/src/Plugins/PluginAPI.ts +119 -0
  76. package/src/Plugins/PluginRegistry.ts +132 -0
  77. package/src/Plugins/index.ts +3 -0
  78. package/src/Queries/QueryEngine.ts +152 -0
  79. package/src/Queries/index.ts +1 -0
  80. package/src/RenderPipeline/RenderPipeline.ts +125 -0
  81. package/src/RenderPipeline/RenderTree.ts +134 -0
  82. package/src/RenderPipeline/index.ts +4 -0
  83. package/src/Semantic/SemanticAnalyzer.ts +506 -0
  84. package/src/Semantic/index.ts +2 -0
  85. package/src/Symbols/SymbolTable.ts +124 -0
  86. package/src/Symbols/index.ts +1 -0
  87. package/src/Syntax/GreenNode.ts +324 -0
  88. package/src/Syntax/GreenNodePool.ts +269 -0
  89. package/src/Syntax/NodeMatcher.ts +370 -0
  90. package/src/Syntax/RedNode.ts +569 -0
  91. package/src/Syntax/RedNodeStore.ts +184 -0
  92. package/src/Syntax/TreeBuilder.ts +214 -0
  93. package/src/Syntax/__tests__/GreenNode.test.ts +33 -0
  94. package/src/Syntax/__tests__/RedNode.test.ts +81 -0
  95. package/src/Syntax/__tests__/RedNodeStore.test.ts +104 -0
  96. package/src/Syntax/greenEdit.ts +110 -0
  97. package/src/Syntax/hash.ts +30 -0
  98. package/src/Syntax/index.ts +12 -0
  99. package/src/Syntax/partition.ts +161 -0
  100. package/src/Syntax/preserveNodeIds.ts +201 -0
  101. package/src/Tests/ASTOptimizerIdempotence.test.ts +77 -0
  102. package/src/Tests/BlockPatcher.test.ts +437 -0
  103. package/src/Tests/BlockPatcherWindowed.test.ts +364 -0
  104. package/src/Tests/BoxDrawer.test.ts +217 -0
  105. package/src/Tests/BoxRichTitle.test.ts +105 -0
  106. package/src/Tests/Chars500kBenchmark.test.ts +151 -0
  107. package/src/Tests/Chars500kEdits.test.ts +321 -0
  108. package/src/Tests/CollabPositions.test.ts +146 -0
  109. package/src/Tests/CompilerPathProfiling.test.ts +186 -0
  110. package/src/Tests/DOMMorpher.test.ts +142 -0
  111. package/src/Tests/DomPatchPerf.test.ts +60 -0
  112. package/src/Tests/EffectSegments.snapshot.json +616 -0
  113. package/src/Tests/EffectSegments.test.ts +68 -0
  114. package/src/Tests/FindNodeAtOffset.test.ts +65 -0
  115. package/src/Tests/Fuzzer.test.ts +166 -0
  116. package/src/Tests/GreenNodePool.test.ts +153 -0
  117. package/src/Tests/Lexer.test.ts +238 -0
  118. package/src/Tests/LyneMode.test.ts +187 -0
  119. package/src/Tests/ModelCoherence.test.ts +180 -0
  120. package/src/Tests/Partition.test.ts +238 -0
  121. package/src/Tests/PluginTags.test.ts +150 -0
  122. package/src/Tests/ProblematicSection.test.ts +46 -0
  123. package/src/Tests/ProblematicSectionHTML.test.ts +58 -0
  124. package/src/Tests/RedReuse.test.ts +134 -0
  125. package/src/Tests/ReproDelete20k.test.ts +62 -0
  126. package/src/Tests/SemanticValidators.test.ts +136 -0
  127. package/src/Tests/StableNodeIds.test.ts +210 -0
  128. package/src/Tests/StudioColorBloat.test.ts +25 -0
  129. package/src/Tests/StudioDebugText.test.ts +27 -0
  130. package/src/Tests/StudioTrailingChar.test.ts +25 -0
  131. package/src/Tests/StudioValidText.test.ts +25 -0
  132. package/src/Tests/UrlImgBug.test.ts +23 -0
  133. package/src/Tests/VisualBuilderFidelity.test.ts +105 -0
  134. package/src/Tests/referenceDocument.ts +119 -0
  135. package/src/Transactions/Transaction.ts +176 -0
  136. package/src/Transactions/UndoManager.ts +111 -0
  137. package/src/Transactions/index.ts +3 -0
  138. package/src/Transformers/ASTOptimizer.ts +315 -0
  139. package/src/Transformers/GradientTransformer.ts +143 -0
  140. package/src/Transformers/GrowTransformer.ts +115 -0
  141. package/src/Transformers/RainbowTransformer.ts +121 -0
  142. package/src/Transformers/SineWaveTransformer.ts +130 -0
  143. package/src/Transformers/Transformer.ts +22 -0
  144. package/src/Types/core.ts +270 -0
  145. package/src/Types/diagnostics.ts +156 -0
  146. package/src/Types/index.ts +23 -0
  147. package/src/Types/operations.ts +180 -0
  148. package/src/Types/queries.ts +121 -0
  149. package/src/Types/symbols.ts +69 -0
  150. package/src/Types/tokens.ts +186 -0
  151. package/src/Utils/BBCodeGenerator.ts +126 -0
  152. package/src/Utils/ColorMath.ts +276 -0
  153. package/src/Utils/color.ts +112 -0
  154. package/src/Utils/dom-to-svg.test.ts +86 -0
  155. package/src/Utils/dom-to-svg.ts +615 -0
  156. package/src/Utils/treeTransformers.ts +717 -0
  157. package/src/Visitors/BBBlocksExporter.ts +69 -0
  158. package/src/Visitors/BBCodeExporter.ts +318 -0
  159. package/src/Visitors/BlockPatcher.ts +963 -0
  160. package/src/Visitors/DOMMorpher.ts +134 -0
  161. package/src/Visitors/HTMLRenderer.ts +1077 -0
  162. package/src/Visitors/JSONExporter.ts +66 -0
  163. package/src/Visitors/MarkdownExporter.ts +99 -0
  164. package/src/Visitors/SVGRenderer.ts +35 -0
  165. package/src/Visitors/TiptapExporter.ts +145 -0
  166. package/src/Visitors/Visitor.ts +48 -0
  167. package/src/Visitors/index.ts +9 -0
  168. package/src/Visuals/BoxDrawer.ts +175 -0
  169. package/src/Visuals/index.ts +42 -0
  170. package/src/Visuals/lyne.css +1027 -0
  171. package/src/Visuals/osu.css +257 -0
  172. package/src/index.ts +197 -0
@@ -0,0 +1,615 @@
1
+ // ============================================================
2
+ // DOM-to-SVG Converter — high-fidelity layout snapshot
3
+ //
4
+ // Strategy:
5
+ // 1. Walk every DOM node depth-first.
6
+ // 2. For each element, read getBoundingClientRect + getComputedStyle.
7
+ // 3. Emit SVG primitives: rect (backgrounds/borders), text (all
8
+ // visual lines of each Text node), image, and g (groups).
9
+ // 4. Text nodes use Range character-by-character scan to map each
10
+ // visual line to its actual substring — this is what makes
11
+ // text wrap correctly in the SVG output.
12
+ //
13
+ // ============================================================
14
+
15
+ export interface DomToSVGOptions {
16
+ backgroundColor?: string
17
+ includeBackground?: boolean
18
+ /** Scale factor applied to all coordinates (default 1) */
19
+ scale?: number
20
+ }
21
+
22
+ export interface SVGLayerInfo {
23
+ id: string
24
+ tag: string
25
+ depth: number
26
+ x: number
27
+ y: number
28
+ width: number
29
+ height: number
30
+ type: 'image' | 'iframe' | 'audio' | 'background' | 'border' | 'group' | 'text'
31
+ text?: string
32
+ }
33
+
34
+ export interface DomToSVGResult {
35
+ svg: string
36
+ width: number
37
+ height: number
38
+ layerCount: number
39
+ layers: SVGLayerInfo[]
40
+ }
41
+
42
+ // ────────────────────────────────────────────────────────────
43
+ // Public API
44
+ // ────────────────────────────────────────────────────────────
45
+
46
+ export function domToSVG(root: HTMLElement, options: DomToSVGOptions = {}): string {
47
+ return domToSVGResult(root, options).svg
48
+ }
49
+
50
+ export function domToSVGResult(
51
+ root: HTMLElement,
52
+ options: DomToSVGOptions = {},
53
+ ): DomToSVGResult {
54
+ const { backgroundColor = "#0d0d0d", includeBackground = true, scale = 1 } = options
55
+
56
+ // OPTIMIZATION & FIX: Remove visual interaction artifacts before measuring.
57
+ // We strip the cursor-highlight class synchronously so getComputedStyle reads the pure colors.
58
+ const highlightedElements = Array.from(root.querySelectorAll('.cursor-highlight'))
59
+ highlightedElements.forEach(el => el.classList.remove('cursor-highlight'))
60
+
61
+ // FIX: Force all spoilerboxes and boxes to be OPEN before taking rootRect so their content is measured.
62
+ // Otherwise, closed <details> elements will return garbage coordinates for their hidden children,
63
+ // causing overlapping text in the SVG output, AND the rootRect height will be too small.
64
+ const closedDetails = Array.from(root.querySelectorAll('details:not([open])'))
65
+ closedDetails.forEach(el => el.setAttribute('open', ''))
66
+
67
+ const rootRect = root.getBoundingClientRect()
68
+ const W = Math.ceil(rootRect.width * scale)
69
+ const H = Math.ceil(rootRect.height * scale)
70
+
71
+ if (W < 4 || H < 4) {
72
+ // RESTORE on error
73
+ highlightedElements.forEach(el => el.classList.add('cursor-highlight'))
74
+ closedDetails.forEach(el => el.removeAttribute('open'))
75
+ throw new Error(
76
+ "Preview element has no visible dimensions. Make sure it is rendered and visible.",
77
+ )
78
+ }
79
+
80
+ try {
81
+ const state: WalkState = { rootRect, scale, parts: [], layerCount: 0, layersList: [] }
82
+
83
+ const fontFamilies = collectFontFamilies(root)
84
+
85
+ const svgOpen = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`
86
+ state.parts.push(svgOpen)
87
+
88
+ if (fontFamilies.size > 0) {
89
+ const families = Array.from(fontFamilies)
90
+ .map((f) => encodeURIComponent(f))
91
+ .join("|")
92
+ state.parts.push(
93
+ ` <defs>`,
94
+ ` <style>@import url('https://fonts.googleapis.com/css2?family=${families}&amp;display=swap');</style>`,
95
+ ` </defs>`,
96
+ )
97
+ }
98
+
99
+ if (includeBackground) {
100
+ state.parts.push(
101
+ ` <rect id="background" x="0" y="0" width="${W}" height="${H}" fill="${escapeXml(backgroundColor)}" />`,
102
+ )
103
+ state.layersList.push({
104
+ id: "background",
105
+ tag: "rect",
106
+ depth: 0,
107
+ x: 0,
108
+ y: 0,
109
+ width: W,
110
+ height: H,
111
+ type: "background",
112
+ })
113
+ state.layerCount++
114
+ }
115
+
116
+ walkElement(root, state, 1)
117
+
118
+ state.parts.push("</svg>")
119
+
120
+ return {
121
+ svg: state.parts.join("\n"),
122
+ width: W,
123
+ height: H,
124
+ layerCount: state.layerCount,
125
+ layers: state.layersList,
126
+ }
127
+ } finally {
128
+ // RESTORE: Put the highlight and closed states back so the user doesn't see it blink
129
+ highlightedElements.forEach(el => el.classList.add('cursor-highlight'))
130
+ closedDetails.forEach(el => el.removeAttribute('open'))
131
+ }
132
+ }
133
+
134
+ // ────────────────────────────────────────────────────────────
135
+ // Internal walk state
136
+ // ────────────────────────────────────────────────────────────
137
+
138
+ interface WalkState {
139
+ rootRect: DOMRect
140
+ scale: number
141
+ parts: string[]
142
+ layerCount: number
143
+ layersList: SVGLayerInfo[]
144
+ }
145
+
146
+ // ────────────────────────────────────────────────────────────
147
+ // Helpers
148
+ // ────────────────────────────────────────────────────────────
149
+
150
+ function px(value: number): string {
151
+ return value.toFixed(2)
152
+ }
153
+
154
+ function ind(depth: number): string {
155
+ return " ".repeat(Math.min(depth, 12))
156
+ }
157
+
158
+ function escapeXml(str: string): string {
159
+ return str
160
+ .replace(/&/g, "&amp;")
161
+ .replace(/</g, "&lt;")
162
+ .replace(/>/g, "&gt;")
163
+ .replace(/"/g, "&quot;")
164
+ .replace(/'/g, "&apos;")
165
+ }
166
+
167
+ function colorToHex(color: string): string {
168
+ if (!color) return "#000000"
169
+ if (color.startsWith("#")) return color
170
+ const m = color.match(/rgba?\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)/)
171
+ if (!m) return color
172
+ const r = Math.round(parseFloat(m[1]))
173
+ const g = Math.round(parseFloat(m[2]))
174
+ const b = Math.round(parseFloat(m[3]))
175
+ return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`
176
+ }
177
+
178
+ function extractAlpha(color: string): number {
179
+ if (!color) return 1
180
+ const m = color.match(/rgba\(\d+(?:\.\d+)?,\s*\d+(?:\.\d+)?,\s*\d+(?:\.\d+)?,\s*(\d+(?:\.\d+)?)/)
181
+ if (m) return parseFloat(m[1])
182
+ return 1
183
+ }
184
+
185
+ function collectFontFamilies(root: HTMLElement): Set<string> {
186
+ const fonts = new Set<string>()
187
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
188
+ let node: Node | null = walker.currentNode
189
+ while (node) {
190
+ if (node.nodeType === Node.ELEMENT_NODE) {
191
+ // Mismo guard que walkElement: los overlays de edición del imagemap no
192
+ // deben colar su fuente en el @import del SVG (p. ej. un tooltip con
193
+ // font-family propia).
194
+ if (!isImagemapOverlay(node as Element)) {
195
+ const cs = window.getComputedStyle(node as Element)
196
+ const family = cs.fontFamily?.split(",")[0]?.replace(/['"]/g, "").trim()
197
+ if (family && family !== "sans-serif" && family !== "monospace" && family !== "serif") {
198
+ fonts.add(family)
199
+ }
200
+ }
201
+ }
202
+ node = walker.nextNode()
203
+ }
204
+ return fonts
205
+ }
206
+
207
+ // ────────────────────────────────────────────────────────────
208
+ // Scan a text node into visual lines
209
+ // ────────────────────────────────────────────────────────────
210
+
211
+ interface VisualLine {
212
+ text: string
213
+ rect: DOMRect
214
+ }
215
+
216
+ /**
217
+ * Walk character-by-character through the text node, using Range to get
218
+ * the DOMRect of each character. Group consecutive characters that share
219
+ * the same top-Y (within 2px tolerance) into one line, then accumulate
220
+ * the text for that line.
221
+ *
222
+ * This is the key to correct text wrapping: instead of emitting the full
223
+ * text string on the first line's rect, we emit only the characters that
224
+ * actually appear on each visual line.
225
+ */
226
+ function getVisualLines(textNode: Text): VisualLine[] {
227
+ const text = textNode.textContent ?? ""
228
+ if (!text.trim()) return [] // Skip purely empty/whitespace nodes
229
+
230
+ const range = document.createRange()
231
+ range.selectNode(textNode)
232
+ const fullRects = Array.from(range.getClientRects()).filter(r => r.width > 0 && r.height > 0)
233
+
234
+ // OPTIMIZATION 1: If the entire text node fits on a single line, return immediately!
235
+ // This skips the expensive scan for 90% of text nodes (short inline elements).
236
+ if (fullRects.length <= 1) {
237
+ if (fullRects.length === 1) {
238
+ return [{ text, rect: fullRects[0] }]
239
+ }
240
+ return []
241
+ }
242
+
243
+ // OPTIMIZATION 2: Word-by-word scan instead of char-by-char for multi-line text.
244
+ // 6x-10x faster because we do getClientRects per word (or whitespace group).
245
+ const lines: VisualLine[] = []
246
+ let lineText = ""
247
+ let lineRect: DOMRect | null = null
248
+ const TOLERANCE = 4 // px
249
+
250
+ // Split into tokens: words and spaces
251
+ const tokens = text.match(/(\s+|\S+)/g) || []
252
+ let currentIndex = 0
253
+
254
+ for (const token of tokens) {
255
+ if (token === "\n") {
256
+ currentIndex++
257
+ continue
258
+ }
259
+
260
+ range.setStart(textNode, currentIndex)
261
+ range.setEnd(textNode, currentIndex + token.length)
262
+ currentIndex += token.length
263
+
264
+ const rects = range.getClientRects()
265
+ if (!rects.length) continue
266
+
267
+ const tokenRect = rects[0]
268
+ if (tokenRect.width === 0 && tokenRect.height === 0) continue
269
+
270
+ if (!lineRect) {
271
+ lineRect = tokenRect
272
+ lineText = token
273
+ } else if (Math.abs(tokenRect.top - lineRect.top) <= TOLERANCE) {
274
+ lineText += token
275
+ const mergedRight = Math.max(lineRect.right, tokenRect.right)
276
+ lineRect = new DOMRect(
277
+ lineRect.left,
278
+ lineRect.top,
279
+ mergedRight - lineRect.left,
280
+ Math.max(lineRect.height, tokenRect.height),
281
+ )
282
+ } else {
283
+ lines.push({ text: lineText, rect: lineRect })
284
+ lineRect = tokenRect
285
+ lineText = token
286
+ }
287
+ }
288
+
289
+ if (lineText && lineRect) {
290
+ lines.push({ text: lineText, rect: lineRect })
291
+ }
292
+
293
+ return lines
294
+ }
295
+
296
+ // ────────────────────────────────────────────────────────────
297
+ // Element walker
298
+ // ────────────────────────────────────────────────────────────
299
+
300
+ const SKIP_TAGS = new Set(["script", "style", "noscript", "head", "meta", "title"])
301
+
302
+ /**
303
+ * Overlays de EDICIÓN del imagemap que no deben salir en el vector exportado:
304
+ * las áreas clicables (rectángulos invisibles de la región) y su tooltip de
305
+ * hover. NO incluye el contenedor (`.imagemap-container`): ese guarda la
306
+ * imagen real, que sí forma parte del contenido.
307
+ *
308
+ * El HTMLRenderer genera las áreas como `imagemap-area bbcode-imap-area`;
309
+ * `bb-imagemap-area` es la clase legacy que también se salta por si algún
310
+ * render anterior la dejó en el DOM.
311
+ */
312
+ const IMAGEMAP_OVERLAY_CLASSES = new Set([
313
+ "imagemap-area",
314
+ "bbcode-imap-area",
315
+ "bb-imagemap-area",
316
+ "bb-imagemap-tooltip",
317
+ ])
318
+
319
+ function isImagemapOverlay(el: Element): boolean {
320
+ for (const c of el.classList) {
321
+ if (IMAGEMAP_OVERLAY_CLASSES.has(c)) return true
322
+ }
323
+ return false
324
+ }
325
+
326
+ function walkElement(el: Element, state: WalkState, depth: number): void {
327
+ const tag = el.tagName.toLowerCase()
328
+ if (SKIP_TAGS.has(tag)) return
329
+
330
+ // Skip interactive overlays that shouldn't be part of the vector export
331
+ if (isImagemapOverlay(el)) return
332
+
333
+ const cs = window.getComputedStyle(el)
334
+ if (cs.display === "none" || cs.visibility === "hidden") return
335
+ const opacityVal = parseFloat(cs.opacity)
336
+ if (opacityVal === 0) return
337
+
338
+ const rect = el.getBoundingClientRect()
339
+ const { rootRect, scale } = state
340
+
341
+ const x = (rect.left - rootRect.left) * scale
342
+ const y = (rect.top - rootRect.top) * scale
343
+ const w = rect.width * scale
344
+ const h = rect.height * scale
345
+ const hasArea = w > 0.5 && h > 0.5
346
+
347
+ const rawId = (el.id || el.className?.toString() || tag)
348
+ .replace(/\s+/g, "-")
349
+ .replace(/[^a-zA-Z0-9_-]/g, "")
350
+ .substring(0, 48) || tag
351
+ const layerId = `${rawId}-${depth}-${state.layerCount}`
352
+ state.layerCount++
353
+
354
+ const opacityAttr = opacityVal < 1 ? ` opacity="${opacityVal.toFixed(2)}"` : ""
355
+
356
+ // ── Leaf tags ──────────────────────────────────────────────
357
+
358
+ if (tag === "img") {
359
+ const src = (el as HTMLImageElement).src || el.getAttribute("src") || ""
360
+ if (src && hasArea) {
361
+ const rx = parseFloat(cs.borderRadius) || 0
362
+ state.parts.push(
363
+ `${ind(depth)}<image id="${layerId}"${opacityAttr} x="${px(x)}" y="${px(y)}" width="${px(w)}" height="${px(h)}" href="${escapeXml(src)}" preserveAspectRatio="xMidYMid meet"${rx > 0 ? ` clip-path="inset(0 round ${px(rx * scale)})"` : ""} />`,
364
+ )
365
+ state.layersList.push({
366
+ id: layerId,
367
+ tag: "img",
368
+ depth,
369
+ x,
370
+ y,
371
+ width: w,
372
+ height: h,
373
+ type: "image",
374
+ })
375
+ }
376
+ return
377
+ }
378
+
379
+ if (tag === "iframe") {
380
+ if (hasArea) {
381
+ state.parts.push(
382
+ `${ind(depth)}<rect id="${layerId}" x="${px(x)}" y="${px(y)}" width="${px(w)}" height="${px(h)}" fill="#0f0f1a" rx="6" />`,
383
+ `${ind(depth)}<text x="${px(x + w / 2)}" y="${px(y + h / 2 + 5)}" text-anchor="middle" fill="#ff66ab" font-size="${Math.round(14 * scale)}" font-family="sans-serif">YouTube</text>`,
384
+ )
385
+ state.layersList.push({
386
+ id: layerId,
387
+ tag: "iframe",
388
+ depth,
389
+ x,
390
+ y,
391
+ width: w,
392
+ height: h,
393
+ type: "iframe",
394
+ })
395
+ }
396
+ return
397
+ }
398
+
399
+ if (tag === "audio") {
400
+ if (hasArea) {
401
+ state.parts.push(
402
+ `${ind(depth)}<rect id="${layerId}" x="${px(x)}" y="${px(y)}" width="${px(w)}" height="${px(h)}" fill="#1c1520" rx="6" stroke="#2e2030" stroke-width="1" />`,
403
+ `${ind(depth)}<text x="${px(x + w / 2)}" y="${px(y + h / 2 + 5)}" text-anchor="middle" fill="#8a7a84" font-size="${Math.round(12 * scale)}" font-family="sans-serif">Audio Player</text>`,
404
+ )
405
+ state.layersList.push({
406
+ id: layerId,
407
+ tag: "audio",
408
+ depth,
409
+ x,
410
+ y,
411
+ width: w,
412
+ height: h,
413
+ type: "audio",
414
+ })
415
+ }
416
+ return
417
+ }
418
+
419
+ // ── Background + borders ───────────────────────────────────
420
+
421
+ if (hasArea) {
422
+ const bg = cs.backgroundColor
423
+ const hasBg = bg && bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent"
424
+ const rx = parseFloat(cs.borderRadius) || 0
425
+
426
+ if (hasBg) {
427
+ const hexBg = colorToHex(bg)
428
+ const alpha = extractAlpha(bg)
429
+ const fillAttr =
430
+ alpha < 1
431
+ ? `fill="${hexBg}" fill-opacity="${alpha.toFixed(3)}"`
432
+ : `fill="${hexBg}"`
433
+ state.parts.push(
434
+ `${ind(depth)}<rect id="${layerId}-bg" x="${px(x)}" y="${px(y)}" width="${px(w)}" height="${px(h)}" ${fillAttr} rx="${px(rx * scale)}"${opacityAttr} />`,
435
+ )
436
+ state.layersList.push({
437
+ id: `${layerId}-bg`,
438
+ tag: "rect",
439
+ depth,
440
+ x,
441
+ y,
442
+ width: w,
443
+ height: h,
444
+ type: "background",
445
+ })
446
+ state.layerCount++
447
+ }
448
+
449
+ // Full border (all sides equal)
450
+ const bTopW = parseFloat(cs.borderTopWidth) || 0
451
+ const bLeftW = parseFloat(cs.borderLeftWidth) || 0
452
+ const bRightW = parseFloat(cs.borderRightWidth) || 0
453
+ const bBottomW = parseFloat(cs.borderBottomWidth) || 0
454
+ const allSidesEqual =
455
+ bTopW > 0 && bTopW === bLeftW && bTopW === bRightW && bTopW === bBottomW
456
+
457
+ if (allSidesEqual) {
458
+ const hexB = colorToHex(cs.borderTopColor || "#2e2030")
459
+ const strokeW = bTopW * scale
460
+ const ry = rx * scale
461
+ state.parts.push(
462
+ `${ind(depth)}<rect id="${layerId}-border" x="${px(x + strokeW / 2)}" y="${px(y + strokeW / 2)}" width="${px(w - strokeW)}" height="${px(h - strokeW)}" fill="none" stroke="${hexB}" stroke-width="${strokeW.toFixed(2)}" rx="${ry.toFixed(2)}" />`,
463
+ )
464
+ state.layersList.push({
465
+ id: `${layerId}-border`,
466
+ tag: "rect",
467
+ depth,
468
+ x: x + strokeW / 2,
469
+ y: y + strokeW / 2,
470
+ width: w - strokeW,
471
+ height: h - strokeW,
472
+ type: "border",
473
+ })
474
+ state.layerCount++
475
+ } else if (bLeftW > 0 && bTopW === 0 && bRightW === 0 && bBottomW === 0) {
476
+ // Left-only border (quote/notice blocks)
477
+ const hexB = colorToHex(cs.borderLeftColor || "#2e2030")
478
+ const lw = bLeftW * scale
479
+ state.parts.push(
480
+ `${ind(depth)}<line id="${layerId}-left-border" x1="${px(x + lw / 2)}" y1="${px(y)}" x2="${px(x + lw / 2)}" y2="${px(y + h)}" stroke="${hexB}" stroke-width="${lw.toFixed(2)}" />`,
481
+ )
482
+ state.layersList.push({
483
+ id: `${layerId}-left-border`,
484
+ tag: "line",
485
+ depth,
486
+ x: x + lw / 2,
487
+ y,
488
+ width: lw,
489
+ height: h,
490
+ type: "border",
491
+ })
492
+ state.layerCount++
493
+ }
494
+ }
495
+
496
+ // ── Children ───────────────────────────────────────────────
497
+
498
+ const hasChildren = el.childNodes.length > 0
499
+ if (hasChildren) {
500
+ state.parts.push(`${ind(depth)}<g id="${layerId}">`)
501
+ state.layersList.push({
502
+ id: layerId,
503
+ tag: "g",
504
+ depth,
505
+ x,
506
+ y,
507
+ width: w,
508
+ height: h,
509
+ type: "group",
510
+ })
511
+ }
512
+
513
+ for (const child of Array.from(el.childNodes)) {
514
+ if (child.nodeType === Node.TEXT_NODE) {
515
+ walkTextNode(child as Text, state, depth + 1)
516
+ } else if (child.nodeType === Node.ELEMENT_NODE) {
517
+ walkElement(child as Element, state, depth + 1)
518
+ }
519
+ }
520
+
521
+ if (hasChildren) {
522
+ state.parts.push(`${ind(depth)}</g>`)
523
+ }
524
+ }
525
+
526
+ // ────────────────────────────────────────────────────────────
527
+ // Text node walker — character-scan for correct line wrapping
528
+ // ────────────────────────────────────────────────────────────
529
+
530
+ function walkTextNode(textNode: Text, state: WalkState, depth: number): void {
531
+ const rawText = textNode.textContent ?? ""
532
+ if (!rawText.trim()) return
533
+
534
+ const parent = textNode.parentElement
535
+ if (!parent) return
536
+
537
+ const cs = window.getComputedStyle(parent)
538
+ if (cs.display === "none" || cs.visibility === "hidden") return
539
+
540
+ // Get correctly-split visual lines via character-scan
541
+ const lines = getVisualLines(textNode)
542
+ if (lines.length === 0) return
543
+
544
+ const { rootRect, scale } = state
545
+
546
+ const color = colorToHex(cs.color)
547
+ const colorAlpha = extractAlpha(cs.color)
548
+ const fillOpacityAttr = colorAlpha < 1 ? ` fill-opacity="${colorAlpha.toFixed(3)}"` : ""
549
+
550
+ const fontSize = parseFloat(cs.fontSize) || 14
551
+ const fontFamilyRaw =
552
+ cs.fontFamily?.split(",")[0]?.replace(/['"]/g, "").trim() || "sans-serif"
553
+ const fontFamily = escapeXml(fontFamilyRaw)
554
+ const fontWeight = cs.fontWeight || "400"
555
+ const fontStyleValue = cs.fontStyle || "normal"
556
+ const textDecoration = cs.textDecorationLine || ""
557
+ const textAlign = cs.textAlign || "left"
558
+ const letterSpacing =
559
+ cs.letterSpacing !== "normal" ? parseFloat(cs.letterSpacing) || 0 : 0
560
+ const lsAttr =
561
+ letterSpacing !== 0
562
+ ? ` letter-spacing="${(letterSpacing * scale).toFixed(2)}"`
563
+ : ""
564
+
565
+ for (const line of lines) {
566
+ if (!line.text.trim()) continue
567
+
568
+ const lx = (line.rect.left - rootRect.left) * scale
569
+ const ly = (line.rect.top - rootRect.top) * scale
570
+ const lw = line.rect.width * scale
571
+ const lh = line.rect.height * scale
572
+
573
+ // Baseline: roughly 80% down the line box
574
+ const baselineY = ly + fontSize * scale * 0.82
575
+
576
+ // Anchor based on text-align
577
+ let anchor = "start"
578
+ let tx = lx
579
+ if (textAlign === "center") { anchor = "middle"; tx = lx + lw / 2 }
580
+ else if (textAlign === "right" || textAlign === "end") { anchor = "end"; tx = lx + lw }
581
+
582
+ const textLayerId = `text-${depth}-${state.layerCount}`
583
+ state.parts.push(
584
+ `${ind(depth)}<text id="${textLayerId}" x="${px(tx)}" y="${px(baselineY)}" fill="${color}"${fillOpacityAttr} font-size="${(fontSize * scale).toFixed(2)}" font-family="${fontFamily}" font-weight="${fontWeight}" font-style="${fontStyleValue}" text-anchor="${anchor}"${lsAttr}>${escapeXml(line.text)}</text>`,
585
+ )
586
+ state.layersList.push({
587
+ id: textLayerId,
588
+ tag: "text",
589
+ depth,
590
+ x: lx,
591
+ y: ly,
592
+ width: lw,
593
+ height: lh,
594
+ type: "text",
595
+ text: line.text,
596
+ })
597
+ state.layerCount++
598
+
599
+ // Underline
600
+ if (textDecoration.includes("underline")) {
601
+ const uly = ly + fontSize * scale * 0.92 + 2
602
+ state.parts.push(
603
+ `${ind(depth)}<line x1="${px(lx)}" y1="${px(uly)}" x2="${px(lx + lw)}" y2="${px(uly)}" stroke="${color}" stroke-width="1" />`,
604
+ )
605
+ }
606
+
607
+ // Line-through
608
+ if (textDecoration.includes("line-through")) {
609
+ const lty = ly + lh * 0.55
610
+ state.parts.push(
611
+ `${ind(depth)}<line x1="${px(lx)}" y1="${px(lty)}" x2="${px(lx + lw)}" y2="${px(lty)}" stroke="${color}" stroke-width="1" />`,
612
+ )
613
+ }
614
+ }
615
+ }