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