@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
@@ -0,0 +1,2462 @@
1
+ // ============================================================
2
+ // Quasar — Effect Math
3
+ // ============================================================
4
+ //
5
+ // The evaluation kernel shared by everything that modulates text: the
6
+ // `[gradient]` / `[grow]` / `[rainbow]` tag handlers, the HTML renderer's
7
+ // preview of them, and Text Studio's own compiler in @miliastry/quasar-studio.
8
+ //
9
+ // It lives here, in the engine, for one reason: the same document has to
10
+ // look the same in all three. When the studio owned this maths and the
11
+ // tag handlers owned a second copy, `[gradient=#a,#b]` rendered one way
12
+ // in the studio preview and exported another way through the registry,
13
+ // and every parameter the studio grew — easing, waveforms, axes — was
14
+ // silently dropped the moment the document round-tripped through BBCode.
15
+ //
16
+ // The model is three stages:
17
+ //
18
+ // axis(sample) → u ∈ [0,1] where the character sits
19
+ // wave(u) → v ∈ [0,1] what the curve says there
20
+ // effect(v) → colour / size what that means visually
21
+ //
22
+ // Everything stochastic is seeded and addressed by character offset, never
23
+ // drawn from `Math.random()`: these functions run on every keystroke, and
24
+ // an unseeded effect produces different output each time it is evaluated,
25
+ // which makes previews flicker and exports unreproducible.
26
+ // ============================================================
27
+
28
+ import {
29
+ hexToRgb, hexToHsl, hslToHex, solveCubicBezierY, mixHex, mixMultipleStops,
30
+ ease, compileEase, __setExpressionCompiler, type ColorStop, type Easing,
31
+ } from './ColorMath'
32
+
33
+ // ── Deterministic randomness ───────────────────────────────────
34
+
35
+ /** FNV-1a over a string → 32-bit unsigned seed. */
36
+ export function hashSeed(input: string): number {
37
+ let h = 0x811c9dc5
38
+ for (let i = 0; i < input.length; i++) {
39
+ h ^= input.charCodeAt(i)
40
+ h = Math.imul(h, 0x01000193)
41
+ }
42
+ return h >>> 0
43
+ }
44
+
45
+ /**
46
+ * Stateless hash → [0,1) for one (seed, index) pair.
47
+ *
48
+ * Stateless matters: layers are evaluated per text node and per range, in
49
+ * an order the user can change by dragging. A stateful generator would
50
+ * hand character #7 a different number depending on how the document was
51
+ * split into nodes. Addressing by index makes the value a pure function
52
+ * of the character's position.
53
+ */
54
+ export function randAt(seed: number, index: number): number {
55
+ let t = (Math.imul(index ^ seed, 0x27d4eb2d) + seed) >>> 0
56
+ t = Math.imul(t ^ (t >>> 15), t | 1)
57
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
58
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
59
+ }
60
+
61
+ /** Smooth (cosine-interpolated) value noise over a 1-D axis. */
62
+ export function valueNoise(x: number, seed: number): number {
63
+ const i = Math.floor(x)
64
+ const f = x - i
65
+ const a = randAt(seed, i)
66
+ const b = randAt(seed, i + 1)
67
+ // Smoothstep between lattice points — cheaper than cosine, same shape.
68
+ const t = f * f * (3 - 2 * f)
69
+ return a + (b - a) * t
70
+ }
71
+
72
+ /** Fractal Brownian motion: octaves of value noise at halving amplitude. */
73
+ export function fbm(x: number, seed: number, octaves = 4): number {
74
+ let sum = 0
75
+ let amp = 0.5
76
+ let freq = 1
77
+ let norm = 0
78
+ const n = Math.max(1, Math.min(8, Math.round(octaves)))
79
+ for (let o = 0; o < n; o++) {
80
+ sum += valueNoise(x * freq, seed + o * 7919) * amp
81
+ norm += amp
82
+ amp *= 0.5
83
+ freq *= 2
84
+ }
85
+ return norm > 0 ? sum / norm : 0
86
+ }
87
+
88
+ // ── Sandboxed expressions ──────────────────────────────────────
89
+
90
+ /**
91
+ * Variables an `expr(...)` waveform can read.
92
+ *
93
+ * `u` is the raw axis position, `t` its alias, `i`/`n` the character's
94
+ * index and the unit count, `line`/`col` its 2-D address, and `rnd` a
95
+ * deterministic per-character random draw.
96
+ */
97
+ export interface ExpressionVars {
98
+ u: number
99
+ t: number
100
+ x: number
101
+ i: number
102
+ n: number
103
+ line: number
104
+ lines: number
105
+ col: number
106
+ cols: number
107
+ word: number
108
+ words: number
109
+ rnd: number
110
+ }
111
+
112
+ export const EXPRESSION_VARS: readonly (keyof ExpressionVars)[] = [
113
+ 'u', 't', 'x', 'i', 'n', 'line', 'lines', 'col', 'cols', 'word', 'words', 'rnd',
114
+ ]
115
+
116
+ /**
117
+ * Math members an expression may name. Anything else is rejected.
118
+ *
119
+ * They are passed to the compiled function as ordinary parameters rather
120
+ * than pulled in with `with (Math)`. `with` is a syntax error under
121
+ * `"use strict"`, and dropping strict mode to keep it would hand the
122
+ * expression a writable global scope — the opposite of what a sandbox is
123
+ * for.
124
+ */
125
+ const MATH_NAMES = [
126
+ 'abs', 'acos', 'acosh', 'asin', 'asinh', 'atan', 'atan2', 'atanh', 'cbrt',
127
+ 'ceil', 'cos', 'cosh', 'exp', 'floor', 'hypot', 'log', 'log2', 'log10',
128
+ 'max', 'min', 'pow', 'round', 'sign', 'sin', 'sinh', 'sqrt', 'tan', 'tanh',
129
+ 'trunc', 'PI', 'E', 'LN2', 'LN10', 'SQRT2',
130
+ ] as const
131
+
132
+ const MATH_ALLOWLIST = new Set<string>(MATH_NAMES)
133
+
134
+ const MATH_VALUES = MATH_NAMES.map(
135
+ name => (Math as unknown as Record<string, unknown>)[name],
136
+ )
137
+
138
+ const IDENTIFIER_RE = /[A-Za-z_$][A-Za-z0-9_$]*/g
139
+ /** Characters an expression may contain outside identifiers and digits. */
140
+ const OPERATOR_RE = /^[0-9.\s+\-*/%()<>!&|?:,=]*$/
141
+
142
+ export type CompiledExpression = (vars: ExpressionVars) => number
143
+
144
+ /**
145
+ * Desaloja la entrada MÁS ANTIGUA hasta volver al límite.
146
+ *
147
+ * Los dos cachés de este fichero hacían `clear()` al tocar el techo: en un
148
+ * documento con más claves distintas que el límite eso los dejaba en cero una
149
+ * y otra vez, así que todo volvía a compilarse o a interpretarse en cada
150
+ * pasada. Un `Map` conserva el orden de inserción, y refrescar la posición en
151
+ * cada acierto lo convierte en un LRU sin estructuras extra.
152
+ */
153
+ function evictOldest<K, V>(cache: Map<K, V>, limit: number): void {
154
+ while (cache.size > limit) {
155
+ const oldest = cache.keys().next()
156
+ if (oldest.done) break
157
+ cache.delete(oldest.value)
158
+ }
159
+ }
160
+
161
+ const expressionCache = new Map<string, CompiledExpression | null>()
162
+ const EXPRESSION_CACHE_LIMIT = 256
163
+
164
+ /**
165
+ * Validate that `src` is pure arithmetic over the allowed vocabulary.
166
+ *
167
+ * The check is allowlist-based in both directions: every identifier must
168
+ * be a known variable or `Math` member, and every remaining character
169
+ * must be an operator or a digit. That rejects property access
170
+ * (`constructor`, `__proto__`), string and template literals, array and
171
+ * object syntax, and statement separators — the whole surface an
172
+ * injected payload would need — without trying to enumerate attacks.
173
+ */
174
+ export function validateExpression(src: string): { ok: true } | { ok: false; reason: string } {
175
+ if (!src.trim()) return { ok: false, reason: 'empty' }
176
+ if (src.length > 512) return { ok: false, reason: 'too long' }
177
+
178
+ const identifiers = src.match(IDENTIFIER_RE) ?? []
179
+ for (const id of identifiers) {
180
+ if (MATH_ALLOWLIST.has(id)) continue
181
+ if ((EXPRESSION_VARS as readonly string[]).includes(id)) continue
182
+ return { ok: false, reason: `unknown name "${id}"` }
183
+ }
184
+
185
+ const skeleton = src.replace(IDENTIFIER_RE, '')
186
+ if (!OPERATOR_RE.test(skeleton)) {
187
+ return { ok: false, reason: 'illegal character' }
188
+ }
189
+ // `=` is only legal as part of a comparison; a bare one is an assignment.
190
+ if (/[^=!<>]=[^=]/.test(` ${skeleton} `)) {
191
+ return { ok: false, reason: 'assignment not allowed' }
192
+ }
193
+ return { ok: true }
194
+ }
195
+
196
+ /** Compile a validated expression, or return null if it is rejected. */
197
+ export function compileExpression(src: string): CompiledExpression | null {
198
+ const cached = expressionCache.get(src)
199
+ if (cached !== undefined) {
200
+ expressionCache.delete(src)
201
+ expressionCache.set(src, cached)
202
+ }
203
+ if (cached !== undefined) return cached
204
+
205
+ let compiled: CompiledExpression | null = null
206
+ if (validateExpression(src).ok) {
207
+ try {
208
+ // eslint-disable-next-line no-new-func
209
+ const fn = new Function(
210
+ ...MATH_NAMES,
211
+ ...EXPRESSION_VARS,
212
+ `"use strict"; return (${src});`,
213
+ ) as (...args: unknown[]) => unknown
214
+ compiled = (vars: ExpressionVars) => {
215
+ const out = fn(...MATH_VALUES, ...EXPRESSION_VARS.map(k => vars[k]))
216
+ const num = Number(out)
217
+ return Number.isFinite(num) ? num : 0
218
+ }
219
+ // Fail fast on expressions that throw for every input rather than
220
+ // discovering it once per character.
221
+ compiled({ u: 0.5, t: 0.5, x: 0.5, i: 0, n: 1, line: 0, lines: 1, col: 0, cols: 1, word: 0, words: 1, rnd: 0.5 })
222
+ } catch {
223
+ compiled = null
224
+ }
225
+ }
226
+
227
+ expressionCache.set(src, compiled)
228
+ evictOldest(expressionCache, EXPRESSION_CACHE_LIMIT)
229
+ return compiled
230
+ }
231
+
232
+ // ── Waveforms ──────────────────────────────────────────────────
233
+
234
+ export type WaveKind =
235
+ | 'none'
236
+ | 'sine'
237
+ | 'cosine'
238
+ | 'triangle'
239
+ | 'sawtooth'
240
+ | 'square'
241
+ | 'bounce'
242
+ | 'elastic'
243
+ | 'exponential'
244
+ | 'pulse'
245
+ | 'edges'
246
+ | 'alternate'
247
+ | 'noise'
248
+ | 'fbm'
249
+ | 'steps'
250
+ | 'bezier'
251
+ | 'expr'
252
+
253
+ export const WAVE_KINDS: readonly WaveKind[] = [
254
+ 'none', 'sine', 'cosine', 'triangle', 'sawtooth', 'square', 'bounce',
255
+ 'elastic', 'exponential', 'pulse', 'edges', 'alternate', 'noise', 'fbm',
256
+ 'steps', 'bezier', 'expr',
257
+ ]
258
+
259
+ export interface WaveOptions {
260
+ /** How many times the waveform repeats across the axis. */
261
+ cycles: number
262
+ /** Shifts the waveform along the axis, in cycles. */
263
+ phase: number
264
+ /** Control points for `bezier`. */
265
+ bezier: readonly [number, number, number, number]
266
+ /** Source for `expr`. */
267
+ expression?: string
268
+ /** Octaves for `fbm`. */
269
+ octaves: number
270
+ /** Quantisation levels for `steps`, and post-quantisation for any wave. */
271
+ steps: number
272
+ /** Seed for `noise` / `fbm`. */
273
+ seed: number
274
+ /** Index used to address deterministic noise and index-parity waves. */
275
+ index: number
276
+ /** Unit count in the scope, for index-based waves. */
277
+ count: number
278
+ /** Full variable bag, for `expr`. */
279
+ vars?: ExpressionVars
280
+ }
281
+
282
+ export const DEFAULT_WAVE_OPTIONS: WaveOptions = {
283
+ cycles: 1,
284
+ phase: 0,
285
+ bezier: [0.25, 0.1, 0.25, 1.0],
286
+ octaves: 4,
287
+ steps: 0,
288
+ seed: 0,
289
+ index: 0,
290
+ count: 1,
291
+ }
292
+
293
+ const TAU = Math.PI * 2
294
+
295
+ /**
296
+ * Evaluate a waveform at axis position `u`, returning [0,1].
297
+ *
298
+ * `cycles`/`phase` are applied first so every periodic wave shares one
299
+ * notion of frequency, and the non-periodic ones (`bezier`, `steps`,
300
+ * `edges`) read the wrapped position too so a cycles slider still does
301
+ * something sensible for them.
302
+ */
303
+ export function waveform(kind: WaveKind, u: number, opts: Partial<WaveOptions> = {}): number {
304
+ // Read through with defaults rather than spreading into a fresh object.
305
+ // This runs once per character per layer, and building a nine-field
306
+ // object each time cost more than every waveform in the table combined.
307
+ const o = opts as WaveOptions
308
+ const oCycles = o.cycles ?? DEFAULT_WAVE_OPTIONS.cycles
309
+ const oPhase = o.phase ?? DEFAULT_WAVE_OPTIONS.phase
310
+ const oBezier = o.bezier ?? DEFAULT_WAVE_OPTIONS.bezier
311
+ const oOctaves = o.octaves ?? DEFAULT_WAVE_OPTIONS.octaves
312
+ const oSteps = o.steps ?? DEFAULT_WAVE_OPTIONS.steps
313
+ const oSeed = o.seed ?? DEFAULT_WAVE_OPTIONS.seed
314
+ const oIndex = o.index ?? DEFAULT_WAVE_OPTIONS.index
315
+
316
+ const clamped = clamp01(u)
317
+
318
+ // Position within the current cycle, in [0,1).
319
+ const cycles = Number.isFinite(oCycles) ? oCycles : 1
320
+ const scaled = clamped * cycles + oPhase
321
+ const p = ((scaled % 1) + 1) % 1
322
+ const angle = scaled * TAU
323
+
324
+ let v: number
325
+ switch (kind) {
326
+ case 'none':
327
+ v = clamped
328
+ break
329
+ case 'sine':
330
+ v = (Math.sin(angle) + 1) / 2
331
+ break
332
+ case 'cosine':
333
+ v = (Math.cos(angle) + 1) / 2
334
+ break
335
+ case 'triangle':
336
+ v = 1 - Math.abs(p * 2 - 1)
337
+ break
338
+ case 'sawtooth':
339
+ v = p
340
+ break
341
+ case 'square':
342
+ v = p < 0.5 ? 1 : 0
343
+ break
344
+ case 'bounce':
345
+ v = bounceOut(p)
346
+ break
347
+ case 'elastic':
348
+ v = elasticOut(p)
349
+ break
350
+ case 'exponential':
351
+ v = p === 0 ? 0 : Math.pow(2, 10 * (p - 1))
352
+ break
353
+ case 'pulse': {
354
+ // A sharp spike at the centre of each cycle.
355
+ const dist = Math.abs(p - 0.5) * 2
356
+ v = Math.pow(Math.max(0, 1 - dist), 8)
357
+ break
358
+ }
359
+ case 'edges': {
360
+ // Maximum at both ends of the cycle, minimum in the middle.
361
+ const dist = Math.abs(p * 2 - 1)
362
+ v = dist * dist
363
+ break
364
+ }
365
+ case 'alternate':
366
+ // Index parity, not a continuous wave: the point is that adjacent
367
+ // characters differ, which a periodic function only approximates.
368
+ v = Math.floor(oIndex * Math.max(1, cycles)) % 2 === 0 ? 1 : 0
369
+ break
370
+ case 'noise':
371
+ v = valueNoise(scaled * 4 + oIndex * 0.0001, oSeed)
372
+ break
373
+ case 'fbm':
374
+ v = fbm(scaled * 3, oSeed, oOctaves)
375
+ break
376
+ case 'steps': {
377
+ const levels = Math.max(2, Math.round(oSteps || 4))
378
+ v = Math.round(p * (levels - 1)) / (levels - 1)
379
+ break
380
+ }
381
+ case 'bezier':
382
+ v = solveCubicBezierY(p, oBezier[0], oBezier[1], oBezier[2], oBezier[3])
383
+ break
384
+ case 'expr': {
385
+ const fn = o.expression ? compileExpression(o.expression) : null
386
+ if (!fn) { v = clamped; break }
387
+ const vars = o.vars
388
+ ? { ...o.vars, u: p, t: p, x: p }
389
+ : { u: p, t: p, x: p, i: oIndex, n: 1, line: 0, lines: 1, col: 0, cols: 1, word: 0, words: 1, rnd: randAt(oSeed, oIndex) }
390
+ v = fn(vars)
391
+ break
392
+ }
393
+ default:
394
+ v = clamped
395
+ }
396
+
397
+ // A post-quantisation pass is what turns any smooth wave into a banded
398
+ // one — and, on the export side, collapses hundreds of near-identical
399
+ // [color] tags into a handful of long runs.
400
+ if (kind !== 'steps' && oSteps && oSteps >= 2) {
401
+ const levels = Math.round(oSteps)
402
+ v = Math.round(clamp01(v) * (levels - 1)) / (levels - 1)
403
+ }
404
+
405
+ return clamp01(v)
406
+ }
407
+
408
+ function bounceOut(t: number): number {
409
+ const n1 = 7.5625
410
+ const d1 = 2.75
411
+ if (t < 1 / d1) return n1 * t * t
412
+ if (t < 2 / d1) { const t2 = t - 1.5 / d1; return n1 * t2 * t2 + 0.75 }
413
+ if (t < 2.5 / d1) { const t2 = t - 2.25 / d1; return n1 * t2 * t2 + 0.9375 }
414
+ const t2 = t - 2.625 / d1
415
+ return n1 * t2 * t2 + 0.984375
416
+ }
417
+
418
+ function elasticOut(t: number): number {
419
+ if (t === 0 || t === 1) return t
420
+ const c4 = TAU / 3
421
+ return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1
422
+ }
423
+
424
+ export function clamp01(v: number): number {
425
+ if (!Number.isFinite(v)) return 0
426
+ return v < 0 ? 0 : v > 1 ? 1 : v
427
+ }
428
+
429
+ // ── Blend modes ────────────────────────────────────────────────
430
+
431
+ export type BlendMode =
432
+ | 'normal'
433
+ | 'multiply'
434
+ | 'screen'
435
+ | 'overlay'
436
+ | 'darken'
437
+ | 'lighten'
438
+ | 'colorDodge'
439
+ | 'colorBurn'
440
+ | 'hardLight'
441
+ | 'softLight'
442
+ | 'difference'
443
+ | 'exclusion'
444
+ | 'hue'
445
+ | 'saturation'
446
+ | 'color'
447
+ | 'luminosity'
448
+
449
+ export const BLEND_MODES: readonly BlendMode[] = [
450
+ 'normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten',
451
+ 'colorDodge', 'colorBurn', 'hardLight', 'softLight', 'difference',
452
+ 'exclusion', 'hue', 'saturation', 'color', 'luminosity',
453
+ ]
454
+
455
+ /** Separable blend functions, operating on one channel in [0,1]. */
456
+ const SEPARABLE: Partial<Record<BlendMode, (b: number, s: number) => number>> = {
457
+ normal: (_b, s) => s,
458
+ multiply: (b, s) => b * s,
459
+ screen: (b, s) => b + s - b * s,
460
+ overlay: (b, s) => (b <= 0.5 ? 2 * b * s : 1 - 2 * (1 - b) * (1 - s)),
461
+ darken: (b, s) => Math.min(b, s),
462
+ lighten: (b, s) => Math.max(b, s),
463
+ colorDodge: (b, s) => (b === 0 ? 0 : s === 1 ? 1 : Math.min(1, b / (1 - s))),
464
+ colorBurn: (b, s) => (b === 1 ? 1 : s === 0 ? 0 : 1 - Math.min(1, (1 - b) / s)),
465
+ hardLight: (b, s) => (s <= 0.5 ? 2 * s * b : 1 - 2 * (1 - s) * (1 - b)),
466
+ softLight: (b, s) => {
467
+ if (s <= 0.5) return b - (1 - 2 * s) * b * (1 - b)
468
+ const d = b <= 0.25 ? ((16 * b - 12) * b + 4) * b : Math.sqrt(b)
469
+ return b + (2 * s - 1) * (d - b)
470
+ },
471
+ difference: (b, s) => Math.abs(b - s),
472
+ exclusion: (b, s) => b + s - 2 * b * s,
473
+ }
474
+
475
+ type Rgb = [number, number, number]
476
+
477
+ function lum(c: Rgb): number {
478
+ return 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
479
+ }
480
+
481
+ function clipColor(c: Rgb): Rgb {
482
+ const l = lum(c)
483
+ const min = Math.min(c[0], c[1], c[2])
484
+ const max = Math.max(c[0], c[1], c[2])
485
+ let out = c
486
+ if (min < 0) {
487
+ const d = l - min
488
+ out = d === 0 ? [l, l, l] : (out.map(v => l + ((v - l) * l) / d) as Rgb)
489
+ }
490
+ if (max > 1) {
491
+ const d = max - l
492
+ out = d === 0 ? [l, l, l] : (out.map(v => l + ((v - l) * (1 - l)) / d) as Rgb)
493
+ }
494
+ return out
495
+ }
496
+
497
+ function setLum(c: Rgb, l: number): Rgb {
498
+ const d = l - lum(c)
499
+ return clipColor([c[0] + d, c[1] + d, c[2] + d])
500
+ }
501
+
502
+ function sat(c: Rgb): number {
503
+ return Math.max(c[0], c[1], c[2]) - Math.min(c[0], c[1], c[2])
504
+ }
505
+
506
+ function setSat(c: Rgb, s: number): Rgb {
507
+ const idx: [number, number, number] = [0, 1, 2]
508
+ idx.sort((a, b) => c[a] - c[b])
509
+ const [minI, midI, maxI] = idx
510
+ const out: Rgb = [0, 0, 0]
511
+ if (c[maxI] > c[minI]) {
512
+ out[midI] = ((c[midI] - c[minI]) * s) / (c[maxI] - c[minI])
513
+ out[maxI] = s
514
+ }
515
+ out[minI] = 0
516
+ return out
517
+ }
518
+
519
+ /**
520
+ * Composite `layer` over `base` with a blend mode and opacity.
521
+ *
522
+ * Follows the W3C compositing spec (the same maths Photoshop uses), so
523
+ * the separable modes match what a designer expects and the four
524
+ * non-separable ones (hue/saturation/color/luminosity) transplant one
525
+ * property while preserving the rest.
526
+ */
527
+ export function blendHex(base: string, layer: string, mode: BlendMode, opacity = 1): string {
528
+ const alpha = clamp01(opacity)
529
+ if (alpha <= 0) return base
530
+ if (mode === 'normal') return alpha >= 1 ? layer : mixHex(base, layer, alpha)
531
+
532
+ const [br, bg, bb] = hexToRgb(base)
533
+ const [sr, sg, sb] = hexToRgb(layer)
534
+ const b: Rgb = [br / 255, bg / 255, bb / 255]
535
+ const s: Rgb = [sr / 255, sg / 255, sb / 255]
536
+
537
+ let blended: Rgb
538
+ const fn = SEPARABLE[mode]
539
+ if (fn) {
540
+ blended = [fn(b[0], s[0]), fn(b[1], s[1]), fn(b[2], s[2])]
541
+ } else {
542
+ switch (mode) {
543
+ case 'hue': blended = setLum(setSat(s, sat(b)), lum(b)); break
544
+ case 'saturation': blended = setLum(setSat(b, sat(s)), lum(b)); break
545
+ case 'color': blended = setLum(s, lum(b)); break
546
+ case 'luminosity': blended = setLum(b, lum(s)); break
547
+ default: blended = s
548
+ }
549
+ }
550
+
551
+ const out: Rgb = [
552
+ b[0] + (blended[0] - b[0]) * alpha,
553
+ b[1] + (blended[1] - b[1]) * alpha,
554
+ b[2] + (blended[2] - b[2]) * alpha,
555
+ ]
556
+ return rgbToHex(out[0] * 255, out[1] * 255, out[2] * 255)
557
+ }
558
+
559
+ export function rgbToHex(r: number, g: number, b: number): string {
560
+ return `#${BYTE_HEX[clampByte(r)]}${BYTE_HEX[clampByte(g)]}${BYTE_HEX[clampByte(b)]}`
561
+ }
562
+
563
+ /** Two-character lowercase hex per byte, built once. */
564
+ const BYTE_HEX: string[] = Array.from({ length: 256 }, (_, i) =>
565
+ i.toString(16).padStart(2, '0'))
566
+
567
+ function clampByte(v: number): number {
568
+ return v < 0 ? 0 : v > 255 ? 255 : Math.round(v)
569
+ }
570
+
571
+ // ── Colour utilities built on the kernel ───────────────────────
572
+
573
+ /** Shift or set a colour's HSL components. Amounts are absolute. */
574
+ export function adjustHsl(
575
+ hex: string,
576
+ opts: { hue?: number; sat?: number; light?: number; absolute?: boolean },
577
+ ): string {
578
+ const [h, s, l] = hexToHsl(hex)
579
+ const dh = opts.hue ?? 0
580
+ const ds = opts.sat ?? 0
581
+ const dl = opts.light ?? 0
582
+ if (opts.absolute) {
583
+ return hslToHex(
584
+ ((dh % 360) + 360) % 360,
585
+ clampRange(ds, 0, 100),
586
+ clampRange(dl, 0, 100),
587
+ )
588
+ }
589
+ return hslToHex(
590
+ (((h + dh) % 360) + 360) % 360,
591
+ clampRange(s + ds, 0, 100),
592
+ clampRange(l + dl, 0, 100),
593
+ )
594
+ }
595
+
596
+ /** Snap each channel to `levels` evenly spaced values. */
597
+ export function posterizeHex(hex: string, levels: number): string {
598
+ const n = Math.max(2, Math.min(64, Math.round(levels)))
599
+ const step = 255 / (n - 1)
600
+ const [r, g, b] = hexToRgb(hex)
601
+ return rgbToHex(
602
+ Math.round(r / step) * step,
603
+ Math.round(g / step) * step,
604
+ Math.round(b / step) * step,
605
+ )
606
+ }
607
+
608
+ export function clampRange(v: number, min: number, max: number): number {
609
+ if (!Number.isFinite(v)) return min
610
+ return v < min ? min : v > max ? max : v
611
+ }
612
+
613
+ // ═══════════════════════════════════════════════════════════════
614
+ // Character coordinate model
615
+ // ═══════════════════════════════════════════════════════════════
616
+
617
+ /** One character's address within the document. */
618
+ export interface CharSample {
619
+ /** Code-point offset in the plain text. */
620
+ offset: number
621
+ /** True for whitespace, which carries no visual index. */
622
+ isSpace: boolean
623
+ /** Running index among non-whitespace characters, document-wide. -1 for spaces. */
624
+ index: number
625
+ /** Zero-based line number. */
626
+ line: number
627
+ /** Running index among non-whitespace characters within the line. -1 for spaces. */
628
+ col: number
629
+ /**
630
+ * Visual column: every character on the line counted, spaces included.
631
+ *
632
+ * `col` deliberately skips whitespace so a gradient does not spend a
633
+ * step of its ramp on a space — the right rule for a ramp, and the
634
+ * wrong one for a picture. In ASCII art the spaces ARE the layout, so
635
+ * `" \u2588\u2588"` has its blocks at visual columns 2 and 3 while `col`
636
+ * calls them 0 and 1. A spatial effect that read `col` would shear
637
+ * every line left by its own indentation.
638
+ *
639
+ * Both coordinates therefore exist side by side: `col` for ramps,
640
+ * `rawCol` for geometry.
641
+ */
642
+ rawCol: number
643
+ /** Zero-based word number, document-wide. -1 for spaces. */
644
+ word: number
645
+ }
646
+
647
+ /** The whole document's coordinate table. */
648
+ export interface SampleTable {
649
+ /** One entry per code point of the plain text. */
650
+ samples: CharSample[]
651
+ /** Non-whitespace character count. */
652
+ count: number
653
+ lineCount: number
654
+ /** Non-whitespace character count per line. */
655
+ lineLengths: number[]
656
+ /** Every-character length per line, for geometry. */
657
+ rawLineLengths: number[]
658
+ /** The widest line, in visual columns. The grid's width. */
659
+ maxCols: number
660
+ wordCount: number
661
+ }
662
+
663
+ const EMPTY_TABLE: SampleTable = {
664
+ samples: [],
665
+ count: 0,
666
+ lineCount: 0,
667
+ lineLengths: [],
668
+ rawLineLengths: [],
669
+ maxCols: 0,
670
+ wordCount: 0,
671
+ }
672
+
673
+ /**
674
+ * Build the coordinate table for a plain-text document.
675
+ *
676
+ * Whitespace is addressed but never indexed: it keeps its offset and line
677
+ * so ranges can span it, but gets `index = -1` so it does not consume a
678
+ * step of a gradient. That is what stops "a b" from spending a third of
679
+ * its colour ramp on the space.
680
+ */
681
+ export function buildSampleTable(plainText: string): SampleTable {
682
+ if (!plainText) return EMPTY_TABLE
683
+
684
+ const cached = sampleTableCache.get(plainText)
685
+ if (cached !== undefined) {
686
+ // Renovar la posición: lo que se sigue pidiendo no debe caer del caché.
687
+ sampleTableCache.delete(plainText)
688
+ sampleTableCache.set(plainText, cached)
689
+ return cached
690
+ }
691
+
692
+ return cacheSampleTable(plainText, computeSampleTable(plainText))
693
+ }
694
+
695
+ /**
696
+ * Tablas de coordenadas por texto, con desalojo del menos usado.
697
+ *
698
+ * La tabla es una función PURA del texto: un objeto `CharSample` de siete
699
+ * campos por carácter, más un `Array.from` que es una segunda copia entera de
700
+ * la cadena. Se reconstruía en cada llamada a `evaluateEffect`, o sea en cada
701
+ * renderizado de cada nodo de efecto, aunque el texto del nodo no hubiera
702
+ * cambiado — que es el caso normal al escribir en otra parte del documento.
703
+ *
704
+ * Los dos límites son necesarios: el de entradas evita acumular texto de
705
+ * documentos ya cerrados, y el de caracteres evita que unos pocos textos
706
+ * enormes se coman la memoria que el de entradas creería estar controlando.
707
+ * El desalojo es por el más antiguo y no un `clear()` entero, para que
708
+ * desbordar no tire también lo que se está pintando ahora mismo.
709
+ *
710
+ * NADIE muta una `SampleTable`; se comprobó en todos los consumidores del
711
+ * repo antes de compartirlas. Si alguna vez hiciera falta, hay que copiar.
712
+ */
713
+ const sampleTableCache = new Map<string, SampleTable>()
714
+ const SAMPLE_TABLE_CACHE_ENTRIES = 128
715
+ const SAMPLE_TABLE_CACHE_CHARS = 512 * 1024
716
+ let sampleTableCacheChars = 0
717
+
718
+ function cacheSampleTable(plainText: string, table: SampleTable): SampleTable {
719
+ // Un texto que por sí solo desborda el presupuesto no entra: guardarlo
720
+ // vaciaría el caché entero en su beneficio y lo dejaría inútil para el
721
+ // resto del documento.
722
+ if (plainText.length > SAMPLE_TABLE_CACHE_CHARS) return table
723
+
724
+ sampleTableCache.set(plainText, table)
725
+ sampleTableCacheChars += plainText.length
726
+
727
+ while (
728
+ sampleTableCache.size > SAMPLE_TABLE_CACHE_ENTRIES ||
729
+ sampleTableCacheChars > SAMPLE_TABLE_CACHE_CHARS
730
+ ) {
731
+ const oldest = sampleTableCache.keys().next()
732
+ if (oldest.done) break
733
+ sampleTableCache.delete(oldest.value)
734
+ sampleTableCacheChars -= oldest.value.length
735
+ }
736
+ return table
737
+ }
738
+
739
+ function computeSampleTable(plainText: string): SampleTable {
740
+
741
+ const chars = Array.from(plainText)
742
+ const samples: CharSample[] = new Array(chars.length)
743
+ const lineLengths: number[] = []
744
+ const rawLineLengths: number[] = []
745
+
746
+ let index = 0
747
+ let line = 0
748
+ let col = 0
749
+ let rawCol = 0
750
+ let word = -1
751
+ let inWord = false
752
+ let maxCols = 0
753
+
754
+ for (let i = 0; i < chars.length; i++) {
755
+ const ch = chars[i]
756
+
757
+ if (ch === '\n') {
758
+ lineLengths.push(col)
759
+ rawLineLengths.push(rawCol)
760
+ if (rawCol > maxCols) maxCols = rawCol
761
+ // The break itself sits one past the line's last character, which
762
+ // is where a mask's right edge belongs.
763
+ samples[i] = { offset: i, isSpace: true, index: -1, line, col: -1, rawCol, word: -1 }
764
+ line++
765
+ col = 0
766
+ rawCol = 0
767
+ inWord = false
768
+ continue
769
+ }
770
+
771
+ const isSpace = ch.trim().length === 0
772
+ if (isSpace) {
773
+ samples[i] = { offset: i, isSpace: true, index: -1, line, col: -1, rawCol, word: -1 }
774
+ rawCol++
775
+ inWord = false
776
+ continue
777
+ }
778
+
779
+ if (!inWord) {
780
+ word++
781
+ inWord = true
782
+ }
783
+
784
+ samples[i] = { offset: i, isSpace: false, index, line, col, rawCol, word }
785
+ index++
786
+ col++
787
+ rawCol++
788
+ }
789
+
790
+ lineLengths.push(col)
791
+ rawLineLengths.push(rawCol)
792
+ if (rawCol > maxCols) maxCols = rawCol
793
+
794
+ return {
795
+ samples,
796
+ count: index,
797
+ lineCount: lineLengths.length,
798
+ lineLengths,
799
+ rawLineLengths,
800
+ maxCols,
801
+ wordCount: word + 1,
802
+ }
803
+ }
804
+
805
+ // ── Scopes ─────────────────────────────────────────────────────
806
+
807
+ /**
808
+ * A character range with its own local coordinate system.
809
+ *
810
+ * Local matters: a gradient applied to characters 40–60 should run its
811
+ * full ramp across those twenty characters, not show the slice of a
812
+ * document-wide ramp that happens to fall there. Every layer reads local
813
+ * coordinates; only `sample` keeps the document-wide ones, for effects
814
+ * that deliberately want the global picture.
815
+ */
816
+ export interface RangeScope {
817
+ start: number
818
+ end: number
819
+ /** Non-whitespace count inside the range. */
820
+ count: number
821
+ /** Local index per offset, `-1` for whitespace and out-of-range. */
822
+ localIndex: Int32Array
823
+ /** Local line number per offset, `-1` out of range. */
824
+ localLine: Int32Array
825
+ /** Local column per offset, `-1` for whitespace and out-of-range. */
826
+ localCol: Int32Array
827
+ /** Local word number per offset, `-1` for whitespace and out-of-range. */
828
+ localWord: Int32Array
829
+ /**
830
+ * Visual column per offset, spaces counted, `-1` out of range.
831
+ *
832
+ * Unlike `localCol` this is NOT re-based per line: it keeps the
833
+ * document's own column so a range starting mid-line still sits where
834
+ * the reader sees it. `rawColMin`/`rawColMax` carry the bounding box
835
+ * that turns it into a [0,1] coordinate.
836
+ */
837
+ localRawCol: Int32Array
838
+ /** Left edge of the range's painted bounding box, in visual columns. */
839
+ rawColMin: number
840
+ /** Right edge of that box. Equal to `rawColMin` for a single column. */
841
+ rawColMax: number
842
+ lineCount: number
843
+ lineLengths: number[]
844
+ wordCount: number
845
+ }
846
+
847
+ /**
848
+ * Derive a range's local coordinate system from the document table.
849
+ *
850
+ * Cost is O(range length) and it is computed once per compile per range,
851
+ * which is what lets `applyLayer` be a straight indexed lookup no matter
852
+ * how many layers the range stacks.
853
+ */
854
+ export function buildRangeScope(table: SampleTable, start: number, end: number): RangeScope {
855
+ const total = table.samples.length
856
+ const s = Math.max(0, Math.min(start, total))
857
+ const e = Math.max(s, Math.min(end, total))
858
+ const len = e - s
859
+
860
+ const localIndex = new Int32Array(len).fill(-1)
861
+ const localLine = new Int32Array(len).fill(-1)
862
+ const localCol = new Int32Array(len).fill(-1)
863
+ const localWord = new Int32Array(len).fill(-1)
864
+ const localRawCol = new Int32Array(len).fill(-1)
865
+ const lineLengths: number[] = []
866
+
867
+ if (len === 0) {
868
+ return {
869
+ start: s, end: e, count: 0,
870
+ localIndex, localLine, localCol, localWord,
871
+ localRawCol, rawColMin: 0, rawColMax: 0,
872
+ lineCount: 0, lineLengths: [], wordCount: 0,
873
+ }
874
+ }
875
+
876
+ // Local lines are the document's lines re-based on the range's first
877
+ // one, so a range that starts mid-paragraph still sees line 0 at its
878
+ // own top edge rather than inheriting the document's numbering.
879
+ const baseLine = table.samples[s].line
880
+
881
+ const lastLine = table.samples[e - 1].line - baseLine
882
+ for (let l = 0; l <= lastLine; l++) lineLengths.push(0)
883
+
884
+ let index = 0
885
+ let word = -1
886
+ let inWord = false
887
+ // The bounding box is measured over PAINTED characters only. Measuring
888
+ // it over every offset would let a line's trailing spaces stretch the
889
+ // box to the right of anything the reader can see, and a shape centred
890
+ // in that box would sit off-centre on the page.
891
+ let rawColMin = Number.POSITIVE_INFINITY
892
+ let rawColMax = Number.NEGATIVE_INFINITY
893
+
894
+ for (let i = 0; i < len; i++) {
895
+ const sample = table.samples[s + i]
896
+ const line = sample.line - baseLine
897
+ localLine[i] = line
898
+ localRawCol[i] = sample.rawCol
899
+
900
+ if (sample.isSpace) {
901
+ inWord = false
902
+ continue
903
+ }
904
+
905
+ if (!inWord) { word++; inWord = true }
906
+ localIndex[i] = index
907
+ localCol[i] = lineLengths[line]
908
+ localWord[i] = word
909
+ lineLengths[line]++
910
+ index++
911
+
912
+ if (sample.rawCol < rawColMin) rawColMin = sample.rawCol
913
+ if (sample.rawCol > rawColMax) rawColMax = sample.rawCol
914
+ }
915
+
916
+ if (!Number.isFinite(rawColMin)) { rawColMin = 0; rawColMax = 0 }
917
+
918
+ return {
919
+ start: s,
920
+ end: e,
921
+ count: index,
922
+ localIndex,
923
+ localLine,
924
+ localCol,
925
+ localWord,
926
+ localRawCol,
927
+ rawColMin,
928
+ rawColMax,
929
+ lineCount: lineLengths.length,
930
+ lineLengths,
931
+ wordCount: word + 1,
932
+ }
933
+ }
934
+
935
+ /** A scope covering the whole document. */
936
+ export function documentScope(table: SampleTable): RangeScope {
937
+ const cached = documentScopeCache.get(table)
938
+ if (cached !== undefined) return cached
939
+ const scope = buildRangeScope(table, 0, table.samples.length)
940
+ documentScopeCache.set(table, scope)
941
+ return scope
942
+ }
943
+
944
+ /**
945
+ * El ámbito de documento completo por tabla.
946
+ *
947
+ * `buildRangeScope` asigna CINCO `Int32Array` de la longitud del texto y los
948
+ * rellena, y `evaluateEffect` lo pedía en cada renderizado de cada nodo de
949
+ * efecto. Es función pura de la tabla, que ahora se comparte entre
950
+ * renderizados (ver `sampleTableCache`), así que la caché acierta siempre que
951
+ * el texto del nodo no haya cambiado.
952
+ *
953
+ * `WeakMap`: la entrada desaparece con la tabla, así que no hay un segundo
954
+ * presupuesto de memoria que vigilar. Nadie muta un `RangeScope`; se
955
+ * comprobó en todos los consumidores antes de compartirlos.
956
+ */
957
+ const documentScopeCache = new WeakMap<SampleTable, RangeScope>()
958
+
959
+ // ── Axes ───────────────────────────────────────────────────────
960
+
961
+ /**
962
+ * How a character's address collapses into the single number `u ∈ [0,1]`
963
+ * that drives a layer.
964
+ */
965
+ export type Axis =
966
+ | 'index'
967
+ | 'word'
968
+ | 'line'
969
+ | 'column'
970
+ | 'diagonal'
971
+ | 'radial'
972
+ | 'angular'
973
+ | 'random'
974
+ | 'wave'
975
+ // ── Placeable, aspect-corrected. See SpatialOptions. ──
976
+ | 'spot'
977
+ | 'sweep'
978
+ | 'linear'
979
+
980
+ export const AXES: readonly Axis[] = [
981
+ 'index', 'word', 'line', 'column', 'diagonal', 'radial', 'angular', 'random', 'wave',
982
+ 'spot', 'sweep', 'linear',
983
+ ]
984
+
985
+ /** Axes that read an origin, an angle and a radius. */
986
+ export const SPATIAL_AXES: ReadonlySet<Axis> = new Set<Axis>(['spot', 'sweep', 'linear'])
987
+
988
+ /** Everything a layer needs to place one character. */
989
+ export interface SampleContext {
990
+ sample: CharSample
991
+ scope: RangeScope
992
+ table: SampleTable
993
+ /** Local index within the scope, `-1` for whitespace. */
994
+ local: number
995
+ }
996
+
997
+ function norm(value: number, count: number): number {
998
+ if (count <= 1) return 0
999
+ return clamp01(value / (count - 1))
1000
+ }
1001
+
1002
+ // ── Page geometry ──────────────────────────────────────────────
1003
+ //
1004
+ // The axes above collapse a character to one number and stop. A shape
1005
+ // cannot: a circle needs to know that the box it sits in is forty
1006
+ // characters wide and six lines tall, and that a character cell is about
1007
+ // twice as tall as it is wide. Without that second fact a circle renders
1008
+ // as a flat oval, because forty columns and forty rows are not the same
1009
+ // distance on the page.
1010
+
1011
+ /**
1012
+ * Width ÷ height of one character cell.
1013
+ *
1014
+ * Monospace faces cluster around 0.5–0.6; this is the middle of that
1015
+ * range and the value every shape assumes unless the document says
1016
+ * otherwise.
1017
+ */
1018
+ export const DEFAULT_CELL_ASPECT = 0.55
1019
+
1020
+ /** Where a placeable effect sits and how far it reaches. */
1021
+ export interface SpatialOptions {
1022
+ /** Origin across the box, 0 = left edge, 1 = right edge. */
1023
+ originX: number
1024
+ /** Origin down the box, 0 = top, 1 = bottom. */
1025
+ originY: number
1026
+ /** Rotation of `linear` and `sweep`, in degrees, clockwise from east. */
1027
+ angle: number
1028
+ /** Reach of `spot` and `linear`, in units of the box's longer side. */
1029
+ radius: number
1030
+ /** Width ÷ height of a character cell. */
1031
+ aspect: number
1032
+ }
1033
+
1034
+ export const DEFAULT_SPATIAL: SpatialOptions = {
1035
+ originX: 0.5,
1036
+ originY: 0.5,
1037
+ angle: 0,
1038
+ radius: 0.5,
1039
+ aspect: DEFAULT_CELL_ASPECT,
1040
+ }
1041
+
1042
+ /**
1043
+ * A character's place on the page, in a square-ish coordinate system.
1044
+ *
1045
+ * `x`/`y` are the plain [0,1] fractions across the range's painted
1046
+ * bounding box. `w`/`h` are that box's physical proportions, normalised
1047
+ * so the longer side is 1 — multiplying the fractions by them is what
1048
+ * makes a circle round instead of an oval.
1049
+ *
1050
+ * A single-line range has no vertical extent, so every character reports
1051
+ * `y = 0.5`: the text is one line tall and sits at its own middle. The
1052
+ * same holds for `x` in a single-column range.
1053
+ */
1054
+ export interface SpatialPoint {
1055
+ x: number
1056
+ y: number
1057
+ w: number
1058
+ h: number
1059
+ }
1060
+
1061
+ export function spatialPoint(ctx: SampleContext, cellAspect = DEFAULT_CELL_ASPECT): SpatialPoint {
1062
+ const { scope, sample } = ctx
1063
+ const rel = sample.offset - scope.start
1064
+
1065
+ const colSpan = scope.rawColMax - scope.rawColMin
1066
+ const rowSpan = scope.lineCount - 1
1067
+
1068
+ const rawCol = rel >= 0 && rel < scope.localRawCol.length && scope.localRawCol[rel] >= 0
1069
+ ? scope.localRawCol[rel]
1070
+ : scope.rawColMin
1071
+ const line = rel >= 0 && rel < scope.localLine.length && scope.localLine[rel] >= 0
1072
+ ? scope.localLine[rel]
1073
+ : 0
1074
+
1075
+ const x = colSpan > 0 ? clamp01((rawCol - scope.rawColMin) / colSpan) : 0.5
1076
+ const y = rowSpan > 0 ? clamp01(line / rowSpan) : 0.5
1077
+
1078
+ // Cell counts, not spans: a box one column wide is still one column of
1079
+ // physical width, and dividing by a zero span would make it infinitely
1080
+ // flat.
1081
+ const aspect = Number.isFinite(cellAspect) && cellAspect > 0 ? cellAspect : DEFAULT_CELL_ASPECT
1082
+ const pw = (colSpan + 1) * aspect
1083
+ const ph = rowSpan + 1
1084
+ const longer = Math.max(pw, ph)
1085
+
1086
+ return { x, y, w: pw / longer, h: ph / longer }
1087
+ }
1088
+
1089
+ /** A character's offset from an origin, in the square-ish space. */
1090
+ function offsetFromOrigin(pt: SpatialPoint, ox: number, oy: number): [number, number] {
1091
+ return [(pt.x - ox) * pt.w, (pt.y - oy) * pt.h]
1092
+ }
1093
+
1094
+ function rotate(px: number, py: number, degrees: number): [number, number] {
1095
+ if (!degrees) return [px, py]
1096
+ const r = (degrees * Math.PI) / 180
1097
+ const c = Math.cos(r)
1098
+ const s = Math.sin(r)
1099
+ return [px * c + py * s, -px * s + py * c]
1100
+ }
1101
+
1102
+ /**
1103
+ * Project a character onto the [0,1] axis a layer reads.
1104
+ *
1105
+ * `radial` and `angular` treat the range as a rectangle of lines by
1106
+ * columns, which is what makes a multi-line block behave like a canvas
1107
+ * rather than a single ribbon of text. They are centred and uncorrected,
1108
+ * and stay that way: documents written against them must keep rendering
1109
+ * identically. `spot`, `sweep` and `linear` are their placeable,
1110
+ * aspect-corrected successors — same idea, with an origin the author
1111
+ * chooses and a circle that comes out round.
1112
+ */
1113
+ export function axisValue(
1114
+ axis: Axis,
1115
+ ctx: SampleContext,
1116
+ seed = 0,
1117
+ geo: Partial<SpatialOptions> = DEFAULT_SPATIAL,
1118
+ ): number {
1119
+ const { sample, scope } = ctx
1120
+ const rel = sample.offset - scope.start
1121
+ if (rel < 0 || rel >= scope.localIndex.length) return 0
1122
+
1123
+ const line = scope.localLine[rel]
1124
+ const col = scope.localCol[rel]
1125
+ const lineLen = scope.lineLengths[line] ?? scope.lineLengths[0] ?? 1
1126
+
1127
+ switch (axis) {
1128
+ case 'index':
1129
+ return norm(ctx.local, scope.count)
1130
+ case 'word':
1131
+ return norm(scope.localWord[rel], scope.wordCount)
1132
+ case 'line':
1133
+ return norm(line, scope.lineCount)
1134
+ case 'column':
1135
+ return norm(col, lineLen)
1136
+ case 'diagonal': {
1137
+ const x = norm(col, lineLen)
1138
+ const y = norm(line, scope.lineCount)
1139
+ return clamp01((x + y) / 2)
1140
+ }
1141
+ case 'radial': {
1142
+ const x = norm(col, lineLen) * 2 - 1
1143
+ const y = norm(line, scope.lineCount) * 2 - 1
1144
+ // Normalised so a corner reads 1 and the centre reads 0.
1145
+ return clamp01(Math.hypot(x, y) / Math.SQRT2)
1146
+ }
1147
+ case 'angular': {
1148
+ const x = norm(col, lineLen) * 2 - 1
1149
+ const y = norm(line, scope.lineCount) * 2 - 1
1150
+ if (x === 0 && y === 0) return 0
1151
+ return clamp01(Math.atan2(y, x) / (Math.PI * 2) + 0.5)
1152
+ }
1153
+ case 'random':
1154
+ return randAt(seed, sample.offset)
1155
+ case 'wave': {
1156
+ // Serpentine: alternate lines run right-to-left, so a gradient
1157
+ // reads continuously down a paragraph instead of snapping back.
1158
+ const x = norm(col, lineLen)
1159
+ return line % 2 === 0 ? x : 1 - x
1160
+ }
1161
+
1162
+ case 'spot': {
1163
+ // Distance from a chosen point. `radius` is the reach: at the
1164
+ // radius the ramp has run out, beyond it it stays at its end.
1165
+ const pt = spatialPoint(ctx, geo.aspect ?? DEFAULT_SPATIAL.aspect)
1166
+ const [px, py] = offsetFromOrigin(
1167
+ pt, geo.originX ?? DEFAULT_SPATIAL.originX, geo.originY ?? DEFAULT_SPATIAL.originY,
1168
+ )
1169
+ const radius = geo.radius ?? DEFAULT_SPATIAL.radius
1170
+ if (!(radius > 0)) return 0
1171
+ return clamp01(Math.hypot(px, py) / radius)
1172
+ }
1173
+
1174
+ case 'sweep': {
1175
+ // Angle around a chosen point, so a ramp can spin about a word in
1176
+ // the middle of a paragraph rather than about the paragraph.
1177
+ const pt = spatialPoint(ctx, geo.aspect ?? DEFAULT_SPATIAL.aspect)
1178
+ const [px, py] = offsetFromOrigin(
1179
+ pt, geo.originX ?? DEFAULT_SPATIAL.originX, geo.originY ?? DEFAULT_SPATIAL.originY,
1180
+ )
1181
+ if (px === 0 && py === 0) return 0
1182
+ const a = Math.atan2(py, px) - ((geo.angle ?? DEFAULT_SPATIAL.angle) * Math.PI) / 180
1183
+ let turns = a / (Math.PI * 2)
1184
+ turns -= Math.floor(turns)
1185
+ return clamp01(turns)
1186
+ }
1187
+
1188
+ case 'linear': {
1189
+ // A ramp along an arbitrary direction. `radius` is its half-length,
1190
+ // so the origin sits at the ramp's midpoint (0.5) and the ends land
1191
+ // one radius away on each side.
1192
+ const pt = spatialPoint(ctx, geo.aspect ?? DEFAULT_SPATIAL.aspect)
1193
+ const [ox, oy] = offsetFromOrigin(
1194
+ pt, geo.originX ?? DEFAULT_SPATIAL.originX, geo.originY ?? DEFAULT_SPATIAL.originY,
1195
+ )
1196
+ const [px] = rotate(ox, oy, geo.angle ?? DEFAULT_SPATIAL.angle)
1197
+ const radius = geo.radius ?? DEFAULT_SPATIAL.radius
1198
+ if (!(radius > 0)) return px >= 0 ? 1 : 0
1199
+ return clamp01(0.5 + px / (radius * 2))
1200
+ }
1201
+
1202
+ default:
1203
+ return norm(ctx.local, scope.count)
1204
+ }
1205
+ }
1206
+
1207
+ // ── Masks ──────────────────────────────────────────────────────
1208
+ //
1209
+ // An axis says WHAT COLOUR a character gets. A mask says WHETHER IT GETS
1210
+ // ONE AT ALL, and how strongly.
1211
+ //
1212
+ // That second question is what a layer stack could not answer before.
1213
+ // `opacity` was a scalar, so a layer applied everywhere at one strength:
1214
+ // three gradients over one paragraph each repainted the whole paragraph,
1215
+ // and the last one won. Making the weight a function of position is the
1216
+ // entire feature — a gradient in the top-left corner is a gradient whose
1217
+ // mask is a circle in the top-left corner, and every shape below is one
1218
+ // more way of writing that function.
1219
+ //
1220
+ // The shapes are signed distance fields: negative inside, zero on the
1221
+ // edge, positive outside. Distance rather than a boolean is what makes
1222
+ // `feather` a single line of maths instead of a special case per shape.
1223
+
1224
+ export type MaskShape =
1225
+ | 'none'
1226
+ | 'circle'
1227
+ | 'ellipse'
1228
+ | 'square'
1229
+ | 'rect'
1230
+ | 'diamond'
1231
+ | 'triangle'
1232
+ | 'star'
1233
+ | 'ring'
1234
+ | 'half'
1235
+
1236
+ export const MASK_SHAPES: readonly MaskShape[] = [
1237
+ 'none', 'circle', 'ellipse', 'square', 'rect', 'diamond', 'triangle',
1238
+ 'star', 'ring', 'half',
1239
+ ]
1240
+
1241
+ /** Placement and form of one layer's mask. */
1242
+ export interface MaskOptions {
1243
+ shape: MaskShape
1244
+ /** Centre across the box, 0 = left edge, 1 = right edge. */
1245
+ x: number
1246
+ /** Centre down the box, 0 = top, 1 = bottom. */
1247
+ y: number
1248
+ /** Half-width, in units of the box's longer side. */
1249
+ width: number
1250
+ /** Half-height. Read only by `ellipse` and `rect`. */
1251
+ height: number
1252
+ /** Rotation in degrees, clockwise. */
1253
+ rotate: number
1254
+ /**
1255
+ * Width of the soft edge, in the same units as `width`.
1256
+ *
1257
+ * Zero gives a hard cut — the right choice for block art, where a
1258
+ * half-lit character reads as a mistake. Anything above it fades, which
1259
+ * is what stops a circle over prose from looking stamped on.
1260
+ */
1261
+ feather: number
1262
+ /** Keep what falls OUTSIDE the shape instead of inside. */
1263
+ invert: boolean
1264
+ /** Points, for `star`. */
1265
+ points: number
1266
+ /** Spike depth for `star` (0 = thin, 1 = polygon); thickness for `ring`. */
1267
+ inner: number
1268
+ /** Width ÷ height of a character cell. */
1269
+ aspect: number
1270
+ }
1271
+
1272
+ export const DEFAULT_MASK: MaskOptions = {
1273
+ shape: 'none',
1274
+ x: 0.5,
1275
+ y: 0.5,
1276
+ width: 0.5,
1277
+ height: 0.5,
1278
+ rotate: 0,
1279
+ feather: 0,
1280
+ invert: false,
1281
+ points: 5,
1282
+ inner: 0.45,
1283
+ aspect: DEFAULT_CELL_ASPECT,
1284
+ }
1285
+
1286
+ function smoothstep(edge0: number, edge1: number, x: number): number {
1287
+ if (edge1 <= edge0) return x < edge0 ? 0 : 1
1288
+ const t = clamp01((x - edge0) / (edge1 - edge0))
1289
+ return t * t * (3 - 2 * t)
1290
+ }
1291
+
1292
+ /** Positive modulo, which `%` is not. */
1293
+ function pmod(a: number, b: number): number {
1294
+ return ((a % b) + b) % b
1295
+ }
1296
+
1297
+ function sdBox(px: number, py: number, bx: number, by: number): number {
1298
+ const dx = Math.abs(px) - bx
1299
+ const dy = Math.abs(py) - by
1300
+ const outside = Math.hypot(Math.max(dx, 0), Math.max(dy, 0))
1301
+ return outside + Math.min(Math.max(dx, dy), 0)
1302
+ }
1303
+
1304
+ function sdRhombus(px: number, py: number, bx: number, by: number): number {
1305
+ const ax = Math.abs(px)
1306
+ const ay = Math.abs(py)
1307
+ const denom = bx * bx + by * by
1308
+ if (denom === 0) return Math.hypot(ax, ay)
1309
+ // ndot(b - 2p, b) / dot(b, b)
1310
+ const h = Math.max(-1, Math.min(1, ((bx - 2 * ax) * bx - (by - 2 * ay) * by) / denom))
1311
+ const d = Math.hypot(ax - 0.5 * bx * (1 - h), ay - 0.5 * by * (1 + h))
1312
+ return d * Math.sign(ax * by + ay * bx - bx * by)
1313
+ }
1314
+
1315
+ function sdEquilateralTriangle(px: number, py: number, r: number): number {
1316
+ if (!(r > 0)) return Math.hypot(px, py)
1317
+ const k = Math.sqrt(3)
1318
+ let x = Math.abs(px) - r
1319
+ let y = py + r / k
1320
+ if (x + k * y > 0) {
1321
+ const nx = (x - k * y) / 2
1322
+ const ny = (-k * x - y) / 2
1323
+ x = nx
1324
+ y = ny
1325
+ }
1326
+ x -= Math.max(-2 * r, Math.min(0, x))
1327
+ return -Math.hypot(x, y) * Math.sign(y)
1328
+ }
1329
+
1330
+ function sdStar(px: number, py: number, r: number, n: number, m: number): number {
1331
+ if (!(r > 0)) return Math.hypot(px, py)
1332
+ const an = Math.PI / n
1333
+ const en = Math.PI / m
1334
+ const acsX = Math.cos(an)
1335
+ const acsY = Math.sin(an)
1336
+ const ecsX = Math.cos(en)
1337
+ const ecsY = Math.sin(en)
1338
+
1339
+ // Fold the plane into one wedge, so one wedge's maths covers every point.
1340
+ const bn = pmod(Math.atan2(px, py), 2 * an) - an
1341
+ const len = Math.hypot(px, py)
1342
+ let qx = len * Math.cos(bn) - r * acsX
1343
+ let qy = len * Math.abs(Math.sin(bn)) - r * acsY
1344
+
1345
+ const reach = ecsY !== 0 ? (r * acsY) / ecsY : 0
1346
+ const t = Math.max(0, Math.min(reach, -(qx * ecsX + qy * ecsY)))
1347
+ qx += ecsX * t
1348
+ qy += ecsY * t
1349
+
1350
+ return Math.hypot(qx, qy) * Math.sign(qx)
1351
+ }
1352
+
1353
+ /**
1354
+ * Signed distance from a character to a shape's edge, negative inside.
1355
+ *
1356
+ * Exported so a preview can draw the outline from the same maths that
1357
+ * decides which characters the shape covers. Two implementations of a
1358
+ * shape is two shapes.
1359
+ */
1360
+ export function maskDistance(ctx: SampleContext, opts: Partial<MaskOptions> = {}): number {
1361
+ const shape = opts.shape ?? DEFAULT_MASK.shape
1362
+ if (shape === 'none') return -1
1363
+
1364
+ const pt = spatialPoint(ctx, opts.aspect ?? DEFAULT_MASK.aspect)
1365
+ const [ox, oy] = offsetFromOrigin(pt, opts.x ?? DEFAULT_MASK.x, opts.y ?? DEFAULT_MASK.y)
1366
+ const [px, py] = rotate(ox, oy, opts.rotate ?? DEFAULT_MASK.rotate)
1367
+
1368
+ const w = Math.max(0, opts.width ?? DEFAULT_MASK.width)
1369
+ const h = Math.max(0, opts.height ?? DEFAULT_MASK.height)
1370
+
1371
+ switch (shape) {
1372
+ case 'circle':
1373
+ return Math.hypot(px, py) - w
1374
+ case 'ellipse': {
1375
+ if (w <= 0 || h <= 0) return Math.hypot(px, py)
1376
+ // Scale to a unit circle, then back by the smaller semi-axis. Exact
1377
+ // for a circle and a close enough approximation elsewhere — the
1378
+ // value only ever feeds a feather ramp.
1379
+ const k = Math.hypot(px / w, py / h)
1380
+ return (k - 1) * Math.min(w, h)
1381
+ }
1382
+ case 'square':
1383
+ return sdBox(px, py, w, w)
1384
+ case 'rect':
1385
+ return sdBox(px, py, w, h)
1386
+ case 'diamond':
1387
+ return sdRhombus(px, py, w, h)
1388
+ case 'triangle':
1389
+ // Screen y grows downward; the SDF is written for y growing up, so
1390
+ // the sign flip is what keeps the triangle pointing at the sky.
1391
+ return sdEquilateralTriangle(px, -py, w)
1392
+ case 'star': {
1393
+ const n = Math.max(3, Math.round(opts.points ?? DEFAULT_MASK.points))
1394
+ const inner = clamp01(opts.inner ?? DEFAULT_MASK.inner)
1395
+ // IQ's parameter runs [2, n]: 2 is a thin spike, n a plain polygon.
1396
+ const m = 2 + inner * (n - 2)
1397
+ return sdStar(px, -py, w, n, m)
1398
+ }
1399
+ case 'ring': {
1400
+ const thickness = Math.max(1e-6, clamp01(opts.inner ?? DEFAULT_MASK.inner) * w)
1401
+ return Math.abs(Math.hypot(px, py) - w) - thickness
1402
+ }
1403
+ case 'half':
1404
+ return px
1405
+ default:
1406
+ return -1
1407
+ }
1408
+ }
1409
+
1410
+ /**
1411
+ * How strongly a mask covers one character, in [0,1].
1412
+ *
1413
+ * 1 is fully inside, 0 fully outside. A layer multiplies its opacity by
1414
+ * this, so a masked layer composites exactly like an unmasked one at
1415
+ * reduced strength — no separate code path, and stacking three masked
1416
+ * layers is the same operation as stacking three plain ones.
1417
+ */
1418
+ export function maskValue(ctx: SampleContext, opts: Partial<MaskOptions> = {}): number {
1419
+ const shape = opts.shape ?? DEFAULT_MASK.shape
1420
+ if (shape === 'none') return 1
1421
+
1422
+ const d = maskDistance(ctx, opts)
1423
+ const feather = Math.max(0, opts.feather ?? DEFAULT_MASK.feather)
1424
+
1425
+ const coverage = feather > 0
1426
+ ? 1 - smoothstep(-feather / 2, feather / 2, d)
1427
+ : (d <= 0 ? 1 : 0)
1428
+
1429
+ return (opts.invert ?? DEFAULT_MASK.invert) ? 1 - coverage : coverage
1430
+ }
1431
+
1432
+ /** Build the variable bag an `expr()` waveform sees for one character. */
1433
+ export function expressionVars(ctx: SampleContext, u: number, seed: number) {
1434
+ const { sample, scope } = ctx
1435
+ const rel = sample.offset - scope.start
1436
+ const line = rel >= 0 && rel < scope.localLine.length ? scope.localLine[rel] : 0
1437
+ const col = rel >= 0 && rel < scope.localCol.length ? scope.localCol[rel] : 0
1438
+ const word = rel >= 0 && rel < scope.localWord.length ? scope.localWord[rel] : 0
1439
+ return {
1440
+ u, t: u, x: u,
1441
+ i: ctx.local,
1442
+ n: scope.count,
1443
+ line,
1444
+ lines: scope.lineCount,
1445
+ col,
1446
+ cols: scope.lineLengths[line] ?? 1,
1447
+ word,
1448
+ words: scope.wordCount,
1449
+ rnd: randAt(seed, sample.offset),
1450
+ }
1451
+ }
1452
+
1453
+ // ── Paint grids ────────────────────────────────────────────────
1454
+ //
1455
+ // Colouring text from an image, without moving a character.
1456
+ //
1457
+ // The obvious implementation — keep the image, sample it at render time
1458
+ // — cannot work here: a BBCode tag is text, and a document has to survive
1459
+ // a copy-paste into a forum post. So the image is reduced ONCE, at author
1460
+ // time, to a small indexed grid: a palette of at most 63 colours and one
1461
+ // character per cell naming which one. That grid is short enough to live
1462
+ // in an attribute, and reopening the document gives back an editable
1463
+ // layer rather than a wall of frozen `[color]` tags.
1464
+ //
1465
+ // It is a quantiser, not a compressor. Text has one colour per character
1466
+ // and a paragraph is maybe eighty columns wide, so the detail an image
1467
+ // can actually deliver is already far below what a photograph holds.
1468
+ // Reducing to a palette up front is not a loss — it is the resolution the
1469
+ // medium has.
1470
+
1471
+ /**
1472
+ * Cell alphabet. Index 0 (`'0'`) means "paint nothing here", so a grid can
1473
+ * have holes and the text keeps its own colour there.
1474
+ *
1475
+ * Every character is safe inside a BBCode attribute: no `;` (the
1476
+ * parameter separator), no `=` (the key separator), no `,` (the colour
1477
+ * list separator) and no bracket.
1478
+ */
1479
+ const PAINT_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_.'
1480
+
1481
+ /** Palette entries a grid can name, the transparent slot excluded. */
1482
+ export const PAINT_MAX_COLORS = PAINT_ALPHABET.length - 1
1483
+
1484
+ const PAINT_INDEX = new Map<string, number>(
1485
+ Array.from(PAINT_ALPHABET).map((ch, i) => [ch, i]),
1486
+ )
1487
+
1488
+ /** An image reduced to something a tag can carry. */
1489
+ export interface PaintGrid {
1490
+ cols: number
1491
+ rows: number
1492
+ /** Hex colours, `#RRGGBB`. At most `PAINT_MAX_COLORS` of them. */
1493
+ palette: string[]
1494
+ /** `rows * cols` indices into `palette`; `-1` paints nothing. */
1495
+ cells: Int16Array
1496
+ }
1497
+
1498
+ /** Serialise a grid's cells to the alphabet. */
1499
+ export function stringifyPaintCells(grid: PaintGrid): string {
1500
+ const out: string[] = new Array(grid.cells.length)
1501
+ for (let i = 0; i < grid.cells.length; i++) {
1502
+ const idx = grid.cells[i]
1503
+ out[i] = idx < 0 || idx >= grid.palette.length ? PAINT_ALPHABET[0] : PAINT_ALPHABET[idx + 1]
1504
+ }
1505
+ return out.join('')
1506
+ }
1507
+
1508
+ /**
1509
+ * Read a grid back from its parts.
1510
+ *
1511
+ * Returns `null` rather than a partial grid when the cell count does not
1512
+ * match `cols * rows`: a grid off by one cell is sheared diagonally
1513
+ * across the whole paragraph, which is far worse than not painting.
1514
+ */
1515
+ export function parsePaintGrid(
1516
+ cols: number,
1517
+ rows: number,
1518
+ paletteStr: string,
1519
+ cellsStr: string,
1520
+ ): PaintGrid | null {
1521
+ const c = Math.round(cols)
1522
+ const r = Math.round(rows)
1523
+ if (!Number.isFinite(c) || !Number.isFinite(r) || c <= 0 || r <= 0) return null
1524
+ if (!cellsStr || cellsStr.length !== c * r) return null
1525
+
1526
+ const palette = paletteStr
1527
+ .split(',')
1528
+ .map(p => p.trim())
1529
+ .filter(Boolean)
1530
+ .map(p => normalizeHex(p.startsWith('#') ? p : `#${p}`))
1531
+ if (palette.length === 0) return null
1532
+
1533
+ const cells = new Int16Array(c * r)
1534
+ for (let i = 0; i < cells.length; i++) {
1535
+ const idx = PAINT_INDEX.get(cellsStr[i])
1536
+ cells[i] = idx === undefined || idx === 0 ? -1 : idx - 1
1537
+ }
1538
+
1539
+ return { cols: c, rows: r, palette, cells }
1540
+ }
1541
+
1542
+ /** Serialise a palette to the attribute form, `#` stripped. */
1543
+ export function stringifyPaintPalette(palette: readonly string[]): string {
1544
+ return palette.map(c => normalizeHex(c).replace('#', '')).join(',')
1545
+ }
1546
+
1547
+ function paintCellAt(grid: PaintGrid, cx: number, cy: number): string | undefined {
1548
+ const x = cx < 0 ? 0 : cx >= grid.cols ? grid.cols - 1 : cx
1549
+ const y = cy < 0 ? 0 : cy >= grid.rows ? grid.rows - 1 : cy
1550
+ const idx = grid.cells[y * grid.cols + x]
1551
+ return idx < 0 ? undefined : grid.palette[idx]
1552
+ }
1553
+
1554
+ /**
1555
+ * The colour a grid paints at a point of the box, or `undefined` for a
1556
+ * hole.
1557
+ *
1558
+ * `smooth` interpolates between the four surrounding cells, which is what
1559
+ * you want over prose — the grid is coarser than the text and hard cells
1560
+ * read as banding. It is the wrong choice for block art, where the grid
1561
+ * and the characters line up one to one and any interpolation invents
1562
+ * colours that belong to neither neighbour, so it defaults off.
1563
+ */
1564
+ export function samplePaintGrid(
1565
+ grid: PaintGrid,
1566
+ x: number,
1567
+ y: number,
1568
+ smooth = false,
1569
+ perceptual = false,
1570
+ ): string | undefined {
1571
+ const gx = clamp01(x) * (grid.cols - 1)
1572
+ const gy = clamp01(y) * (grid.rows - 1)
1573
+
1574
+ if (!smooth) {
1575
+ return paintCellAt(grid, Math.round(gx), Math.round(gy))
1576
+ }
1577
+
1578
+ const x0 = Math.floor(gx)
1579
+ const y0 = Math.floor(gy)
1580
+ const fx = gx - x0
1581
+ const fy = gy - y0
1582
+
1583
+ const c00 = paintCellAt(grid, x0, y0)
1584
+ const c10 = paintCellAt(grid, x0 + 1, y0)
1585
+ const c01 = paintCellAt(grid, x0, y0 + 1)
1586
+ const c11 = paintCellAt(grid, x0 + 1, y0 + 1)
1587
+
1588
+ // A hole next to a colour stays a hole rather than half-fading into it:
1589
+ // partial transparency is not expressible in a `[color]` tag, so the
1590
+ // honest answer at the edge of a hole is the neighbour's colour.
1591
+ const top = c00 === undefined ? c10 : c10 === undefined ? c00 : mixStop(c00, c10, fx, perceptual)
1592
+ const bottom = c01 === undefined ? c11 : c11 === undefined ? c01 : mixStop(c01, c11, fx, perceptual)
1593
+ if (top === undefined) return bottom
1594
+ if (bottom === undefined) return top
1595
+ return mixStop(top, bottom, fy, perceptual)
1596
+ }
1597
+
1598
+ function mixStop(a: string, b: string, t: number, perceptual: boolean): string {
1599
+ return mixMultipleStops(
1600
+ [{ color: a, position: 0 }, { color: b, position: 1 }],
1601
+ clamp01(t),
1602
+ perceptual,
1603
+ )
1604
+ }
1605
+
1606
+ // ═══════════════════════════════════════════════════════════════
1607
+ // Effect parameters
1608
+ // ═══════════════════════════════════════════════════════════════
1609
+ //
1610
+ // One attribute grammar for every effect tag:
1611
+ //
1612
+ // [gradient=#ff0000,#00ff00] classic, still valid
1613
+ // [gradient=#ff0000,#00ff00;easing=easeInOut] with modulation
1614
+ // [rainbow=spread=360;sat=90;axis=line] no positional part
1615
+ // [grow=min=90;max=160;wave=sine;cycles=3]
1616
+ //
1617
+ // Segments are separated by `;`. A segment containing `=` is a named
1618
+ // parameter; one without is the tag's positional value (the colour list).
1619
+ // That keeps every document ever written with the old one-value form
1620
+ // parsing unchanged, while giving the studio somewhere to put the rest.
1621
+ //
1622
+ // Without this, Miliastry mode was a lossy export: `[rainbow]` carried no
1623
+ // saturation, no spread and no offset, so re-opening a saved document
1624
+ // showed a rainbow nobody had configured.
1625
+
1626
+ /** Everything an effect tag can carry, after parsing. */
1627
+ export interface EffectParams {
1628
+ /** Colour list for gradient / rainbow ramps. */
1629
+ colors?: string[]
1630
+ /** Raw stop list, positions included. */
1631
+ stops?: ColorStop[]
1632
+
1633
+ axis?: Axis
1634
+ wave?: WaveKind
1635
+ cycles?: number
1636
+ phase?: number
1637
+ easing?: string
1638
+ bezier?: [number, number, number, number]
1639
+ parabolaCenter?: number
1640
+ parabolaPower?: number
1641
+ invert?: boolean
1642
+ steps?: number
1643
+ octaves?: number
1644
+ expression?: string
1645
+ seed?: number
1646
+ unit?: EffectUnit
1647
+ opacity?: number
1648
+ perceptual?: boolean
1649
+
1650
+ // rainbow
1651
+ saturation?: number
1652
+ lightness?: number
1653
+ spread?: number
1654
+ offset?: number
1655
+ preserveSL?: boolean
1656
+
1657
+ // grow
1658
+ min?: number
1659
+ max?: number
1660
+
1661
+ // sinewave — an index-in-radians oscillator, not a modulated axis
1662
+ freq?: number
1663
+ step?: 'char' | 'word'
1664
+
1665
+ // ── Placement (spot / sweep / linear) ──
1666
+ originX?: number
1667
+ originY?: number
1668
+ /** Degrees, clockwise from east. */
1669
+ angle?: number
1670
+ /** Reach, in units of the box's longer side. */
1671
+ radius?: number
1672
+ /** Width \u00f7 height of a character cell. */
1673
+ aspect?: number
1674
+
1675
+ // ── Mask ──
1676
+ maskShape?: MaskShape
1677
+ maskX?: number
1678
+ maskY?: number
1679
+ maskWidth?: number
1680
+ maskHeight?: number
1681
+ maskRotate?: number
1682
+ maskFeather?: number
1683
+ maskInvert?: boolean
1684
+ maskPoints?: number
1685
+ maskInner?: number
1686
+
1687
+ // ── Paint grid (the `image` effect) ──
1688
+ gridCols?: number
1689
+ gridRows?: number
1690
+ /** Comma-separated hex colours, `#` stripped. */
1691
+ palette?: string
1692
+ /** One character per cell, in the paint alphabet. */
1693
+ cells?: string
1694
+ /** Interpolate between cells instead of snapping to the nearest. */
1695
+ smooth?: boolean
1696
+
1697
+ /**
1698
+ * The colour underneath, so a partial weight has something to fade to.
1699
+ *
1700
+ * A colour tag paints; unlike a studio layer it has nothing to
1701
+ * composite against, which is why `opacity` was never expressible
1702
+ * natively. A mask makes the weight vary per character, so the question
1703
+ * stops being avoidable: carrying the base colour is what lets a
1704
+ * feathered edge and a partial opacity survive as one tag instead of
1705
+ * expanding to one `[color]` per letter.
1706
+ *
1707
+ * Absent, a partial weight falls back to a hard cut at half strength.
1708
+ */
1709
+ baseColor?: string
1710
+
1711
+ /**
1712
+ * Position of this tag's text inside a longer effect, in visible
1713
+ * characters. Set when one logical effect is split across several tags
1714
+ * — a gradient down a multi-paragraph block — so each tag continues the
1715
+ * ramp instead of restarting it.
1716
+ */
1717
+ globalOffset?: number
1718
+ /** Total visible characters of that longer effect. */
1719
+ documentLength?: number
1720
+ }
1721
+
1722
+ export type EffectUnit = 'character' | 'word' | 'line'
1723
+
1724
+ /**
1725
+ * The stepping units, as data.
1726
+ *
1727
+ * The type alone cannot be enumerated at runtime, so every UI that offered
1728
+ * these had to retype them — and a tool that wants to *validate* `unit=`
1729
+ * had nothing to validate against at all.
1730
+ */
1731
+ export const EFFECT_UNITS: readonly EffectUnit[] = ['character', 'word', 'line']
1732
+
1733
+ /**
1734
+ * The axis a layer actually reads, given its stepping unit.
1735
+ *
1736
+ * Stepping per word while measuring per character is almost never what
1737
+ * someone means: a word-unit gradient over "one two three" would give
1738
+ * every word the colour of its first letter, so the ramp reached 60% of
1739
+ * the way to its end colour and stopped. `unit` therefore promotes the
1740
+ * default `index` axis to the matching coordinate. An axis the user
1741
+ * chose deliberately — radial, line, random — is left alone.
1742
+ */
1743
+ export function effectiveAxis(axis: Axis, unit: EffectUnit | undefined): Axis {
1744
+ if (axis !== 'index') return axis
1745
+ if (unit === 'word') return 'word'
1746
+ if (unit === 'line') return 'line'
1747
+ return 'index'
1748
+ }
1749
+
1750
+ /** Short attribute keys, so a tag stays readable in a document. */
1751
+ /**
1752
+ * The attribute key each parameter is written as, and the only place that
1753
+ * mapping exists.
1754
+ *
1755
+ * Exported because it is the answer to "what can be spelled inside
1756
+ * `[gradient=…]`" — the same question `TagVocabulary` answers for the tags
1757
+ * whose attribute is a plain enum. A consumer that retyped `pc`, `oct` or
1758
+ * `mrat` would be a second transcription of a grammar that already has
1759
+ * exactly one, which is the drift this module was written to end.
1760
+ *
1761
+ * Note this says how a parameter is SPELLED, not which effect kinds read
1762
+ * it: `sat` is rainbow's and `min` is grow's, and a caller that offers
1763
+ * every key on every tag would be promising things the evaluator ignores.
1764
+ */
1765
+ export const EFFECT_PARAM_KEYS = {
1766
+ axis: 'axis',
1767
+ wave: 'wave',
1768
+ cycles: 'cycles',
1769
+ phase: 'phase',
1770
+ easing: 'easing',
1771
+ bezier: 'bezier',
1772
+ parabolaCenter: 'pc',
1773
+ parabolaPower: 'pp',
1774
+ invert: 'invert',
1775
+ steps: 'steps',
1776
+ octaves: 'oct',
1777
+ expression: 'expr',
1778
+ seed: 'seed',
1779
+ unit: 'unit',
1780
+ opacity: 'opacity',
1781
+ perceptual: 'oklab',
1782
+ saturation: 'sat',
1783
+ lightness: 'light',
1784
+ spread: 'spread',
1785
+ offset: 'offset',
1786
+ preserveSL: 'keepsl',
1787
+ min: 'min',
1788
+ max: 'max',
1789
+ freq: 'freq',
1790
+ step: 'step',
1791
+ originX: 'ox',
1792
+ originY: 'oy',
1793
+ angle: 'ang',
1794
+ radius: 'rad',
1795
+ aspect: 'asp',
1796
+ maskShape: 'mask',
1797
+ maskX: 'mx',
1798
+ maskY: 'my',
1799
+ maskWidth: 'mw',
1800
+ maskHeight: 'mh',
1801
+ maskRotate: 'mrot',
1802
+ maskFeather: 'mfea',
1803
+ maskInvert: 'minv',
1804
+ maskPoints: 'mpts',
1805
+ maskInner: 'mrat',
1806
+ gridCols: 'cols',
1807
+ gridRows: 'rows',
1808
+ palette: 'pal',
1809
+ cells: 'map',
1810
+ smooth: 'smooth',
1811
+ baseColor: 'base',
1812
+ globalOffset: 'at',
1813
+ documentLength: 'of',
1814
+ } as const
1815
+
1816
+ const KEY_TO_PARAM = new Map<string, keyof typeof EFFECT_PARAM_KEYS>(
1817
+ Object.entries(EFFECT_PARAM_KEYS).map(([param, key]) => [key, param as keyof typeof EFFECT_PARAM_KEYS]),
1818
+ )
1819
+
1820
+ const NUMERIC_PARAMS = new Set([
1821
+ 'cycles', 'phase', 'parabolaCenter', 'parabolaPower', 'steps', 'octaves',
1822
+ 'seed', 'opacity', 'saturation', 'lightness', 'spread', 'offset', 'min', 'max',
1823
+ 'freq', 'globalOffset', 'documentLength',
1824
+ 'originX', 'originY', 'angle', 'radius', 'aspect',
1825
+ 'maskX', 'maskY', 'maskWidth', 'maskHeight', 'maskRotate', 'maskFeather',
1826
+ 'maskPoints', 'maskInner', 'gridCols', 'gridRows',
1827
+ ])
1828
+ const BOOLEAN_PARAMS = new Set(['invert', 'preserveSL', 'perceptual', 'maskInvert', 'smooth'])
1829
+
1830
+ const stopCache = new Map<string, ColorStop[]>()
1831
+ const STOP_CACHE_LIMIT = 256
1832
+
1833
+ /**
1834
+ * Parse a stop list, `"#hex 0%, #hex 50%, #hex"`.
1835
+ *
1836
+ * Unpositioned stops are spread evenly between their positioned
1837
+ * neighbours, matching CSS gradient semantics.
1838
+ *
1839
+ * Cached because a gradient layer asks for the same string once per
1840
+ * character; the result is treated as immutable by every caller.
1841
+ */
1842
+ export function parseColorStops(colorsStr: string): ColorStop[] {
1843
+ const key = colorsStr ?? ''
1844
+ const cached = stopCache.get(key)
1845
+ if (cached !== undefined) {
1846
+ stopCache.delete(key)
1847
+ stopCache.set(key, cached)
1848
+ }
1849
+ if (cached) return cached
1850
+
1851
+ const parts = key.split(',').map(s => s.trim()).filter(Boolean)
1852
+ const stops: ColorStop[] = []
1853
+
1854
+ for (const part of parts) {
1855
+ const match = part.match(/^(\S+)(?:\s+(-?[\d.]+)%)?$/)
1856
+ if (match) {
1857
+ const pos = match[2] !== undefined ? parseFloat(match[2]) / 100 : -1
1858
+ stops.push({ color: match[1], position: Number.isFinite(pos) ? pos : -1 })
1859
+ } else {
1860
+ stops.push({ color: part, position: -1 })
1861
+ }
1862
+ }
1863
+
1864
+ if (stops.length > 0) {
1865
+ if (stops[0].position === -1) stops[0].position = 0
1866
+ if (stops[stops.length - 1].position === -1) stops[stops.length - 1].position = 1
1867
+
1868
+ let lastKnown = 0
1869
+ for (let i = 1; i < stops.length; i++) {
1870
+ if (stops[i].position === -1) continue
1871
+ const span = stops[i].position - stops[lastKnown].position
1872
+ const steps = i - lastKnown
1873
+ for (let j = 1; j < steps; j++) {
1874
+ stops[lastKnown + j].position = stops[lastKnown].position + (span * j) / steps
1875
+ }
1876
+ lastKnown = i
1877
+ }
1878
+ }
1879
+
1880
+ stopCache.set(key, stops)
1881
+ evictOldest(stopCache, STOP_CACHE_LIMIT)
1882
+ return stops
1883
+ }
1884
+
1885
+ /** Serialise stops to the canonical `"#hex 0.0%, …"` form. */
1886
+ export function stringifyColorStops(stops: readonly ColorStop[]): string {
1887
+ return stops.map(s => `${s.color} ${(s.position * 100).toFixed(1)}%`).join(', ')
1888
+ }
1889
+
1890
+ /** Parse an effect tag's attribute value into parameters. */
1891
+ export function parseEffectParams(value: string): EffectParams {
1892
+ const params: EffectParams = {}
1893
+ if (!value) return params
1894
+
1895
+ for (const rawSegment of value.split(';')) {
1896
+ const segment = rawSegment.trim()
1897
+ if (!segment) continue
1898
+
1899
+ const eq = segment.indexOf('=')
1900
+ if (eq < 0) {
1901
+ // Positional: the colour list.
1902
+ const stops = parseColorStops(segment)
1903
+ const colors = stops.map(s => s.color).filter(c => c.startsWith('#'))
1904
+ if (colors.length > 0) {
1905
+ params.colors = colors
1906
+ params.stops = stops
1907
+ }
1908
+ continue
1909
+ }
1910
+
1911
+ const key = segment.slice(0, eq).trim().toLowerCase()
1912
+ const raw = segment.slice(eq + 1).trim()
1913
+ const param = KEY_TO_PARAM.get(key)
1914
+ if (!param) continue
1915
+
1916
+ if (NUMERIC_PARAMS.has(param)) {
1917
+ const n = Number(raw)
1918
+ if (Number.isFinite(n)) (params as Record<string, unknown>)[param] = n
1919
+ } else if (BOOLEAN_PARAMS.has(param)) {
1920
+ (params as Record<string, unknown>)[param] = raw === '1' || raw === 'true'
1921
+ } else if (param === 'bezier') {
1922
+ const nums = raw.split(/[,\s]+/).map(Number)
1923
+ if (nums.length === 4 && nums.every(Number.isFinite)) {
1924
+ params.bezier = nums as [number, number, number, number]
1925
+ }
1926
+ } else {
1927
+ (params as Record<string, unknown>)[param] = raw
1928
+ }
1929
+ }
1930
+
1931
+ return params
1932
+ }
1933
+
1934
+ /**
1935
+ * Serialise parameters back to an attribute value.
1936
+ *
1937
+ * Only what differs from `defaults` is written, so a plain gradient stays
1938
+ * `[gradient=#a,#b]` and only a configured one grows an attribute tail.
1939
+ */
1940
+ export function stringifyEffectParams(
1941
+ params: EffectParams,
1942
+ defaults: Partial<EffectParams> = {},
1943
+ ): string {
1944
+ const segments: string[] = []
1945
+
1946
+ if (params.stops && params.stops.length > 0) {
1947
+ segments.push(stringifyColorStops(params.stops))
1948
+ } else if (params.colors && params.colors.length > 0) {
1949
+ segments.push(params.colors.join(','))
1950
+ }
1951
+
1952
+ for (const [param, key] of Object.entries(EFFECT_PARAM_KEYS) as [keyof typeof EFFECT_PARAM_KEYS, string][]) {
1953
+ const value = (params as Record<string, unknown>)[param]
1954
+ if (value === undefined || value === null || value === '') continue
1955
+ const fallback = (defaults as Record<string, unknown>)[param]
1956
+ if (fallback !== undefined && valuesEqual(value, fallback)) continue
1957
+
1958
+ if (param === 'bezier' && Array.isArray(value)) {
1959
+ segments.push(`${key}=${value.join(',')}`)
1960
+ } else if (typeof value === 'boolean') {
1961
+ segments.push(`${key}=${value ? '1' : '0'}`)
1962
+ } else if (typeof value === 'number') {
1963
+ segments.push(`${key}=${round4(value)}`)
1964
+ } else {
1965
+ segments.push(`${key}=${String(value)}`)
1966
+ }
1967
+ }
1968
+
1969
+ return segments.join(';')
1970
+ }
1971
+
1972
+ function valuesEqual(a: unknown, b: unknown): boolean {
1973
+ if (Array.isArray(a) && Array.isArray(b)) {
1974
+ return a.length === b.length && a.every((v, i) => v === b[i])
1975
+ }
1976
+ if (typeof a === 'number' && typeof b === 'number') return Math.abs(a - b) < 1e-6
1977
+ return a === b
1978
+ }
1979
+
1980
+ function round4(n: number): number {
1981
+ return Math.round(n * 10000) / 10000
1982
+ }
1983
+
1984
+ // ═══════════════════════════════════════════════════════════════
1985
+ // Effect evaluation over a plain string
1986
+ // ═══════════════════════════════════════════════════════════════
1987
+
1988
+ /** A run of text with the style its effect computed. */
1989
+ export interface StyledSegment {
1990
+ text: string
1991
+ color?: string
1992
+ size?: number
1993
+ }
1994
+
1995
+ /** Which effect a set of parameters describes. */
1996
+ export type EffectKind = 'gradient' | 'rainbow' | 'grow' | 'sinewave' | 'paint'
1997
+
1998
+ /** The size a character has with no size effect on it, in percent. */
1999
+ export const NEUTRAL_SIZE = 100
2000
+
2001
+ /** Where a node sits in a larger logical effect that spans several nodes. */
2002
+ export interface EffectSpan {
2003
+ globalOffset?: number
2004
+ documentLength?: number
2005
+ }
2006
+
2007
+ const EFFECT_DEFAULTS = {
2008
+ axis: 'index' as Axis,
2009
+ wave: 'none' as WaveKind,
2010
+ cycles: 1,
2011
+ phase: 0,
2012
+ easing: 'linear',
2013
+ bezier: [0.25, 0.1, 0.25, 1.0] as [number, number, number, number],
2014
+ parabolaCenter: 0.5,
2015
+ parabolaPower: 2,
2016
+ invert: false,
2017
+ steps: 0,
2018
+ octaves: 4,
2019
+ seed: 1,
2020
+ unit: 'character' as EffectUnit,
2021
+ opacity: 1,
2022
+ perceptual: false,
2023
+ // Placement and mask belong to every effect kind, not to one of them: a
2024
+ // rainbow can be a star in the corner exactly as a gradient can. They
2025
+ // live in the shared defaults so `stringifyEffectParams` omits them
2026
+ // when untouched — otherwise every tag ever written would grow fifteen
2027
+ // attributes restating the defaults.
2028
+ originX: DEFAULT_SPATIAL.originX,
2029
+ originY: DEFAULT_SPATIAL.originY,
2030
+ angle: DEFAULT_SPATIAL.angle,
2031
+ radius: DEFAULT_SPATIAL.radius,
2032
+ aspect: DEFAULT_SPATIAL.aspect,
2033
+ maskShape: DEFAULT_MASK.shape,
2034
+ maskX: DEFAULT_MASK.x,
2035
+ maskY: DEFAULT_MASK.y,
2036
+ maskWidth: DEFAULT_MASK.width,
2037
+ maskHeight: DEFAULT_MASK.height,
2038
+ maskRotate: DEFAULT_MASK.rotate,
2039
+ maskFeather: DEFAULT_MASK.feather,
2040
+ maskInvert: DEFAULT_MASK.invert,
2041
+ maskPoints: DEFAULT_MASK.points,
2042
+ maskInner: DEFAULT_MASK.inner,
2043
+ }
2044
+
2045
+ export const GRADIENT_DEFAULTS: Partial<EffectParams> = { ...EFFECT_DEFAULTS }
2046
+ export const RAINBOW_DEFAULTS: Partial<EffectParams> = {
2047
+ ...EFFECT_DEFAULTS, saturation: 80, lightness: 60, spread: 300, offset: 0, preserveSL: false,
2048
+ }
2049
+ export const GROW_DEFAULTS: Partial<EffectParams> = {
2050
+ ...EFFECT_DEFAULTS, wave: 'sine', min: 50, max: 200,
2051
+ }
2052
+ export const PAINT_DEFAULTS: Partial<EffectParams> = {
2053
+ ...EFFECT_DEFAULTS, smooth: false,
2054
+ }
2055
+
2056
+ /**
2057
+ * The placement and mask subset of the shared defaults.
2058
+ *
2059
+ * Every effect kind already carries these; this names them separately so
2060
+ * a UI can reset just the placement without touching the modulation.
2061
+ */
2062
+ export const SPATIAL_DEFAULTS: Partial<EffectParams> = {
2063
+ originX: EFFECT_DEFAULTS.originX,
2064
+ originY: EFFECT_DEFAULTS.originY,
2065
+ angle: EFFECT_DEFAULTS.angle,
2066
+ radius: EFFECT_DEFAULTS.radius,
2067
+ aspect: EFFECT_DEFAULTS.aspect,
2068
+ maskShape: EFFECT_DEFAULTS.maskShape,
2069
+ maskX: EFFECT_DEFAULTS.maskX,
2070
+ maskY: EFFECT_DEFAULTS.maskY,
2071
+ maskWidth: EFFECT_DEFAULTS.maskWidth,
2072
+ maskHeight: EFFECT_DEFAULTS.maskHeight,
2073
+ maskRotate: EFFECT_DEFAULTS.maskRotate,
2074
+ maskFeather: EFFECT_DEFAULTS.maskFeather,
2075
+ maskInvert: EFFECT_DEFAULTS.maskInvert,
2076
+ maskPoints: EFFECT_DEFAULTS.maskPoints,
2077
+ maskInner: EFFECT_DEFAULTS.maskInner,
2078
+ }
2079
+
2080
+ /** Read a tag's placement parameters, filling in the defaults. */
2081
+ export function spatialFromParams(p: EffectParams): SpatialOptions {
2082
+ return {
2083
+ originX: p.originX ?? DEFAULT_SPATIAL.originX,
2084
+ originY: p.originY ?? DEFAULT_SPATIAL.originY,
2085
+ angle: p.angle ?? DEFAULT_SPATIAL.angle,
2086
+ radius: p.radius ?? DEFAULT_SPATIAL.radius,
2087
+ aspect: p.aspect ?? DEFAULT_SPATIAL.aspect,
2088
+ }
2089
+ }
2090
+
2091
+ /** Read a tag's mask parameters, filling in the defaults. */
2092
+ export function maskFromParams(p: EffectParams): MaskOptions {
2093
+ return {
2094
+ shape: p.maskShape ?? DEFAULT_MASK.shape,
2095
+ x: p.maskX ?? DEFAULT_MASK.x,
2096
+ y: p.maskY ?? DEFAULT_MASK.y,
2097
+ width: p.maskWidth ?? DEFAULT_MASK.width,
2098
+ height: p.maskHeight ?? DEFAULT_MASK.height,
2099
+ rotate: p.maskRotate ?? DEFAULT_MASK.rotate,
2100
+ feather: p.maskFeather ?? DEFAULT_MASK.feather,
2101
+ invert: p.maskInvert ?? DEFAULT_MASK.invert,
2102
+ points: p.maskPoints ?? DEFAULT_MASK.points,
2103
+ inner: p.maskInner ?? DEFAULT_MASK.inner,
2104
+ // A mask and a placeable axis measure the same page, so they must
2105
+ // agree about how wide a character is. One value, read twice.
2106
+ aspect: p.aspect ?? DEFAULT_MASK.aspect,
2107
+ }
2108
+ }
2109
+
2110
+ /** Read a tag's paint grid, or `null` when it carries none. */
2111
+ export function gridFromParams(p: EffectParams): PaintGrid | null {
2112
+ if (p.gridCols === undefined || p.gridRows === undefined) return null
2113
+ if (!p.palette || !p.cells) return null
2114
+ return parsePaintGrid(p.gridCols, p.gridRows, p.palette, p.cells)
2115
+ }
2116
+
2117
+ /**
2118
+ * Evaluate an effect over a plain string, one styled run per unit.
2119
+ *
2120
+ * This is the single implementation the tag registry's BBCode export, the
2121
+ * HTML renderer's preview and the studio's own compiler all call, so a
2122
+ * document cannot render one way and export another.
2123
+ *
2124
+ * `span` carries the document-wide position when one logical effect is
2125
+ * split across several nodes — without it, a gradient interrupted by a
2126
+ * `[b]` would restart at its first colour on the other side.
2127
+ */
2128
+ export function evaluateEffect(
2129
+ text: string,
2130
+ kind: EffectKind,
2131
+ params: EffectParams,
2132
+ span: EffectSpan = {},
2133
+ ): StyledSegment[] {
2134
+ if (!text) return []
2135
+
2136
+ // `sinewave` predates the axis/waveform model and is not expressible in
2137
+ // it: its argument is the character index in radians (`sin(i * freq)`),
2138
+ // not a position normalised over the run, so its period is fixed in
2139
+ // characters rather than stretching with the text. Folding it into the
2140
+ // general path silently changed every document that used it.
2141
+ if (kind === 'sinewave') return mergeStyledSegments(sinewaveSegments(text, params))
2142
+
2143
+ const defaults =
2144
+ kind === 'rainbow' ? RAINBOW_DEFAULTS :
2145
+ kind === 'grow' ? GROW_DEFAULTS :
2146
+ kind === 'paint' ? PAINT_DEFAULTS :
2147
+ GRADIENT_DEFAULTS
2148
+ const p = { ...defaults, ...params } as Required<Pick<EffectParams,
2149
+ 'axis' | 'wave' | 'cycles' | 'phase' | 'easing' | 'bezier' | 'parabolaCenter' |
2150
+ 'parabolaPower' | 'invert' | 'steps' | 'octaves' | 'seed' | 'unit' | 'perceptual'
2151
+ >> & EffectParams
2152
+
2153
+ const table = buildSampleTable(text)
2154
+ const scope = documentScope(table)
2155
+ const stops = params.stops ?? (params.colors ? params.colors.map((color, i, arr) => ({
2156
+ color, position: arr.length > 1 ? i / (arr.length - 1) : 0,
2157
+ })) : [{ color: '#FF0000', position: 0 }, { color: '#00FF00', position: 1 }])
2158
+
2159
+ const chars = Array.from(text)
2160
+ const easingArg: Easing = p.easing === 'custom'
2161
+ ? `bezier(${p.bezier.join(',')})`
2162
+ : p.easing
2163
+ // Compilado FUERA del bucle: el suavizado es el mismo para todos los
2164
+ // caracteres, y `ease` volvía a interpretar la cadena en cada uno. Ver
2165
+ // `compileEase`.
2166
+ const easeFn = compileEase(easingArg, p.parabolaCenter, p.parabolaPower)
2167
+
2168
+ // Placement, mask and grid are resolved once. They do not vary per
2169
+ // character, and reading fifteen `??` fallbacks inside the loop cost
2170
+ // more than every shape SDF put together.
2171
+ const geo = spatialFromParams(p)
2172
+ const maskOpts = maskFromParams(p)
2173
+ const masked = maskOpts.shape !== 'none'
2174
+ const grid = kind === 'paint' ? gridFromParams(p) : null
2175
+
2176
+ // A document-wide span replaces the local index so several nodes read
2177
+ // as one continuous effect.
2178
+ // The span may arrive as node metadata (set by a tree transform) or in
2179
+ // the tag's own attribute (written by Text Studio when it splits one
2180
+ // effect across paragraphs). Either way it means the same thing.
2181
+ const spanLengthRaw = span.documentLength ?? params.documentLength
2182
+ const spanned = spanLengthRaw !== undefined && spanLengthRaw > 1
2183
+ const spanOffset = span.globalOffset ?? params.globalOffset ?? 0
2184
+ const spanLength = spanLengthRaw ?? scope.count
2185
+
2186
+ const out: StyledSegment[] = []
2187
+ let groupKey: number | null = null
2188
+ let pending: { text: string; value: number; weight: number; color?: string } | null = null
2189
+
2190
+ const flush = () => {
2191
+ if (!pending) return
2192
+ out.push(styleFor(kind, pending.text, pending.value, p, stops, pending.weight, pending.color))
2193
+ pending = null
2194
+ }
2195
+
2196
+ for (let i = 0; i < chars.length; i++) {
2197
+ const sample = table.samples[i]
2198
+
2199
+ if (sample.isSpace) {
2200
+ flush()
2201
+ groupKey = null
2202
+ out.push({ text: chars[i] })
2203
+ continue
2204
+ }
2205
+
2206
+ const key = p.unit === 'word' ? sample.word : p.unit === 'line' ? sample.line : sample.index
2207
+ if (pending && key === groupKey) {
2208
+ pending.text += chars[i]
2209
+ continue
2210
+ }
2211
+ flush()
2212
+ groupKey = key
2213
+
2214
+ const ctx: SampleContext = { sample, scope, table, local: sample.index }
2215
+
2216
+ // A mask that excludes the character settles the question before any
2217
+ // of the modulation runs: there is no colour to compute.
2218
+ const weight = masked ? maskValue(ctx, maskOpts) : 1
2219
+ if (weight <= 0) {
2220
+ flush()
2221
+ groupKey = null
2222
+ out.push({ text: chars[i] })
2223
+ continue
2224
+ }
2225
+
2226
+ if (kind === 'paint') {
2227
+ const pt = spatialPoint(ctx, geo.aspect)
2228
+ const hex = grid
2229
+ ? samplePaintGrid(grid, pt.x, pt.y, p.smooth === true, p.perceptual)
2230
+ : undefined
2231
+ if (hex === undefined) {
2232
+ out.push({ text: chars[i] })
2233
+ groupKey = null
2234
+ continue
2235
+ }
2236
+ pending = { text: chars[i], value: 0, weight, color: hex }
2237
+ continue
2238
+ }
2239
+
2240
+ let u = spanned
2241
+ ? clamp01((spanOffset + sample.index) / (spanLength - 1))
2242
+ : axisValue(effectiveAxis(p.axis, p.unit), ctx, p.seed | 0, geo)
2243
+ if (p.invert) u = 1 - u
2244
+
2245
+ let v = waveform(p.wave, u, {
2246
+ cycles: p.cycles,
2247
+ phase: p.phase,
2248
+ bezier: p.bezier,
2249
+ expression: p.expression,
2250
+ octaves: p.octaves,
2251
+ steps: 0,
2252
+ seed: p.seed | 0,
2253
+ index: sample.index,
2254
+ count: scope.count,
2255
+ vars: p.wave === 'expr' ? expressionVars(ctx, u, p.seed | 0) : undefined,
2256
+ })
2257
+ v = easeFn(v)
2258
+ if (p.steps >= 2) {
2259
+ const levels = Math.round(p.steps)
2260
+ v = Math.round(clamp01(v) * (levels - 1)) / (levels - 1)
2261
+ }
2262
+
2263
+ pending = { text: chars[i], value: clamp01(v), weight }
2264
+ }
2265
+ flush()
2266
+
2267
+ return mergeStyledSegments(out)
2268
+ }
2269
+
2270
+ /**
2271
+ * Collapse neighbouring runs that resolved to the same style.
2272
+ *
2273
+ * Every consumer wins: the export writes `[color=#f00]ab[/color]` instead
2274
+ * of two tags, and the preview builds one span instead of two. It matters
2275
+ * most exactly where the output is largest — a quantised gradient, a
2276
+ * word- or line-stepped effect, a long flat tail — which is where the
2277
+ * 60 000-character budget actually gets spent.
2278
+ *
2279
+ * A whitespace run between two identical styles is absorbed rather than
2280
+ * splitting them; between different ones it stays unstyled, so a space
2281
+ * never picks up a colour it would have to pay a tag for.
2282
+ */
2283
+ export function mergeStyledSegments(segments: StyledSegment[]): StyledSegment[] {
2284
+ if (segments.length <= 1) return segments
2285
+
2286
+ const out: StyledSegment[] = []
2287
+ for (let i = 0; i < segments.length; i++) {
2288
+ const seg = segments[i]
2289
+ const prev = out[out.length - 1]
2290
+
2291
+ if (prev && sameStyle(prev, seg)) {
2292
+ prev.text += seg.text
2293
+ continue
2294
+ }
2295
+
2296
+ // Look through a plain gap to the next styled run.
2297
+ if (prev && isPlain(seg)) {
2298
+ let j = i + 1
2299
+ let gap = seg.text
2300
+ while (j < segments.length && isPlain(segments[j])) { gap += segments[j].text; j++ }
2301
+ if (j < segments.length && sameStyle(prev, segments[j])) {
2302
+ prev.text += gap + segments[j].text
2303
+ i = j
2304
+ continue
2305
+ }
2306
+ }
2307
+
2308
+ out.push({ ...seg })
2309
+ }
2310
+ return out
2311
+ }
2312
+
2313
+ function isPlain(seg: StyledSegment): boolean {
2314
+ return seg.color === undefined && seg.size === undefined
2315
+ }
2316
+
2317
+ function sameStyle(a: StyledSegment, b: StyledSegment): boolean {
2318
+ return a.color === b.color && a.size === b.size
2319
+ }
2320
+
2321
+ /** The legacy sine oscillator, preserved exactly. */
2322
+ function sinewaveSegments(text: string, params: EffectParams): StyledSegment[] {
2323
+ const min = params.min ?? 20
2324
+ const max = params.max ?? 80
2325
+ const freq = params.freq ?? 0.4
2326
+ const step = params.step ?? 'char'
2327
+ const amplitude = (max - min) / 2
2328
+ const center = min + amplitude
2329
+
2330
+ if (step === 'word') {
2331
+ let wordIndex = 0
2332
+ return text.split(/(\s+)/).filter(c => c.length > 0).map(chunk => {
2333
+ if (chunk.trim() === '') return { text: chunk }
2334
+ const size = Math.round(center + Math.sin(wordIndex * freq) * amplitude)
2335
+ wordIndex++
2336
+ return { text: chunk, size }
2337
+ })
2338
+ }
2339
+
2340
+ return Array.from(text).map((ch, i) => ({
2341
+ text: ch,
2342
+ size: Math.round(center + Math.sin(i * freq) * amplitude),
2343
+ }))
2344
+ }
2345
+
2346
+ function styleFor(
2347
+ kind: EffectKind,
2348
+ text: string,
2349
+ v: number,
2350
+ p: EffectParams & { perceptual: boolean },
2351
+ stops: ColorStop[],
2352
+ weight = 1,
2353
+ directColor?: string,
2354
+ ): StyledSegment {
2355
+ if (kind === 'grow' || kind === 'sinewave') {
2356
+ const min = p.min ?? 50
2357
+ const max = p.max ?? 200
2358
+ const size = min + (max - min) * v
2359
+ // `opacity` on a size effect is a STRENGTH: how far the computed size
2360
+ // travels from the neutral 100%. It is the same lerp-toward-what-is-
2361
+ // underneath that opacity performs on a colour, which is why it shares
2362
+ // the field — but a font size has no alpha, so the UI labels it
2363
+ // "strength" rather than pretending otherwise.
2364
+ //
2365
+ // A mask multiplies into it, which is exactly right for a size: the
2366
+ // neutral 100% IS what is underneath, so a half-covered character is
2367
+ // half-grown with nothing extra to know.
2368
+ const strength = (p.opacity ?? 1) * weight
2369
+ return { text, size: Math.round(NEUTRAL_SIZE + (size - NEUTRAL_SIZE) * strength) }
2370
+ }
2371
+
2372
+ if (kind === 'paint') {
2373
+ return directColor === undefined
2374
+ ? { text }
2375
+ : applyWeight(text, directColor, weight * (p.opacity ?? 1), p.baseColor)
2376
+ }
2377
+
2378
+ if (kind === 'rainbow') {
2379
+ const spread = p.spread ?? 300
2380
+ const offset = p.offset ?? 0
2381
+ let hue: number
2382
+ if (p.colors && p.colors.length > 0) {
2383
+ let mapped = (offset / 360 + v * (spread / 360)) % 1
2384
+ if (mapped < 0) mapped += 1
2385
+ hue = hexToHsl(mixMultipleStops(stops, mapped, p.perceptual))[0]
2386
+ } else {
2387
+ hue = offset + v * spread
2388
+ }
2389
+ return applyWeight(
2390
+ text,
2391
+ hslToHex(((hue % 360) + 360) % 360, p.saturation ?? 80, p.lightness ?? 60),
2392
+ weight * (p.opacity ?? 1),
2393
+ p.baseColor,
2394
+ )
2395
+ }
2396
+
2397
+ return applyWeight(text, mixMultipleStops(stops, v, p.perceptual), weight * (p.opacity ?? 1), p.baseColor)
2398
+ }
2399
+
2400
+ /**
2401
+ * Composite one computed colour at a partial weight.
2402
+ *
2403
+ * With a known base the answer is a plain mix, and a feathered edge comes
2404
+ * out smooth. Without one there is nothing to fade toward, so the weight
2405
+ * becomes a threshold: past halfway the character takes the colour,
2406
+ * before it the character keeps whatever it inherits. That is a visible
2407
+ * step rather than a gradient, and it is the honest limit of a bare tag —
2408
+ * the studio, which always knows the colour underneath, never reaches it.
2409
+ */
2410
+ function applyWeight(
2411
+ text: string,
2412
+ hex: string,
2413
+ weight: number,
2414
+ baseColor?: string,
2415
+ ): StyledSegment {
2416
+ if (weight >= 1) return { text, color: normalizeHex(hex) }
2417
+ if (weight <= 0) return { text }
2418
+ if (baseColor) return { text, color: normalizeHex(mixHex(baseColor, hex, weight)) }
2419
+ return weight >= 0.5 ? { text, color: normalizeHex(hex) } : { text }
2420
+ }
2421
+
2422
+ /**
2423
+ * Upper-case an emitted hex colour.
2424
+ *
2425
+ * @remarks Exported so the studio's own compiler can apply the same rule;
2426
+ * the two must agree byte for byte or the same document exports
2427
+ * differently depending on which path produced it.
2428
+ *
2429
+ * Interpolated colours come back lower-case while a stop copied straight
2430
+ * from the attribute keeps whatever the author typed, so a single
2431
+ * gradient used to export `#FF0000` next to `#bf4000`. Normalising here
2432
+ * — the one place every effect colour passes through — keeps the output
2433
+ * uniform without touching the colour maths.
2434
+ */
2435
+ export function normalizeHex(hex: string): string {
2436
+ // Equivale a `/^#[0-9a-fA-F]+$/.test(hex) ? hex.toUpperCase() : hex`, pero
2437
+ // por códigos de carácter: era un literal de expresión regular DENTRO de la
2438
+ // función —y en ES2015+ eso asigna un objeto nuevo en cada evaluación— en
2439
+ // una función que corre una vez por segmento coloreado, o sea una vez por
2440
+ // carácter en un degradado sin fusionar. De paso, un valor que ya está en
2441
+ // mayúsculas se devuelve tal cual en vez de copiarse.
2442
+ if (hex.length < 2 || hex.charCodeAt(0) !== 0x23 /* # */) return hex
2443
+ let hasLower = false
2444
+ for (let i = 1; i < hex.length; i++) {
2445
+ const c = hex.charCodeAt(i)
2446
+ if (c >= 0x30 && c <= 0x39) continue // 0-9
2447
+ if (c >= 0x41 && c <= 0x46) continue // A-F
2448
+ if (c >= 0x61 && c <= 0x66) { hasLower = true; continue } // a-f
2449
+ return hex
2450
+ }
2451
+ return hasLower ? hex.toUpperCase() : hex
2452
+ }
2453
+
2454
+ // ── Wiring ─────────────────────────────────────────────────────
2455
+
2456
+ // `ease` needs the validating compiler for `expr(...)` easings but cannot
2457
+ // import it (EffectMath already imports ColorMath). Injecting it here,
2458
+ // once, at load, keeps the dependency one-way.
2459
+ __setExpressionCompiler(src => {
2460
+ const fn = compileExpression(src)
2461
+ return fn ? (vars: Record<string, number>) => fn(vars as unknown as ExpressionVars) : null
2462
+ })