@miliastry/quasar 1.0.6 → 1.1.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 (273) hide show
  1. package/dist/Visuals/lyne.css +117 -6
  2. package/dist/Visuals/osu.css +36 -5
  3. package/dist/index.js +9664 -3282
  4. package/dist/index.mjs +9535 -3283
  5. package/dist/types/Analysis/Contracts/AnalysisReport.d.ts +22 -0
  6. package/dist/types/Analysis/Contracts/Contribution.d.ts +68 -0
  7. package/dist/types/Analysis/Contracts/Pass.d.ts +59 -0
  8. package/dist/types/Analysis/Contracts/PipelineContext.d.ts +34 -0
  9. package/dist/types/Analysis/Passes/Analysis/ColorUsageAnalyzer.d.ts +56 -0
  10. package/dist/types/Analysis/Passes/Analysis/GradientAnalyzer.d.ts +111 -0
  11. package/dist/types/Analysis/Passes/Analysis/MergeableColorAnalyzer.d.ts +26 -0
  12. package/dist/types/Analysis/Passes/Analysis/RainbowAnalyzer.d.ts +47 -0
  13. package/dist/types/Analysis/Passes/Analysis/SymbolAnalyzer.d.ts +89 -0
  14. package/dist/types/Analysis/Passes/Analysis/WaveAnalyzer.d.ts +44 -0
  15. package/dist/types/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.d.ts +4 -0
  16. package/dist/types/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.d.ts +4 -0
  17. package/dist/types/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.d.ts +4 -0
  18. package/dist/types/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.d.ts +4 -0
  19. package/dist/types/Analysis/Passes/Decision/DefaultDecision.d.ts +30 -0
  20. package/dist/types/Analysis/Passes/Decision/PaletteRemapDecision.d.ts +169 -0
  21. package/dist/types/Analysis/Passes/Decision/__tests__/DefaultDecision.test.d.ts +4 -0
  22. package/dist/types/Analysis/Passes/Transform/CollapseGradientTransform.d.ts +37 -0
  23. package/dist/types/Analysis/Passes/Transform/MergeColorsTransform.d.ts +28 -0
  24. package/dist/types/Analysis/Passes/Transform/RainbowCollapseTransform.d.ts +19 -0
  25. package/dist/types/Analysis/Passes/Transform/WaveCollapseTransform.d.ts +20 -0
  26. package/dist/types/Analysis/Passes/Utility/CharacterCountAnalyzer.d.ts +19 -0
  27. package/dist/types/Analysis/Pipeline/Pipeline.d.ts +69 -0
  28. package/dist/types/Analysis/Pipeline/PipelineBuilder.d.ts +31 -0
  29. package/dist/types/Analysis/Pipeline/PipelineStage.d.ts +17 -0
  30. package/dist/types/Analysis/Utils/color-utils.d.ts +60 -0
  31. package/dist/types/Analysis/__tests__/Integration.test.d.ts +8 -0
  32. package/dist/types/Analysis/__tests__/PaletteRemap.test.d.ts +14 -0
  33. package/dist/types/Analysis/__tests__/Pipeline.test.d.ts +10 -0
  34. package/dist/types/Analysis/__tests__/RoundTrip.test.d.ts +30 -0
  35. package/dist/types/Analysis/__tests__/SymbolAnalyzer.test.d.ts +11 -0
  36. package/dist/types/Analysis/index.d.ts +40 -0
  37. package/dist/types/BBCode/BBCodeDocumentModel.d.ts +92 -0
  38. package/dist/types/BBCode/BBCodeToGreenNode.d.ts +111 -0
  39. package/dist/types/BBCode/Parser.d.ts +78 -0
  40. package/dist/types/BBCode/index.d.ts +4 -0
  41. package/dist/types/Collab/positions.d.ts +55 -0
  42. package/dist/types/Commands/Command.d.ts +37 -0
  43. package/dist/types/Commands/CommandRegistry.d.ts +48 -0
  44. package/dist/types/Commands/DeleteNode.d.ts +7 -0
  45. package/dist/types/Commands/InsertText.d.ts +8 -0
  46. package/dist/types/Commands/SplitMerge.d.ts +9 -0
  47. package/dist/types/Commands/WrapInTag.d.ts +8 -0
  48. package/dist/types/Commands/index.d.ts +6 -0
  49. package/dist/types/Diff/TreeDiffer.d.ts +52 -0
  50. package/dist/types/Diff/__tests__/TreeDiffer.test.d.ts +1 -0
  51. package/dist/types/Diff/index.d.ts +2 -0
  52. package/dist/types/Edits/EditPlan.d.ts +144 -0
  53. package/dist/types/Edits/Optimizer.d.ts +78 -0
  54. package/dist/types/Edits/Rules/Rule.d.ts +91 -0
  55. package/dist/types/Edits/Rules/dropEmptyTags.d.ts +40 -0
  56. package/dist/types/Edits/Rules/dropRedundantNesting.d.ts +59 -0
  57. package/dist/types/Edits/Rules/index.d.ts +9 -0
  58. package/dist/types/Edits/Rules/mergeAdjacent.d.ts +93 -0
  59. package/dist/types/Edits/Rules/reorderWrappers.d.ts +42 -0
  60. package/dist/types/Edits/Rules/shortenHex.d.ts +30 -0
  61. package/dist/types/Edits/Rules/tagValue.d.ts +56 -0
  62. package/dist/types/Edits/Rules/unwrapInvisibleColor.d.ts +47 -0
  63. package/dist/types/Edits/applyEdits.d.ts +36 -0
  64. package/dist/types/Edits/index.d.ts +6 -0
  65. package/dist/types/Events/EventBus.d.ts +103 -0
  66. package/dist/types/Events/index.d.ts +2 -0
  67. package/dist/types/Formatter/Formatter.d.ts +32 -0
  68. package/dist/types/Formatter/index.d.ts +2 -0
  69. package/dist/types/HTML/HTMLDocumentModel.d.ts +13 -0
  70. package/dist/types/HTML/HTMLToGreenNode.d.ts +4 -0
  71. package/dist/types/Incremental/BracketIndex.d.ts +101 -0
  72. package/dist/types/Incremental/ChangeTracker.d.ts +69 -0
  73. package/dist/types/Incremental/IncrementalParser.d.ts +246 -0
  74. package/dist/types/Incremental/__tests__/BracketIndex.test.d.ts +1 -0
  75. package/dist/types/Incremental/__tests__/IncrementalParser.test.d.ts +1 -0
  76. package/dist/types/Incremental/index.d.ts +4 -0
  77. package/dist/types/Lexer/BBCodeLexer.d.ts +74 -0
  78. package/dist/types/Lexer/Lexer.d.ts +54 -0
  79. package/dist/types/Lexer/index.d.ts +4 -0
  80. package/dist/types/Linter/Linter.d.ts +58 -0
  81. package/dist/types/Linter/index.d.ts +2 -0
  82. package/dist/types/Markdown/MarkdownAST.d.ts +122 -0
  83. package/dist/types/Markdown/MarkdownDocumentModel.d.ts +15 -0
  84. package/dist/types/Markdown/MarkdownLexer.d.ts +155 -0
  85. package/dist/types/Markdown/MarkdownParser.d.ts +42 -0
  86. package/dist/types/Markdown/MarkdownToGreenNode.d.ts +6 -0
  87. package/dist/types/MilHibri/MilHibriDocumentModel.d.ts +21 -0
  88. package/dist/types/Model/DocumentModel.d.ts +318 -0
  89. package/dist/types/Model/NodeFactory.d.ts +29 -0
  90. package/dist/types/Model/TagRegistry.d.ts +165 -0
  91. package/dist/types/Model/index.d.ts +5 -0
  92. package/dist/types/Plugins/PluginAPI.d.ts +44 -0
  93. package/dist/types/Plugins/PluginRegistry.d.ts +81 -0
  94. package/dist/types/Plugins/index.d.ts +3 -0
  95. package/dist/types/Queries/QueryEngine.d.ts +32 -0
  96. package/dist/types/Queries/index.d.ts +1 -0
  97. package/dist/types/Reconciler/SurgicalReconciler.d.ts +23 -0
  98. package/dist/types/RenderPipeline/RenderPipeline.d.ts +46 -0
  99. package/dist/types/RenderPipeline/RenderTree.d.ts +55 -0
  100. package/dist/types/RenderPipeline/index.d.ts +4 -0
  101. package/dist/types/Repair/NestingRepair.d.ts +48 -0
  102. package/dist/types/Repair/index.d.ts +1 -0
  103. package/dist/types/Semantic/SemanticAnalyzer.d.ts +321 -0
  104. package/dist/types/Semantic/index.d.ts +2 -0
  105. package/dist/types/Symbols/SymbolTable.d.ts +47 -0
  106. package/dist/types/Symbols/index.d.ts +1 -0
  107. package/dist/types/Syntax/GreenNode.d.ts +154 -0
  108. package/dist/types/Syntax/GreenNodePool.d.ts +134 -0
  109. package/dist/types/Syntax/NodeMatcher.d.ts +75 -0
  110. package/dist/types/Syntax/RedNode.d.ts +280 -0
  111. package/dist/types/Syntax/RedNodeStore.d.ts +116 -0
  112. package/dist/types/Syntax/TreeBuilder.d.ts +54 -0
  113. package/dist/types/Syntax/__tests__/GreenNode.test.d.ts +1 -0
  114. package/dist/types/Syntax/__tests__/RedNode.test.d.ts +1 -0
  115. package/dist/types/Syntax/__tests__/RedNodeStore.test.d.ts +1 -0
  116. package/dist/types/Syntax/greenEdit.d.ts +64 -0
  117. package/dist/types/Syntax/hash.d.ts +17 -0
  118. package/dist/types/Syntax/index.d.ts +8 -0
  119. package/dist/types/Syntax/nodeAttr.d.ts +63 -0
  120. package/dist/types/Syntax/partition.d.ts +67 -0
  121. package/dist/types/Syntax/preserveNodeIds.d.ts +47 -0
  122. package/dist/types/Tokens/TokenResolver.d.ts +25 -0
  123. package/dist/types/Tokens/index.d.ts +1 -0
  124. package/dist/types/Transactions/Transaction.d.ts +36 -0
  125. package/dist/types/Transactions/UndoManager.d.ts +63 -0
  126. package/dist/types/Transactions/index.d.ts +3 -0
  127. package/dist/types/Transformers/ASTOptimizer.d.ts +34 -0
  128. package/dist/types/Transformers/GradientTransformer.d.ts +18 -0
  129. package/dist/types/Transformers/GrowTransformer.d.ts +16 -0
  130. package/dist/types/Transformers/RainbowTransformer.d.ts +17 -0
  131. package/dist/types/Transformers/SineWaveTransformer.d.ts +17 -0
  132. package/dist/types/Transformers/Transformer.d.ts +17 -0
  133. package/dist/types/Types/core.d.ts +104 -0
  134. package/dist/types/Types/diagnostics.d.ts +85 -0
  135. package/dist/types/Types/index.d.ts +11 -0
  136. package/dist/types/Types/operations.d.ts +109 -0
  137. package/dist/types/Types/queries.d.ts +67 -0
  138. package/dist/types/Types/symbols.d.ts +42 -0
  139. package/dist/types/Types/tokens.d.ts +75 -0
  140. package/dist/types/Utils/BBCodeGenerator.d.ts +21 -0
  141. package/dist/types/Utils/ColorMath.d.ts +92 -0
  142. package/dist/types/Utils/EffectMath.d.ts +643 -0
  143. package/dist/types/Utils/TagVocabulary.d.ts +104 -0
  144. package/dist/types/Utils/TagVocabulary.test.d.ts +1 -0
  145. package/dist/types/Utils/color.d.ts +29 -0
  146. package/dist/types/Utils/dom-to-svg.d.ts +28 -0
  147. package/dist/types/Utils/dom-to-svg.test.d.ts +1 -0
  148. package/dist/types/Utils/treeTransformers.d.ts +160 -0
  149. package/dist/types/Visitors/BBBlocksExporter.d.ts +27 -0
  150. package/dist/types/Visitors/BBCodeExporter.d.ts +90 -0
  151. package/dist/types/Visitors/BlockPatcher.d.ts +91 -0
  152. package/dist/types/Visitors/DOMMorpher.d.ts +13 -0
  153. package/dist/types/Visitors/HTMLRenderer.d.ts +380 -0
  154. package/dist/types/Visitors/JSONExporter.d.ts +42 -0
  155. package/dist/types/Visitors/MarkdownExporter.d.ts +28 -0
  156. package/dist/types/Visitors/SVGRenderer.d.ts +16 -0
  157. package/dist/types/Visitors/TiptapExporter.d.ts +40 -0
  158. package/dist/types/Visitors/Visitor.d.ts +35 -0
  159. package/dist/types/Visitors/index.d.ts +9 -0
  160. package/dist/types/Visuals/BoxDrawer.d.ts +49 -0
  161. package/dist/types/Visuals/index.d.ts +38 -0
  162. package/dist/types/index.d.ts +154 -0
  163. package/package.json +14 -10
  164. package/src/Analysis/Passes/Analysis/ColorUsageAnalyzer.ts +103 -0
  165. package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +294 -20
  166. package/src/Analysis/Passes/Analysis/SymbolAnalyzer.ts +257 -0
  167. package/src/Analysis/Passes/Decision/PaletteRemapDecision.ts +425 -0
  168. package/src/Analysis/Pipeline/Pipeline.ts +15 -2
  169. package/src/Analysis/index.ts +8 -2
  170. package/src/BBCode/BBCodeDocumentModel.ts +3 -0
  171. package/src/BBCode/BBCodeToGreenNode.ts +18 -12
  172. package/src/BBCode/Parser.ts +38 -1
  173. package/src/Edits/EditPlan.ts +290 -0
  174. package/src/Edits/Optimizer.ts +185 -0
  175. package/src/Edits/Rules/Rule.ts +145 -0
  176. package/src/Edits/Rules/dropEmptyTags.ts +110 -0
  177. package/src/Edits/Rules/dropRedundantNesting.ts +120 -0
  178. package/src/Edits/Rules/index.ts +28 -0
  179. package/src/Edits/Rules/mergeAdjacent.ts +224 -0
  180. package/src/Edits/Rules/reorderWrappers.ts +154 -0
  181. package/src/Edits/Rules/shortenHex.ts +79 -0
  182. package/src/Edits/Rules/tagValue.ts +105 -0
  183. package/src/Edits/Rules/unwrapInvisibleColor.ts +93 -0
  184. package/src/Edits/applyEdits.ts +54 -0
  185. package/src/Edits/index.ts +22 -0
  186. package/src/Events/EventBus.ts +18 -0
  187. package/src/HTML/HTMLToGreenNode.ts +185 -37
  188. package/src/Incremental/BracketIndex.ts +249 -0
  189. package/src/Incremental/IncrementalParser.ts +464 -41
  190. package/src/Lexer/BBCodeLexer.ts +25 -3
  191. package/src/Lexer/index.ts +1 -1
  192. package/src/Markdown/MarkdownAST.ts +65 -2
  193. package/src/Markdown/MarkdownLexer.ts +69 -14
  194. package/src/Markdown/MarkdownParser.ts +749 -62
  195. package/src/Markdown/MarkdownToGreenNode.ts +148 -27
  196. package/src/MilHibri/MilHibriDocumentModel.ts +56 -0
  197. package/src/Model/DocumentModel.ts +138 -5
  198. package/src/Model/TagRegistry.ts +129 -134
  199. package/src/Reconciler/SurgicalReconciler.ts +460 -0
  200. package/src/Repair/NestingRepair.ts +116 -0
  201. package/src/Repair/index.ts +1 -0
  202. package/src/Semantic/SemanticAnalyzer.ts +1802 -65
  203. package/src/Syntax/GreenNodePool.ts +12 -12
  204. package/src/Syntax/RedNode.ts +17 -3
  205. package/src/Syntax/RedNodeStore.ts +9 -1
  206. package/src/Syntax/nodeAttr.ts +172 -0
  207. package/src/Tokens/TokenResolver.ts +55 -0
  208. package/src/Tokens/index.ts +1 -0
  209. package/src/Types/core.ts +15 -0
  210. package/src/Utils/ColorMath.ts +276 -55
  211. package/src/Utils/EffectMath.ts +2462 -0
  212. package/src/Utils/TagVocabulary.ts +141 -0
  213. package/src/Utils/dom-to-svg.ts +28 -12
  214. package/src/Visitors/BBBlocksExporter.ts +15 -1
  215. package/src/Visitors/BBCodeExporter.ts +319 -32
  216. package/src/Visitors/BlockPatcher.ts +87 -11
  217. package/src/Visitors/DOMMorpher.ts +15 -5
  218. package/src/Visitors/HTMLRenderer.ts +374 -151
  219. package/src/Visitors/MarkdownExporter.ts +98 -41
  220. package/src/Visitors/TiptapExporter.ts +1 -0
  221. package/src/Visuals/index.ts +7 -4
  222. package/src/Visuals/lyne.css +117 -6
  223. package/src/Visuals/osu.css +36 -5
  224. package/src/index.ts +172 -5
  225. package/dist/index.d.mts +0 -4612
  226. package/dist/index.d.ts +0 -4612
  227. package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +0 -135
  228. package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +0 -84
  229. package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +0 -99
  230. package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +0 -119
  231. package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +0 -179
  232. package/src/Analysis/__tests__/Integration.test.ts +0 -162
  233. package/src/Analysis/__tests__/Pipeline.test.ts +0 -133
  234. package/src/Diff/__tests__/TreeDiffer.test.ts +0 -65
  235. package/src/Incremental/__tests__/IncrementalParser.test.ts +0 -164
  236. package/src/Syntax/__tests__/GreenNode.test.ts +0 -33
  237. package/src/Syntax/__tests__/RedNode.test.ts +0 -81
  238. package/src/Syntax/__tests__/RedNodeStore.test.ts +0 -104
  239. package/src/Tests/ASTOptimizerIdempotence.test.ts +0 -77
  240. package/src/Tests/BlockPatcher.test.ts +0 -437
  241. package/src/Tests/BlockPatcherWindowed.test.ts +0 -364
  242. package/src/Tests/BoxDrawer.test.ts +0 -217
  243. package/src/Tests/BoxRichTitle.test.ts +0 -105
  244. package/src/Tests/Chars500kBenchmark.test.ts +0 -151
  245. package/src/Tests/Chars500kEdits.test.ts +0 -321
  246. package/src/Tests/CollabPositions.test.ts +0 -146
  247. package/src/Tests/CompilerPathProfiling.test.ts +0 -186
  248. package/src/Tests/DOMMorpher.test.ts +0 -142
  249. package/src/Tests/DomPatchPerf.test.ts +0 -60
  250. package/src/Tests/EffectSegments.snapshot.json +0 -616
  251. package/src/Tests/EffectSegments.test.ts +0 -68
  252. package/src/Tests/FindNodeAtOffset.test.ts +0 -65
  253. package/src/Tests/Fuzzer.test.ts +0 -166
  254. package/src/Tests/GreenNodePool.test.ts +0 -153
  255. package/src/Tests/Lexer.test.ts +0 -238
  256. package/src/Tests/LyneMode.test.ts +0 -291
  257. package/src/Tests/ModelCoherence.test.ts +0 -180
  258. package/src/Tests/Partition.test.ts +0 -238
  259. package/src/Tests/PluginTags.test.ts +0 -150
  260. package/src/Tests/ProblematicSection.test.ts +0 -46
  261. package/src/Tests/ProblematicSectionHTML.test.ts +0 -58
  262. package/src/Tests/RedReuse.test.ts +0 -134
  263. package/src/Tests/ReproDelete20k.test.ts +0 -62
  264. package/src/Tests/SemanticValidators.test.ts +0 -136
  265. package/src/Tests/StableNodeIds.test.ts +0 -210
  266. package/src/Tests/StudioColorBloat.test.ts +0 -25
  267. package/src/Tests/StudioDebugText.test.ts +0 -27
  268. package/src/Tests/StudioTrailingChar.test.ts +0 -25
  269. package/src/Tests/StudioValidText.test.ts +0 -25
  270. package/src/Tests/UrlImgBug.test.ts +0 -23
  271. package/src/Tests/VisualBuilderFidelity.test.ts +0 -105
  272. package/src/Tests/referenceDocument.ts +0 -119
  273. package/src/Utils/dom-to-svg.test.ts +0 -86
@@ -36,7 +36,7 @@ import type { Contribution } from '../../Contracts/Contribution'
36
36
  import { ContributionKind } from '../../Contracts/Contribution'
37
37
  import type { GreenNode } from '../../../Syntax/GreenNode'
38
38
  import { childOffsets } from '../../../Syntax/GreenNode'
39
- import { hexToOklab, mixHexOklab, perceptualDistance, hexToRgb } from '../../../Utils/ColorMath'
39
+ import { hexToOklab, mixOklabToHex, perceptualDistanceOklab, hexToRgb } from '../../../Utils/ColorMath'
40
40
  import { extractHex, sigmoid, extractSequences, checkFormattingBreaks } from '../../Utils/color-utils'
41
41
 
42
42
  // ── Constants ─────────────────────────────────────────────────────
@@ -82,6 +82,61 @@ export interface GradientDiagnostics {
82
82
 
83
83
  // ── Main Analyzer ─────────────────────────────────────────────────
84
84
 
85
+ export interface CollapsibleGradient {
86
+ readonly range: { start: number; end: number }
87
+ readonly colors: string[]
88
+ readonly stops: GradientStop[]
89
+ readonly easing: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut'
90
+ readonly combinedText: string
91
+ readonly replacementText: string
92
+ readonly confidence: number
93
+ readonly colorCount: number
94
+ }
95
+
96
+ export function formatGradientTag(
97
+ stops: GradientStop[],
98
+ colors: string[],
99
+ easing: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut',
100
+ text: string,
101
+ ): string {
102
+ const effectiveStops = stops.length >= 2
103
+ ? stops
104
+ : [{ color: colors[0], position: 0 }, { color: colors[colors.length - 1], position: 1 }]
105
+
106
+ // Deduplicate consecutive identical colors
107
+ const filteredStops: GradientStop[] = []
108
+ for (const s of effectiveStops) {
109
+ if (
110
+ filteredStops.length === 0 ||
111
+ filteredStops[filteredStops.length - 1].color.toLowerCase() !== s.color.toLowerCase()
112
+ ) {
113
+ filteredStops.push(s)
114
+ }
115
+ }
116
+
117
+ const finalStops = filteredStops.length >= 2
118
+ ? filteredStops
119
+ : [{ color: colors[0], position: 0 }, { color: colors[colors.length - 1], position: 1 }]
120
+
121
+ const n = finalStops.length
122
+ const isEvenlySpaced = finalStops.every((s, i) => {
123
+ const expected = n <= 1 ? 0 : i / (n - 1)
124
+ return Math.abs(s.position - expected) < 0.04
125
+ })
126
+
127
+ let stopsStr: string
128
+ if (isEvenlySpaced) {
129
+ stopsStr = finalStops.map(s => s.color).join(',')
130
+ } else {
131
+ stopsStr = finalStops
132
+ .map(s => `${s.color} ${Math.round(s.position * 100)}%`)
133
+ .join(',')
134
+ }
135
+
136
+ const easingStr = easing !== 'linear' ? `;easing=${easing}` : ''
137
+ return `[gradient=${stopsStr}${easingStr}]${text}[/gradient]`
138
+ }
139
+
85
140
  export class GradientAnalyzer implements AnalyzerPass {
86
141
  readonly id = 'gradient-analyzer'
87
142
 
@@ -91,6 +146,115 @@ export class GradientAnalyzer implements AnalyzerPass {
91
146
  return contributions
92
147
  }
93
148
 
149
+ findCollapsibleGradients(tree: GreenNode, minConfidence = 0.6): CollapsibleGradient[] {
150
+ const results: CollapsibleGradient[] = []
151
+ this.collectCollapsibleGradients(tree, results, 0, minConfidence)
152
+ return results
153
+ }
154
+
155
+ private collectCollapsibleGradients(
156
+ node: GreenNode,
157
+ sink: CollapsibleGradient[],
158
+ nodeStart: number = 0,
159
+ minConfidence: number = 0.6,
160
+ ): void {
161
+ const children = node.children as GreenNode[]
162
+ if (children.length === 0) return
163
+ this.collapsibleGradientsAt(node, nodeStart, sink, minConfidence)
164
+ // Los desplazamientos se acumulan sobre la marcha, que es lo mismo que
165
+ // hace `childOffsets` pero sin el array.
166
+ let offset = nodeStart + node.leadingWidth
167
+ for (let i = 0; i < children.length; i++) {
168
+ this.collectCollapsibleGradients(children[i], sink, offset, minConfidence)
169
+ offset += children[i].width
170
+ }
171
+ }
172
+
173
+ /**
174
+ * The collapsible runs among the DIRECT children of `node`, and nothing
175
+ * below them.
176
+ *
177
+ * A run is a property of one children list: which siblings are `color`,
178
+ * what sits between them, what each one wraps. That is what makes it the
179
+ * unit the incremental analysis can work with — an edit rebuilds the child
180
+ * lists on the path down to it and nothing else, so only those lists can
181
+ * have gained or lost a run (see `SemanticAnalyzer.analyzeWindow`). The
182
+ * full scan above is this, applied to every node in pre-order; running it
183
+ * per list keeps the two paths producing the same items in the same order.
184
+ *
185
+ * Returns the number of runs appended to `sink`. `nodeStart` is the absolute
186
+ * offset of `node`, so the ranges come out in document coordinates.
187
+ */
188
+ collapsibleGradientsAt(
189
+ node: GreenNode,
190
+ nodeStart: number,
191
+ sink: CollapsibleGradient[],
192
+ minConfidence: number = 0.6,
193
+ ): number {
194
+ // El corte por arriba: la inmensa mayoría de los nodos del documento son
195
+ // hojas o no tienen suficientes hijos `color` seguidos como para formar
196
+ // un degradado. `childOffsets` asigna un array por nodo y
197
+ // `extractSequences` otro más; hacerlo para todos era pagar dos
198
+ // asignaciones por nodo del árbol para descartarlos acto seguido.
199
+ const children = node.children as GreenNode[]
200
+ if (children.length === 0) return 0
201
+ if (countColorChildren(children) < MIN_SEQUENCE_LENGTH) return 0
202
+
203
+ const offsets = childOffsets(node, nodeStart)
204
+ const sequences = extractSequences(children, 'color', extractHex)
205
+ let found = 0
206
+
207
+ for (const seq of sequences) {
208
+ const colors = seq.values as string[]
209
+ if (colors.length < MIN_SEQUENCE_LENGTH) continue
210
+
211
+ let textLen = 0
212
+ const textChunks: string[] = []
213
+ for (let i = seq.startIdx; i < seq.endIdx; i++) {
214
+ const child = children[i]
215
+ if (child.kind === 'color') {
216
+ for (const textChild of child.children as GreenNode[]) {
217
+ if (textChild.kind === 'text') {
218
+ textLen += textChild.text.length
219
+ textChunks.push(textChild.text)
220
+ } else if (textChild.kind === 'spacing' || textChild.kind === 'empty_line') {
221
+ textChunks.push('\n')
222
+ }
223
+ }
224
+ } else if (child.kind === 'text') {
225
+ textChunks.push(child.text)
226
+ }
227
+ }
228
+
229
+ const combinedText = textChunks.join('')
230
+ const hasBreaks = checkFormattingBreaks(children, seq, 'color')
231
+ const { stops, easing } = this.detectStops(colors)
232
+ const diag = this.buildDiagnostics(colors, stops)
233
+ const { score: rawScore } = this.calculateRawScore(diag, hasBreaks)
234
+ const confidence = sigmoid(rawScore, SIGMOID_STEEPNESS)
235
+
236
+ if (confidence >= minConfidence && stops.length >= 2) {
237
+ const range = {
238
+ start: offsets[seq.startIdx],
239
+ end: offsets[seq.endIdx],
240
+ }
241
+ const replacementText = formatGradientTag(stops, colors, easing, combinedText)
242
+ sink.push({
243
+ range,
244
+ colors,
245
+ stops,
246
+ easing,
247
+ combinedText,
248
+ replacementText,
249
+ confidence,
250
+ colorCount: colors.length,
251
+ })
252
+ found++
253
+ }
254
+ }
255
+ return found
256
+ }
257
+
94
258
  // ── Sequence Detection ──────────────────────────────────────────
95
259
 
96
260
  private findGradients(node: GreenNode, sink: Contribution[], nodeStart: number = 0): void {
@@ -152,33 +316,104 @@ export class GradientAnalyzer implements AnalyzerPass {
152
316
  */
153
317
  private detectStops(colors: string[]): { stops: GradientStop[]; easing: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' } {
154
318
  const n = colors.length
155
- const stops: GradientStop[] = [{ color: colors[0], position: 0 }]
319
+ if (n <= 2) {
320
+ return {
321
+ stops: colors.map((c, i) => ({ color: c, position: n === 1 ? 0 : i / (n - 1) })),
322
+ easing: 'linear',
323
+ }
324
+ }
156
325
 
157
- const totalDist = perceptualDistance(colors[0], colors[n - 1])
158
- const minStep = totalDist * 0.08 // At least 8% of total range
159
- let accumulated = 0
326
+ const oklab = colors.map(c => hexToOklab(c))
327
+ const rgb = colors.map(h => {
328
+ const [r, g, b] = hexToRgb(h)
329
+ return [r / 255, g / 255, b / 255]
330
+ })
160
331
 
332
+ // From `oklab`, not from the hex strings: `perceptualDistance` would
333
+ // convert both endpoints again, and both are already in hand.
334
+ const deltas: number[] = []
161
335
  for (let i = 1; i < n; i++) {
162
- const dist = perceptualDistance(colors[i - 1], colors[i])
163
- accumulated += dist
164
- if (accumulated >= minStep && i < n - 1) {
165
- stops.push({ color: colors[i], position: i / (n - 1) })
166
- accumulated = 0
336
+ deltas.push(perceptualDistanceOklab(oklab[i - 1], oklab[i]))
337
+ }
338
+
339
+ // 1. Text Studio change-point detection (derivative jump between neighbours)
340
+ const detectedIndices = new Set<number>([0, n - 1])
341
+ const STOP_THRESHOLD = 0.08
342
+ for (let i = 1; i < n - 1; i++) {
343
+ const curr = deltas[i]
344
+ const prev = deltas[i - 1]
345
+ if (curr > prev * 1.5 && curr > STOP_THRESHOLD) {
346
+ detectedIndices.add(i)
167
347
  }
168
348
  }
169
349
 
170
- // Always include the last colour
171
- if (stops[stops.length - 1].position < 1) {
172
- stops.push({ color: colors[n - 1], position: 1 })
350
+ // 2. Dual-space (RGB + OKLab) chord deviation (Douglas-Peucker)
351
+ // Avoids over-segmenting smooth linear ramps (which are straight lines in RGB or OKLab)
352
+ // while accurately capturing true inflection points / color turns.
353
+ function checkChord(startIdx: number, endIdx: number): void {
354
+ if (endIdx - startIdx <= 1) return
355
+
356
+ const aR = rgb[startIdx]
357
+ const bR = rgb[endIdx]
358
+ const abRLenSq = (bR[0] - aR[0]) ** 2 + (bR[1] - aR[1]) ** 2 + (bR[2] - aR[2]) ** 2
359
+
360
+ const aO = oklab[startIdx]
361
+ const bO = oklab[endIdx]
362
+ const abOLenSq = (bO[0] - aO[0]) ** 2 + (bO[1] - aO[1]) ** 2 + (bO[2] - aO[2]) ** 2
363
+
364
+ let maxDev = 0
365
+ let maxIdx = -1
366
+
367
+ for (let i = startIdx + 1; i < endIdx; i++) {
368
+ let dR = 0
369
+ if (abRLenSq > 1e-7) {
370
+ const tR = Math.max(0, Math.min(1, ((rgb[i][0] - aR[0]) * (bR[0] - aR[0]) + (rgb[i][1] - aR[1]) * (bR[1] - aR[1]) + (rgb[i][2] - aR[2]) * (bR[2] - aR[2])) / abRLenSq))
371
+ dR = Math.hypot(rgb[i][0] - (aR[0] + tR * (bR[0] - aR[0])), rgb[i][1] - (aR[1] + tR * (bR[1] - aR[1])), rgb[i][2] - (aR[2] + tR * (bR[2] - aR[2])))
372
+ } else {
373
+ dR = Math.hypot(rgb[i][0] - aR[0], rgb[i][1] - aR[1], rgb[i][2] - aR[2])
374
+ }
375
+
376
+ let dO = 0
377
+ if (abOLenSq > 1e-7) {
378
+ const tO = Math.max(0, Math.min(1, ((oklab[i][0] - aO[0]) * (bO[0] - aO[0]) + (oklab[i][1] - aO[1]) * (bO[1] - aO[1]) + (oklab[i][2] - aO[2]) * (bO[2] - aO[2])) / abOLenSq))
379
+ dO = Math.hypot(oklab[i][0] - (aO[0] + tO * (bO[0] - aO[0])), oklab[i][1] - (aO[1] + tO * (bO[1] - aO[1])), oklab[i][2] - (aO[2] + tO * (bO[2] - aO[2])))
380
+ } else {
381
+ dO = Math.hypot(oklab[i][0] - aO[0], oklab[i][1] - aO[1], oklab[i][2] - aO[2])
382
+ }
383
+
384
+ // Must deviate from linear in both sRGB and OKLab models to count as a genuine stop
385
+ const dev = Math.min(dR / 0.05, dO / 0.08)
386
+ if (dev > maxDev) {
387
+ maxDev = dev
388
+ maxIdx = i
389
+ }
390
+ }
391
+
392
+ if (maxDev > 1.0 && maxIdx !== -1) {
393
+ detectedIndices.add(maxIdx)
394
+ checkChord(startIdx, maxIdx)
395
+ checkChord(maxIdx, endIdx)
396
+ }
173
397
  }
174
398
 
399
+ checkChord(0, n - 1)
400
+
401
+ const sortedIndices = Array.from(detectedIndices).sort((a, b) => a - b)
402
+ const stops: GradientStop[] = sortedIndices.map(idx => ({
403
+ color: colors[idx],
404
+ position: idx / (n - 1),
405
+ }))
406
+ // Where each stop sits in `colors`, so the easing pass below can index
407
+ // into `oklab` instead of re-converting `stop.color`.
408
+ const stopSourceIndex = sortedIndices
409
+
175
410
  // Infer easing from change in perceptual distance across segments
176
411
  let easing: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut' = 'linear'
177
412
  if (stops.length >= 3) {
178
413
  const segDists: number[] = []
179
414
  for (let i = 1; i < stops.length; i++) {
180
415
  const segLen = stops[i].position - stops[i - 1].position
181
- const segDist = perceptualDistance(stops[i - 1].color, stops[i].color)
416
+ const segDist = perceptualDistanceOklab(oklab[stopSourceIndex[i - 1]], oklab[stopSourceIndex[i]])
182
417
  segDists.push(segLen > 0 ? segDist / segLen : 0)
183
418
  }
184
419
 
@@ -211,7 +446,7 @@ export class GradientAnalyzer implements AnalyzerPass {
211
446
  // 1. Uniform perceptual spacing
212
447
  const pDiffs: number[] = []
213
448
  for (let i = 1; i < n; i++) {
214
- pDiffs.push(perceptualDistance(colors[i - 1], colors[i]))
449
+ pDiffs.push(perceptualDistanceOklab(oklabArray[i - 1], oklabArray[i]))
215
450
  }
216
451
  const avgDiff = pDiffs.reduce((a, b) => a + b, 0) / pDiffs.length
217
452
  const diffVariance = pDiffs.reduce((sum, d) => sum + (d - avgDiff) ** 2, 0) / pDiffs.length
@@ -234,19 +469,26 @@ export class GradientAnalyzer implements AnalyzerPass {
234
469
  }
235
470
  const monotonic = violations < n * 0.2
236
471
 
237
- // 3. Count plateaus (consecutive perceptually identical colours)
472
+ // 3. Count plateaus (consecutive perceptually identical colours).
473
+ // These are the neighbour distances from step 1, not a new measurement.
238
474
  let plateauCount = 0
239
- for (let i = 1; i < n; i++) {
240
- if (perceptualDistance(colors[i - 1], colors[i]) < 0.01) plateauCount++
475
+ for (let i = 0; i < pDiffs.length; i++) {
476
+ if (pDiffs[i] < 0.01) plateauCount++
241
477
  }
242
478
 
243
479
  // 4. Perceptual error vs ideal OKLab interpolation
244
480
  let maxPerceptualError = 0
245
481
  if (n >= 3 && stops.length >= 2) {
482
+ // The ramp's endpoints are fixed for the whole loop; only `t` moves.
483
+ // The mix still round-trips through hex because the comparison is
484
+ // against a colour that has been quantised to 8 bits per channel, and
485
+ // dropping that would change the score.
486
+ const rampFrom = hexToOklab(stops[0].color)
487
+ const rampTo = hexToOklab(stops[stops.length - 1].color)
246
488
  for (let i = 0; i < n; i++) {
247
489
  const t = n > 1 ? i / (n - 1) : 0
248
- const ideal = mixHexOklab(stops[0].color, stops[stops.length - 1].color, t)
249
- const error = perceptualDistance(ideal, colors[i])
490
+ const ideal = mixOklabToHex(rampFrom, rampTo, t)
491
+ const error = perceptualDistanceOklab(hexToOklab(ideal), oklabArray[i])
250
492
  maxPerceptualError = Math.max(maxPerceptualError, error)
251
493
  }
252
494
  }
@@ -290,3 +532,35 @@ export class GradientAnalyzer implements AnalyzerPass {
290
532
  return { score, featureScores }
291
533
  }
292
534
  }
535
+
536
+ /** Cuántos hijos directos son nodos `color`. Sin asignar nada. */
537
+ function countColorChildren(children: readonly GreenNode[]): number {
538
+ let count = 0
539
+ for (let i = 0; i < children.length; i++) {
540
+ if (children[i].kind === 'color') count++
541
+ }
542
+ return count
543
+ }
544
+
545
+ export function findCollapsibleGradients(
546
+ tree: GreenNode,
547
+ minConfidence = 0.6,
548
+ ): CollapsibleGradient[] {
549
+ return new GradientAnalyzer().findCollapsibleGradients(tree, minConfidence)
550
+ }
551
+
552
+ const sharedAnalyzer = new GradientAnalyzer()
553
+
554
+ /**
555
+ * The collapsible runs among the direct children of `node`, whose absolute
556
+ * start is `nodeStart`. See {@link GradientAnalyzer.collapsibleGradientsAt}.
557
+ */
558
+ export function collapsibleGradientsAt(
559
+ node: GreenNode,
560
+ nodeStart: number,
561
+ sink: CollapsibleGradient[],
562
+ minConfidence = 0.6,
563
+ ): number {
564
+ return sharedAnalyzer.collapsibleGradientsAt(node, nodeStart, sink, minConfidence)
565
+ }
566
+
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Quasar Analysis Framework — Symbol Analyzer
3
+ *
4
+ * Detects decorative Unicode glyphs — `✧ ⚔ ☽ ━ ◈` — in document text and
5
+ * reports them as SemanticContributions.
6
+ *
7
+ * The colour analyzers recognise structure that BBCode itself encodes: a
8
+ * gradient is a *sequence of tags*, so the tree already separates it from the
9
+ * prose around it. Symbols have no such marker. They are ordinary characters
10
+ * inside ordinary text nodes, indistinguishable at the syntax level from the
11
+ * words beside them. Recognising them therefore has to happen at the character
12
+ * level, against the Unicode blocks they come from.
13
+ *
14
+ * ## Runs, not characters
15
+ *
16
+ * Symbols are reported as contiguous *runs*, because the two ways a document
17
+ * uses them need different treatment:
18
+ *
19
+ * `✧ airi ✧` two one-glyph runs — ornaments framing a word
20
+ * `✧⋆⋅⋆⋅⋆✧` one seven-glyph run — a divider line
21
+ *
22
+ * Swapping the first means substituting glyph for glyph. Swapping the second
23
+ * means replacing the whole line with a different divider. A run of
24
+ * `MIN_SEPARATOR_LENGTH` or more is labelled `Separator`; anything shorter is
25
+ * labelled `Symbol`. Both carry per-glyph offsets in their metadata, so a
26
+ * consumer can address either the run or its individual characters.
27
+ *
28
+ * ## Confidence
29
+ *
30
+ * Detection is exact — a code point is either in a decorative block or it is
31
+ * not. Confidence answers the question that actually matters: is this glyph
32
+ * *ornamental*, or does it carry meaning? A dingbat is almost always
33
+ * decoration; `≈` in a sentence is probably arithmetic, and re-theming it
34
+ * would corrupt the text.
35
+ *
36
+ * Each block carries a base confidence, a run takes the *lowest* of its
37
+ * members — a run is only as safely ornamental as its least ornamental glyph —
38
+ * and separator-length runs receive a bonus, because a seven-character line of
39
+ * symbols is decoration regardless of which blocks it draws from.
40
+ *
41
+ * @see SemanticContribution
42
+ */
43
+
44
+ import type { AnalyzerPass } from '../../Contracts/Pass'
45
+ import type { PipelineContext } from '../../Contracts/PipelineContext'
46
+ import type { Contribution } from '../../Contracts/Contribution'
47
+ import { ContributionKind } from '../../Contracts/Contribution'
48
+ import type { GreenNode } from '../../../Syntax/GreenNode'
49
+ import { childOffsets } from '../../../Syntax/GreenNode'
50
+
51
+ // ── Constants ─────────────────────────────────────────────────────
52
+
53
+ /** Runs at least this long read as a divider rather than as ornaments. */
54
+ const MIN_SEPARATOR_LENGTH = 3
55
+
56
+ /** Added to a separator-length run, on top of its lowest block confidence. */
57
+ const SEPARATOR_BONUS = 0.25
58
+
59
+ /** Confidence is never reported as certainty. */
60
+ const MAX_CONFIDENCE = 0.99
61
+
62
+ /**
63
+ * Node kinds whose text is not prose and must never be re-themed.
64
+ *
65
+ * `code` and `inline_code` are the lexer's raw tags (`BBCODE_RAW_TAGS`): their
66
+ * content is literal by definition, and a user who typed `✧` inside a code
67
+ * block meant that exact character. `image` holds a URL as its child text,
68
+ * where a matching code point would be part of an address, not decoration.
69
+ */
70
+ const OPAQUE_KINDS: ReadonlySet<string> = new Set(['code', 'inline_code', 'image'])
71
+
72
+ // ── Unicode blocks ────────────────────────────────────────────────
73
+
74
+ interface SymbolBlock {
75
+ readonly name: string
76
+ readonly first: number
77
+ readonly last: number
78
+ /** How reliably a glyph from this block is decoration rather than content. */
79
+ readonly confidence: number
80
+ }
81
+
82
+ /**
83
+ * The decorative blocks, ordered by code point.
84
+ *
85
+ * Deliberately narrower than "everything that is not a letter". Latin
86
+ * punctuation, currency signs and CJK are all excluded: they appear in real
87
+ * prose, and a re-theming pass that rewrote them would be destroying text
88
+ * rather than restyling it.
89
+ */
90
+ const SYMBOL_BLOCKS: readonly SymbolBlock[] = [
91
+ // Arrows and maths are the least certain: `→` and `≈` are ordinary in prose.
92
+ { name: 'arrows', first: 0x2190, last: 0x21ff, confidence: 0.65 },
93
+ { name: 'math-operators', first: 0x2200, last: 0x22ff, confidence: 0.60 },
94
+ { name: 'technical', first: 0x2300, last: 0x23ff, confidence: 0.80 },
95
+ // Box drawing exists only to draw boxes and rules.
96
+ { name: 'box-drawing', first: 0x2500, last: 0x257f, confidence: 0.95 },
97
+ { name: 'block-elements', first: 0x2580, last: 0x259f, confidence: 0.90 },
98
+ { name: 'geometric', first: 0x25a0, last: 0x25ff, confidence: 0.90 },
99
+ { name: 'misc-symbols', first: 0x2600, last: 0x26ff, confidence: 0.90 },
100
+ { name: 'dingbats', first: 0x2700, last: 0x27bf, confidence: 0.95 },
101
+ { name: 'symbols-arrows', first: 0x2b00, last: 0x2bff, confidence: 0.90 },
102
+ // Astral emoji — always two UTF-16 units, see `scanText`.
103
+ { name: 'pictographs', first: 0x1f300, last: 0x1f5ff, confidence: 0.85 },
104
+ { name: 'emoticons', first: 0x1f600, last: 0x1f64f, confidence: 0.85 },
105
+ { name: 'supplemental', first: 0x1f900, last: 0x1f9ff, confidence: 0.85 },
106
+ ]
107
+
108
+ function blockOf(codePoint: number): SymbolBlock | null {
109
+ for (const block of SYMBOL_BLOCKS) {
110
+ if (codePoint >= block.first && codePoint <= block.last) return block
111
+ }
112
+ return null
113
+ }
114
+
115
+ // ── Types ─────────────────────────────────────────────────────────
116
+
117
+ export interface SymbolGlyph {
118
+ readonly char: string
119
+ readonly codePoint: number
120
+ /** Unicode block name this glyph was matched in. */
121
+ readonly block: string
122
+ /** Absolute source offsets, UTF-16 code units — `source.slice(start, end)`. */
123
+ readonly start: number
124
+ readonly end: number
125
+ }
126
+
127
+ export interface SymbolRunModel {
128
+ /** The run exactly as it appears in the source. */
129
+ readonly text: string
130
+ readonly glyphs: readonly SymbolGlyph[]
131
+ /** Distinct block names present, in first-seen order. */
132
+ readonly blocks: readonly string[]
133
+ /** Length in glyphs — not in UTF-16 units, which astral symbols inflate. */
134
+ readonly length: number
135
+ /** Whether the run was long enough to read as a divider. */
136
+ readonly isSeparator: boolean
137
+ }
138
+
139
+ // ── Analyzer ──────────────────────────────────────────────────────
140
+
141
+ export class SymbolAnalyzer implements AnalyzerPass {
142
+ readonly id = 'symbol'
143
+
144
+ run(tree: GreenNode, _context: PipelineContext): Contribution[] {
145
+ const contributions: Contribution[] = []
146
+ this.walk(tree, contributions, 0)
147
+ return contributions
148
+ }
149
+
150
+ // ── Private ─────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * Descend the tree, accumulating absolute offsets.
154
+ *
155
+ * Green nodes carry widths rather than positions (see `GreenNode.ts`), so
156
+ * the offsets a Contribution reports are built on the way down. Only `text`
157
+ * leaves are scanned: an element node's own `text` holds its attributes
158
+ * (`=#D194B3`), which are syntax, not content.
159
+ */
160
+ private walk(node: GreenNode, sink: Contribution[], start: number): void {
161
+ if (OPAQUE_KINDS.has(node.kind)) return
162
+
163
+ if (node.kind === 'text') {
164
+ this.scanText(node.text, start, sink)
165
+ return
166
+ }
167
+
168
+ const children = node.children as readonly GreenNode[]
169
+ if (children.length === 0) return
170
+
171
+ const offsets = childOffsets(node, start)
172
+ for (let i = 0; i < children.length; i++) {
173
+ this.walk(children[i], sink, offsets[i])
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Find runs of decorative glyphs in one text leaf.
179
+ *
180
+ * Iterates by code point, not by index: an astral symbol such as `🌸`
181
+ * occupies two UTF-16 units, and stepping one unit at a time would split it
182
+ * into unpaired surrogates that match no block. The offsets recorded stay in
183
+ * UTF-16 units so they address the source directly.
184
+ */
185
+ private scanText(text: string, textStart: number, sink: Contribution[]): void {
186
+ let run: SymbolGlyph[] = []
187
+
188
+ const flush = () => {
189
+ if (run.length > 0) {
190
+ sink.push(this.toContribution(text, textStart, run))
191
+ run = []
192
+ }
193
+ }
194
+
195
+ let i = 0
196
+ while (i < text.length) {
197
+ const codePoint = text.codePointAt(i)!
198
+ const width = codePoint > 0xffff ? 2 : 1
199
+ const block = blockOf(codePoint)
200
+
201
+ if (block) {
202
+ run.push({
203
+ char: String.fromCodePoint(codePoint),
204
+ codePoint,
205
+ block: block.name,
206
+ start: textStart + i,
207
+ end: textStart + i + width,
208
+ })
209
+ } else {
210
+ flush()
211
+ }
212
+
213
+ i += width
214
+ }
215
+
216
+ flush()
217
+ }
218
+
219
+ private toContribution(
220
+ text: string,
221
+ textStart: number,
222
+ glyphs: readonly SymbolGlyph[],
223
+ ): Contribution {
224
+ const start = glyphs[0].start
225
+ const end = glyphs[glyphs.length - 1].end
226
+ const isSeparator = glyphs.length >= MIN_SEPARATOR_LENGTH
227
+
228
+ const blocks: string[] = []
229
+ for (const glyph of glyphs) {
230
+ if (!blocks.includes(glyph.block)) blocks.push(glyph.block)
231
+ }
232
+
233
+ // The lowest base wins: a run is only as safely ornamental as its least
234
+ // ornamental glyph, so `≈≈≈` stays more doubtful than `✧✧✧`.
235
+ let confidence = Math.min(...glyphs.map(g => blockOf(g.codePoint)!.confidence))
236
+ if (isSeparator) confidence = Math.min(MAX_CONFIDENCE, confidence + SEPARATOR_BONUS)
237
+
238
+ const model: SymbolRunModel = {
239
+ text: text.slice(start - textStart, end - textStart),
240
+ glyphs,
241
+ blocks,
242
+ length: glyphs.length,
243
+ isSeparator,
244
+ }
245
+
246
+ return {
247
+ kind: ContributionKind.Semantic,
248
+ label: isSeparator ? 'Separator' : 'Symbol',
249
+ confidence,
250
+ range: { start, end },
251
+ metadata: { model },
252
+ description: isSeparator
253
+ ? `Separator of ${glyphs.length} glyphs (${blocks.join(', ')})`
254
+ : `Decorative symbol ${model.text} (${blocks.join(', ')})`,
255
+ }
256
+ }
257
+ }