@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.
- package/dist/Visuals/lyne.css +117 -6
- package/dist/Visuals/osu.css +36 -5
- package/dist/index.js +9664 -3282
- package/dist/index.mjs +9535 -3283
- package/dist/types/Analysis/Contracts/AnalysisReport.d.ts +22 -0
- package/dist/types/Analysis/Contracts/Contribution.d.ts +68 -0
- package/dist/types/Analysis/Contracts/Pass.d.ts +59 -0
- package/dist/types/Analysis/Contracts/PipelineContext.d.ts +34 -0
- package/dist/types/Analysis/Passes/Analysis/ColorUsageAnalyzer.d.ts +56 -0
- package/dist/types/Analysis/Passes/Analysis/GradientAnalyzer.d.ts +111 -0
- package/dist/types/Analysis/Passes/Analysis/MergeableColorAnalyzer.d.ts +26 -0
- package/dist/types/Analysis/Passes/Analysis/RainbowAnalyzer.d.ts +47 -0
- package/dist/types/Analysis/Passes/Analysis/SymbolAnalyzer.d.ts +89 -0
- package/dist/types/Analysis/Passes/Analysis/WaveAnalyzer.d.ts +44 -0
- package/dist/types/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.d.ts +4 -0
- package/dist/types/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.d.ts +4 -0
- package/dist/types/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.d.ts +4 -0
- package/dist/types/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.d.ts +4 -0
- package/dist/types/Analysis/Passes/Decision/DefaultDecision.d.ts +30 -0
- package/dist/types/Analysis/Passes/Decision/PaletteRemapDecision.d.ts +169 -0
- package/dist/types/Analysis/Passes/Decision/__tests__/DefaultDecision.test.d.ts +4 -0
- package/dist/types/Analysis/Passes/Transform/CollapseGradientTransform.d.ts +37 -0
- package/dist/types/Analysis/Passes/Transform/MergeColorsTransform.d.ts +28 -0
- package/dist/types/Analysis/Passes/Transform/RainbowCollapseTransform.d.ts +19 -0
- package/dist/types/Analysis/Passes/Transform/WaveCollapseTransform.d.ts +20 -0
- package/dist/types/Analysis/Passes/Utility/CharacterCountAnalyzer.d.ts +19 -0
- package/dist/types/Analysis/Pipeline/Pipeline.d.ts +69 -0
- package/dist/types/Analysis/Pipeline/PipelineBuilder.d.ts +31 -0
- package/dist/types/Analysis/Pipeline/PipelineStage.d.ts +17 -0
- package/dist/types/Analysis/Utils/color-utils.d.ts +60 -0
- package/dist/types/Analysis/__tests__/Integration.test.d.ts +8 -0
- package/dist/types/Analysis/__tests__/PaletteRemap.test.d.ts +14 -0
- package/dist/types/Analysis/__tests__/Pipeline.test.d.ts +10 -0
- package/dist/types/Analysis/__tests__/RoundTrip.test.d.ts +30 -0
- package/dist/types/Analysis/__tests__/SymbolAnalyzer.test.d.ts +11 -0
- package/dist/types/Analysis/index.d.ts +40 -0
- package/dist/types/BBCode/BBCodeDocumentModel.d.ts +92 -0
- package/dist/types/BBCode/BBCodeToGreenNode.d.ts +111 -0
- package/dist/types/BBCode/Parser.d.ts +78 -0
- package/dist/types/BBCode/index.d.ts +4 -0
- package/dist/types/Collab/positions.d.ts +55 -0
- package/dist/types/Commands/Command.d.ts +37 -0
- package/dist/types/Commands/CommandRegistry.d.ts +48 -0
- package/dist/types/Commands/DeleteNode.d.ts +7 -0
- package/dist/types/Commands/InsertText.d.ts +8 -0
- package/dist/types/Commands/SplitMerge.d.ts +9 -0
- package/dist/types/Commands/WrapInTag.d.ts +8 -0
- package/dist/types/Commands/index.d.ts +6 -0
- package/dist/types/Diff/TreeDiffer.d.ts +52 -0
- package/dist/types/Diff/__tests__/TreeDiffer.test.d.ts +1 -0
- package/dist/types/Diff/index.d.ts +2 -0
- package/dist/types/Edits/EditPlan.d.ts +144 -0
- package/dist/types/Edits/Optimizer.d.ts +78 -0
- package/dist/types/Edits/Rules/Rule.d.ts +91 -0
- package/dist/types/Edits/Rules/dropEmptyTags.d.ts +40 -0
- package/dist/types/Edits/Rules/dropRedundantNesting.d.ts +59 -0
- package/dist/types/Edits/Rules/index.d.ts +9 -0
- package/dist/types/Edits/Rules/mergeAdjacent.d.ts +93 -0
- package/dist/types/Edits/Rules/reorderWrappers.d.ts +42 -0
- package/dist/types/Edits/Rules/shortenHex.d.ts +30 -0
- package/dist/types/Edits/Rules/tagValue.d.ts +56 -0
- package/dist/types/Edits/Rules/unwrapInvisibleColor.d.ts +47 -0
- package/dist/types/Edits/applyEdits.d.ts +36 -0
- package/dist/types/Edits/index.d.ts +6 -0
- package/dist/types/Events/EventBus.d.ts +103 -0
- package/dist/types/Events/index.d.ts +2 -0
- package/dist/types/Formatter/Formatter.d.ts +32 -0
- package/dist/types/Formatter/index.d.ts +2 -0
- package/dist/types/HTML/HTMLDocumentModel.d.ts +13 -0
- package/dist/types/HTML/HTMLToGreenNode.d.ts +4 -0
- package/dist/types/Incremental/BracketIndex.d.ts +101 -0
- package/dist/types/Incremental/ChangeTracker.d.ts +69 -0
- package/dist/types/Incremental/IncrementalParser.d.ts +246 -0
- package/dist/types/Incremental/__tests__/BracketIndex.test.d.ts +1 -0
- package/dist/types/Incremental/__tests__/IncrementalParser.test.d.ts +1 -0
- package/dist/types/Incremental/index.d.ts +4 -0
- package/dist/types/Lexer/BBCodeLexer.d.ts +74 -0
- package/dist/types/Lexer/Lexer.d.ts +54 -0
- package/dist/types/Lexer/index.d.ts +4 -0
- package/dist/types/Linter/Linter.d.ts +58 -0
- package/dist/types/Linter/index.d.ts +2 -0
- package/dist/types/Markdown/MarkdownAST.d.ts +122 -0
- package/dist/types/Markdown/MarkdownDocumentModel.d.ts +15 -0
- package/dist/types/Markdown/MarkdownLexer.d.ts +155 -0
- package/dist/types/Markdown/MarkdownParser.d.ts +42 -0
- package/dist/types/Markdown/MarkdownToGreenNode.d.ts +6 -0
- package/dist/types/MilHibri/MilHibriDocumentModel.d.ts +21 -0
- package/dist/types/Model/DocumentModel.d.ts +318 -0
- package/dist/types/Model/NodeFactory.d.ts +29 -0
- package/dist/types/Model/TagRegistry.d.ts +165 -0
- package/dist/types/Model/index.d.ts +5 -0
- package/dist/types/Plugins/PluginAPI.d.ts +44 -0
- package/dist/types/Plugins/PluginRegistry.d.ts +81 -0
- package/dist/types/Plugins/index.d.ts +3 -0
- package/dist/types/Queries/QueryEngine.d.ts +32 -0
- package/dist/types/Queries/index.d.ts +1 -0
- package/dist/types/Reconciler/SurgicalReconciler.d.ts +23 -0
- package/dist/types/RenderPipeline/RenderPipeline.d.ts +46 -0
- package/dist/types/RenderPipeline/RenderTree.d.ts +55 -0
- package/dist/types/RenderPipeline/index.d.ts +4 -0
- package/dist/types/Repair/NestingRepair.d.ts +48 -0
- package/dist/types/Repair/index.d.ts +1 -0
- package/dist/types/Semantic/SemanticAnalyzer.d.ts +321 -0
- package/dist/types/Semantic/index.d.ts +2 -0
- package/dist/types/Symbols/SymbolTable.d.ts +47 -0
- package/dist/types/Symbols/index.d.ts +1 -0
- package/dist/types/Syntax/GreenNode.d.ts +154 -0
- package/dist/types/Syntax/GreenNodePool.d.ts +134 -0
- package/dist/types/Syntax/NodeMatcher.d.ts +75 -0
- package/dist/types/Syntax/RedNode.d.ts +280 -0
- package/dist/types/Syntax/RedNodeStore.d.ts +116 -0
- package/dist/types/Syntax/TreeBuilder.d.ts +54 -0
- package/dist/types/Syntax/__tests__/GreenNode.test.d.ts +1 -0
- package/dist/types/Syntax/__tests__/RedNode.test.d.ts +1 -0
- package/dist/types/Syntax/__tests__/RedNodeStore.test.d.ts +1 -0
- package/dist/types/Syntax/greenEdit.d.ts +64 -0
- package/dist/types/Syntax/hash.d.ts +17 -0
- package/dist/types/Syntax/index.d.ts +8 -0
- package/dist/types/Syntax/nodeAttr.d.ts +63 -0
- package/dist/types/Syntax/partition.d.ts +67 -0
- package/dist/types/Syntax/preserveNodeIds.d.ts +47 -0
- package/dist/types/Tokens/TokenResolver.d.ts +25 -0
- package/dist/types/Tokens/index.d.ts +1 -0
- package/dist/types/Transactions/Transaction.d.ts +36 -0
- package/dist/types/Transactions/UndoManager.d.ts +63 -0
- package/dist/types/Transactions/index.d.ts +3 -0
- package/dist/types/Transformers/ASTOptimizer.d.ts +34 -0
- package/dist/types/Transformers/GradientTransformer.d.ts +18 -0
- package/dist/types/Transformers/GrowTransformer.d.ts +16 -0
- package/dist/types/Transformers/RainbowTransformer.d.ts +17 -0
- package/dist/types/Transformers/SineWaveTransformer.d.ts +17 -0
- package/dist/types/Transformers/Transformer.d.ts +17 -0
- package/dist/types/Types/core.d.ts +104 -0
- package/dist/types/Types/diagnostics.d.ts +85 -0
- package/dist/types/Types/index.d.ts +11 -0
- package/dist/types/Types/operations.d.ts +109 -0
- package/dist/types/Types/queries.d.ts +67 -0
- package/dist/types/Types/symbols.d.ts +42 -0
- package/dist/types/Types/tokens.d.ts +75 -0
- package/dist/types/Utils/BBCodeGenerator.d.ts +21 -0
- package/dist/types/Utils/ColorMath.d.ts +92 -0
- package/dist/types/Utils/EffectMath.d.ts +643 -0
- package/dist/types/Utils/TagVocabulary.d.ts +104 -0
- package/dist/types/Utils/TagVocabulary.test.d.ts +1 -0
- package/dist/types/Utils/color.d.ts +29 -0
- package/dist/types/Utils/dom-to-svg.d.ts +28 -0
- package/dist/types/Utils/dom-to-svg.test.d.ts +1 -0
- package/dist/types/Utils/treeTransformers.d.ts +160 -0
- package/dist/types/Visitors/BBBlocksExporter.d.ts +27 -0
- package/dist/types/Visitors/BBCodeExporter.d.ts +90 -0
- package/dist/types/Visitors/BlockPatcher.d.ts +91 -0
- package/dist/types/Visitors/DOMMorpher.d.ts +13 -0
- package/dist/types/Visitors/HTMLRenderer.d.ts +380 -0
- package/dist/types/Visitors/JSONExporter.d.ts +42 -0
- package/dist/types/Visitors/MarkdownExporter.d.ts +28 -0
- package/dist/types/Visitors/SVGRenderer.d.ts +16 -0
- package/dist/types/Visitors/TiptapExporter.d.ts +40 -0
- package/dist/types/Visitors/Visitor.d.ts +35 -0
- package/dist/types/Visitors/index.d.ts +9 -0
- package/dist/types/Visuals/BoxDrawer.d.ts +49 -0
- package/dist/types/Visuals/index.d.ts +38 -0
- package/dist/types/index.d.ts +154 -0
- package/package.json +14 -10
- package/src/Analysis/Passes/Analysis/ColorUsageAnalyzer.ts +103 -0
- package/src/Analysis/Passes/Analysis/GradientAnalyzer.ts +294 -20
- package/src/Analysis/Passes/Analysis/SymbolAnalyzer.ts +257 -0
- package/src/Analysis/Passes/Decision/PaletteRemapDecision.ts +425 -0
- package/src/Analysis/Pipeline/Pipeline.ts +15 -2
- package/src/Analysis/index.ts +8 -2
- package/src/BBCode/BBCodeDocumentModel.ts +3 -0
- package/src/BBCode/BBCodeToGreenNode.ts +18 -12
- package/src/BBCode/Parser.ts +38 -1
- package/src/Edits/EditPlan.ts +290 -0
- package/src/Edits/Optimizer.ts +185 -0
- package/src/Edits/Rules/Rule.ts +145 -0
- package/src/Edits/Rules/dropEmptyTags.ts +110 -0
- package/src/Edits/Rules/dropRedundantNesting.ts +120 -0
- package/src/Edits/Rules/index.ts +28 -0
- package/src/Edits/Rules/mergeAdjacent.ts +224 -0
- package/src/Edits/Rules/reorderWrappers.ts +154 -0
- package/src/Edits/Rules/shortenHex.ts +79 -0
- package/src/Edits/Rules/tagValue.ts +105 -0
- package/src/Edits/Rules/unwrapInvisibleColor.ts +93 -0
- package/src/Edits/applyEdits.ts +54 -0
- package/src/Edits/index.ts +22 -0
- package/src/Events/EventBus.ts +18 -0
- package/src/HTML/HTMLToGreenNode.ts +185 -37
- package/src/Incremental/BracketIndex.ts +249 -0
- package/src/Incremental/IncrementalParser.ts +464 -41
- package/src/Lexer/BBCodeLexer.ts +25 -3
- package/src/Lexer/index.ts +1 -1
- package/src/Markdown/MarkdownAST.ts +65 -2
- package/src/Markdown/MarkdownLexer.ts +69 -14
- package/src/Markdown/MarkdownParser.ts +749 -62
- package/src/Markdown/MarkdownToGreenNode.ts +148 -27
- package/src/MilHibri/MilHibriDocumentModel.ts +56 -0
- package/src/Model/DocumentModel.ts +138 -5
- package/src/Model/TagRegistry.ts +129 -134
- package/src/Reconciler/SurgicalReconciler.ts +460 -0
- package/src/Repair/NestingRepair.ts +116 -0
- package/src/Repair/index.ts +1 -0
- package/src/Semantic/SemanticAnalyzer.ts +1802 -65
- package/src/Syntax/GreenNodePool.ts +12 -12
- package/src/Syntax/RedNode.ts +17 -3
- package/src/Syntax/RedNodeStore.ts +9 -1
- package/src/Syntax/nodeAttr.ts +172 -0
- package/src/Tokens/TokenResolver.ts +55 -0
- package/src/Tokens/index.ts +1 -0
- package/src/Types/core.ts +15 -0
- package/src/Utils/ColorMath.ts +276 -55
- package/src/Utils/EffectMath.ts +2462 -0
- package/src/Utils/TagVocabulary.ts +141 -0
- package/src/Utils/dom-to-svg.ts +28 -12
- package/src/Visitors/BBBlocksExporter.ts +15 -1
- package/src/Visitors/BBCodeExporter.ts +319 -32
- package/src/Visitors/BlockPatcher.ts +87 -11
- package/src/Visitors/DOMMorpher.ts +15 -5
- package/src/Visitors/HTMLRenderer.ts +374 -151
- package/src/Visitors/MarkdownExporter.ts +98 -41
- package/src/Visitors/TiptapExporter.ts +1 -0
- package/src/Visuals/index.ts +7 -4
- package/src/Visuals/lyne.css +117 -6
- package/src/Visuals/osu.css +36 -5
- package/src/index.ts +172 -5
- package/dist/index.d.mts +0 -4612
- package/dist/index.d.ts +0 -4612
- package/src/Analysis/Passes/Analysis/__tests__/GradientAnalyzer.test.ts +0 -135
- package/src/Analysis/Passes/Analysis/__tests__/MergeableColorAnalyzer.test.ts +0 -84
- package/src/Analysis/Passes/Analysis/__tests__/RainbowAnalyzer.test.ts +0 -99
- package/src/Analysis/Passes/Analysis/__tests__/WaveAnalyzer.test.ts +0 -119
- package/src/Analysis/Passes/Decision/__tests__/DefaultDecision.test.ts +0 -179
- package/src/Analysis/__tests__/Integration.test.ts +0 -162
- package/src/Analysis/__tests__/Pipeline.test.ts +0 -133
- package/src/Diff/__tests__/TreeDiffer.test.ts +0 -65
- package/src/Incremental/__tests__/IncrementalParser.test.ts +0 -164
- package/src/Syntax/__tests__/GreenNode.test.ts +0 -33
- package/src/Syntax/__tests__/RedNode.test.ts +0 -81
- package/src/Syntax/__tests__/RedNodeStore.test.ts +0 -104
- package/src/Tests/ASTOptimizerIdempotence.test.ts +0 -77
- package/src/Tests/BlockPatcher.test.ts +0 -437
- package/src/Tests/BlockPatcherWindowed.test.ts +0 -364
- package/src/Tests/BoxDrawer.test.ts +0 -217
- package/src/Tests/BoxRichTitle.test.ts +0 -105
- package/src/Tests/Chars500kBenchmark.test.ts +0 -151
- package/src/Tests/Chars500kEdits.test.ts +0 -321
- package/src/Tests/CollabPositions.test.ts +0 -146
- package/src/Tests/CompilerPathProfiling.test.ts +0 -186
- package/src/Tests/DOMMorpher.test.ts +0 -142
- package/src/Tests/DomPatchPerf.test.ts +0 -60
- package/src/Tests/EffectSegments.snapshot.json +0 -616
- package/src/Tests/EffectSegments.test.ts +0 -68
- package/src/Tests/FindNodeAtOffset.test.ts +0 -65
- package/src/Tests/Fuzzer.test.ts +0 -166
- package/src/Tests/GreenNodePool.test.ts +0 -153
- package/src/Tests/Lexer.test.ts +0 -238
- package/src/Tests/LyneMode.test.ts +0 -291
- package/src/Tests/ModelCoherence.test.ts +0 -180
- package/src/Tests/Partition.test.ts +0 -238
- package/src/Tests/PluginTags.test.ts +0 -150
- package/src/Tests/ProblematicSection.test.ts +0 -46
- package/src/Tests/ProblematicSectionHTML.test.ts +0 -58
- package/src/Tests/RedReuse.test.ts +0 -134
- package/src/Tests/ReproDelete20k.test.ts +0 -62
- package/src/Tests/SemanticValidators.test.ts +0 -136
- package/src/Tests/StableNodeIds.test.ts +0 -210
- package/src/Tests/StudioColorBloat.test.ts +0 -25
- package/src/Tests/StudioDebugText.test.ts +0 -27
- package/src/Tests/StudioTrailingChar.test.ts +0 -25
- package/src/Tests/StudioValidText.test.ts +0 -25
- package/src/Tests/UrlImgBug.test.ts +0 -23
- package/src/Tests/VisualBuilderFidelity.test.ts +0 -105
- package/src/Tests/referenceDocument.ts +0 -119
- 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
|
+
})
|