punctilio 4.3.1 → 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.
Files changed (65) hide show
  1. package/README.md +36 -22
  2. package/dist/constants.d.ts +0 -27
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +0 -40
  5. package/dist/constants.js.map +1 -1
  6. package/dist/dashes.d.ts +11 -7
  7. package/dist/dashes.d.ts.map +1 -1
  8. package/dist/dashes.js +423 -284
  9. package/dist/dashes.js.map +1 -1
  10. package/dist/index.d.ts +14 -118
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +65 -116
  13. package/dist/index.js.map +1 -1
  14. package/dist/markdown.js +1 -1
  15. package/dist/markdown.js.map +1 -1
  16. package/dist/nbsp.d.ts +19 -13
  17. package/dist/nbsp.d.ts.map +1 -1
  18. package/dist/nbsp.js +214 -69
  19. package/dist/nbsp.js.map +1 -1
  20. package/dist/prose-view.d.ts +48 -7
  21. package/dist/prose-view.d.ts.map +1 -1
  22. package/dist/prose-view.js +163 -27
  23. package/dist/prose-view.js.map +1 -1
  24. package/dist/quote-classifier.d.ts +5 -4
  25. package/dist/quote-classifier.d.ts.map +1 -1
  26. package/dist/quote-classifier.js +601 -88
  27. package/dist/quote-classifier.js.map +1 -1
  28. package/dist/quotes.d.ts +12 -4
  29. package/dist/quotes.d.ts.map +1 -1
  30. package/dist/quotes.js +24 -27
  31. package/dist/quotes.js.map +1 -1
  32. package/dist/rehype.d.ts +53 -8
  33. package/dist/rehype.d.ts.map +1 -1
  34. package/dist/rehype.js +105 -62
  35. package/dist/rehype.js.map +1 -1
  36. package/dist/remark.d.ts +1 -1
  37. package/dist/remark.d.ts.map +1 -1
  38. package/dist/remark.js +6 -9
  39. package/dist/remark.js.map +1 -1
  40. package/dist/symbols.d.ts +25 -14
  41. package/dist/symbols.d.ts.map +1 -1
  42. package/dist/symbols.js +812 -220
  43. package/dist/symbols.js.map +1 -1
  44. package/dist/transform-options.d.ts +97 -0
  45. package/dist/transform-options.d.ts.map +1 -0
  46. package/dist/transform-options.js +34 -0
  47. package/dist/transform-options.js.map +1 -0
  48. package/dist/utils.d.ts +0 -16
  49. package/dist/utils.d.ts.map +1 -1
  50. package/dist/utils.js +0 -56
  51. package/dist/utils.js.map +1 -1
  52. package/package.json +6 -2
  53. package/src/constants.ts +0 -46
  54. package/src/dashes.ts +450 -269
  55. package/src/index.ts +70 -291
  56. package/src/markdown.ts +1 -1
  57. package/src/nbsp.ts +255 -78
  58. package/src/prose-view.ts +234 -39
  59. package/src/quote-classifier.ts +618 -88
  60. package/src/quotes.ts +36 -19
  61. package/src/rehype.ts +169 -88
  62. package/src/remark.ts +6 -11
  63. package/src/symbols.ts +814 -251
  64. package/src/transform-options.ts +143 -0
  65. package/src/utils.ts +0 -82
package/dist/symbols.js CHANGED
@@ -1,180 +1,668 @@
1
- import { cachedRegExp, DEFAULT_SEPARATOR, getEscapedSeparator, LATIN_LETTERS, SPACE_CHARS, UNICODE_SYMBOLS, wordBoundaryEnd } from "./constants.js";
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
- /** Convert "..." or ". . ." to "…". */
6
- export function ellipsis(text, options = {}) {
7
- const chr = getEscapedSeparator(options);
8
- // Convert consecutive or spaced dots: ... or . . . → …
9
- // Each gap allows EITHER a space (same-text-node spaced dots) OR a separator
10
- // (cross-boundary contiguous dots), but never both — a space adjacent to a
11
- // separator means the dots straddle a text-node boundary and shouldn't fold
12
- // into one ellipsis.
13
- const pattern = cachedRegExp(`\\.(?:[${SPACE_CHARS}]|(?<sep1>${chr}))?\\.(?:[${SPACE_CHARS}]?|(?<sep2>${chr})?)\\.`, "g");
14
- text = text.replace(pattern, (...args) => {
15
- const { sep1, sep2 } = namedGroups(args);
16
- return ELLIPSIS + (sep1 ?? "") + (sep2 ?? "");
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
- text = text.replace(cachedRegExp(`${ELLIPSIS}(?=[${LATIN_LETTERS}\\d])`, "gu"), `${ELLIPSIS} `);
19
- return text;
20
- }
21
- /** Convert "5x5" to "5×5". Skips hex (0x5F). */
22
- export function multiplication(text, options = {}) {
23
- const chr = getEscapedSeparator(options);
24
- // After a digit run, either a prime mark (10′) or a length/size unit may
25
- // attach before the multiplication operator. Allowing both lets dimensions
26
- // like "5m × 5m", "210mm × 297mm", or "1920px × 1080px" convert, matching
27
- // Chicago §9.17's preference for × in dimension notation.
28
- //
29
- // Only length/size units that plausibly appear in two-dimensional
30
- // dimensions are listed — mass/time/electrical units (kg, min, V, …) don't
31
- // participate in "N × N" constructions and excluding them avoids false
32
- // matches on words ending in those letters.
33
- const dimensionUnits = "rem|vh|vw|km|cm|mm|nm|pm|mi|ft|yd|in|px|pt|em|m";
34
- // Word-boundary lookahead: the unit must be followed by whitespace, a
35
- // separator marker, the multiplication operator, end-of-string, or
36
- // sentence punctuation — never another letter (which would mean the
37
- // "unit" is actually a word prefix like "mold").
38
- const unitBoundary = `(?=\\s|${chr}|[xX*.,;!?)}]|$)`;
39
- const unitAlt = `(?:\\s|${chr})?(?:${dimensionUnits})${unitBoundary}`;
40
- const primeAlt = `${chr}?[${PRIME}${DOUBLE_PRIME}]`;
41
- const digitSuffix = `(?:${primeAlt}|${unitAlt})?`;
42
- // Match entire multiplication chains in one pass: "5 x 5 x 5" or "5x5x5"
43
- // Pattern matches: digit(s), then one or more (operator, digit(s)) groups
44
- // The (?<!\d[eE]) lookbehind prevents matching inside ambiguous unsigned
45
- // scientific notation (1e5x3 — could also be a model SKU). Signed exponents
46
- // (1e-5x3, 3.5E+10x2) are unambiguously scientific so the multiplication
47
- // is converted. The Latin-letter lookbehind blocks model-name identifiers
48
- // (Surface5x3, RTX3060x2).
49
- const chainPattern = cachedRegExp(`(?<!\\d[eE])(?<![${LATIN_LETTERS}\\d])(?<firstNum>\\d+${digitSuffix})(?<rest>(?:${chr}?\\s*[xX*]\\s*${chr}?\\d+${digitSuffix})+)`, "g");
50
- text = text.replace(chainPattern, (...args) => {
51
- const { firstNum, rest } = namedGroups(args);
52
- // Skip hexadecimal: 0x... or 0X...
53
- if (firstNum === "0" && /^x/i.test(rest))
54
- return args[0];
55
- // Replace each "operator + next digit group" segment in lockstep.
56
- // Consuming the digit-suffix as part of each segment keeps the inner
57
- // replace from misreading the `x` inside a trailing unit like `px` as
58
- // another operator.
59
- // Sticky flag (`y`) anchors each segment at the previous one's end.
60
- // `rest` always starts with a chain segment from the outer match, so
61
- // the engine never tries multiple start positions across whitespace.
62
- const converted = rest.replace(cachedRegExp(`(?<pre>${chr}?)(?<spaceBefore>\\s*)[xX*](?<spaceAfter>\\s*)(?<post>${chr}?)(?<num>\\d+${digitSuffix})`, "gy"), (...innerArgs) => {
63
- const groups = namedGroups(innerArgs);
64
- const space = groups.spaceBefore || groups.spaceAfter ? " " : "";
65
- return `${groups.pre}${space}${MULTIPLICATION}${space}${groups.post}${groups.num}`;
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
- return `${firstNum}${converted}`;
68
- });
69
- // Trailing multiplier: 5x (followed by word boundary - space, punctuation, etc.)
70
- const wbe = wordBoundaryEnd(chr);
71
- const trailingPattern = cachedRegExp(`(?<!\\d[eE])(?<![${LATIN_LETTERS}\\d])(?<num>\\d+${chr}?)[xX*]${wbe}`, "g");
72
- text = text.replace(trailingPattern, (match, num) => {
73
- // Skip if this looks like start of hexadecimal
74
- if (num === "0")
75
- return match;
76
- return `${num}${MULTIPLICATION}`;
77
- });
78
- return text;
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
- ["!", "=", (chr) => `(?!${chr}?=)`, NOT_EQUAL],
82
- ["\\+/", "-", () => "", PLUS_MINUS],
83
- ["\\+", "-", () => "", PLUS_MINUS],
84
- ["<", "=", (chr) => `(?!${chr}?=)`, LESS_EQUAL],
85
- [">", "=", (chr) => `(?!${chr}?=)`, GREATER_EQUAL],
86
- ["~", "=", () => "", APPROXIMATE],
87
- ["=", "~", () => "", APPROXIMATE],
350
+ ["!", "=", "=", NOT_EQUAL],
351
+ ["\\+/", "-", "", PLUS_MINUS],
352
+ ["\\+", "-", "", PLUS_MINUS],
353
+ ["<", "=", "=", LESS_EQUAL],
354
+ [">", "=", "=", GREATER_EQUAL],
355
+ ["~", "=", "", APPROXIMATE],
356
+ ["=", "~", "", APPROXIMATE],
88
357
  ];
89
- /** Convert !=, <=, >=, +/-, ~= to Unicode equivalents. */
90
- export function mathSymbols(text, options = {}) {
91
- const chr = getEscapedSeparator(options);
92
- for (const [left, right, lookahead, replacement] of MATH_SYMBOL_MAP) {
93
- // Capture an optional separator between left and right so a cross-boundary
94
- // match (e.g., `!<SEP>=`) re-emits the separator alongside the replacement
95
- // and preserves the text-node-count invariant in transformTextNodes.
96
- const pattern = cachedRegExp(`${left}(?<sep>${chr})?${right}${lookahead(chr)}`, "g");
97
- text = text.replace(pattern, (...args) => {
98
- const { sep } = namedGroups(args);
99
- return `${replacement}${sep ?? ""}`;
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 text;
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(text, pattern, replacement, shouldConvert, separator) {
113
- return text.replace(pattern, (match, offset, str) => {
114
- const before = str
115
- .slice(Math.max(0, offset - LEGAL_SYMBOL_CONTEXT_WINDOW), offset)
116
- .replaceAll(separator, "");
117
- const after = str
118
- .slice(offset + match.length, offset + match.length + LEGAL_SYMBOL_CONTEXT_WINDOW)
119
- .replaceAll(separator, "");
120
- return shouldConvert(before, after) ? replacement : match;
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
- const LEGAL_COPYRIGHT_RE = /\(c\)/gi;
124
- const LEGAL_REGISTERED_RE = /\(r\)/gi;
125
- const LEGAL_TRADEMARK_RE = /\(tm\)/gi;
126
- /** Convert (c), (r), (tm) to ©, ®, ™. */
127
- export function legalSymbols(text, options = {}) {
128
- const separator = options.separator ?? DEFAULT_SEPARATOR;
129
- // (c) → © only with positive copyright evidence (year or "copyright" keyword)
130
- // and not in a path context (e.g., example.com/path(c))
131
- text = contextAwareLegalReplace(text, LEGAL_COPYRIGHT_RE, COPYRIGHT, (before, after) => !isPathContext(before) && (/^\s*(?:19|20)\d{2}\b/.test(after) || /\bcopyright\s*$/i.test(before)), separator);
132
- // (r) → ® unless in enumeration "(q), (r)", legal citation "(r)(1)", or path context
133
- text = contextAwareLegalReplace(text, LEGAL_REGISTERED_RE, REGISTERED, (before, after) => !/\([a-z]\)[,;]\s*$/i.test(before) && !/^\(\d/.test(after) && !isPathContext(before), separator);
134
- // (tm) → ™ unless in a path context
135
- text = contextAwareLegalReplace(text, LEGAL_TRADEMARK_RE, TRADEMARK, (before) => !isPathContext(before), separator);
136
- return text;
137
- }
138
- // Leading `<` on bidi anchors the dash-run group for linear time.
139
- const ARROW_RULES = [
140
- [(sep) => `<${sep}?-+(?:${sep}-+)*${sep}?>`, ARROW_LEFT_RIGHT],
141
- [(sep) => `-+${sep}?>`, ARROW_RIGHT],
142
- [(sep) => `<${sep}?-+`, ARROW_LEFT],
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
- /** Convert -> and <-> to arrows. */
145
- export function arrows(text, options = {}) {
146
- const chr = getEscapedSeparator(options);
147
- const sepPattern = cachedRegExp(chr, "g");
148
- // Boundary on the left is a capturing group rather than a lookbehind so
149
- // the alternation stays out of the assertion (recheck-safe); the captured
150
- // boundary char is re-emitted before the replacement.
151
- const startBoundary = `(^|\\s|${chr})`;
152
- const endBoundary = `(?=\\s|${chr}|$)`;
153
- for (const [buildArrow, replacement] of ARROW_RULES) {
154
- const pattern = cachedRegExp(`${startBoundary}${buildArrow(chr)}${endBoundary}`, "g");
155
- // Re-emit every separator the arrow body consumed; dropping any would
156
- // break the text-node-count invariant in transformTextNodes.
157
- text = text.replace(pattern, (match, boundary) => {
158
- const arrowPart = match.slice(boundary.length);
159
- const seps = arrowPart.match(sepPattern) ?? [];
160
- return `${boundary}${replacement}${seps.join("")}`;
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
- return text;
164
- }
165
- export function degrees(text, options = {}) {
166
- const chr = getEscapedSeparator(options);
167
- // Temperature with optional space before C or F (uppercase only)
168
- // Handles separator between digit and unit
169
- // Uses marker-aware boundary to avoid false matches like "20C\uE000elsius"
170
- const wbe = wordBoundaryEnd(chr);
171
- // Reject C/F followed by hyphen+letter ("C-compiler"), +/# ("C++", "F#")
172
- const notCompound = `(?!-[${LATIN_LETTERS}]|[+#])`;
173
- return text.replace(cachedRegExp(`(?<num>\\d${chr}?) ?(?<unit>[CF])${notCompound}${wbe}`, "g"), (_, num, unit) => `${num} ${DEGREE}${unit}`);
174
- }
175
- /** Convert 5'10" to 5′10″ (prime marks). Call before smart quotes. */
176
- export function primeMarks(text, options = {}) {
177
- return convertPrimeMarks(text, options.separator ?? DEFAULT_SEPARATOR);
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
- /** Convert 1/2, 1/4, etc. to ½, ¼, etc. Single-pass using alternation. */
198
- export function fractions(text, options = {}) {
199
- const chr = getEscapedSeparator(options);
200
- // Build alternation of exact valid pairs: `1${sep}?/${sep}?4|1${sep}?/${sep}?2|...`
201
- // Only exact pairs from FRACTION_TUPLES match — no cross-product.
202
- const pairAlternation = FRACTION_TUPLES.map(([n, d]) => `${n}${chr}?/${chr}?${d}`).join("|");
203
- // - (?<![/.\d]) prevents matching after slashes, dots, or digits
204
- // - (?![/\d]|\.\d) prevents matching before slashes, digits, or decimal points
205
- const pattern = cachedRegExp(`(?<![/\\.\\d])(?:${pairAlternation})(?![/\\d]|\\.\\d)`, "g");
206
- const sepPattern = cachedRegExp(chr, "g");
207
- return text.replace(pattern, (match) => {
208
- // Split on separator to isolate the raw fraction and preserve markers.
209
- // e.g. "1\uE000/\uE0002" → parts ["1", "/", "2"], separators ["\uE000", "\uE000"]
210
- const parts = match.split(sepPattern);
211
- const raw = parts.join("");
212
- const separators = match.match(sepPattern) ?? [];
213
- // Distribute separators: first goes before the unicode char, second after
214
- return `${separators[0] ?? ""}${FRACTION_MAP[raw]}${separators[1] ?? ""}`;
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
- /** Convert 1st, 2nd, 3rd, 4th to superscript ordinals. */
224
- export function superscriptOrdinal(text, options = {}) {
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
- // Preserves highest-priority space type (NBSP > NNBSP > regular) and leading indentation.
237
- export function collapseSpaces(text) {
238
- return text.replace(cachedRegExp(`(?<=[^\\n${SPACE_CHARS}])[${SPACE_CHARS}]{2,}`, "g"), (match) => {
239
- if (match.includes(NBSP))
240
- return NBSP;
241
- if (match.includes(UNICODE_SYMBOLS.NNBSP))
242
- return UNICODE_SYMBOLS.NNBSP;
243
- return " ";
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
- ["\\?", "!", QUESTION_EXCLAMATION], // ?!+ → ⁈
249
- ["!", "\\?", EXCLAMATION_QUESTION], // !?+ → ⁉
250
- ["\\?", "\\?", DOUBLE_QUESTION], // ??+ → ⁇
820
+ ["?", "!", QUESTION_EXCLAMATION], // ?!+ → ⁈
821
+ ["!", "?", EXCLAMATION_QUESTION], // !?+ → ⁉
822
+ ["?", "?", DOUBLE_QUESTION], // ??+ → ⁇
251
823
  ["!", "!", "!"], // !!+ → ! (normalize)
252
824
  ];
253
- /** Convert ?? to ⁇, ?! to ⁈, !? to ⁉. Disabled by default (poor font support). */
254
- export function punctuationLigatures(text, options = {}) {
255
- const chr = getEscapedSeparator(options);
256
- const sepPattern = cachedRegExp(chr, "g");
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
- const pattern = cachedRegExp(`${first}(?:${chr}?${repeated})+`, "g");
259
- // Re-emit every separator that fell inside the match. The repeat group
260
- // can swallow more than one (e.g. across 3+ split text nodes), and
261
- // dropping any of them breaks transformTextNodes's text-node-count
262
- // invariant.
263
- text = text.replace(pattern, (match) => {
264
- const seps = match.match(sepPattern) ?? [];
265
- return replacement + seps.join("");
266
- });
267
- }
268
- return text;
269
- }
270
- export function symbolTransform(text, options = {}) {
271
- text = ellipsis(text, options);
272
- text = multiplication(text, options);
273
- text = mathSymbols(text, options);
274
- text = legalSymbols(text, options);
275
- if (options.includeArrows !== false) {
276
- text = arrows(text, options);
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
- return text;
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