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