punctilio 4.3.1 → 5.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -23
- package/dist/constants.d.ts +0 -27
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +0 -40
- package/dist/constants.js.map +1 -1
- package/dist/dashes.d.ts +11 -7
- package/dist/dashes.d.ts.map +1 -1
- package/dist/dashes.js +423 -284
- package/dist/dashes.js.map +1 -1
- package/dist/index.d.ts +14 -118
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +65 -116
- package/dist/index.js.map +1 -1
- package/dist/markdown.js +1 -1
- package/dist/markdown.js.map +1 -1
- package/dist/nbsp.d.ts +19 -13
- package/dist/nbsp.d.ts.map +1 -1
- package/dist/nbsp.js +214 -69
- package/dist/nbsp.js.map +1 -1
- package/dist/prose-view.d.ts +48 -7
- package/dist/prose-view.d.ts.map +1 -1
- package/dist/prose-view.js +163 -27
- package/dist/prose-view.js.map +1 -1
- package/dist/quote-classifier.d.ts +5 -4
- package/dist/quote-classifier.d.ts.map +1 -1
- package/dist/quote-classifier.js +601 -88
- package/dist/quote-classifier.js.map +1 -1
- package/dist/quotes.d.ts +12 -4
- package/dist/quotes.d.ts.map +1 -1
- package/dist/quotes.js +24 -27
- package/dist/quotes.js.map +1 -1
- package/dist/rehype.d.ts +53 -8
- package/dist/rehype.d.ts.map +1 -1
- package/dist/rehype.js +105 -62
- package/dist/rehype.js.map +1 -1
- package/dist/remark.d.ts +1 -1
- package/dist/remark.d.ts.map +1 -1
- package/dist/remark.js +6 -9
- package/dist/remark.js.map +1 -1
- package/dist/symbols.d.ts +25 -14
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +812 -220
- package/dist/symbols.js.map +1 -1
- package/dist/transform-options.d.ts +97 -0
- package/dist/transform-options.d.ts.map +1 -0
- package/dist/transform-options.js +34 -0
- package/dist/transform-options.js.map +1 -0
- package/dist/utils.d.ts +0 -16
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +0 -56
- package/dist/utils.js.map +1 -1
- package/package.json +8 -3
- package/src/constants.ts +0 -46
- package/src/dashes.ts +450 -269
- package/src/index.ts +70 -291
- package/src/markdown.ts +1 -1
- package/src/nbsp.ts +255 -78
- package/src/prose-view.ts +234 -39
- package/src/quote-classifier.ts +618 -88
- package/src/quotes.ts +36 -19
- package/src/rehype.ts +169 -88
- package/src/remark.ts +6 -11
- package/src/symbols.ts +814 -251
- package/src/transform-options.ts +143 -0
- package/src/utils.ts +0 -82
package/src/dashes.ts
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
import { cachedRegExp,
|
|
2
|
-
import { boundaryCountAt, type ProseView, replaceAllInView, type ReplaceAllOptions
|
|
1
|
+
import { cachedRegExp, LATIN_LETTERS, NBSP_CHARS, SPACE_CHARS, UNICODE_SYMBOLS } from "./constants.js"
|
|
2
|
+
import { boundaryCountAt, overInput, type ProseView, replaceAllInView, type ReplaceAllOptions } from "./prose-view.js"
|
|
3
3
|
import { namedGroups } from "./utils.js"
|
|
4
4
|
|
|
5
5
|
export const DASH_STYLES = ["american", "british", "none"] as const
|
|
6
6
|
export type DashStyle = (typeof DASH_STYLES)[number]
|
|
7
7
|
|
|
8
8
|
export interface DashOptions {
|
|
9
|
-
/** Boundary marker for HTML element boundaries. Default: "" */
|
|
10
|
-
separator?: string
|
|
11
9
|
/** "american" (unspaced em), "british" (spaced en), "none". Default: "american" */
|
|
12
10
|
dashStyle?: DashStyle
|
|
13
11
|
}
|
|
14
12
|
|
|
15
|
-
const {
|
|
13
|
+
const {
|
|
14
|
+
EN_DASH, EM_DASH, MINUS, MULTIPLICATION, PLUS_MINUS,
|
|
15
|
+
LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, LEFT_SINGLE_QUOTE, RIGHT_SINGLE_QUOTE,
|
|
16
|
+
SUPERSCRIPT_ST, SUPERSCRIPT_ND, SUPERSCRIPT_RD, SUPERSCRIPT_TH,
|
|
17
|
+
} = UNICODE_SYMBOLS
|
|
16
18
|
|
|
17
19
|
// Prevents false-positive ranges in model names like "Llama-2-7B".
|
|
18
20
|
export const numberRangeDisallowedPrefixes = ["-", EN_DASH, EM_DASH, MINUS] as const
|
|
@@ -35,11 +37,54 @@ const monthPattern = months.join("|")
|
|
|
35
37
|
const WORD_CHAR_RE = /\w/
|
|
36
38
|
const LATIN_LETTER_RE = new RegExp(`[${LATIN_LETTERS}]`)
|
|
37
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Glyphs the symbol passes fold word characters into (multiplication `x` →
|
|
42
|
+
* `×`, ordinal suffixes → superscripts). The dash rules run before those
|
|
43
|
+
* passes, so their word-character tests must treat a folded glyph like the
|
|
44
|
+
* word character it folds from — otherwise a range blocked at `6x3` converts
|
|
45
|
+
* once a re-run sees `6×3`.
|
|
46
|
+
*/
|
|
47
|
+
const FOLDED_WORD_CHARS = new Set<string>([
|
|
48
|
+
MULTIPLICATION,
|
|
49
|
+
...SUPERSCRIPT_ST, ...SUPERSCRIPT_ND, ...SUPERSCRIPT_RD, ...SUPERSCRIPT_TH,
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
/** `\w` extended with {@link FOLDED_WORD_CHARS} for fold-stable `\b` checks. */
|
|
53
|
+
function isWordChar(ch: string | undefined): boolean {
|
|
54
|
+
return ch !== undefined && (WORD_CHAR_RE.test(ch) || FOLDED_WORD_CHARS.has(ch))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Glyphs the math-symbol pass folds two-character operators into (`=~` → `≈`,
|
|
59
|
+
* `!=` → `≠`, ...). The fold tolerates one node boundary at its junction, so
|
|
60
|
+
* it can consume the character that sat between a boundary and a dash.
|
|
61
|
+
*/
|
|
62
|
+
const FOLDED_MATH_OPERATORS = new Set<string>([
|
|
63
|
+
UNICODE_SYMBOLS.APPROXIMATE,
|
|
64
|
+
UNICODE_SYMBOLS.GREATER_EQUAL,
|
|
65
|
+
UNICODE_SYMBOLS.LESS_EQUAL,
|
|
66
|
+
UNICODE_SYMBOLS.NOT_EQUAL,
|
|
67
|
+
UNICODE_SYMBOLS.PLUS_MINUS,
|
|
68
|
+
])
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Glyphs the symbol passes fold from a source ending in a dash char (`<-` →
|
|
72
|
+
* `←`, `+-` → `±`). Where a trailing hyphen blocks a dash rule, its folded
|
|
73
|
+
* glyph must keep blocking, or the rule fires only on the re-run.
|
|
74
|
+
*/
|
|
75
|
+
const FOLDED_FROM_TRAILING_DASH = new Set<string>([
|
|
76
|
+
UNICODE_SYMBOLS.ARROW_LEFT,
|
|
77
|
+
UNICODE_SYMBOLS.PLUS_MINUS,
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
/** Mirror of {@link FOLDED_FROM_TRAILING_DASH} for sources starting with a
|
|
81
|
+
* dash char (`->` → `→`). */
|
|
82
|
+
const FOLDED_FROM_LEADING_DASH = new Set<string>([UNICODE_SYMBOLS.ARROW_RIGHT])
|
|
83
|
+
|
|
38
84
|
/**
|
|
39
85
|
* Runs one boundary-gated regex pass over the view and commits it immediately.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* pass sees the earlier edits and never queues an overlapping edit.
|
|
86
|
+
* Committing after every pass sequences the passes so a later pass sees the
|
|
87
|
+
* earlier edits and never queues an overlapping edit.
|
|
43
88
|
*/
|
|
44
89
|
function pass(
|
|
45
90
|
view: ProseView,
|
|
@@ -54,11 +99,13 @@ function pass(
|
|
|
54
99
|
// ---------------------------------------------------------------------------
|
|
55
100
|
// Boundary-tolerance helpers over a ProseView
|
|
56
101
|
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
// `
|
|
61
|
-
//
|
|
102
|
+
// The boundary-tolerance positions throughout this module reproduce the
|
|
103
|
+
// element-boundary semantics of the pre-v5 sentinel-marked pipeline; they are
|
|
104
|
+
// pinned by the golden corpus and the migration's differential fuzz. Over a
|
|
105
|
+
// clean ProseView, `replaceAllInView` skips any match with an interior boundary
|
|
106
|
+
// unless `allowBoundaries` opts in. Each pass below builds an `allowBoundaries`
|
|
107
|
+
// callback that admits a match only when every interior boundary sits at a
|
|
108
|
+
// tolerated slot offset.
|
|
62
109
|
// ---------------------------------------------------------------------------
|
|
63
110
|
|
|
64
111
|
/** Interior boundary offsets of a match, strictly inside (matchStart, matchEnd). */
|
|
@@ -75,9 +122,9 @@ function interiorBoundaryOffsets(match: RegExpExecArray, view: ProseView): numbe
|
|
|
75
122
|
|
|
76
123
|
/**
|
|
77
124
|
* True iff every interior boundary of the match lands on a tolerated offset,
|
|
78
|
-
* with no offset carrying more boundaries than its budget. A
|
|
79
|
-
*
|
|
80
|
-
* exceed the single-
|
|
125
|
+
* with no offset carrying more boundaries than its budget. A tolerated slot
|
|
126
|
+
* admits exactly one boundary; two stacked boundaries (an empty fragment)
|
|
127
|
+
* exceed the single-boundary budget and block the match.
|
|
81
128
|
*/
|
|
82
129
|
function interiorBoundariesAllowed(
|
|
83
130
|
match: RegExpExecArray,
|
|
@@ -108,8 +155,7 @@ const MAX_BOUNDARY_SEPARATORS = 3
|
|
|
108
155
|
function wordBoundaryStartOk(view: ProseView, matchStart: number): boolean {
|
|
109
156
|
if (matchStart === 0) return true
|
|
110
157
|
const nb = boundaryCountAt(view, matchStart)
|
|
111
|
-
const
|
|
112
|
-
const prevWord = prev !== undefined && WORD_CHAR_RE.test(prev)
|
|
158
|
+
const prevWord = isWordChar(view.text[matchStart - 1])
|
|
113
159
|
if (nb === 0) return !prevWord // `\b` needs a non-word char immediately left
|
|
114
160
|
// A separator sits left (non-word) so `\b` holds; the lookbehind blocks only
|
|
115
161
|
// when a word char is within ≤MAX boundaries.
|
|
@@ -124,13 +170,17 @@ function wordBoundaryStartOk(view: ProseView, matchStart: number): boolean {
|
|
|
124
170
|
*/
|
|
125
171
|
function wordBoundaryEndOk(view: ProseView, pos: number): boolean {
|
|
126
172
|
const text = view.text
|
|
127
|
-
// `wordBoundaryEndOk` is only ever called at the end of a digit
|
|
128
|
-
// is always ≥ 1 and `text[pos - 1]` is a real char.
|
|
129
|
-
const
|
|
130
|
-
|
|
173
|
+
// `wordBoundaryEndOk` is only ever called at the end of a digit, letter, or
|
|
174
|
+
// suffix run, so `pos` is always ≥ 1 and `text[pos - 1]` is a real char.
|
|
175
|
+
const leftWord = isWordChar(text[pos - 1])
|
|
176
|
+
// A multiplication sign one space to the right blocks like the word char
|
|
177
|
+
// it folds from: the multiplication pass renders `5X 8` as `5 × 8`, and a
|
|
178
|
+
// range blocked at `3-5X` must stay blocked at `3-5 ×`.
|
|
179
|
+
if (text[pos] !== undefined && SPACE_CHAR_RE.test(text[pos]) && text[pos + 1] === MULTIPLICATION) {
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
131
182
|
const nb = boundaryCountAt(view, pos)
|
|
132
|
-
const
|
|
133
|
-
const nextCleanWord = nextClean !== undefined && WORD_CHAR_RE.test(nextClean)
|
|
183
|
+
const nextCleanWord = isWordChar(text[pos])
|
|
134
184
|
// The marked char right after pos is a separator when nb > 0 (non-word).
|
|
135
185
|
const rightWord = nb > 0 ? false : nextCleanWord
|
|
136
186
|
if (leftWord === rightWord) return false // no `\b`
|
|
@@ -145,23 +195,22 @@ function wordBoundaryEndOk(view: ProseView, pos: number): boolean {
|
|
|
145
195
|
const CURRENCY_RE = /[$€£¥₹]/
|
|
146
196
|
|
|
147
197
|
/** Convert number ranges to en-dash (e.g., "1-5" → "1–5"). */
|
|
148
|
-
export function enDashNumberRange(
|
|
149
|
-
|
|
150
|
-
|
|
198
|
+
export function enDashNumberRange(input: string): string
|
|
199
|
+
export function enDashNumberRange(input: ProseView): void
|
|
200
|
+
export function enDashNumberRange(input: string | ProseView): string | void {
|
|
201
|
+
return overInput(input, (view) => {
|
|
151
202
|
convertPositiveRanges(view)
|
|
152
203
|
convertNegativeRanges(view)
|
|
153
204
|
})
|
|
154
205
|
}
|
|
155
206
|
|
|
156
207
|
/**
|
|
157
|
-
*
|
|
158
|
-
* -{1,3} [currency]?\d[\d.,]* (
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* match (boundary-aware), consuming its span on success (whether or not the
|
|
164
|
-
* dash converts) and advancing one character otherwise. So a multi-segment
|
|
208
|
+
* The positive-range shape is `phoneAreaCode? \b (?:p\.?|[currency])?\d[\d.,]*
|
|
209
|
+
* -{1,3} [currency]?\d[\d.,]* (?:[-−]\d+)* (?:[AaPp][Mm]|[xKBTMCF])? \b`, scanned
|
|
210
|
+
* left to right. The consume/advance behaviour determines which sub-ranges a
|
|
211
|
+
* later pass sees. The scan below realizes it: at each offset it attempts the
|
|
212
|
+
* structural match (boundary-aware), consuming its span on success (whether or
|
|
213
|
+
* not the dash converts) and advancing one character otherwise. So a multi-segment
|
|
165
214
|
* number whose head fails to form a structural match (e.g. boundary-broken
|
|
166
215
|
* "2024-01-15") still exposes a later sub-range ("01-15"), while a structurally
|
|
167
216
|
* matching but non-converting number hides its inner dashes.
|
|
@@ -177,17 +226,17 @@ function convertPositiveRanges(view: ProseView): void {
|
|
|
177
226
|
}
|
|
178
227
|
|
|
179
228
|
/**
|
|
180
|
-
* Attempts
|
|
229
|
+
* Attempts the `phoneAreaCode? \b rangeStart -{1,3} rangeEnd following* suffix?
|
|
181
230
|
* \b` structural match beginning at clean offset `i`, converting the dash when
|
|
182
|
-
*
|
|
231
|
+
* the gates pass. Returns the consumed end offset on a structural match (so the
|
|
183
232
|
* scan skips it), or null when no structural match begins at `i`.
|
|
184
233
|
*/
|
|
185
234
|
function matchPositiveRangeAt(view: ProseView, i: number): number | null {
|
|
186
235
|
// Optional phoneAreaCode (`\d{3}-` or `(\d{3}) `); when it ends in a dash that
|
|
187
|
-
// dash can instead be the range dash, so both interpretations are tried.
|
|
188
|
-
// `rangeStart` carries no leading
|
|
189
|
-
//
|
|
190
|
-
//
|
|
236
|
+
// dash can instead be the range dash, so both interpretations are tried.
|
|
237
|
+
// `rangeStart` carries no leading slot, so a boundary right where it would
|
|
238
|
+
// begin (just past the area code) invalidates the area-code reading: fall back
|
|
239
|
+
// to treating the area-code digits as the range start.
|
|
191
240
|
const areaCode = readPhoneAreaCode(view, i)
|
|
192
241
|
const startStarts = areaCode === null || view.hasBoundary(i + areaCode) ? [i] : [i + areaCode, i]
|
|
193
242
|
for (const startStart of startStarts) {
|
|
@@ -200,10 +249,10 @@ function matchPositiveRangeAt(view: ProseView, i: number): number | null {
|
|
|
200
249
|
const THREE_DIGITS_RE = /^\d\d\d/
|
|
201
250
|
const THREE_DIGITS_IN_PARENS_RE = /^\(\d\d\d\)/
|
|
202
251
|
|
|
203
|
-
/**
|
|
252
|
+
/** `phoneAreaCode` = `\d{3}-|\(\d{3}\) ?` at `i`; returns its length or null. */
|
|
204
253
|
function readPhoneAreaCode(view: ProseView, i: number): number | null {
|
|
205
254
|
const text = view.text
|
|
206
|
-
// `\d{3}-` with no
|
|
255
|
+
// `\d{3}-` with no boundary inside the digits or before the dash.
|
|
207
256
|
if (THREE_DIGITS_RE.test(text.slice(i, i + 3)) && text[i + 3] === "-"
|
|
208
257
|
&& !view.hasBoundary(i + 1) && !view.hasBoundary(i + 2) && !view.hasBoundary(i + 3)) {
|
|
209
258
|
return 4
|
|
@@ -217,8 +266,8 @@ function readPhoneAreaCode(view: ProseView, i: number): number | null {
|
|
|
217
266
|
|
|
218
267
|
/**
|
|
219
268
|
* Matches `\b rangeStart -{1,3} rangeEnd following* suffix? \b` boundary-aware,
|
|
220
|
-
* beginning the start at `startStart`. Converts the dash on a
|
|
221
|
-
*
|
|
269
|
+
* beginning the start at `startStart`. Converts the dash on a converting match.
|
|
270
|
+
* Returns the consumed end offset (structural span end) or null.
|
|
222
271
|
*/
|
|
223
272
|
function matchRangeBody(view: ProseView, startStart: number): number | null {
|
|
224
273
|
const text = view.text
|
|
@@ -226,10 +275,10 @@ function matchRangeBody(view: ProseView, startStart: number): number | null {
|
|
|
226
275
|
const start = readStartRun(view, startStart)
|
|
227
276
|
if (start === null) return null
|
|
228
277
|
const startSpanEnd = startStart + start.length
|
|
229
|
-
// One
|
|
278
|
+
// One slot between start and the dash tolerates a single boundary there.
|
|
230
279
|
if (boundaryCountAt(view, startSpanEnd) > 1) return null
|
|
231
280
|
// Dash run of 1..3 hyphens that never spans an interior boundary (a boundary
|
|
232
|
-
// between two hyphens truncates the run
|
|
281
|
+
// between two hyphens truncates the run).
|
|
233
282
|
if (text[startSpanEnd] !== "-") return null
|
|
234
283
|
let dashEnd = startSpanEnd + 1
|
|
235
284
|
while (text[dashEnd] === "-" && dashEnd - startSpanEnd < 3 && !view.hasBoundary(dashEnd)) dashEnd++
|
|
@@ -243,7 +292,7 @@ function matchRangeBody(view: ProseView, startStart: number): number | null {
|
|
|
243
292
|
|
|
244
293
|
const end = readEndRun(view, dashEnd)
|
|
245
294
|
if (end === null) return null
|
|
246
|
-
//
|
|
295
|
+
// The greedy end `[\d.,]*` and greedy `following*` backtrack together; the
|
|
247
296
|
// structural match commits at the longest end whose tail completes.
|
|
248
297
|
for (let len = end.length; len >= end.minLen; len--) {
|
|
249
298
|
const endPos = end.firstDigitStart + len
|
|
@@ -259,8 +308,8 @@ function matchRangeBody(view: ProseView, startStart: number): number | null {
|
|
|
259
308
|
}
|
|
260
309
|
|
|
261
310
|
/**
|
|
262
|
-
*
|
|
263
|
-
* (`(?<!\w
|
|
311
|
+
* The `(?:p\.?|[currency])?\d[\d.,]*` start run, boundary-truncated. The `wb`
|
|
312
|
+
* (`(?<!\w)\b`) anchors the start at a word boundary, so the currency
|
|
264
313
|
* alternative is unreachable: a leading currency symbol is non-word, and the only
|
|
265
314
|
* `\b` sits after it, at the digit — so the currency is never part of `start`
|
|
266
315
|
* (e.g. "$100-2000" reads start "100", which the 3+4 phone gate then blocks). The
|
|
@@ -270,8 +319,8 @@ function readStartRun(view: ProseView, pos: number): { length: number } | null {
|
|
|
270
319
|
const text = view.text
|
|
271
320
|
let i = pos
|
|
272
321
|
if (text[i] === "p") {
|
|
273
|
-
//
|
|
274
|
-
//
|
|
322
|
+
// `p\.?\d` is contiguous (no interior slot), so a boundary between `p`, an
|
|
323
|
+
// optional `.`, and the first digit breaks the prefix.
|
|
275
324
|
if (view.hasBoundary(i + 1)) return null
|
|
276
325
|
i++
|
|
277
326
|
if (text[i] === ".") {
|
|
@@ -279,7 +328,7 @@ function readStartRun(view: ProseView, pos: number): { length: number } | null {
|
|
|
279
328
|
i++
|
|
280
329
|
}
|
|
281
330
|
}
|
|
282
|
-
//
|
|
331
|
+
// `\d` is required after the optional `p\.?` prefix. The prefix chars are
|
|
283
332
|
// never digits, so a missing digit here means no start run begins.
|
|
284
333
|
if (!/\d/.test(text[i] ?? "")) return null
|
|
285
334
|
i++
|
|
@@ -296,12 +345,12 @@ interface EndRun {
|
|
|
296
345
|
minLen: number
|
|
297
346
|
}
|
|
298
347
|
|
|
299
|
-
/**
|
|
348
|
+
/** The `[currency]?\d[\d.,]*` end run starting at `pos`, boundary-truncated. */
|
|
300
349
|
function readEndRun(view: ProseView, pos: number): EndRun | null {
|
|
301
350
|
const text = view.text
|
|
302
351
|
let i = pos
|
|
303
352
|
if (CURRENCY_RE.test(text[i] ?? "")) {
|
|
304
|
-
//
|
|
353
|
+
// No slot between the currency symbol and the digit.
|
|
305
354
|
if (view.hasBoundary(i + 1)) return null
|
|
306
355
|
i++
|
|
307
356
|
}
|
|
@@ -320,7 +369,7 @@ interface ResolvedTail {
|
|
|
320
369
|
|
|
321
370
|
/**
|
|
322
371
|
* Resolves the tail at `endPos`: finds the longest `following` whose
|
|
323
|
-
* `suffix? wbe` completes (
|
|
372
|
+
* `suffix? wbe` completes (the greedy choice). `followingEmpty` reports whether
|
|
324
373
|
* that following is empty (the convert condition); `consumedEnd` is the tail's
|
|
325
374
|
* end including any suffix (for the scan's consume/advance). Returns null when
|
|
326
375
|
* no following completes.
|
|
@@ -339,9 +388,9 @@ function resolvePositiveTail(view: ProseView, endPos: number): ResolvedTail | nu
|
|
|
339
388
|
|
|
340
389
|
/**
|
|
341
390
|
* Negative ranges: −5-5 → −5–5, −5--2 → −5–−2. A separate hand scan (not a regex)
|
|
342
|
-
* because the end run
|
|
343
|
-
* boundaries before its tail is resolved:
|
|
344
|
-
*
|
|
391
|
+
* because the end run (`\d[\d.,]*`, with one tolerated boundary at its head)
|
|
392
|
+
* must truncate at node boundaries before its tail is resolved: a regex would
|
|
393
|
+
* fuse boundary-split digits into one run (e.g. "8{b}{b}{b}{b}{b}0" reads as
|
|
345
394
|
* "80") and mis-place or miss the conversion. The scan mirrors `matchRangeBody`:
|
|
346
395
|
* at each MINUS-led start it resolves the longest end whose `following* suffix?
|
|
347
396
|
* wbe` tail completes, converting only when that tail leaves `following` empty.
|
|
@@ -363,25 +412,25 @@ function convertNegativeRanges(view: ProseView): void {
|
|
|
363
412
|
}
|
|
364
413
|
|
|
365
414
|
/**
|
|
366
|
-
* Attempts
|
|
367
|
-
* beginning the MINUS at clean offset `i`. Converts the dash run when
|
|
415
|
+
* Attempts `(?<![LAT]) ${MINUS}\d[\d.,]* -{1,3} -? end following* suffix? wbe`
|
|
416
|
+
* beginning the MINUS at clean offset `i`. Converts the dash run when the gates
|
|
368
417
|
* pass. Returns the consumed structural end offset, or null when no match begins.
|
|
369
418
|
*/
|
|
370
419
|
function matchNegativeRangeAt(view: ProseView, i: number): number | null {
|
|
371
420
|
const text = view.text
|
|
372
|
-
//
|
|
421
|
+
// `(?<![LAT])`: a Latin letter directly before the MINUS (no intervening
|
|
373
422
|
// boundary, which would hide it) blocks the match.
|
|
374
423
|
if (boundaryCountAt(view, i) === 0) {
|
|
375
424
|
const before = text[i - 1]
|
|
376
425
|
if (before !== undefined && LATIN_LETTER_RE.test(before)) return null
|
|
377
426
|
}
|
|
378
427
|
|
|
379
|
-
// start = `${MINUS}\d[\d.,]
|
|
380
|
-
// boundary (
|
|
428
|
+
// start = `${MINUS}\d[\d.,]*`: the digits after MINUS must not cross a
|
|
429
|
+
// boundary (the `start` body has no interior slot before its trailing one).
|
|
381
430
|
if (!/\d/.test(text[i + 1] ?? "")) return null
|
|
382
431
|
let startEnd = i + 1
|
|
383
432
|
while (/[\d.,]/.test(text[startEnd] ?? "") && !view.hasBoundary(startEnd)) startEnd++
|
|
384
|
-
// One
|
|
433
|
+
// One slot between start and the dash tolerates a single boundary there.
|
|
385
434
|
if (boundaryCountAt(view, startEnd) > 1) return null
|
|
386
435
|
if (text[startEnd] !== "-") return null
|
|
387
436
|
|
|
@@ -392,12 +441,12 @@ function matchNegativeRangeAt(view: ProseView, i: number): number | null {
|
|
|
392
441
|
const dashRunLen = dashRunEnd - startEnd
|
|
393
442
|
const hasNeg = dashRunLen >= 2
|
|
394
443
|
const negEnd = dashRunEnd
|
|
395
|
-
// The `
|
|
444
|
+
// The `end` head slot tolerates one boundary.
|
|
396
445
|
if (boundaryCountAt(view, negEnd) > 1) return null
|
|
397
446
|
|
|
398
447
|
const end = readNegEndRun(view, negEnd)
|
|
399
448
|
if (end === null) return null
|
|
400
|
-
//
|
|
449
|
+
// The greedy end and `following*` backtrack together; commit at the longest end
|
|
401
450
|
// whose tail completes, converting only when that tail's `following` is empty.
|
|
402
451
|
for (let len = end.length; len >= 1; len--) {
|
|
403
452
|
const endPos = end.firstDigitStart + len
|
|
@@ -419,7 +468,7 @@ interface NegEndRun {
|
|
|
419
468
|
length: number
|
|
420
469
|
}
|
|
421
470
|
|
|
422
|
-
/**
|
|
471
|
+
/** The negative `end` body `\d[\d.,]*` starting at `pos`, boundary-truncated. */
|
|
423
472
|
function readNegEndRun(view: ProseView, pos: number): NegEndRun | null {
|
|
424
473
|
const text = view.text
|
|
425
474
|
if (!/\d/.test(text[pos] ?? "")) return null
|
|
@@ -430,9 +479,9 @@ function readNegEndRun(view: ProseView, pos: number): NegEndRun | null {
|
|
|
430
479
|
|
|
431
480
|
/**
|
|
432
481
|
* Boundary-aware `following` end positions, shortest (empty) first, walking
|
|
433
|
-
* segments
|
|
434
|
-
*
|
|
435
|
-
*
|
|
482
|
+
* segments `[-(−)]\d+`. Each segment tolerates one boundary on each side of its
|
|
483
|
+
* dash, and each `\d+` stops at a boundary. `allowMinus` admits a MINUS as a
|
|
484
|
+
* segment's dash (positive ranges).
|
|
436
485
|
*/
|
|
437
486
|
function followingCandidates(view: ProseView, endSpanEnd: number, allowMinus: boolean): number[] {
|
|
438
487
|
const candidates = [endSpanEnd]
|
|
@@ -452,39 +501,34 @@ function followingCandidates(view: ProseView, endSpanEnd: number, allowMinus: bo
|
|
|
452
501
|
return candidates
|
|
453
502
|
}
|
|
454
503
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
504
|
+
/**
|
|
505
|
+
* Single-letter range suffixes: the multipliers `x`/`K`/`B`/`T`/`M` and the
|
|
506
|
+
* temperature units `C`/`F`. The degree pass rewrites `5C` to `5 °C` — a
|
|
507
|
+
* space a re-run's word boundary sees through — so the range must already
|
|
508
|
+
* convert with the unit attached. `X` and `×` are deliberately absent: an
|
|
509
|
+
* unconverted `1-10X` folds to `1-10×`, which the word-boundary check (which
|
|
510
|
+
* treats `×` as the word char it folds from) keeps blocking on re-runs.
|
|
511
|
+
*/
|
|
512
|
+
const RANGE_SUFFIX_RE = /[xKBTMCF]/
|
|
461
513
|
|
|
462
514
|
/**
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
* (superscript ordinals, `×`) abut the digit run and must block conversion even
|
|
467
|
-
* though `wbe` would read them as a clean word boundary; both return null first.
|
|
515
|
+
* The `(?:[AaPp][Mm]|[xKBTMCF])?${wbe}` suffix at `pos`, with one tolerated
|
|
516
|
+
* boundary before the suffix. Returns the end offset after the optional suffix
|
|
517
|
+
* when the tail completes (wbe passes), or null when it does not.
|
|
468
518
|
*/
|
|
469
519
|
function suffixWbeEnd(view: ProseView, pos: number, allowAmPm: boolean): number | null {
|
|
470
|
-
const text = view.text
|
|
471
|
-
// A superscript ordinal immediately following the digit run blocks conversion:
|
|
472
|
-
// `52ⁿᵈ` is an ordinal, not a range endpoint (same semantics as `52nd`).
|
|
473
|
-
if (SUPERSCRIPT_ORDINAL_FIRST_RE.test(text[pos] ?? "")) return null
|
|
474
|
-
// `×` (MULTIPLICATION) is the Unicode form of `x` produced by the symbols pass.
|
|
475
|
-
// It is non-\w so wordBoundaryEndOk would treat it as a clean boundary, but
|
|
476
|
-
// `55×5` is multiplication, not a range endpoint — block the same as `52nd`.
|
|
477
|
-
if (text[pos] === MULTIPLICATION) return null
|
|
478
520
|
if (wordBoundaryEndOk(view, pos)) return pos
|
|
479
|
-
// An optional suffix: one
|
|
480
|
-
//
|
|
521
|
+
// An optional suffix: one tolerated boundary then am/pm or one of
|
|
522
|
+
// {@link RANGE_SUFFIX_RE}'s letters, followed by wbe. The suffix letters sit
|
|
523
|
+
// at the clean `pos`.
|
|
481
524
|
if (boundaryCountAt(view, pos) > 1) return null
|
|
482
|
-
|
|
483
|
-
// the
|
|
525
|
+
const text = view.text
|
|
526
|
+
// `[AaPp][Mm]` is contiguous — a boundary between the two letters breaks the
|
|
527
|
+
// suffix.
|
|
484
528
|
if (allowAmPm && /[ap]/i.test(text[pos] ?? "") && /m/i.test(text[pos + 1] ?? "") && !view.hasBoundary(pos + 1)) {
|
|
485
529
|
return wordBoundaryEndOk(view, pos + 2) ? pos + 2 : null
|
|
486
530
|
}
|
|
487
|
-
if (
|
|
531
|
+
if (RANGE_SUFFIX_RE.test(text[pos] ?? "")) {
|
|
488
532
|
return wordBoundaryEndOk(view, pos + 1) ? pos + 1 : null
|
|
489
533
|
}
|
|
490
534
|
return null
|
|
@@ -492,20 +536,83 @@ function suffixWbeEnd(view: ProseView, pos: number, allowAmPm: boolean): number
|
|
|
492
536
|
|
|
493
537
|
const NOT_AFTER_DASH_RE = new RegExp(`[${DISALLOWED_PREFIX_CLASS_FRAGMENT}${LATIN_LETTERS}${MULTIPLICATION}${PLUS_MINUS}.+]`)
|
|
494
538
|
|
|
539
|
+
const SPACE_CHAR_RE = new RegExp(`[${SPACE_CHARS}]`)
|
|
540
|
+
|
|
495
541
|
/**
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
542
|
+
* Offset of the dot reachable backwards across one inter-dot gap ending at the
|
|
543
|
+
* dot at `dotIndex` (the mirror of the ellipsis pass's forward gap rule: the
|
|
544
|
+
* gap is nothing, one space character, or one node boundary). -1 when none.
|
|
545
|
+
*/
|
|
546
|
+
function ellipsisPrevDot(view: ProseView, dotIndex: number): number {
|
|
547
|
+
const text = view.text
|
|
548
|
+
if (text[dotIndex - 1] === ".") {
|
|
549
|
+
// Empty gap: at most one boundary between the two dots.
|
|
550
|
+
return boundaryCountAt(view, dotIndex) <= 1 ? dotIndex - 1 : -1
|
|
551
|
+
}
|
|
552
|
+
// Space gap: one space character with no boundary on either side of it.
|
|
553
|
+
if (boundaryCountAt(view, dotIndex) === 0 && dotIndex >= 2 && SPACE_CHAR_RE.test(text[dotIndex - 1])
|
|
554
|
+
&& text[dotIndex - 2] === "." && boundaryCountAt(view, dotIndex - 1) === 0) {
|
|
555
|
+
return dotIndex - 2
|
|
556
|
+
}
|
|
557
|
+
return -1
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** True iff the dot at `dotIndex` can close a `...`/`. . .` triple. */
|
|
561
|
+
function endsFoldableEllipsisDots(view: ProseView, dotIndex: number): boolean {
|
|
562
|
+
const second = ellipsisPrevDot(view, dotIndex)
|
|
563
|
+
return second >= 0 && ellipsisPrevDot(view, second) >= 0
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* `notAfterDash` is a single-char negative lookbehind. A node boundary
|
|
568
|
+
* immediately before the start hides the clean char (a boundary is not the
|
|
569
|
+
* dash/letter), so the guard only fires when a disallowed clean char sits
|
|
499
570
|
* directly before the start with no intervening boundary.
|
|
571
|
+
*
|
|
572
|
+
* A `.` that closes a dot triple does not block: the ellipsis pass (which
|
|
573
|
+
* runs after the dash rules) folds the triple to `…` and pads a following
|
|
574
|
+
* digit with a space, so a re-run would see a plain space here and convert —
|
|
575
|
+
* converting now reaches that fixed point in one pass. A line-leading em dash
|
|
576
|
+
* does not block for the same reason: the em-dash spacing normalizer pads it
|
|
577
|
+
* before a digit (`^—5` → `— 5`), mirroring that rule's one tolerated
|
|
578
|
+
* boundary at line start.
|
|
500
579
|
*/
|
|
501
580
|
function notAfterDashOk(view: ProseView, startOffset: number): boolean {
|
|
502
581
|
if (startOffset === 0) return true
|
|
503
582
|
if (boundaryCountAt(view, startOffset) > 0) return true
|
|
504
583
|
const prev = view.text[startOffset - 1]
|
|
505
|
-
|
|
584
|
+
// A multiplication sign one space slot further left blocks like the `x` it
|
|
585
|
+
// folds from: the multiplication pass renders `5x8` as `5 × 8`, so a range
|
|
586
|
+
// blocked at `x8-6` must stay blocked at `× 8-6`.
|
|
587
|
+
if (prev !== undefined && SPACE_CHAR_RE.test(prev) && view.text[startOffset - 2] === MULTIPLICATION) {
|
|
588
|
+
return false
|
|
589
|
+
}
|
|
590
|
+
if (prev === undefined || !NOT_AFTER_DASH_RE.test(prev)) return true
|
|
591
|
+
if (prev === "." && endsFoldableEllipsisDots(view, startOffset - 1)) return true
|
|
592
|
+
const dashIndex = startOffset - 1
|
|
593
|
+
if (prev === EM_DASH && boundaryCountAt(view, dashIndex) <= 1
|
|
594
|
+
&& (dashIndex === 0 || view.text[dashIndex - 1] === "\n" || emDashBehindLeadingSpaces(view, dashIndex))) {
|
|
595
|
+
return true
|
|
596
|
+
}
|
|
597
|
+
// An en dash behind leading spaces converts the same way: the spaced-dash
|
|
598
|
+
// rule renders it as a line-leading em dash, which the normalizer then pads.
|
|
599
|
+
return prev === EN_DASH && boundaryCountAt(view, dashIndex) <= 1
|
|
600
|
+
&& emDashBehindLeadingSpaces(view, dashIndex)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* True iff only plain spaces sit between text start and the em dash at
|
|
605
|
+
* `emIndex`. The spaced-dash rule collapses that run, leaving the em dash
|
|
606
|
+
* line-leading, which the spacing normalizer then pads before a digit — the
|
|
607
|
+
* same state the `\n` arm of {@link notAfterDashOk} accepts.
|
|
608
|
+
*/
|
|
609
|
+
function emDashBehindLeadingSpaces(view: ProseView, emIndex: number): boolean {
|
|
610
|
+
let j = emIndex
|
|
611
|
+
while (j > 0 && view.text[j - 1] === " ") j--
|
|
612
|
+
return j < emIndex && j === 0
|
|
506
613
|
}
|
|
507
614
|
|
|
508
|
-
/**
|
|
615
|
+
/** Range-number gates: reject year-month, phone-shaped, and toll-free pairs. */
|
|
509
616
|
function rangeNumbersConvert(startNum: string, endNum: string): boolean {
|
|
510
617
|
if (/^(?:19|20)\d{2}$/.test(startNum) && /^(?:0[1-9]|1[0-2])$/.test(endNum)) return false
|
|
511
618
|
// Skip 3+4 digit phone-shaped patterns (555-1234, or the second half of
|
|
@@ -525,23 +632,52 @@ function rangeNumbersConvert(startNum: string, endNum: string): boolean {
|
|
|
525
632
|
// ---------------------------------------------------------------------------
|
|
526
633
|
|
|
527
634
|
/** Convert month ranges to en-dash (e.g., "January-March" → "January–March"). */
|
|
528
|
-
export function enDashDateRange(
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
635
|
+
export function enDashDateRange(input: string, options?: DashOptions): string
|
|
636
|
+
export function enDashDateRange(input: ProseView, options?: DashOptions): void
|
|
637
|
+
export function enDashDateRange(input: string | ProseView, options: DashOptions = {}): string | void {
|
|
638
|
+
return overInput(input, (view) => enDashDateRangeOverView(view, options.dashStyle ?? "american"))
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function enDashDateRangeOverView(view: ProseView, dashStyle: DashStyle): void {
|
|
642
|
+
if (dashStyle === "none") return
|
|
532
643
|
const [pre, post] = dashStyle === "british" ? [" ", " "] : ["", ""]
|
|
533
644
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
645
|
+
// Atomic-optional year groups (lookahead + backref). The capture inside the
|
|
646
|
+
// lookahead is locked in once matched, so the year group commits without
|
|
647
|
+
// backtracking. The year space also matches NBSP/NNBSP: the nbsp pass (which
|
|
648
|
+
// runs after the dash rules) glues "March 2025" with an NBSP, and the year
|
|
649
|
+
// must still capture on a re-run so the trailing-boundary gate sees the same
|
|
650
|
+
// end position.
|
|
651
|
+
const startYear = `(?=(?<startYear>[ ${NBSP_CHARS}]\\d{4})?)\\k<startYear>`
|
|
652
|
+
const endYear = `(?=(?<endYear>[ ${NBSP_CHARS}]\\d{4})?)\\k<endYear>`
|
|
653
|
+
const pattern = `\\b(?<startMonth>${monthPattern})${startYear}(?<preSpace> ?)-(?<postSpace> ?)(?<endMonth>${monthPattern})${endYear}\\b`
|
|
654
|
+
pass(
|
|
655
|
+
view,
|
|
656
|
+
cachedRegExp(pattern, "g"),
|
|
657
|
+
(match, v) => {
|
|
658
|
+
// The pattern's trailing `\b` sees a folded glyph (`×`, a superscript
|
|
659
|
+
// ordinal) as a non-word char, but the source character it folds from is
|
|
660
|
+
// a word char that blocks the match ("March 2025x3"); re-check the end
|
|
661
|
+
// boundary fold-stably.
|
|
662
|
+
if (!wordBoundaryEndOk(v, match.index + match[0].length)) return null
|
|
663
|
+
const groups = namedGroups<{
|
|
664
|
+
startMonth: string
|
|
665
|
+
startYear?: string
|
|
666
|
+
preSpace: string
|
|
667
|
+
postSpace: string
|
|
668
|
+
endMonth: string
|
|
669
|
+
endYear?: string
|
|
670
|
+
}>(matchArgs(match))
|
|
671
|
+
// Replace `[preSpace]-[postSpace]` (the dash and its spaces) with the
|
|
672
|
+
// styled en-dash, leaving the months and any boundaries around them.
|
|
673
|
+
const startLen = groups.startMonth.length + (groups.startYear?.length ?? 0)
|
|
674
|
+
const dashSpan = groups.preSpace.length + 1 + groups.postSpace.length
|
|
675
|
+
const dashStart = match.index + startLen
|
|
676
|
+
v.replace(dashStart, dashStart + dashSpan, `${pre}${EN_DASH}${post}`)
|
|
677
|
+
return null
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
allowBoundaries: (match, v) => {
|
|
545
681
|
const groups = namedGroups<{
|
|
546
682
|
startMonth: string
|
|
547
683
|
startYear?: string
|
|
@@ -550,38 +686,19 @@ export function enDashDateRange(text: string, options: DashOptions = {}): string
|
|
|
550
686
|
endMonth: string
|
|
551
687
|
endYear?: string
|
|
552
688
|
}>(matchArgs(match))
|
|
553
|
-
// Replace `[preSpace]-[postSpace]` (the dash and its spaces) with the
|
|
554
|
-
// styled en-dash, leaving the months and any boundaries around them.
|
|
555
689
|
const startLen = groups.startMonth.length + (groups.startYear?.length ?? 0)
|
|
556
|
-
|
|
690
|
+
// One boundary is tolerated after the start year and one before the end
|
|
691
|
+
// month, bracketing the dash and spaces.
|
|
557
692
|
const dashStart = match.index + startLen
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
startMonth: string
|
|
565
|
-
startYear?: string
|
|
566
|
-
preSpace: string
|
|
567
|
-
postSpace: string
|
|
568
|
-
endMonth: string
|
|
569
|
-
endYear?: string
|
|
570
|
-
}>(matchArgs(match))
|
|
571
|
-
const startLen = groups.startMonth.length + (groups.startYear?.length ?? 0)
|
|
572
|
-
// v4 tolerated one separator after the start year (`preSep`) and one
|
|
573
|
-
// before the end month (`postSep`), bracketing the dash and spaces.
|
|
574
|
-
const dashStart = match.index + startLen
|
|
575
|
-
const dashEnd = dashStart + groups.preSpace.length + 1 + groups.postSpace.length
|
|
576
|
-
const tolerated = new Map<number, number>()
|
|
577
|
-
tolerated.set(dashStart, 1)
|
|
578
|
-
tolerated.set(dashEnd, 1)
|
|
579
|
-
if (!interiorBoundariesAllowed(match, v, tolerated)) return false
|
|
580
|
-
return wordBoundaryStartOk(v, match.index) && wordBoundaryEndOk(v, match.index + match[0].length)
|
|
581
|
-
},
|
|
693
|
+
const dashEnd = dashStart + groups.preSpace.length + 1 + groups.postSpace.length
|
|
694
|
+
const tolerated = new Map<number, number>()
|
|
695
|
+
tolerated.set(dashStart, 1)
|
|
696
|
+
tolerated.set(dashEnd, 1)
|
|
697
|
+
if (!interiorBoundariesAllowed(match, v, tolerated)) return false
|
|
698
|
+
return wordBoundaryStartOk(v, match.index) && wordBoundaryEndOk(v, match.index + match[0].length)
|
|
582
699
|
},
|
|
583
|
-
|
|
584
|
-
|
|
700
|
+
},
|
|
701
|
+
)
|
|
585
702
|
}
|
|
586
703
|
|
|
587
704
|
// ---------------------------------------------------------------------------
|
|
@@ -589,9 +706,10 @@ export function enDashDateRange(text: string, options: DashOptions = {}): string
|
|
|
589
706
|
// ---------------------------------------------------------------------------
|
|
590
707
|
|
|
591
708
|
/** Convert hyphens to minus signs in numeric contexts (e.g., "-5" → "−5"). */
|
|
592
|
-
export function minusReplace(
|
|
593
|
-
|
|
594
|
-
|
|
709
|
+
export function minusReplace(input: string): string
|
|
710
|
+
export function minusReplace(input: ProseView): void
|
|
711
|
+
export function minusReplace(input: string | ProseView): string | void {
|
|
712
|
+
return overInput(input, (view) => {
|
|
595
713
|
minusSubtractionOfNegative(view)
|
|
596
714
|
minusSpacedSubtraction(view)
|
|
597
715
|
minusDirectNegative(view)
|
|
@@ -600,14 +718,14 @@ export function minusReplace(text: string, options: DashOptions = {}): string {
|
|
|
600
718
|
|
|
601
719
|
/** Pattern 1a/1b: spaced math subtraction after a digit (`5 - 3`, `5 - -3`). */
|
|
602
720
|
function minusSubtractionOfNegative(view: ProseView): void {
|
|
603
|
-
// "5 - -3" → "5 − −3"; the negative hyphen is consumed first.
|
|
604
|
-
//
|
|
721
|
+
// "5 - -3" → "5 − −3"; the negative hyphen is consumed first. The slot before
|
|
722
|
+
// `num` tolerates one boundary there (the `- -` prefix has 4 chars).
|
|
605
723
|
pass(
|
|
606
724
|
view,
|
|
607
725
|
cachedRegExp(`(?<=\\d) - -(?<num>\\d*\\.?\\d+)`, "g"),
|
|
608
726
|
(match, v) => {
|
|
609
727
|
// Replace only the ` - -` prefix; `num` (which may carry interior
|
|
610
|
-
// boundaries
|
|
728
|
+
// boundaries that truncate it) stays untouched.
|
|
611
729
|
if (!minusSubtractionPrefixOk(match, v, 4)) return null
|
|
612
730
|
v.replace(match.index, match.index + 4, ` ${MINUS} ${MINUS}`)
|
|
613
731
|
return null
|
|
@@ -633,27 +751,27 @@ function minusSpacedSubtraction(view: ProseView): void {
|
|
|
633
751
|
const NUM_BODY_RE = /^\d*\.?\d+/
|
|
634
752
|
|
|
635
753
|
/**
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
* in "5 -
|
|
643
|
-
*
|
|
644
|
-
*
|
|
754
|
+
* The subtraction patterns tolerate one boundary after the leading digit (at
|
|
755
|
+
* the match start) and one before `num`. The `-` prefix span ([match.index,
|
|
756
|
+
* match.index + prefixLen)) must stay boundary-free; one boundary may sit at
|
|
757
|
+
* `num`'s head. The `num` body (`\d*\.?\d+`) carries no interior slot, so it
|
|
758
|
+
* stops at the first interior boundary: a boundary that truncates `num` to a
|
|
759
|
+
* still-valid number (e.g. "12" in "5 - 12{b}5") leaves the match standing, but
|
|
760
|
+
* one that strips the mandatory `\d+` (e.g. "." in "5 - .{b}5") breaks it. The
|
|
761
|
+
* targeted edit only rewrites the ` - ` prefix, so a converting match must keep
|
|
762
|
+
* `num` satisfiable.
|
|
645
763
|
*/
|
|
646
764
|
function minusSubtractionPrefixOk(match: RegExpExecArray, view: ProseView, prefixLen: number): boolean {
|
|
647
|
-
// The
|
|
648
|
-
//
|
|
765
|
+
// The slot in the lookbehind (`(?<=\d)`) sits at the match start between the
|
|
766
|
+
// leading digit and the space; it tolerates at most one boundary.
|
|
649
767
|
if (boundaryCountAt(view, match.index) > 1) return false
|
|
650
768
|
const numStart = match.index + prefixLen
|
|
651
769
|
for (const b of view.boundaries) {
|
|
652
770
|
if (b > match.index && b < numStart) return false
|
|
653
771
|
}
|
|
654
|
-
// The
|
|
772
|
+
// The slot before `num` tolerates at most one boundary at the num head.
|
|
655
773
|
if (boundaryCountAt(view, numStart) > 1) return false
|
|
656
|
-
//
|
|
774
|
+
// `num` stops at the first interior boundary; the clean run up to it must
|
|
657
775
|
// still satisfy `\d*\.?\d+` (the mandatory trailing `\d+`).
|
|
658
776
|
const numEnd = match.index + match[0].length
|
|
659
777
|
let truncatedEnd = numEnd
|
|
@@ -665,15 +783,27 @@ function minusSubtractionPrefixOk(match: RegExpExecArray, view: ProseView, prefi
|
|
|
665
783
|
}
|
|
666
784
|
|
|
667
785
|
const MINUS_BEFORE_CLASS_RE = new RegExp(`[\\s("'${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}]`)
|
|
668
|
-
|
|
786
|
+
// The folded word glyphs block like the word chars they fold from ("1x|-0"
|
|
787
|
+
// and "1×|-0" must agree), the folded math operators block like the operator
|
|
788
|
+
// chars they fold from (the `!=` → `≠` fold consumes the `=` that blocked
|
|
789
|
+
// Pattern 2 in "!=-1"), and the folded terminals — plus the bare `!` the
|
|
790
|
+
// `!!` normalization leaves — block like the `!`/`?`/`.` runs they fold from
|
|
791
|
+
// ("?|!-0" folds to "⁈|-0" with the hyphen stranded boundary-adjacent).
|
|
792
|
+
const FOLDED_WORD_CLASS_FRAGMENT = [...FOLDED_WORD_CHARS].join("")
|
|
793
|
+
const FOLDED_MATH_CLASS_FRAGMENT = [...FOLDED_MATH_OPERATORS].join("")
|
|
794
|
+
const FOLDED_TERMINAL_CLASS_FRAGMENT = [
|
|
795
|
+
UNICODE_SYMBOLS.ELLIPSIS, UNICODE_SYMBOLS.DOUBLE_QUESTION, UNICODE_SYMBOLS.QUESTION_EXCLAMATION,
|
|
796
|
+
UNICODE_SYMBOLS.EXCLAMATION_QUESTION, UNICODE_SYMBOLS.DOUBLE_EXCLAMATION, UNICODE_SYMBOLS.INTERROBANG, "!",
|
|
797
|
+
].join("")
|
|
798
|
+
const NUM_BEFORE_2B_BLOCK_RE = new RegExp(`[\\d.,${LATIN_LETTERS}${FOLDED_WORD_CLASS_FRAGMENT}${FOLDED_MATH_CLASS_FRAGMENT}${FOLDED_TERMINAL_CLASS_FRAGMENT}]`)
|
|
669
799
|
|
|
670
800
|
/**
|
|
671
|
-
* Direct negative numbers
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
*
|
|
801
|
+
* Direct negative numbers, in two cases: Pattern 2 converts `-\d` after line
|
|
802
|
+
* start, whitespace, `(`, or a quote; Pattern 2b converts a boundary-led hyphen
|
|
803
|
+
* `-\d` when no digit/`.`/`,`/Latin precedes the boundary — which prevents
|
|
804
|
+
* `1{b}-2` and `GPT{b}-3` from reading as negatives. Pattern 2 fires when the
|
|
805
|
+
* clean char before the hyphen qualifies and no boundary intrudes; Pattern 2b
|
|
806
|
+
* fires when a boundary leads the hyphen.
|
|
677
807
|
*/
|
|
678
808
|
function minusDirectNegative(view: ProseView): void {
|
|
679
809
|
pass(
|
|
@@ -681,11 +811,11 @@ function minusDirectNegative(view: ProseView): void {
|
|
|
681
811
|
cachedRegExp(`-(?<num>\\d*\\.?\\d+)`, "gm"),
|
|
682
812
|
(match, v) => {
|
|
683
813
|
const start = match.index
|
|
684
|
-
//
|
|
685
|
-
//
|
|
814
|
+
// `num` (`\d*\.?\d+`) carries no interior slot, so it stops at the first
|
|
815
|
+
// interior boundary; the clean run up to it must still satisfy
|
|
686
816
|
// `\d*\.?\d+`. A boundary right after the hyphen (head) leaves no digits
|
|
687
|
-
// ("-{
|
|
688
|
-
//
|
|
817
|
+
// ("-{b}5"), and one that strips the mandatory `\d+` ("-.{b}1") breaks the
|
|
818
|
+
// match, but one past a valid number ("-5{b}5") leaves it intact.
|
|
689
819
|
const numEnd = start + match[0].length
|
|
690
820
|
let numTruncEnd = numEnd
|
|
691
821
|
for (const b of v.boundaries) {
|
|
@@ -698,9 +828,9 @@ function minusDirectNegative(view: ProseView): void {
|
|
|
698
828
|
// hyphen has either a boundary or a clean char immediately before it.)
|
|
699
829
|
const nb = boundaryCountAt(v, start)
|
|
700
830
|
if (nb > 0) {
|
|
701
|
-
// Pattern 2b: the
|
|
702
|
-
//
|
|
703
|
-
//
|
|
831
|
+
// Pattern 2b: the char before the boundary run must not be a digit,
|
|
832
|
+
// `.`, `,`, or Latin letter. With ≥2 stacked boundaries that char is
|
|
833
|
+
// another boundary (admitted); with one, it is the clean char left.
|
|
704
834
|
if (nb < 2) {
|
|
705
835
|
const before = v.text[start - 1]
|
|
706
836
|
if (before !== undefined && NUM_BEFORE_2B_BLOCK_RE.test(before)) return null
|
|
@@ -747,21 +877,21 @@ function isDashChar(ch: string | undefined): boolean {
|
|
|
747
877
|
* before a word char (the suspended/hanging hyphen, "Yes-men and -women") is
|
|
748
878
|
* skipped. The arrow guard rejects shapes like "- >" so `foo -> bar` stays.
|
|
749
879
|
*
|
|
750
|
-
*
|
|
751
|
-
*
|
|
752
|
-
*
|
|
753
|
-
*
|
|
754
|
-
*
|
|
880
|
+
* The dash core (`[${EN}${EM}][-${EN}${EM}]*|-{2,}|-(?![LAT\d])`) never spans a
|
|
881
|
+
* boundary, so a node boundary inside the dash run truncates the run — the
|
|
882
|
+
* boundary becomes the trailing slot and any dashes past it are left untouched.
|
|
883
|
+
* The replacer realizes this by truncating the run at the first interior
|
|
884
|
+
* boundary and re-validating the dash core on the truncated form.
|
|
755
885
|
*/
|
|
756
886
|
function convertSpacedDashes(view: ProseView, rendered: string): void {
|
|
757
|
-
//
|
|
758
|
-
//
|
|
887
|
+
// The `(?<=[^\s]|^)` lookbehind is validated in the replacer instead, since a
|
|
888
|
+
// boundary (non-space) also satisfies it, letting a match begin even when the
|
|
759
889
|
// clean char to the left is whitespace. The cheap `(?<![ ])` anchor keeps each
|
|
760
890
|
// `[ ]+` run starting at its leftmost space, avoiding quadratic backtracking.
|
|
761
891
|
const pattern = `(?<![ ])[ ]+(?<dashStart>[-${EN_DASH}${EM_DASH}])`
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
//
|
|
892
|
+
// The full shape consumes the trailing `[ ]*`, advancing past it; this pattern
|
|
893
|
+
// does not, so a later match could begin inside a previous match's consumed
|
|
894
|
+
// trailing spaces. Track the last consumed offset and skip.
|
|
765
895
|
let consumedThrough = -1
|
|
766
896
|
pass(
|
|
767
897
|
view,
|
|
@@ -770,8 +900,8 @@ function convertSpacedDashes(view: ProseView, rendered: string): void {
|
|
|
770
900
|
const text = v.text
|
|
771
901
|
const groups = namedGroups<{ dashStart: string }>(matchArgs(match))
|
|
772
902
|
const firstDash = match.index + match[0].length - groups.dashStart.length
|
|
773
|
-
//
|
|
774
|
-
// holds: line/text start, a non-whitespace clean char, or a
|
|
903
|
+
// The greedy `[ ]+` starts at the leftmost position whose `(?<=[^\s]|^)`
|
|
904
|
+
// holds: line/text start, a non-whitespace clean char, or a boundary. A
|
|
775
905
|
// boundary inside the leading spaces restarts the match just after it (the
|
|
776
906
|
// spaces before it stay with the previous node). The previous match's
|
|
777
907
|
// consumed span is also a floor: leading spaces it already consumed are not
|
|
@@ -785,24 +915,33 @@ function convertSpacedDashes(view: ProseView, rendered: string): void {
|
|
|
785
915
|
stops at this dash, so `consumedThrough` never reaches past `firstDash`. */
|
|
786
916
|
if (matchStart >= firstDash + 1) return null
|
|
787
917
|
// Validate the lookbehind at matchStart: start of text/line, a boundary
|
|
788
|
-
// immediately left
|
|
918
|
+
// immediately left, or a non-whitespace clean char.
|
|
789
919
|
const leftOk = matchStart === 0
|
|
790
920
|
|| view.hasBoundary(matchStart)
|
|
791
921
|
|| (text[matchStart - 1] !== undefined && !/\s/.test(text[matchStart - 1]))
|
|
792
922
|
if (!leftOk) return null
|
|
923
|
+
// A dash char left of the leading spaces (boundaries are zero-width, so
|
|
924
|
+
// this reads through them) marks this run as the tail of an
|
|
925
|
+
// already-spaced dash ("x – -"): the overlap consumption above leaves
|
|
926
|
+
// it on the pass that converts the head, and the head's edits can
|
|
927
|
+
// rewrite the linking context so the consumption never re-links; block
|
|
928
|
+
// the tail deterministically. Glyphs folded from a trailing dash (`←`,
|
|
929
|
+
// `±`) block like the hyphen that sat in this slot before the fold.
|
|
930
|
+
const charBeforeSpaces = text[matchStart - 1]
|
|
931
|
+
if (matchStart > 0
|
|
932
|
+
&& (isDashChar(charBeforeSpaces) || FOLDED_FROM_TRAILING_DASH.has(charBeforeSpaces))) return null
|
|
793
933
|
// The leading spaces between matchStart and the dash must be at least one
|
|
794
|
-
// (
|
|
934
|
+
// (`[ ]+` is one-or-more) — a boundary directly before the dash leaves
|
|
795
935
|
// none, which is the boundary-led pattern's job, not this one.
|
|
796
936
|
if (matchStart === firstDash) return null
|
|
797
|
-
// A
|
|
798
|
-
//
|
|
799
|
-
// matches, so skip.
|
|
937
|
+
// A boundary between the leading spaces and the dash breaks `[ ]+dashCore`
|
|
938
|
+
// (no slot there); neither spaced nor boundary-led matches, so skip.
|
|
800
939
|
if (view.hasBoundary(firstDash)) return null
|
|
801
|
-
// Maximal dash run from firstDash that does not cross a boundary (
|
|
802
|
-
// never
|
|
940
|
+
// Maximal dash run from firstDash that does not cross a boundary (the core
|
|
941
|
+
// never spans a boundary).
|
|
803
942
|
let fullRunEnd = firstDash + 1
|
|
804
943
|
while (isDashChar(text[fullRunEnd]) && !v.hasBoundary(fullRunEnd)) fullRunEnd++
|
|
805
|
-
//
|
|
944
|
+
// The dashCore (`-{2,}` greedy, then single `-`) backtracks past the arrow
|
|
806
945
|
// guard AND the trailing `[ ]*(?=\S|$)`, so try run ends longest-first and
|
|
807
946
|
// take the first one whose whole tail validates.
|
|
808
947
|
const chosen = chooseSpacedDashRun(v, firstDash, fullRunEnd)
|
|
@@ -816,7 +955,7 @@ function convertSpacedDashes(view: ProseView, rendered: string): void {
|
|
|
816
955
|
}
|
|
817
956
|
|
|
818
957
|
/**
|
|
819
|
-
* Picks
|
|
958
|
+
* Picks the dashCore from the maximal dash run [firstDash, fullRunEnd). Tries
|
|
820
959
|
* `[EN EM][-EN EM]*`, then `-{2,}` (greedy), then a single `-`, backtracking past
|
|
821
960
|
* the arrow guard and the trailing `[ ]*(?=\S|$)`. Returns the chosen run end and
|
|
822
961
|
* the trailing-spaces end (`scan`) to replace, or null when no branch validates.
|
|
@@ -835,7 +974,7 @@ function chooseSpacedDashRun(view: ProseView, firstDash: number, fullRunEnd: num
|
|
|
835
974
|
}
|
|
836
975
|
return null
|
|
837
976
|
}
|
|
838
|
-
//
|
|
977
|
+
// The `-{2,}` and single-`-` branches match hyphens only, so a leading-hyphen
|
|
839
978
|
// run stops at the first EN/EM dash (e.g. "-–" is a single `-` core, not a
|
|
840
979
|
// two-dash run that would swallow the EN dash).
|
|
841
980
|
let hyphenRunEnd = firstDash
|
|
@@ -863,19 +1002,19 @@ interface SpacedTrailing {
|
|
|
863
1002
|
}
|
|
864
1003
|
|
|
865
1004
|
/**
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
* next node) but still consumed. The lookahead then requires
|
|
869
|
-
* boundary, or end of text. Returns the edit/consumed
|
|
1005
|
+
* The trailing `[ ]*(?:[ ]*)?(?=\S|$)` with one tolerated boundary. The leading
|
|
1006
|
+
* `[ ]*` (before any boundary) is deleted; spaces after a boundary are kept
|
|
1007
|
+
* (they belong to the next node) but still consumed. The lookahead then requires
|
|
1008
|
+
* a non-space char, a boundary, or end of text. Returns the edit/consumed
|
|
1009
|
+
* offsets, or null on failure.
|
|
870
1010
|
*/
|
|
871
1011
|
function spacedTrailingEnd(view: ProseView, runEnd: number): SpacedTrailing | null {
|
|
872
1012
|
const text = view.text
|
|
873
1013
|
let editEnd = runEnd
|
|
874
1014
|
while (text[editEnd] === " " && !view.hasBoundary(editEnd)) editEnd++
|
|
875
|
-
//
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
// separator), a non-space clean char, or end of text.
|
|
1015
|
+
// The trailing optional group is greedy: try consuming a single boundary and
|
|
1016
|
+
// its kept trailing spaces first, then fall back to no boundary. `(?=\S|$)` is
|
|
1017
|
+
// satisfied by a boundary (non-space), a non-space clean char, or end of text.
|
|
879
1018
|
if (boundaryCountAt(view, editEnd) === 1) {
|
|
880
1019
|
let consumedEnd = editEnd
|
|
881
1020
|
while (text[consumedEnd] === " " && (consumedEnd === editEnd || !view.hasBoundary(consumedEnd))) consumedEnd++
|
|
@@ -885,17 +1024,17 @@ function spacedTrailingEnd(view: ProseView, runEnd: number): SpacedTrailing | nu
|
|
|
885
1024
|
return null
|
|
886
1025
|
}
|
|
887
1026
|
|
|
888
|
-
/**
|
|
1027
|
+
/** `(?=\S|$)`: end of text, a node boundary, or a non-space clean char. */
|
|
889
1028
|
function lookaheadNonSpace(view: ProseView, pos: number): boolean {
|
|
890
1029
|
return pos === view.text.length || view.hasBoundary(pos) || !/\s/.test(view.text[pos])
|
|
891
1030
|
}
|
|
892
1031
|
|
|
893
1032
|
/**
|
|
894
|
-
*
|
|
895
|
-
*
|
|
896
|
-
*
|
|
897
|
-
*
|
|
898
|
-
*
|
|
1033
|
+
* The `(?!-*>)` arrow guard, boundary-aware. Two slots bracket the `-*` hyphen
|
|
1034
|
+
* run: one boundary may sit before the run and one after. With no hyphens both
|
|
1035
|
+
* slots collapse to the run-end offset (up to two boundaries there); a boundary
|
|
1036
|
+
* inside the hyphen run, or more than the two slots allow, hides the `>` (so the
|
|
1037
|
+
* dash is not an arrow and converts).
|
|
899
1038
|
*/
|
|
900
1039
|
function spacedArrowAhead(view: ProseView, runEnd: number): boolean {
|
|
901
1040
|
const text = view.text
|
|
@@ -905,24 +1044,26 @@ function spacedArrowAhead(view: ProseView, runEnd: number): boolean {
|
|
|
905
1044
|
h++
|
|
906
1045
|
}
|
|
907
1046
|
if (h === runEnd) {
|
|
908
|
-
// No hyphens: both
|
|
1047
|
+
// No hyphens: both slots fall at this offset (≤2 boundaries total).
|
|
909
1048
|
if (boundaryCountAt(view, runEnd) > 2) return false
|
|
910
1049
|
} else {
|
|
911
|
-
if (boundaryCountAt(view, runEnd) > 1) return false // first
|
|
912
|
-
if (boundaryCountAt(view, h) > 1) return false // second
|
|
1050
|
+
if (boundaryCountAt(view, runEnd) > 1) return false // first slot
|
|
1051
|
+
if (boundaryCountAt(view, h) > 1) return false // second slot
|
|
913
1052
|
}
|
|
914
|
-
|
|
1053
|
+
// `≥` guards like the `>=` it folds from: the math pass folds the `>` this
|
|
1054
|
+
// guard keys on, and a dash blocked before `–>=` must stay blocked at `–≥`.
|
|
1055
|
+
return text[h] === ">" || text[h] === UNICODE_SYMBOLS.GREATER_EQUAL
|
|
915
1056
|
}
|
|
916
1057
|
|
|
917
1058
|
/**
|
|
918
|
-
* Dashes where a
|
|
919
|
-
*
|
|
920
|
-
* the dash run, a non-space
|
|
921
|
-
* trailing space. Because the leading
|
|
922
|
-
* where a node boundary opens a dash run, so the pass scans
|
|
923
|
-
* than the clean text. The "non-space before the
|
|
924
|
-
* real non-space clean char (single boundary) or by another
|
|
925
|
-
* (two or more boundaries — a
|
|
1059
|
+
* Dashes where a boundary alone precedes the dash run: "word{b}– rest". The
|
|
1060
|
+
* shape `(?<=[^\s])[dash]+[ ]+` with a leading boundary requires a boundary
|
|
1061
|
+
* immediately before the dash run, a non-space char before that boundary, and
|
|
1062
|
+
* at least one trailing space. Because the leading boundary is required, this
|
|
1063
|
+
* only fires where a node boundary opens a dash run, so the pass scans
|
|
1064
|
+
* boundaries rather than the clean text. The "non-space before the boundary" is
|
|
1065
|
+
* satisfied by a real non-space clean char (single boundary) or by another
|
|
1066
|
+
* stacked boundary (two or more boundaries — a boundary is itself non-space).
|
|
926
1067
|
*/
|
|
927
1068
|
function convertBoundaryLedDashes(view: ProseView, rendered: string): void {
|
|
928
1069
|
const text = view.text
|
|
@@ -935,19 +1076,33 @@ function convertBoundaryLedDashes(view: ProseView, rendered: string): void {
|
|
|
935
1076
|
prevOffset = b
|
|
936
1077
|
if (b < lastProcessedEnd) continue
|
|
937
1078
|
if (!isDashChar(text[b])) continue
|
|
938
|
-
// Non-space before the
|
|
939
|
-
// clean char immediately left.
|
|
1079
|
+
// Non-space before the boundary: ≥2 stacked boundaries, or a non-space
|
|
1080
|
+
// clean char immediately left. A folded math operator there marks the
|
|
1081
|
+
// fold-stranded configuration: the math pass consumes one character
|
|
1082
|
+
// across this junction (`=` + `~—` folds to `≈` + `—`), so the character
|
|
1083
|
+
// that blocked this rule on the source text is gone, and converting now
|
|
1084
|
+
// would diverge from that blocked first pass.
|
|
940
1085
|
if (stackedHere < 2) {
|
|
941
1086
|
const before = text[b - 1]
|
|
942
|
-
if (before === undefined || /\s/.test(before)) continue
|
|
1087
|
+
if (before === undefined || /\s/.test(before) || FOLDED_MATH_OPERATORS.has(before)) continue
|
|
943
1088
|
}
|
|
944
1089
|
// Dash run from the boundary, not crossing a further boundary.
|
|
945
1090
|
let runEnd = b + 1
|
|
946
1091
|
while (isDashChar(text[runEnd]) && !view.hasBoundary(runEnd)) runEnd++
|
|
947
|
-
// Required trailing space run; stop at a boundary
|
|
1092
|
+
// Required trailing space run; stop at a boundary.
|
|
948
1093
|
let spaceEnd = runEnd
|
|
949
1094
|
while (text[spaceEnd] === " " && !view.hasBoundary(spaceEnd)) spaceEnd++
|
|
950
1095
|
if (spaceEnd === runEnd) continue // no trailing space
|
|
1096
|
+
// A dash (or a glyph folded from a dash-led source) after the spaces
|
|
1097
|
+
// blocks when the rendered form ends in a dash: consuming the spaces
|
|
1098
|
+
// would fuse the rendered dash with it into a run the next pass
|
|
1099
|
+
// collapses (see the same guard in the multiple-dash rule). A boundary
|
|
1100
|
+
// at the junction prevents the fusion, so only a same-node follower
|
|
1101
|
+
// blocks.
|
|
1102
|
+
const afterSpaces = text[spaceEnd]
|
|
1103
|
+
if (afterSpaces !== undefined && !view.hasBoundary(spaceEnd)
|
|
1104
|
+
&& isDashChar(rendered[rendered.length - 1])
|
|
1105
|
+
&& (isDashChar(afterSpaces) || FOLDED_FROM_LEADING_DASH.has(afterSpaces))) continue
|
|
951
1106
|
edits.push([b, spaceEnd])
|
|
952
1107
|
lastProcessedEnd = spaceEnd
|
|
953
1108
|
}
|
|
@@ -956,12 +1111,12 @@ function convertBoundaryLedDashes(view: ProseView, rendered: string): void {
|
|
|
956
1111
|
}
|
|
957
1112
|
|
|
958
1113
|
/**
|
|
959
|
-
* Multiple dashes: "word--word", "word---word", `"quote"--"quote"`.
|
|
960
|
-
* `(?<=[LAT\d quote])
|
|
961
|
-
*
|
|
962
|
-
*
|
|
963
|
-
*
|
|
964
|
-
*
|
|
1114
|
+
* Multiple dashes: "word--word", "word---word", `"quote"--"quote"`. The shape
|
|
1115
|
+
* `(?<=[LAT\d quote])[dash]{2,50}(?=[LAT quote space])` tolerates one boundary
|
|
1116
|
+
* on each side of the run, but its `[dash]{2,50}` never spans a boundary. A
|
|
1117
|
+
* boundary inside the run truncates it (and the 2..50 count must still hold for
|
|
1118
|
+
* the truncated run); two stacked boundaries on either edge exceed the single
|
|
1119
|
+
* slot and block the match.
|
|
965
1120
|
*/
|
|
966
1121
|
function convertMultipleDashes(view: ProseView, rendered: string): void {
|
|
967
1122
|
const before = `[${LATIN_LETTERS}\\d${QUOTE_CHARS}]`
|
|
@@ -975,18 +1130,36 @@ function convertMultipleDashes(view: ProseView, rendered: string): void {
|
|
|
975
1130
|
(match, v) => {
|
|
976
1131
|
const text = v.text
|
|
977
1132
|
const start = match.index
|
|
978
|
-
// Two stacked boundaries before the run exceed
|
|
1133
|
+
// Two stacked boundaries before the run exceed the leading slot's budget.
|
|
979
1134
|
if (boundaryCountAt(v, start) >= 2) return null
|
|
980
|
-
// Truncate the run at the first interior boundary (
|
|
1135
|
+
// Truncate the run at the first interior boundary (the class stops there).
|
|
981
1136
|
let runEnd = start + 1
|
|
982
1137
|
while (isDashChar(text[runEnd]) && !v.hasBoundary(runEnd)) runEnd++
|
|
983
1138
|
if (runEnd - start < 2 || runEnd - start > 50) return null
|
|
984
|
-
//
|
|
985
|
-
// clean char after the run is [LAT quote space].
|
|
1139
|
+
// The trailing slot tolerates one boundary; the trailing lookahead then
|
|
1140
|
+
// checks the clean char after the run is [LAT quote space].
|
|
986
1141
|
if (boundaryCountAt(v, runEnd) >= 2) return null
|
|
987
1142
|
const next = text[runEnd]
|
|
988
1143
|
if (next === undefined || !afterRe.test(next)) return null
|
|
989
|
-
|
|
1144
|
+
// Consume the trailing space run (not crossing a boundary): the rendered
|
|
1145
|
+
// dash carries its own spacing, and a re-run's spaced-dash rule would
|
|
1146
|
+
// otherwise collapse the leftover spaces into it. When the rendered
|
|
1147
|
+
// form ends in a dash (the unspaced American em dash), a dash (or a
|
|
1148
|
+
// glyph folded from a dash-led source) after the spaces keeps them:
|
|
1149
|
+
// consuming would fuse the rendered dash with it into a fresh run, and
|
|
1150
|
+
// the spaced-dash rule's dash-tail guard already holds the leftover
|
|
1151
|
+
// spaces still.
|
|
1152
|
+
let editEnd = runEnd
|
|
1153
|
+
while (text[editEnd] === " " && !v.hasBoundary(editEnd)) editEnd++
|
|
1154
|
+
// A boundary at the junction prevents the fusion (dash runs never span
|
|
1155
|
+
// boundaries), so only a same-node follower blocks.
|
|
1156
|
+
const afterSpaces = text[editEnd]
|
|
1157
|
+
if (editEnd > runEnd && afterSpaces !== undefined && !v.hasBoundary(editEnd)
|
|
1158
|
+
&& isDashChar(rendered[rendered.length - 1])
|
|
1159
|
+
&& (isDashChar(afterSpaces) || FOLDED_FROM_LEADING_DASH.has(afterSpaces))) {
|
|
1160
|
+
editEnd = runEnd
|
|
1161
|
+
}
|
|
1162
|
+
v.replace(start, editEnd, rendered)
|
|
990
1163
|
return null
|
|
991
1164
|
},
|
|
992
1165
|
{ allowBoundaries: () => true },
|
|
@@ -994,11 +1167,10 @@ function convertMultipleDashes(view: ProseView, rendered: string): void {
|
|
|
994
1167
|
}
|
|
995
1168
|
|
|
996
1169
|
/**
|
|
997
|
-
* Dashes at start of line: "^- " → styled dash.
|
|
998
|
-
*
|
|
999
|
-
* The dash run has no interior
|
|
1000
|
-
*
|
|
1001
|
-
* want here.
|
|
1170
|
+
* Dashes at start of line: "^- " → styled dash. One boundary is tolerated at
|
|
1171
|
+
* line start (before the dashes); it sits at the match start, never interior.
|
|
1172
|
+
* The dash run has no interior slot, so a boundary inside the run blocks the
|
|
1173
|
+
* match — the default interior-boundary rejection, which is what we want here.
|
|
1002
1174
|
*/
|
|
1003
1175
|
function convertLeadingLineDashes(view: ProseView, localizedDash: string): void {
|
|
1004
1176
|
pass(view, cachedRegExp(`^-+ `, "gm"), () => `${localizedDash} `)
|
|
@@ -1011,8 +1183,8 @@ function convertUnspacedEmDashes(view: ProseView, rendered: string): void {
|
|
|
1011
1183
|
view,
|
|
1012
1184
|
cachedRegExp(pattern, "g"),
|
|
1013
1185
|
(match, v) => {
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1186
|
+
// One boundary is tolerated on each side of the em-dash; two or more
|
|
1187
|
+
// exceed the slot and block the match.
|
|
1016
1188
|
if (boundaryCountAt(v, match.index) > 1) return null
|
|
1017
1189
|
if (boundaryCountAt(v, match.index + match[0].length) > 1) return null
|
|
1018
1190
|
v.replace(match.index, match.index + match[0].length, rendered)
|
|
@@ -1034,12 +1206,11 @@ function normalizeEmDashSpacing(view: ProseView): void {
|
|
|
1034
1206
|
|
|
1035
1207
|
/**
|
|
1036
1208
|
* Remove spaces directly around an em-dash that has non-whitespace on both
|
|
1037
|
-
* sides.
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
* the anchor.
|
|
1209
|
+
* sides. Only the spaces immediately adjacent to the dash are consumed; a
|
|
1210
|
+
* boundary between a space and the dash keeps that space (space consumption
|
|
1211
|
+
* cannot reach across a boundary). The walk therefore stops space consumption
|
|
1212
|
+
* at node boundaries, and the `\S|^` / `\S|$` anchors treat a boundary (a
|
|
1213
|
+
* non-space) as satisfying the anchor.
|
|
1043
1214
|
*/
|
|
1044
1215
|
function removeSpacesAroundEmDash(view: ProseView): void {
|
|
1045
1216
|
pass(
|
|
@@ -1057,9 +1228,15 @@ function removeSpacesAroundEmDash(view: ProseView): void {
|
|
|
1057
1228
|
const leftAnchored = left === 0 || v.hasBoundary(left) || !/\s/.test(text[left - 1])
|
|
1058
1229
|
const rightAnchored = right === text.length || v.hasBoundary(right) || !/\s/.test(text[right])
|
|
1059
1230
|
if (!leftAnchored || !rightAnchored) return null
|
|
1060
|
-
// Drop the adjacent space runs; the em-dash and any edge boundaries
|
|
1061
|
-
|
|
1062
|
-
|
|
1231
|
+
// Drop the adjacent space runs; the em-dash and any edge boundaries
|
|
1232
|
+
// stay. A side whose anchor is another dash keeps its spaces: deleting
|
|
1233
|
+
// them would fuse the dashes into a run the multiple-dash rule then
|
|
1234
|
+
// collapses on the next pass. Glyphs folded from a dash-edged source
|
|
1235
|
+
// (`±`, `←`, `→`) anchor like the dash they fold from.
|
|
1236
|
+
const leftAnchor = text[left - 1]
|
|
1237
|
+
const rightAnchor = text[right]
|
|
1238
|
+
if (left < d && !isDashChar(leftAnchor) && !FOLDED_FROM_TRAILING_DASH.has(leftAnchor)) v.replace(left, d, "")
|
|
1239
|
+
if (right > d + 1 && !isDashChar(rightAnchor) && !FOLDED_FROM_LEADING_DASH.has(rightAnchor)) v.replace(d + 1, right, "")
|
|
1063
1240
|
return null
|
|
1064
1241
|
},
|
|
1065
1242
|
)
|
|
@@ -1071,8 +1248,8 @@ function preserveLineLeadingEmDashSpace(view: ProseView): void {
|
|
|
1071
1248
|
view,
|
|
1072
1249
|
cachedRegExp(`^${EM_DASH}(?<after>[A-Z0-9])`, "gm"),
|
|
1073
1250
|
(match, v) => {
|
|
1074
|
-
//
|
|
1075
|
-
//
|
|
1251
|
+
// One boundary is tolerated between the line start and the em-dash; two or
|
|
1252
|
+
// more exceed that slot and block the match.
|
|
1076
1253
|
if (boundaryCountAt(v, match.index) > 1) return null
|
|
1077
1254
|
const groups = namedGroups<{ after: string }>(matchArgs(match))
|
|
1078
1255
|
return `${EM_DASH} ${groups.after}`
|
|
@@ -1084,18 +1261,18 @@ function preserveLineLeadingEmDashSpace(view: ProseView): void {
|
|
|
1084
1261
|
// Public dash pipeline
|
|
1085
1262
|
// ---------------------------------------------------------------------------
|
|
1086
1263
|
|
|
1087
|
-
export function hyphenReplace(
|
|
1088
|
-
|
|
1264
|
+
export function hyphenReplace(input: string, options?: DashOptions): string
|
|
1265
|
+
export function hyphenReplace(input: ProseView, options?: DashOptions): void
|
|
1266
|
+
export function hyphenReplace(input: string | ProseView, options: DashOptions = {}): string | void {
|
|
1089
1267
|
const style = options.dashStyle ?? "american"
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1268
|
+
return overInput(input, (view) => {
|
|
1269
|
+
if (style === "none") return
|
|
1270
|
+
minusReplace(view)
|
|
1271
|
+
enDashDateRangeOverView(view, style)
|
|
1272
|
+
enDashNumberRange(view)
|
|
1095
1273
|
convertParentheticalDashes(view, style)
|
|
1096
1274
|
if (style === "american") normalizeEmDashSpacing(view)
|
|
1097
1275
|
})
|
|
1098
|
-
return text
|
|
1099
1276
|
}
|
|
1100
1277
|
|
|
1101
1278
|
const { WORD_JOINER } = UNICODE_SYMBOLS
|
|
@@ -1115,9 +1292,13 @@ const { WORD_JOINER } = UNICODE_SYMBOLS
|
|
|
1115
1292
|
* `nbspTransform` (U+00A0 breaks `Fig. 1` searches). Apply only in rendered
|
|
1116
1293
|
* HTML; do not write into Markdown source.
|
|
1117
1294
|
*/
|
|
1118
|
-
export function dashWordJoiner(
|
|
1119
|
-
|
|
1120
|
-
|
|
1295
|
+
export function dashWordJoiner(input: string): string
|
|
1296
|
+
export function dashWordJoiner(input: ProseView): void
|
|
1297
|
+
export function dashWordJoiner(input: string | ProseView): string | void {
|
|
1298
|
+
return overInput(input, (view) => {
|
|
1299
|
+
const re = cachedRegExp(`(?<=[^\\s${WORD_JOINER}])[${EM_DASH}${EN_DASH}]`, "gu")
|
|
1300
|
+
replaceAllInView(view, re, (match) => `${WORD_JOINER}${match[0]}`)
|
|
1301
|
+
})
|
|
1121
1302
|
}
|
|
1122
1303
|
|
|
1123
1304
|
/** Reconstructs `.replace()`-style callback args from a RegExpExecArray. */
|