punctilio 4.3.1 → 5.0.1
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/README.md +37 -23
- package/dist/constants.d.ts +0 -27
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +0 -40
- package/dist/constants.js.map +1 -1
- package/dist/dashes.d.ts +11 -7
- package/dist/dashes.d.ts.map +1 -1
- package/dist/dashes.js +423 -284
- package/dist/dashes.js.map +1 -1
- package/dist/index.d.ts +14 -118
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +65 -116
- package/dist/index.js.map +1 -1
- package/dist/markdown.js +1 -1
- package/dist/markdown.js.map +1 -1
- package/dist/nbsp.d.ts +19 -13
- package/dist/nbsp.d.ts.map +1 -1
- package/dist/nbsp.js +214 -69
- package/dist/nbsp.js.map +1 -1
- package/dist/prose-view.d.ts +48 -7
- package/dist/prose-view.d.ts.map +1 -1
- package/dist/prose-view.js +163 -27
- package/dist/prose-view.js.map +1 -1
- package/dist/quote-classifier.d.ts +5 -4
- package/dist/quote-classifier.d.ts.map +1 -1
- package/dist/quote-classifier.js +601 -88
- package/dist/quote-classifier.js.map +1 -1
- package/dist/quotes.d.ts +12 -4
- package/dist/quotes.d.ts.map +1 -1
- package/dist/quotes.js +24 -27
- package/dist/quotes.js.map +1 -1
- package/dist/rehype.d.ts +53 -8
- package/dist/rehype.d.ts.map +1 -1
- package/dist/rehype.js +105 -62
- package/dist/rehype.js.map +1 -1
- package/dist/remark.d.ts +1 -1
- package/dist/remark.d.ts.map +1 -1
- package/dist/remark.js +6 -9
- package/dist/remark.js.map +1 -1
- package/dist/symbols.d.ts +25 -14
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +812 -220
- package/dist/symbols.js.map +1 -1
- package/dist/transform-options.d.ts +97 -0
- package/dist/transform-options.d.ts.map +1 -0
- package/dist/transform-options.js +34 -0
- package/dist/transform-options.js.map +1 -0
- package/dist/utils.d.ts +0 -16
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +0 -56
- package/dist/utils.js.map +1 -1
- package/package.json +8 -3
- package/src/constants.ts +0 -46
- package/src/dashes.ts +450 -269
- package/src/index.ts +70 -291
- package/src/markdown.ts +1 -1
- package/src/nbsp.ts +255 -78
- package/src/prose-view.ts +234 -39
- package/src/quote-classifier.ts +618 -88
- package/src/quotes.ts +36 -19
- package/src/rehype.ts +169 -88
- package/src/remark.ts +6 -11
- package/src/symbols.ts +814 -251
- package/src/transform-options.ts +143 -0
- package/src/utils.ts +0 -82
package/src/symbols.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { cachedRegExp,
|
|
1
|
+
import { cachedRegExp, LATIN_LETTERS, SPACE_CHARS, UNICODE_SYMBOLS } from "./constants.js"
|
|
2
|
+
import { boundaryCountAt, overInput, type ProseView, replaceAllInView } from "./prose-view.js"
|
|
2
3
|
import { convertPrimeMarks } from "./quote-classifier.js"
|
|
3
|
-
import { namedGroups } from "./utils.js"
|
|
4
4
|
|
|
5
5
|
export interface SymbolOptions {
|
|
6
|
-
/** Boundary marker for HTML element boundaries. Default: "\uE000\uE001" */
|
|
7
|
-
separator?: string
|
|
8
6
|
/** Include arrow transforms (-> → →). Default: true */
|
|
9
7
|
includeArrows?: boolean
|
|
10
8
|
}
|
|
@@ -36,134 +34,428 @@ const {
|
|
|
36
34
|
EXCLAMATION_QUESTION,
|
|
37
35
|
} = UNICODE_SYMBOLS
|
|
38
36
|
|
|
37
|
+
// The boundary-tolerance positions throughout this module reproduce the
|
|
38
|
+
// element-boundary semantics of the pre-v5 sentinel-marked pipeline; they are
|
|
39
|
+
// pinned by the golden corpus and the migration's differential fuzz.
|
|
40
|
+
|
|
41
|
+
const LATIN_LETTER_RE = new RegExp(`[${LATIN_LETTERS}]`)
|
|
42
|
+
const WORD_RE = /\w/
|
|
43
|
+
|
|
39
44
|
/** Convert "..." or ". . ." to "…". */
|
|
40
|
-
export function ellipsis(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
export function ellipsis(input: string): string
|
|
46
|
+
export function ellipsis(input: ProseView): void
|
|
47
|
+
export function ellipsis(input: string | ProseView): string | void {
|
|
48
|
+
return overInput(input, ellipsisOverView)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SPACE_CHAR_RE = new RegExp(`[${SPACE_CHARS}]`)
|
|
52
|
+
|
|
53
|
+
function ellipsisOverView(view: ProseView): void {
|
|
54
|
+
ellipsisFoldDots(view)
|
|
55
|
+
view.commit()
|
|
56
|
+
|
|
57
|
+
// A space is inserted only when a letter or digit directly follows the
|
|
58
|
+
// ellipsis. A node boundary between the ellipsis and the letter is a non-letter
|
|
59
|
+
// for the `(?=[A-Za-z…\d])` lookahead, so it blocks the space; re-check the
|
|
60
|
+
// boundary at the lookahead position.
|
|
61
|
+
const trailing = cachedRegExp(`${ELLIPSIS}(?=[${LATIN_LETTERS}\\d])`, "gu")
|
|
62
|
+
replaceAllInView(view, trailing, (match, v) => {
|
|
63
|
+
if (v.hasBoundary(match.index + match[0].length)) return null
|
|
64
|
+
return `${ELLIPSIS} `
|
|
52
65
|
})
|
|
66
|
+
view.commit()
|
|
67
|
+
}
|
|
53
68
|
|
|
54
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Offset of the dot following one inter-dot gap that begins at `afterDot`, or
|
|
71
|
+
* -1 when no dot is reachable. The gap is one of: nothing, one space character,
|
|
72
|
+
* or one node boundary — never two and never a space-plus-boundary mix. A dot
|
|
73
|
+
* is reachable only when no unconsumed boundary shadows it (the `\.` cannot
|
|
74
|
+
* cross a boundary).
|
|
75
|
+
*/
|
|
76
|
+
function ellipsisNextDot(view: ProseView, text: string, afterDot: number): number {
|
|
77
|
+
const boundariesHere = boundaryCountAt(view, afterDot)
|
|
78
|
+
if (text[afterDot] === ".") {
|
|
79
|
+
// Empty gap (no boundary) or a single-boundary gap consuming exactly one.
|
|
80
|
+
return boundariesHere <= 1 ? afterDot : -1
|
|
81
|
+
}
|
|
82
|
+
// Space gap: one space character with no boundary, then a non-shadowed dot.
|
|
83
|
+
if (boundariesHere === 0 && text[afterDot] !== undefined && SPACE_CHAR_RE.test(text[afterDot])) {
|
|
84
|
+
const dot = afterDot + 1
|
|
85
|
+
if (text[dot] === "." && boundaryCountAt(view, dot) === 0) return dot
|
|
86
|
+
}
|
|
87
|
+
return -1
|
|
88
|
+
}
|
|
55
89
|
|
|
56
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Fold `...`, `. . .`, and the cross-boundary variants into `…`, scanning
|
|
92
|
+
* left-to-right so a blocked leftmost triple still lets the next valid triple
|
|
93
|
+
* fold. Each fold replaces the whole `. gap . gap .` span with `…`; consumed
|
|
94
|
+
* interior boundaries collapse to just after the ellipsis.
|
|
95
|
+
*/
|
|
96
|
+
function ellipsisFoldDots(view: ProseView): void {
|
|
97
|
+
const text = view.text
|
|
98
|
+
let i = 0
|
|
99
|
+
while (i < text.length) {
|
|
100
|
+
if (text[i] !== ".") { i++; continue }
|
|
101
|
+
const secondDot = ellipsisNextDot(view, text, i + 1)
|
|
102
|
+
if (secondDot < 0) { i++; continue }
|
|
103
|
+
const thirdDot = ellipsisNextDot(view, text, secondDot + 1)
|
|
104
|
+
if (thirdDot < 0) { i++; continue }
|
|
105
|
+
view.replace(i, thirdDot + 1, ELLIPSIS)
|
|
106
|
+
i = thirdDot + 1
|
|
107
|
+
}
|
|
57
108
|
}
|
|
58
109
|
|
|
110
|
+
|
|
59
111
|
/** Convert "5x5" to "5×5". Skips hex (0x5F). */
|
|
60
|
-
export function multiplication(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// like "5m × 5m", "210mm × 297mm", or "1920px × 1080px" convert, matching
|
|
66
|
-
// Chicago §9.17's preference for × in dimension notation.
|
|
67
|
-
//
|
|
68
|
-
// Only length/size units that plausibly appear in two-dimensional
|
|
69
|
-
// dimensions are listed — mass/time/electrical units (kg, min, V, …) don't
|
|
70
|
-
// participate in "N × N" constructions and excluding them avoids false
|
|
71
|
-
// matches on words ending in those letters.
|
|
72
|
-
const dimensionUnits = "rem|vh|vw|km|cm|mm|nm|pm|mi|ft|yd|in|px|pt|em|m"
|
|
73
|
-
// Word-boundary lookahead: the unit must be followed by whitespace, a
|
|
74
|
-
// separator marker, the multiplication operator, end-of-string, or
|
|
75
|
-
// sentence punctuation — never another letter (which would mean the
|
|
76
|
-
// "unit" is actually a word prefix like "mold").
|
|
77
|
-
const unitBoundary = `(?=\\s|${chr}|[xX*.,;!?)}]|$)`
|
|
78
|
-
const unitAlt = `(?:\\s|${chr})?(?:${dimensionUnits})${unitBoundary}`
|
|
79
|
-
const primeAlt = `${chr}?[${PRIME}${DOUBLE_PRIME}]`
|
|
80
|
-
const digitSuffix = `(?:${primeAlt}|${unitAlt})?`
|
|
81
|
-
|
|
82
|
-
// Match entire multiplication chains in one pass: "5 x 5 x 5" or "5x5x5"
|
|
83
|
-
// Pattern matches: digit(s), then one or more (operator, digit(s)) groups
|
|
84
|
-
// The (?<!\d[eE]) lookbehind prevents matching inside ambiguous unsigned
|
|
85
|
-
// scientific notation (1e5x3 — could also be a model SKU). Signed exponents
|
|
86
|
-
// (1e-5x3, 3.5E+10x2) are unambiguously scientific so the multiplication
|
|
87
|
-
// is converted. The Latin-letter lookbehind blocks model-name identifiers
|
|
88
|
-
// (Surface5x3, RTX3060x2).
|
|
89
|
-
const chainPattern = cachedRegExp(
|
|
90
|
-
`(?<!\\d[eE])(?<![${LATIN_LETTERS}\\d])(?<firstNum>\\d+${digitSuffix})(?<rest>(?:${chr}?\\s*[xX*]\\s*${chr}?\\d+${digitSuffix})+)`,
|
|
91
|
-
"g"
|
|
92
|
-
)
|
|
112
|
+
export function multiplication(input: string): string
|
|
113
|
+
export function multiplication(input: ProseView): void
|
|
114
|
+
export function multiplication(input: string | ProseView): string | void {
|
|
115
|
+
return overInput(input, multiplicationOverView)
|
|
116
|
+
}
|
|
93
117
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
118
|
+
// After a digit run, either a prime mark (10′) or a length/size unit may
|
|
119
|
+
// attach before the multiplication operator. Allowing both lets dimensions
|
|
120
|
+
// like "5m × 5m", "210mm × 297mm", or "1920px × 1080px" convert, matching
|
|
121
|
+
// Chicago §9.17's preference for × in dimension notation.
|
|
122
|
+
//
|
|
123
|
+
// Only length/size units that plausibly appear in two-dimensional dimensions
|
|
124
|
+
// are listed — mass/time/electrical units (kg, min, V, …) don't participate in
|
|
125
|
+
// "N × N" constructions and excluding them avoids false matches on words
|
|
126
|
+
// ending in those letters.
|
|
127
|
+
const DIMENSION_UNITS = "rem|vh|vw|km|cm|mm|nm|pm|mi|ft|yd|in|px|pt|em|m"
|
|
128
|
+
// Word-boundary lookahead: the unit must be followed by whitespace, the
|
|
129
|
+
// multiplication operator, end-of-string, or sentence punctuation — never
|
|
130
|
+
// another letter (which would mean the "unit" is actually a word prefix like
|
|
131
|
+
// "mold"). A node boundary directly after the unit also satisfies this position
|
|
132
|
+
// and is permitted by allowBoundaries, so it is dropped from the literal
|
|
133
|
+
// lookahead.
|
|
134
|
+
const UNIT_BOUNDARY = `(?=\\s|[xX*.,;!?)}]|$)`
|
|
135
|
+
const UNIT_ALT = `\\s?(?:${DIMENSION_UNITS})${UNIT_BOUNDARY}`
|
|
136
|
+
const PRIME_ALT = `[${PRIME}${DOUBLE_PRIME}]`
|
|
137
|
+
const DIGIT_SUFFIX = `(?:${PRIME_ALT}|${UNIT_ALT})?`
|
|
138
|
+
|
|
139
|
+
// Match entire multiplication chains in one pass: "5 x 5 x 5" or "5x5x5".
|
|
140
|
+
// Pattern matches: digit(s), then one or more (operator, digit(s)) groups.
|
|
141
|
+
// The (?<!\d[eE]) lookbehind prevents matching inside ambiguous unsigned
|
|
142
|
+
// scientific notation (1e5x3 — could also be a model SKU). Signed exponents
|
|
143
|
+
// (1e-5x3, 3.5E+10x2) are unambiguously scientific so the multiplication is
|
|
144
|
+
// converted. The Latin-letter lookbehind blocks model-name identifiers
|
|
145
|
+
// (Surface5x3, RTX3060x2).
|
|
146
|
+
const MULTIPLICATION_CHAIN = `(?<firstNum>\\d+${DIGIT_SUFFIX})(?<rest>(?:\\s*[xX*]\\s*\\d+${DIGIT_SUFFIX})+)`
|
|
147
|
+
const MULTIPLICATION_SEGMENT = `(?<spaceBefore>\\s*)[xX*](?<spaceAfter>\\s*)(?<num>\\d+${DIGIT_SUFFIX})`
|
|
148
|
+
|
|
149
|
+
/** A `\s*[xX*]\s*\d+suffix` segment located by absolute clean-text offsets. */
|
|
150
|
+
interface ChainSegment {
|
|
151
|
+
/** Absolute offset of the operator character. */
|
|
152
|
+
operatorOffset: number
|
|
153
|
+
/** Absolute offset where the right operand's digits begin. */
|
|
154
|
+
operandStart: number
|
|
155
|
+
/** Absolute offset just past the right operand (including any suffix). */
|
|
156
|
+
operandEnd: number
|
|
157
|
+
spaceBefore: string
|
|
158
|
+
spaceAfter: string
|
|
159
|
+
}
|
|
125
160
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
161
|
+
/** Locate each operator segment of `rest` (absolute offsets via `chainStart`). */
|
|
162
|
+
function chainSegments(chainStart: number, firstNum: string, rest: string): ChainSegment[] {
|
|
163
|
+
const segments: ChainSegment[] = []
|
|
164
|
+
const segmentRe = cachedRegExp(MULTIPLICATION_SEGMENT, "gy")
|
|
165
|
+
segmentRe.lastIndex = 0
|
|
166
|
+
let m: RegExpExecArray | null
|
|
167
|
+
while ((m = segmentRe.exec(rest)) !== null) {
|
|
168
|
+
const groups = m.groups as { spaceBefore: string; spaceAfter: string; num: string }
|
|
169
|
+
const operatorAbs = chainStart + firstNum.length + m.index + groups.spaceBefore.length
|
|
170
|
+
segments.push({
|
|
171
|
+
operatorOffset: operatorAbs,
|
|
172
|
+
operandStart: operatorAbs + 1 + groups.spaceAfter.length,
|
|
173
|
+
operandEnd: chainStart + firstNum.length + m.index + m[0].length,
|
|
174
|
+
spaceBefore: groups.spaceBefore,
|
|
175
|
+
spaceAfter: groups.spaceAfter,
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
return segments
|
|
179
|
+
}
|
|
134
180
|
|
|
135
|
-
|
|
181
|
+
/**
|
|
182
|
+
* Each operator's spacing slot (`\s*[xX*]\s*`) tolerates one node boundary at
|
|
183
|
+
* each outer edge and none inside the spacing; a second adjacent boundary at an
|
|
184
|
+
* edge breaks the segment. The operator's slot is the span [slotStart, slotEnd)
|
|
185
|
+
* covering its surrounding spaces and the operator itself.
|
|
186
|
+
*/
|
|
187
|
+
function operatorSlotClean(view: ProseView, slotStart: number, slotEnd: number): boolean {
|
|
188
|
+
// No boundary strictly inside the spacing-plus-operator slot.
|
|
189
|
+
for (const boundary of view.boundaries) {
|
|
190
|
+
if (boundary > slotStart && boundary < slotEnd) return false
|
|
191
|
+
}
|
|
192
|
+
// At most one boundary hugging each outer edge (duplicates at an offset come
|
|
193
|
+
// from empty nodes and each counts as one boundary).
|
|
194
|
+
return boundaryCountAt(view, slotStart) <= 1 && boundaryCountAt(view, slotEnd) <= 1
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The chain's leading lookbehinds at `operandStart`
|
|
199
|
+
* (`(?<!\d[eE])(?<![A-Za-z…\d])`); the Latin-letter arm already rejects any
|
|
200
|
+
* operand preceded by `e`/`E`, so the scientific-notation arm adds nothing for
|
|
201
|
+
* the operand's immediate left character and is folded in here. A node boundary
|
|
202
|
+
* right before the operand shadows the clean character (a boundary is not a
|
|
203
|
+
* letter or digit), satisfying the lookbehind.
|
|
204
|
+
*/
|
|
205
|
+
function chainGuardOk(view: ProseView, operandStart: number): boolean {
|
|
206
|
+
if (view.hasBoundary(operandStart)) return true
|
|
207
|
+
const prior = view.text[operandStart - 1]
|
|
208
|
+
return prior === undefined || !(LATIN_LETTER_RE.test(prior) || /\d/.test(prior))
|
|
136
209
|
}
|
|
137
210
|
|
|
138
|
-
|
|
211
|
+
/**
|
|
212
|
+
* The hex skip `firstNum === "0" && /^x/i.test(rest)`: a `0` operand directly
|
|
213
|
+
* followed (no boundary) by `x`/`X` skips the whole matched chain without
|
|
214
|
+
* re-anchoring.
|
|
215
|
+
*/
|
|
216
|
+
function chainHexBlocked(view: ProseView, operandStart: number, operandText: string): boolean {
|
|
217
|
+
if (operandText !== "0") return false
|
|
218
|
+
const after = operandStart + operandText.length
|
|
219
|
+
// Hex requires `x`/`X` directly after the `0` (a unit between them is not hex).
|
|
220
|
+
// `after` always indexes the operator/unit reached by the match, so it is in
|
|
221
|
+
// bounds.
|
|
222
|
+
return !view.hasBoundary(after) && /x/i.test(view.text[after])
|
|
223
|
+
}
|
|
139
224
|
|
|
140
|
-
|
|
225
|
+
/**
|
|
226
|
+
* Start offset of the maximal boundary-free digit run ending at `digitsEnd`
|
|
227
|
+
* (exclusive), not crossing below `lowerBound`. A node boundary inside a digit
|
|
228
|
+
* run truncates the operand an operator actually sees; that truncated run drives
|
|
229
|
+
* the hex and guard checks.
|
|
230
|
+
*/
|
|
231
|
+
function leftDigitRunStart(view: ProseView, text: string, digitsEnd: number, lowerBound: number): number {
|
|
232
|
+
let start = digitsEnd
|
|
233
|
+
// A boundary at `digitsEnd` sits between the last digit and the operator and
|
|
234
|
+
// does not split the run; only boundaries strictly inside the digits do.
|
|
235
|
+
while (start > lowerBound && /\d/.test(text[start - 1]) && (start === digitsEnd || !view.hasBoundary(start))) start--
|
|
236
|
+
return start
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* End offset of the digit run of the left operand whose operator spacing slot
|
|
241
|
+
* begins at `slotStart`: walk back over the operand's optional suffix (a prime,
|
|
242
|
+
* or a unit's letters, possibly with one space) to the last digit.
|
|
243
|
+
*/
|
|
244
|
+
function leftDigitsEnd(text: string, slotStart: number): number {
|
|
245
|
+
let i = slotStart
|
|
246
|
+
while (i > 0 && !/\d/.test(text[i - 1])) i--
|
|
247
|
+
return i
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* True iff the operand's unit suffix between `digitsEnd` and `slotStart` is not
|
|
252
|
+
* split by a node boundary. One boundary is tolerated before the unit, but the
|
|
253
|
+
* unit's letters must be contiguous, so a boundary between two unit letters
|
|
254
|
+
* detaches the operator from the operand.
|
|
255
|
+
*/
|
|
256
|
+
function leftSuffixUnsplit(view: ProseView, text: string, digitsEnd: number, slotStart: number): boolean {
|
|
257
|
+
// A prime or unit suffix begins after one optional space-or-boundary slot;
|
|
258
|
+
// a second boundary in that slot (before the suffix) leaves `\d+suffix`
|
|
259
|
+
// matching only the digits, detaching the operator.
|
|
260
|
+
if (slotStart > digitsEnd && boundaryCountAt(view, digitsEnd) > 1) return false
|
|
261
|
+
for (let i = digitsEnd + 1; i < slotStart; i++) {
|
|
262
|
+
if (LATIN_LETTER_RE.test(text[i - 1]) && LATIN_LETTER_RE.test(text[i]) && view.hasBoundary(i)) return false
|
|
263
|
+
}
|
|
264
|
+
return true
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* True iff a node boundary falls strictly inside the leading digit run of the
|
|
269
|
+
* operand starting at `operandStart` (`\d+` cannot cross a boundary, so such a
|
|
270
|
+
* boundary truncates the operand and ends the chain there).
|
|
271
|
+
*/
|
|
272
|
+
function runHasInteriorBoundary(view: ProseView, operandStart: number, operandEnd: number): boolean {
|
|
273
|
+
const text = view.text
|
|
274
|
+
let i = operandStart + 1
|
|
275
|
+
while (i < operandEnd && /\d/.test(text[i])) {
|
|
276
|
+
if (view.hasBoundary(i)) return true
|
|
277
|
+
i++
|
|
278
|
+
}
|
|
279
|
+
return false
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function multiplicationOverView(view: ProseView): void {
|
|
283
|
+
// The chain is matched with a sticky regex driven by a manual left-to-right
|
|
284
|
+
// scan. Sticky anchoring keeps the nested `\d+(…\d+)+` quantifier ReDoS-safe
|
|
285
|
+
// without leading lookbehinds; dropping those lookbehinds lets a chain anchor
|
|
286
|
+
// on an operand whose preceding letter/digit is shadowed by a node boundary.
|
|
287
|
+
// Boundary-aware guard/hex/operand checks below make the per-operand
|
|
288
|
+
// decisions.
|
|
289
|
+
const chainPattern = cachedRegExp(MULTIPLICATION_CHAIN, "y")
|
|
290
|
+
const chainText = view.text
|
|
291
|
+
let scan = 0
|
|
292
|
+
while (scan < chainText.length) {
|
|
293
|
+
chainPattern.lastIndex = scan
|
|
294
|
+
const match = chainPattern.exec(chainText)
|
|
295
|
+
if (match === null) { scan++; continue }
|
|
296
|
+
const { firstNum, rest } = match.groups as { firstNum: string; rest: string }
|
|
297
|
+
const text = chainText
|
|
298
|
+
const chainStart = match.index
|
|
299
|
+
const segments = chainSegments(chainStart, firstNum, rest)
|
|
300
|
+
|
|
301
|
+
// Edit each operator's spacing slot in place, leaving the digit operands
|
|
302
|
+
// (and any node boundaries inside them) untouched so boundary positions are
|
|
303
|
+
// preserved.
|
|
304
|
+
//
|
|
305
|
+
// The chain matches contiguously, then the hex skip is applied. Two
|
|
306
|
+
// outcomes propagate differently:
|
|
307
|
+
// - Leading-guard failure does not anchor here, so the match is re-tried
|
|
308
|
+
// at the next operand: keep retrying each operator until one anchors.
|
|
309
|
+
// - Hex skip and a successful anchor consume the matched chain, so no
|
|
310
|
+
// operator inside it converts or re-anchors until a STRUCTURAL break (a
|
|
311
|
+
// boundary inside an operator's spacing slot or splitting an operand's
|
|
312
|
+
// digit run) lets the chain re-match at a later operand.
|
|
313
|
+
let chainActive = false
|
|
314
|
+
let anchored = false
|
|
315
|
+
segments.forEach((segment, index) => {
|
|
316
|
+
const slotStart = segment.operatorOffset - segment.spaceBefore.length
|
|
317
|
+
const slotEnd = segment.operatorOffset + 1 + segment.spaceAfter.length
|
|
318
|
+
const digitsEnd = leftDigitsEnd(text, slotStart)
|
|
319
|
+
// The left operand's unit suffix must be a contiguous match: a boundary
|
|
320
|
+
// splitting the unit's letters breaks `\d+suffix`, detaching the operator
|
|
321
|
+
// from any operand (its left side is no longer `\d+unit`).
|
|
322
|
+
const slotClean = operatorSlotClean(view, slotStart, slotEnd) && leftSuffixUnsplit(view, text, digitsEnd, slotStart)
|
|
323
|
+
if (!anchored) {
|
|
324
|
+
const lowerBound = index === 0 ? chainStart : segments[index - 1].operandStart
|
|
325
|
+
const runStart = leftDigitRunStart(view, text, digitsEnd, lowerBound)
|
|
326
|
+
const operand = text.slice(runStart, digitsEnd)
|
|
327
|
+
if (!chainGuardOk(view, runStart)) {
|
|
328
|
+
// Did not anchor here; re-try at the next operand.
|
|
329
|
+
chainActive = false
|
|
330
|
+
} else {
|
|
331
|
+
anchored = true
|
|
332
|
+
chainActive = !chainHexBlocked(view, runStart, operand)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (!slotClean) {
|
|
336
|
+
// Structural break: the matched chain ends; the next operand re-anchors.
|
|
337
|
+
chainActive = false
|
|
338
|
+
anchored = false
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
if (chainActive) {
|
|
342
|
+
const space = segment.spaceBefore || segment.spaceAfter ? " " : ""
|
|
343
|
+
view.replace(slotStart, slotEnd, `${space}${MULTIPLICATION}${space}`)
|
|
344
|
+
}
|
|
345
|
+
// A boundary inside this operand's digit run truncates it (`\d+` stops
|
|
346
|
+
// there): a structural break, so the next operand re-anchors.
|
|
347
|
+
if (runHasInteriorBoundary(view, segment.operandStart, segment.operandEnd)) {
|
|
348
|
+
chainActive = false
|
|
349
|
+
anchored = false
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
scan = chainStart + match[0].length
|
|
353
|
+
}
|
|
354
|
+
view.commit()
|
|
355
|
+
|
|
356
|
+
// Trailing multiplier: 5x (followed by a word boundary). The rule is
|
|
357
|
+
// `(?<!\d[eE])(?<![A-Za-z…\d])\d+ [xX*] \b(?!\w)` with one tolerated boundary
|
|
358
|
+
// before the operator. The leading guard, the slot before the operator, the
|
|
359
|
+
// hex skip, and the trailing word boundary each consult boundaries at their
|
|
360
|
+
// position. `*` never trails because it is not a word character, so the
|
|
361
|
+
// closing `\b` cannot anchor on it.
|
|
362
|
+
const trailingPattern = cachedRegExp(`(?<num>\\d+)(?<op>[xX*])`, "y")
|
|
363
|
+
const trailingText = view.text
|
|
364
|
+
let trailingScan = 0
|
|
365
|
+
while (trailingScan < trailingText.length) {
|
|
366
|
+
trailingPattern.lastIndex = trailingScan
|
|
367
|
+
const match = trailingPattern.exec(trailingText)
|
|
368
|
+
if (match === null) { trailingScan++; continue }
|
|
369
|
+
const num = match.groups!.num
|
|
370
|
+
const op = match.groups!.op
|
|
371
|
+
const operatorOffset = match.index + num.length
|
|
372
|
+
trailingScan = match.index + match[0].length
|
|
373
|
+
// One optional boundary may sit between the digits and the operator.
|
|
374
|
+
if (boundaryCountAt(view, operatorOffset) > 1) continue
|
|
375
|
+
// Leading guard and hex skip, on the boundary-free digit run ending at the
|
|
376
|
+
// operator (a boundary inside the digits truncates the operand).
|
|
377
|
+
const runStart = leftDigitRunStart(view, trailingText, operatorOffset, 0)
|
|
378
|
+
const operand = trailingText.slice(runStart, operatorOffset)
|
|
379
|
+
if (!chainGuardOk(view, runStart) || chainHexBlocked(view, runStart, operand)) continue
|
|
380
|
+
// Trailing word boundary: `*` is not a word character so it never anchors a
|
|
381
|
+
// trailing `\b`; otherwise reject when a word character follows the operator
|
|
382
|
+
// through at most three boundaries.
|
|
383
|
+
if (op === "*") continue
|
|
384
|
+
const afterOp = operatorOffset + 1
|
|
385
|
+
const followChar = trailingText[afterOp]
|
|
386
|
+
if (boundaryCountAt(view, afterOp) <= 3 && followChar !== undefined && WORD_RE.test(followChar)) continue
|
|
387
|
+
view.replace(operatorOffset, afterOp, MULTIPLICATION)
|
|
388
|
+
}
|
|
389
|
+
view.commit()
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** `[left, right, forbiddenFollow, replacement]`; forbiddenFollow is "" when none. */
|
|
393
|
+
type MathSymbolRule = [string, string, string, string]
|
|
141
394
|
|
|
142
395
|
const MATH_SYMBOL_MAP: MathSymbolRule[] = [
|
|
143
|
-
["!", "=",
|
|
144
|
-
["\\+/", "-",
|
|
145
|
-
["\\+", "-",
|
|
146
|
-
["<", "=",
|
|
147
|
-
[">", "=",
|
|
148
|
-
["~", "=",
|
|
149
|
-
["=", "~",
|
|
396
|
+
["!", "=", "=", NOT_EQUAL],
|
|
397
|
+
["\\+/", "-", "", PLUS_MINUS],
|
|
398
|
+
["\\+", "-", "", PLUS_MINUS],
|
|
399
|
+
["<", "=", "=", LESS_EQUAL],
|
|
400
|
+
[">", "=", "=", GREATER_EQUAL],
|
|
401
|
+
["~", "=", "", APPROXIMATE],
|
|
402
|
+
["=", "~", "", APPROXIMATE],
|
|
150
403
|
]
|
|
151
404
|
|
|
152
405
|
/** Convert !=, <=, >=, +/-, ~= to Unicode equivalents. */
|
|
153
|
-
export function mathSymbols(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
406
|
+
export function mathSymbols(input: string): string
|
|
407
|
+
export function mathSymbols(input: ProseView): void
|
|
408
|
+
export function mathSymbols(input: string | ProseView): string | void {
|
|
409
|
+
return overInput(input, mathSymbolsOverView)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function mathSymbolsOverView(view: ProseView): void {
|
|
413
|
+
for (const [left, right, forbiddenFollow, replacement] of MATH_SYMBOL_MAP) {
|
|
414
|
+
// Re-read after each rule's commit: prior rules mutate the clean text.
|
|
415
|
+
const text = view.text
|
|
416
|
+
// One boundary is tolerated at the left/right junction (e.g. `!<boundary>=`).
|
|
417
|
+
// Position editing keeps the surrounding fragments (and so the boundary)
|
|
418
|
+
// intact; allowBoundaries permits that one boundary. The negative lookahead
|
|
419
|
+
// `(?!=)` is checked manually because it tolerates one boundary before the
|
|
420
|
+
// forbidden `=`: with two or more boundaries the `=` is out of reach, so the
|
|
421
|
+
// match is not blocked.
|
|
422
|
+
const pattern = cachedRegExp(`${left}${right}`, "g")
|
|
423
|
+
replaceAllInView(view, pattern, (match, v) => {
|
|
424
|
+
if (forbiddenFollow && mathLookaheadBlocks(text, v, match.index + match[0].length, forbiddenFollow)) {
|
|
425
|
+
return null
|
|
426
|
+
}
|
|
427
|
+
return replacement
|
|
428
|
+
}, {
|
|
429
|
+
allowBoundaries: (m, v) => mathOperatorAllowBoundary(m, v),
|
|
164
430
|
})
|
|
431
|
+
view.commit()
|
|
165
432
|
}
|
|
166
|
-
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The `(?!=)` guard: the match is blocked when the forbidden character follows
|
|
437
|
+
* through at most one boundary. Two or more boundaries put the character out of
|
|
438
|
+
* reach (one boundary tolerated), so the match proceeds. `≈` blocks where `=`
|
|
439
|
+
* does: a later rule in this same pass folds `=~` to `≈`, consuming the `=`
|
|
440
|
+
* this guard keys on (`!==~` must stay blocked once it reads `!=≈`).
|
|
441
|
+
*/
|
|
442
|
+
function mathLookaheadBlocks(text: string, view: ProseView, end: number, forbidden: string): boolean {
|
|
443
|
+
const next = text[end]
|
|
444
|
+
const matches = next === forbidden || (forbidden === "=" && next === APPROXIMATE)
|
|
445
|
+
return matches && boundaryCountAt(view, end) <= 1
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Only the junction between the `left` operator string and the single-character
|
|
450
|
+
* `right` tolerates one interior boundary (the `right` char ends the match). A
|
|
451
|
+
* boundary inside a multi-character `left` like `+/` breaks the operator.
|
|
452
|
+
*/
|
|
453
|
+
function mathOperatorAllowBoundary(match: RegExpExecArray, view: ProseView): boolean {
|
|
454
|
+
const junction = match.index + match[0].length - 1
|
|
455
|
+
for (const boundary of view.boundaries) {
|
|
456
|
+
if (boundary > match.index && boundary < match.index + match[0].length && boundary !== junction) return false
|
|
457
|
+
}
|
|
458
|
+
return boundaryCountAt(view, junction) <= 1
|
|
167
459
|
}
|
|
168
460
|
|
|
169
461
|
type ContextPredicate = (before: string, after: string) => boolean
|
|
@@ -171,111 +463,289 @@ type ContextPredicate = (before: string, after: string) => boolean
|
|
|
171
463
|
// 25 chars: fits "copyright " or a 4-digit year with padding, without making slicing expensive.
|
|
172
464
|
const LEGAL_SYMBOL_CONTEXT_WINDOW = 25
|
|
173
465
|
|
|
466
|
+
// Window cost charged per node boundary: an element edge stands in for two
|
|
467
|
+
// characters of context, so boundary-dense markup exposes less of it.
|
|
468
|
+
const BOUNDARY_CONTEXT_COST = 2
|
|
469
|
+
|
|
470
|
+
// Vulgar fraction glyphs (½, ¾, …). A fraction folds from `n/m`, which the path
|
|
471
|
+
// heuristic below reads as a path context — so the glyph must read the same way,
|
|
472
|
+
// or `1/2(tm)` (blocked) would convert once fractions strips the slash to `½(tm)`.
|
|
473
|
+
const FRACTION_GLYPH_RE = new RegExp(
|
|
474
|
+
`[${Object.entries(UNICODE_SYMBOLS)
|
|
475
|
+
.filter(([key]) => key.startsWith("FRACTION_"))
|
|
476
|
+
.map(([, glyph]) => glyph)
|
|
477
|
+
.join("")}]`,
|
|
478
|
+
)
|
|
479
|
+
|
|
174
480
|
const isPathContext = (before: string): boolean => {
|
|
175
481
|
const parts = before.split(/\s+/)
|
|
176
482
|
const trailing = parts[parts.length - 1]
|
|
483
|
+
if (FRACTION_GLYPH_RE.test(trailing)) return true
|
|
177
484
|
const slashIdx = trailing.indexOf("/")
|
|
178
485
|
return slashIdx >= 0 && slashIdx < trailing.length - 1
|
|
179
486
|
}
|
|
180
487
|
|
|
181
488
|
function contextAwareLegalReplace(
|
|
182
|
-
|
|
489
|
+
view: ProseView,
|
|
183
490
|
pattern: RegExp,
|
|
184
491
|
replacement: string,
|
|
185
492
|
shouldConvert: ContextPredicate,
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
493
|
+
): void {
|
|
494
|
+
const text = view.text
|
|
495
|
+
replaceAllInView(view, pattern, (match, v) => {
|
|
496
|
+
const offset = match.index
|
|
497
|
+
const before = legalContextBefore(text, v, offset)
|
|
498
|
+
const after = legalContextAfter(text, v, offset + match[0].length)
|
|
499
|
+
return shouldConvert(before, after) ? replacement : null
|
|
500
|
+
}, {
|
|
501
|
+
// The `(c)`/`(r)`/`(tm)` token itself never spans a boundary (no interior
|
|
502
|
+
// slot), so a match containing an interior boundary is skipped, exactly as
|
|
503
|
+
// the default behavior.
|
|
504
|
+
allowBoundaries: undefined,
|
|
196
505
|
})
|
|
197
506
|
}
|
|
198
507
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
508
|
+
/**
|
|
509
|
+
* Context preceding `offset`, spanning the 25-character window. Each node
|
|
510
|
+
* boundary costs {@link BOUNDARY_CONTEXT_COST} characters of the window, so a
|
|
511
|
+
* region dense with boundaries exposes fewer characters of context.
|
|
512
|
+
*/
|
|
513
|
+
function legalContextBefore(text: string, view: ProseView, offset: number): string {
|
|
514
|
+
let cost = 0
|
|
515
|
+
let i = offset
|
|
516
|
+
while (i > 0) {
|
|
517
|
+
// Cost of stepping back over this clean char plus the boundaries hugging it.
|
|
518
|
+
cost += boundaryCountAt(view, i) * BOUNDARY_CONTEXT_COST + 1
|
|
519
|
+
if (cost > LEGAL_SYMBOL_CONTEXT_WINDOW) break
|
|
520
|
+
i--
|
|
521
|
+
}
|
|
522
|
+
return text.slice(i, offset)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Mirror of {@link legalContextBefore} for the text following `end`. */
|
|
526
|
+
function legalContextAfter(text: string, view: ProseView, end: number): string {
|
|
527
|
+
let cost = 0
|
|
528
|
+
let i = end
|
|
529
|
+
while (i < text.length) {
|
|
530
|
+
cost += boundaryCountAt(view, i) * BOUNDARY_CONTEXT_COST + 1
|
|
531
|
+
if (cost > LEGAL_SYMBOL_CONTEXT_WINDOW) break
|
|
532
|
+
i++
|
|
533
|
+
}
|
|
534
|
+
return text.slice(end, i)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const LEGAL_COPYRIGHT_RE = "\\(c\\)"
|
|
538
|
+
const LEGAL_REGISTERED_RE = "\\(r\\)"
|
|
539
|
+
const LEGAL_TRADEMARK_RE = "\\(tm\\)"
|
|
202
540
|
|
|
203
541
|
/** Convert (c), (r), (tm) to ©, ®, ™. */
|
|
204
|
-
export function legalSymbols(
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
542
|
+
export function legalSymbols(input: string): string
|
|
543
|
+
export function legalSymbols(input: ProseView): void
|
|
544
|
+
export function legalSymbols(input: string | ProseView): string | void {
|
|
545
|
+
return overInput(input, legalSymbolsOverView)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function legalSymbolsOverView(view: ProseView): void {
|
|
549
|
+
// (c) → © only with positive copyright evidence (year or "copyright"
|
|
550
|
+
// keyword) and not in a path context (e.g. example.com/path(c)).
|
|
551
|
+
contextAwareLegalReplace(view, cachedRegExp(LEGAL_COPYRIGHT_RE, "gi"), COPYRIGHT, (before, after) =>
|
|
209
552
|
!isPathContext(before) && (/^\s*(?:19|20)\d{2}\b/.test(after) || /\bcopyright\s*$/i.test(before)),
|
|
210
|
-
separator
|
|
211
553
|
)
|
|
554
|
+
view.commit()
|
|
212
555
|
|
|
213
|
-
// (r) → ® unless in enumeration "(q), (r)", legal citation "(r)(1)", or path context
|
|
214
|
-
|
|
556
|
+
// (r) → ® unless in enumeration "(q), (r)", legal citation "(r)(1)", or path context.
|
|
557
|
+
contextAwareLegalReplace(view, cachedRegExp(LEGAL_REGISTERED_RE, "gi"), REGISTERED, (before, after) =>
|
|
215
558
|
!/\([a-z]\)[,;]\s*$/i.test(before) && !/^\(\d/.test(after) && !isPathContext(before),
|
|
216
|
-
separator
|
|
217
559
|
)
|
|
560
|
+
view.commit()
|
|
218
561
|
|
|
219
|
-
// (tm) → ™ unless in a path context
|
|
220
|
-
|
|
562
|
+
// (tm) → ™ unless in a path context.
|
|
563
|
+
contextAwareLegalReplace(view, cachedRegExp(LEGAL_TRADEMARK_RE, "gi"), TRADEMARK, (before) =>
|
|
221
564
|
!isPathContext(before),
|
|
222
|
-
separator
|
|
223
565
|
)
|
|
566
|
+
view.commit()
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Matches one arrow shape starting at `start`; returns its end offset or -1. */
|
|
570
|
+
type ArrowMatcher = (view: ProseView, text: string, start: number) => number
|
|
571
|
+
|
|
572
|
+
const SPACE_OR_TAB_RE = /\s/
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* One boundary is tolerated after `<`, between dash runs, and before `>`, and a
|
|
576
|
+
* boundary cannot split a dash run (`-+`). Each helper walks the clean text and
|
|
577
|
+
* consults boundaries at exactly those positions, tolerating at most one
|
|
578
|
+
* boundary per slot.
|
|
579
|
+
*/
|
|
580
|
+
const ARROW_MATCHERS: readonly [ArrowMatcher, string][] = [
|
|
581
|
+
[matchLeftRightArrow, ARROW_LEFT_RIGHT],
|
|
582
|
+
[matchRightArrow, ARROW_RIGHT],
|
|
583
|
+
[matchLeftArrow, ARROW_LEFT],
|
|
584
|
+
]
|
|
224
585
|
|
|
225
|
-
|
|
586
|
+
/** Length of a `-+` dash run at `pos` (no boundary may split it); 0 if none. */
|
|
587
|
+
function dashRunLength(view: ProseView, text: string, pos: number): number {
|
|
588
|
+
let i = pos
|
|
589
|
+
while (text[i] === "-" && (i === pos || boundaryCountAt(view, i) === 0)) i++
|
|
590
|
+
return i - pos
|
|
226
591
|
}
|
|
227
592
|
|
|
228
|
-
|
|
593
|
+
/** Skip at most one boundary slot at `pos`; returns the offset after it. -1 on a double boundary. */
|
|
594
|
+
function arrowSepSlot(view: ProseView, pos: number): number {
|
|
595
|
+
const count = boundaryCountAt(view, pos)
|
|
596
|
+
return count <= 1 ? pos : -1
|
|
597
|
+
}
|
|
229
598
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
599
|
+
/** `-+${sep}?>`: a dash run, an optional boundary, then `>`. */
|
|
600
|
+
function matchRightArrow(view: ProseView, text: string, start: number): number {
|
|
601
|
+
const run = dashRunLength(view, text, start)
|
|
602
|
+
if (run === 0) return -1
|
|
603
|
+
const beforeGt = start + run
|
|
604
|
+
if (arrowSepSlot(view, beforeGt) < 0) return -1
|
|
605
|
+
return text[beforeGt] === ">" ? beforeGt + 1 : -1
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** `<${sep}?-+`: `<`, an optional boundary, then a dash run. */
|
|
609
|
+
function matchLeftArrow(view: ProseView, text: string, start: number): number {
|
|
610
|
+
if (text[start] !== "<") return -1
|
|
611
|
+
const afterLt = start + 1
|
|
612
|
+
if (arrowSepSlot(view, afterLt) < 0) return -1
|
|
613
|
+
const run = dashRunLength(view, text, afterLt)
|
|
614
|
+
return run === 0 ? -1 : afterLt + run
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** `<${sep}?-+(?:${sep}-+)*${sep}?>`: bidirectional arrow with dash runs. */
|
|
618
|
+
function matchLeftRightArrow(view: ProseView, text: string, start: number): number {
|
|
619
|
+
if (text[start] !== "<") return -1
|
|
620
|
+
let i = start + 1
|
|
621
|
+
if (arrowSepSlot(view, i) < 0) return -1
|
|
622
|
+
let run = dashRunLength(view, text, i)
|
|
623
|
+
if (run === 0) return -1
|
|
624
|
+
i += run
|
|
625
|
+
// Additional `${sep}-+` runs: each separated by exactly one boundary.
|
|
626
|
+
while (boundaryCountAt(view, i) === 1) {
|
|
627
|
+
run = dashRunLength(view, text, i)
|
|
628
|
+
if (run === 0) break
|
|
629
|
+
i += run
|
|
630
|
+
}
|
|
631
|
+
if (arrowSepSlot(view, i) < 0) return -1
|
|
632
|
+
return text[i] === ">" ? i + 1 : -1
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Left context `(^|\s|${chr})`: start of text, a space, or a node boundary. */
|
|
636
|
+
function arrowLeftContextOk(view: ProseView, text: string, start: number): boolean {
|
|
637
|
+
if (start === 0 || view.hasBoundary(start)) return true
|
|
638
|
+
return SPACE_OR_TAB_RE.test(text[start - 1])
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Right context `(?=\s|${chr}|$)`: end of text, a space, or a node boundary. */
|
|
642
|
+
function arrowRightContextOk(view: ProseView, text: string, end: number): boolean {
|
|
643
|
+
if (end >= text.length || view.hasBoundary(end)) return true
|
|
644
|
+
return SPACE_OR_TAB_RE.test(text[end])
|
|
645
|
+
}
|
|
236
646
|
|
|
237
647
|
/** Convert -> and <-> to arrows. */
|
|
238
|
-
export function arrows(
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
648
|
+
export function arrows(input: string): string
|
|
649
|
+
export function arrows(input: ProseView): void
|
|
650
|
+
export function arrows(input: string | ProseView): string | void {
|
|
651
|
+
return overInput(input, arrowsOverView)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function arrowsOverView(view: ProseView): void {
|
|
655
|
+
// Each arrow shape is matched by a left-to-right scan so a boundary that
|
|
656
|
+
// splits a dash run still lets the valid suffix match. The whole shape span is
|
|
657
|
+
// replaced with the arrow; consumed boundaries collapse to just after it.
|
|
658
|
+
for (const [matcher, replacement] of ARROW_MATCHERS) {
|
|
659
|
+
const text = view.text
|
|
660
|
+
let i = 0
|
|
661
|
+
while (i < text.length) {
|
|
662
|
+
if (!arrowLeftContextOk(view, text, i)) { i++; continue }
|
|
663
|
+
const end = matcher(view, text, i)
|
|
664
|
+
if (end < 0 || !arrowRightContextOk(view, text, end)) { i++; continue }
|
|
665
|
+
view.replace(i, end, replacement)
|
|
666
|
+
i = end
|
|
667
|
+
}
|
|
668
|
+
view.commit()
|
|
256
669
|
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export function degrees(input: string): string
|
|
673
|
+
export function degrees(input: ProseView): void
|
|
674
|
+
export function degrees(input: string | ProseView): string | void {
|
|
675
|
+
return overInput(input, degreesOverView)
|
|
676
|
+
}
|
|
257
677
|
|
|
258
|
-
|
|
678
|
+
function degreesOverView(view: ProseView): void {
|
|
679
|
+
// Temperature with optional space before C or F (uppercase only). One boundary
|
|
680
|
+
// is tolerated between the digit and the unit, and the unit is followed by
|
|
681
|
+
// `(?!-[A-Za-z…]|[+#])` (reject "C-compiler", "C++", "F#") plus a word
|
|
682
|
+
// boundary. Those trailing assertions are checked manually so a node boundary
|
|
683
|
+
// right after the unit shadows the compound character, and the word-boundary
|
|
684
|
+
// arm sees through up to three boundaries.
|
|
685
|
+
const pattern = cachedRegExp(`\\d ?(?<unit>[CF])`, "g")
|
|
686
|
+
const text = view.text
|
|
687
|
+
replaceAllInView(view, pattern, (match, v) => {
|
|
688
|
+
const unitEnd = match.index + match[0].length
|
|
689
|
+
if (!degreeUnitFollowOk(text, v, unitEnd)) return null
|
|
690
|
+
const { unit } = match.groups!
|
|
691
|
+
// Replace everything after the leading digit (the optional space and the
|
|
692
|
+
// unit) with ` °C`/` °F`, leaving the digit and any boundary right after it
|
|
693
|
+
// in place so the boundary keeps its position.
|
|
694
|
+
v.replace(match.index + 1, unitEnd, ` ${DEGREE}${unit}`)
|
|
695
|
+
return null
|
|
696
|
+
}, {
|
|
697
|
+
allowBoundaries: (match, v) => digitSuffixBoundaryOk(match, v),
|
|
698
|
+
})
|
|
699
|
+
view.commit()
|
|
259
700
|
}
|
|
260
701
|
|
|
261
|
-
|
|
262
|
-
|
|
702
|
+
/**
|
|
703
|
+
* The `(?!-[A-Za-z…]|[+#])` compound guard plus `\b(?!${chr}{0,3}\w)` word
|
|
704
|
+
* boundary that follow the unit. A node boundary directly after the unit
|
|
705
|
+
* shadows a `+`/`#`/`-`, satisfying the compound guard; the word boundary
|
|
706
|
+
* blocks only when a word character follows the unit through at most three
|
|
707
|
+
* boundaries.
|
|
708
|
+
*/
|
|
709
|
+
function degreeUnitFollowOk(text: string, view: ProseView, unitEnd: number): boolean {
|
|
710
|
+
const boundaryAfterUnit = view.hasBoundary(unitEnd)
|
|
711
|
+
// Compound guard: `+`/`#` directly after the unit (no boundary shadowing it),
|
|
712
|
+
// or `-` directly followed by a Latin letter (boundary-aware).
|
|
713
|
+
if (!boundaryAfterUnit) {
|
|
714
|
+
const next = text[unitEnd]
|
|
715
|
+
if (next === "+" || next === "#") return false
|
|
716
|
+
if (next === "-" && !view.hasBoundary(unitEnd + 1) && LATIN_LETTER_RE.test(text[unitEnd + 1] ?? "")) return false
|
|
717
|
+
}
|
|
718
|
+
// Word boundary: reject when a word character follows the unit across up to
|
|
719
|
+
// three boundaries; with no boundary a word character directly after the unit
|
|
720
|
+
// also removes the `\b`. Consecutive boundaries pile at the same clean offset,
|
|
721
|
+
// so count them there.
|
|
722
|
+
const followChar = text[unitEnd]
|
|
723
|
+
if (boundaryCountAt(view, unitEnd) <= 3 && followChar !== undefined && WORD_RE.test(followChar)) return false
|
|
724
|
+
return true
|
|
725
|
+
}
|
|
263
726
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
)
|
|
727
|
+
/**
|
|
728
|
+
* Tolerate at most one boundary directly after the leading digit (the single
|
|
729
|
+
* slot between `\d` and the rest); a second adjacent boundary breaks the slot.
|
|
730
|
+
* The digit is the first match character, so the only legal interior-boundary
|
|
731
|
+
* offset is match.index + 1.
|
|
732
|
+
*/
|
|
733
|
+
function digitSuffixBoundaryOk(match: RegExpExecArray, view: ProseView): boolean {
|
|
734
|
+
const digitEnd = match.index + 1
|
|
735
|
+
const matchEnd = match.index + match[0].length
|
|
736
|
+
for (const boundary of view.boundaries) {
|
|
737
|
+
// Reject any interior boundary that is not the single tolerated slot after
|
|
738
|
+
// the leading digit.
|
|
739
|
+
if (boundary > match.index && boundary < matchEnd && boundary !== digitEnd) return false
|
|
740
|
+
}
|
|
741
|
+
return boundaryCountAt(view, digitEnd) <= 1
|
|
274
742
|
}
|
|
275
743
|
|
|
276
744
|
/** Convert 5'10" to 5′10″ (prime marks). Call before smart quotes. */
|
|
277
|
-
export function primeMarks(
|
|
278
|
-
|
|
745
|
+
export function primeMarks(input: string): string
|
|
746
|
+
export function primeMarks(input: ProseView): void
|
|
747
|
+
export function primeMarks(input: string | ProseView): string | void {
|
|
748
|
+
return overInput(input, convertPrimeMarks)
|
|
279
749
|
}
|
|
280
750
|
|
|
281
751
|
type FractionRule = [string, string, string]
|
|
@@ -301,33 +771,77 @@ const FRACTION_TUPLES: FractionRule[] = [
|
|
|
301
771
|
const FRACTION_MAP = Object.fromEntries(FRACTION_TUPLES.map(([n, d, u]) => [`${n}/${d}`, u]))
|
|
302
772
|
|
|
303
773
|
/** Convert 1/2, 1/4, etc. to ½, ¼, etc. Single-pass using alternation. */
|
|
304
|
-
export function fractions(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const pairAlternation = FRACTION_TUPLES.map(([n, d]) =>
|
|
310
|
-
`${n}${chr}?/${chr}?${d}`
|
|
311
|
-
).join("|")
|
|
312
|
-
|
|
313
|
-
// - (?<![/.\d]) prevents matching after slashes, dots, or digits
|
|
314
|
-
// - (?![/\d]|\.\d) prevents matching before slashes, digits, or decimal points
|
|
315
|
-
const pattern = cachedRegExp(
|
|
316
|
-
`(?<![/\\.\\d])(?:${pairAlternation})(?![/\\d]|\\.\\d)`,
|
|
317
|
-
"g"
|
|
318
|
-
)
|
|
319
|
-
|
|
320
|
-
const sepPattern = cachedRegExp(chr, "g")
|
|
774
|
+
export function fractions(input: string): string
|
|
775
|
+
export function fractions(input: ProseView): void
|
|
776
|
+
export function fractions(input: string | ProseView): string | void {
|
|
777
|
+
return overInput(input, fractionsOverView)
|
|
778
|
+
}
|
|
321
779
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
780
|
+
function fractionsOverView(view: ProseView): void {
|
|
781
|
+
// Build alternation of exact valid pairs: `1/4|1/2|...`. Only exact pairs
|
|
782
|
+
// from FRACTION_TUPLES match — no cross-product. One boundary is tolerated on
|
|
783
|
+
// each side of the slash, and the pair is surrounded by the lookarounds
|
|
784
|
+
// `(?<![/.\d])` and `(?![/\d]|\.\d)`. The lookarounds are checked manually so
|
|
785
|
+
// a node boundary at the numerator's left edge or the denominator's right edge
|
|
786
|
+
// satisfies them (a boundary is none of those characters).
|
|
787
|
+
const pairAlternation = FRACTION_TUPLES.map(([n, d]) => `${n}/${d}`).join("|")
|
|
788
|
+
const pattern = cachedRegExp(`(?:${pairAlternation})`, "g")
|
|
789
|
+
const text = view.text
|
|
790
|
+
replaceAllInView(view, pattern, (match, v) => {
|
|
791
|
+
const start = match.index
|
|
792
|
+
const end = start + match[0].length
|
|
793
|
+
if (!fractionLookbehindOk(text, v, start)) return null
|
|
794
|
+
if (!fractionLookaheadOk(text, v, end)) return null
|
|
795
|
+
// Distribute interior boundaries: the first lands before the unicode char,
|
|
796
|
+
// any remaining after it.
|
|
797
|
+
let firstBoundary = end
|
|
798
|
+
for (const boundary of v.boundaries) {
|
|
799
|
+
if (boundary > start && boundary < end) { firstBoundary = boundary; break }
|
|
800
|
+
}
|
|
801
|
+
if (firstBoundary > start) v.replace(start, firstBoundary, "")
|
|
802
|
+
v.replace(firstBoundary, end, FRACTION_MAP[match[0]])
|
|
803
|
+
return null
|
|
804
|
+
}, {
|
|
805
|
+
allowBoundaries: (match, v) => fractionSlotBoundaryOk(match, v),
|
|
330
806
|
})
|
|
807
|
+
view.commit()
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* `(?<![/.\d])` at the numerator's left edge. A node boundary immediately
|
|
812
|
+
* before the numerator shadows the clean character (a boundary is not `/`, `.`,
|
|
813
|
+
* or a digit), so the lookbehind passes.
|
|
814
|
+
*/
|
|
815
|
+
function fractionLookbehindOk(text: string, view: ProseView, start: number): boolean {
|
|
816
|
+
if (start === 0 || view.hasBoundary(start)) return true
|
|
817
|
+
return !/[/.\d]/.test(text[start - 1])
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* `(?![/\d]|\.\d)` at the denominator's right edge. A node boundary immediately
|
|
822
|
+
* after the denominator shadows the clean characters, satisfying the lookahead.
|
|
823
|
+
*/
|
|
824
|
+
function fractionLookaheadOk(text: string, view: ProseView, end: number): boolean {
|
|
825
|
+
if (view.hasBoundary(end)) return true
|
|
826
|
+
const next = text[end]
|
|
827
|
+
if (next === undefined) return true
|
|
828
|
+
if (/[/\d]/.test(next)) return false
|
|
829
|
+
// `\.\d`: a dot directly followed by a digit (a node boundary between them
|
|
830
|
+
// breaks the pair, so re-test for the boundary).
|
|
831
|
+
if (next === "." && !view.hasBoundary(end + 1) && /\d/.test(text[end + 1] ?? "")) return false
|
|
832
|
+
return true
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* A fraction tolerates one boundary on each side of the slash (two slots). The
|
|
837
|
+
* numerator and denominator are single digits, so the match spans exactly
|
|
838
|
+
* `digit/digit` and the only interior-boundary offsets are right before the
|
|
839
|
+
* slash (match.index + 1) and right after it (match.index + 2); each accepts at
|
|
840
|
+
* most one boundary.
|
|
841
|
+
*/
|
|
842
|
+
function fractionSlotBoundaryOk(match: RegExpExecArray, view: ProseView): boolean {
|
|
843
|
+
const slashOffset = match.index + 1
|
|
844
|
+
return boundaryCountAt(view, slashOffset) <= 1 && boundaryCountAt(view, slashOffset + 1) <= 1
|
|
331
845
|
}
|
|
332
846
|
|
|
333
847
|
const ORDINAL_MAP: Record<string, string> = {
|
|
@@ -338,70 +852,119 @@ const ORDINAL_MAP: Record<string, string> = {
|
|
|
338
852
|
}
|
|
339
853
|
|
|
340
854
|
/** Convert 1st, 2nd, 3rd, 4th to superscript ordinals. */
|
|
341
|
-
export function superscriptOrdinal(
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
// Uses marker-aware boundary to avoid false matches like "1st\uE000ly"
|
|
347
|
-
const wbe = wordBoundaryEnd(chr)
|
|
348
|
-
const pattern = cachedRegExp(
|
|
349
|
-
`(?<num>\\d${chr}?)(?<suffix>st|nd|rd|th)${wbe}`,
|
|
350
|
-
"gi"
|
|
351
|
-
)
|
|
855
|
+
export function superscriptOrdinal(input: string): string
|
|
856
|
+
export function superscriptOrdinal(input: ProseView): void
|
|
857
|
+
export function superscriptOrdinal(input: string | ProseView): string | void {
|
|
858
|
+
return overInput(input, superscriptOrdinalOverView)
|
|
859
|
+
}
|
|
352
860
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
861
|
+
function superscriptOrdinalOverView(view: ProseView): void {
|
|
862
|
+
// Match number + ordinal suffix at word boundary, case-insensitively. One
|
|
863
|
+
// boundary is tolerated between the digit and the suffix, with a word boundary
|
|
864
|
+
// after the suffix. Only the suffix is replaced, leaving the digit and any
|
|
865
|
+
// boundary between them untouched so the boundary keeps its position.
|
|
866
|
+
const pattern = cachedRegExp(`(?<num>\\d)(?<suffix>st|nd|rd|th)\\b(?![${LATIN_LETTERS}\\d])`, "gi")
|
|
867
|
+
replaceAllInView(view, pattern, (match, v) => {
|
|
868
|
+
const { num, suffix } = match.groups!
|
|
869
|
+
const suffixStart = match.index + num.length
|
|
870
|
+
v.replace(suffixStart, match.index + match[0].length, ORDINAL_MAP[suffix.toLowerCase()])
|
|
871
|
+
return null
|
|
872
|
+
}, {
|
|
873
|
+
allowBoundaries: (match, v) => digitSuffixBoundaryOk(match, v),
|
|
356
874
|
})
|
|
875
|
+
view.commit()
|
|
357
876
|
}
|
|
358
877
|
|
|
359
878
|
// Preserves highest-priority space type (NBSP > NNBSP > regular) and leading indentation.
|
|
360
|
-
export function collapseSpaces(
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
879
|
+
export function collapseSpaces(input: string): string
|
|
880
|
+
export function collapseSpaces(input: ProseView): void
|
|
881
|
+
export function collapseSpaces(input: string | ProseView): string | void {
|
|
882
|
+
return overInput(input, collapseSpacesOverView)
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Collapses each run of two or more space characters to its highest-priority
|
|
887
|
+
* space. A run never crosses a node boundary, and the anchor requiring a
|
|
888
|
+
* non-newline, non-space character before the run is satisfied by a boundary
|
|
889
|
+
* (so a run opening a node still collapses); runs at line or text start are
|
|
890
|
+
* preserved so indented blocks survive.
|
|
891
|
+
*/
|
|
892
|
+
function collapseSpacesOverView(view: ProseView): void {
|
|
893
|
+
const text = view.text
|
|
894
|
+
let i = 0
|
|
895
|
+
while (i < text.length) {
|
|
896
|
+
if (!SPACE_CHAR_RE.test(text[i])) {
|
|
897
|
+
i++
|
|
898
|
+
continue
|
|
899
|
+
}
|
|
900
|
+
let end = i + 1
|
|
901
|
+
while (end < text.length && SPACE_CHAR_RE.test(text[end]) && !view.hasBoundary(end)) end++
|
|
902
|
+
const anchored = view.hasBoundary(i) || (i > 0 && text[i - 1] !== "\n" && !SPACE_CHAR_RE.test(text[i - 1]))
|
|
903
|
+
if (anchored && end - i >= 2) {
|
|
904
|
+
const run = text.slice(i, end)
|
|
905
|
+
const kept = run.includes(NBSP) ? NBSP : run.includes(UNICODE_SYMBOLS.NNBSP) ? UNICODE_SYMBOLS.NNBSP : " "
|
|
906
|
+
view.replace(i, end, kept)
|
|
907
|
+
}
|
|
908
|
+
i = end
|
|
909
|
+
}
|
|
910
|
+
view.commit()
|
|
366
911
|
}
|
|
367
912
|
|
|
913
|
+
/** `[first, repeated, replacement]` with literal punctuation characters. */
|
|
368
914
|
type LigatureRule = [string, string, string]
|
|
369
915
|
|
|
370
916
|
// Order matters: mixed punctuation first, then repeated.
|
|
371
917
|
const PUNCTUATION_LIGATURE_MAP: LigatureRule[] = [
|
|
372
|
-
["
|
|
373
|
-
["!", "
|
|
374
|
-
["
|
|
375
|
-
["!", "!", "!"],
|
|
918
|
+
["?", "!", QUESTION_EXCLAMATION], // ?!+ → ⁈
|
|
919
|
+
["!", "?", EXCLAMATION_QUESTION], // !?+ → ⁉
|
|
920
|
+
["?", "?", DOUBLE_QUESTION], // ??+ → ⁇
|
|
921
|
+
["!", "!", "!"], // !!+ → ! (normalize)
|
|
376
922
|
]
|
|
377
923
|
|
|
378
924
|
/** Convert ?? to ⁇, ?! to ⁈, !? to ⁉. Disabled by default (poor font support). */
|
|
379
|
-
export function punctuationLigatures(
|
|
380
|
-
|
|
381
|
-
|
|
925
|
+
export function punctuationLigatures(input: string): string
|
|
926
|
+
export function punctuationLigatures(input: ProseView): void
|
|
927
|
+
export function punctuationLigatures(input: string | ProseView): string | void {
|
|
928
|
+
return overInput(input, punctuationLigaturesOverView)
|
|
929
|
+
}
|
|
382
930
|
|
|
931
|
+
function punctuationLigaturesOverView(view: ProseView): void {
|
|
383
932
|
for (const [first, repeated, replacement] of PUNCTUATION_LIGATURE_MAP) {
|
|
384
|
-
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
text = text
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
933
|
+
// The rule is `first (repeated)+`: the leading char, then one or more
|
|
934
|
+
// repeated chars each preceded by at most one boundary (a second adjacent
|
|
935
|
+
// boundary breaks the run). A left-to-right scan replaces the whole run with
|
|
936
|
+
// the ligature; consumed boundaries collapse to just after it, and a broken
|
|
937
|
+
// run re-anchors at the next leading char.
|
|
938
|
+
const text = view.text
|
|
939
|
+
let i = 0
|
|
940
|
+
while (i < text.length) {
|
|
941
|
+
if (text[i] !== first) { i++; continue }
|
|
942
|
+
let end = i + 1
|
|
943
|
+
let consumed = 0
|
|
944
|
+
for (;;) {
|
|
945
|
+
const afterSep = arrowSepSlot(view, end)
|
|
946
|
+
if (afterSep < 0 || text[afterSep] !== repeated) break
|
|
947
|
+
end = afterSep + 1
|
|
948
|
+
consumed++
|
|
949
|
+
}
|
|
950
|
+
if (consumed === 0) { i++; continue }
|
|
951
|
+
view.replace(i, end, replacement)
|
|
952
|
+
i = end
|
|
953
|
+
}
|
|
954
|
+
view.commit()
|
|
393
955
|
}
|
|
394
|
-
|
|
395
|
-
return text
|
|
396
956
|
}
|
|
397
957
|
|
|
398
|
-
export function symbolTransform(
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
958
|
+
export function symbolTransform(input: string, options?: SymbolOptions): string
|
|
959
|
+
export function symbolTransform(input: ProseView, options?: SymbolOptions): void
|
|
960
|
+
export function symbolTransform(input: string | ProseView, options: SymbolOptions = {}): string | void {
|
|
961
|
+
return overInput(input, (view) => {
|
|
962
|
+
ellipsisOverView(view)
|
|
963
|
+
multiplicationOverView(view)
|
|
964
|
+
mathSymbolsOverView(view)
|
|
965
|
+
legalSymbolsOverView(view)
|
|
966
|
+
if (options.includeArrows !== false) {
|
|
967
|
+
arrowsOverView(view)
|
|
968
|
+
}
|
|
969
|
+
})
|
|
407
970
|
}
|