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/quote-classifier.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { LATIN_LETTERS, TERMINAL_PUNCTUATION, UNICODE_SYMBOLS } from "./constants.js"
|
|
2
|
-
import {
|
|
1
|
+
import { LATIN_LETTERS, SPACE_CHARS, TERMINAL_PUNCTUATION, UNICODE_SYMBOLS } from "./constants.js"
|
|
2
|
+
import type { ProseView } from "./prose-view.js"
|
|
3
3
|
|
|
4
4
|
const {
|
|
5
5
|
EM_DASH,
|
|
6
|
+
EN_DASH,
|
|
7
|
+
MINUS,
|
|
6
8
|
LEFT_DOUBLE_QUOTE,
|
|
7
9
|
RIGHT_DOUBLE_QUOTE,
|
|
8
10
|
LEFT_SINGLE_QUOTE,
|
|
@@ -16,8 +18,32 @@ const {
|
|
|
16
18
|
NNBSP,
|
|
17
19
|
PRIME,
|
|
18
20
|
DOUBLE_PRIME,
|
|
21
|
+
ELLIPSIS,
|
|
22
|
+
DOUBLE_QUESTION,
|
|
23
|
+
QUESTION_EXCLAMATION,
|
|
24
|
+
EXCLAMATION_QUESTION,
|
|
25
|
+
DOUBLE_EXCLAMATION,
|
|
26
|
+
INTERROBANG,
|
|
27
|
+
MULTIPLICATION,
|
|
28
|
+
APPROXIMATE,
|
|
29
|
+
GREATER_EQUAL,
|
|
30
|
+
LESS_EQUAL,
|
|
31
|
+
NOT_EQUAL,
|
|
32
|
+
PLUS_MINUS,
|
|
33
|
+
SUPERSCRIPT_ST,
|
|
34
|
+
SUPERSCRIPT_ND,
|
|
35
|
+
SUPERSCRIPT_RD,
|
|
36
|
+
SUPERSCRIPT_TH,
|
|
19
37
|
} = UNICODE_SYMBOLS
|
|
20
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Glyphs the symbol passes fold `?`/`!` runs and `...` into. The quote rules
|
|
41
|
+
* run before those passes, so every ending-context set that contains the
|
|
42
|
+
* source punctuation must contain the folded glyph too — otherwise a decision
|
|
43
|
+
* gated on `?!` flips once a re-run sees `⁈`.
|
|
44
|
+
*/
|
|
45
|
+
const FOLDED_TERMINALS = [DOUBLE_QUESTION, QUESTION_EXCLAMATION, EXCLAMATION_QUESTION, DOUBLE_EXCLAMATION, INTERROBANG] as const
|
|
46
|
+
|
|
21
47
|
export const PUNCTUATION_STYLES = ["american", "british", "german", "french", "none"] as const
|
|
22
48
|
export type PunctuationStyle = (typeof PUNCTUATION_STYLES)[number]
|
|
23
49
|
|
|
@@ -55,9 +81,12 @@ const ROLE_BY_CHAR = new Map<string, Exclude<QuoteRole, "LITERAL">>([
|
|
|
55
81
|
* One element of the classifier's working sequence: either a single clean-text
|
|
56
82
|
* character (occasionally a multi-character span, for guillemets absorbed with
|
|
57
83
|
* their padding) or a zero-width node boundary between source fragments.
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
84
|
+
*
|
|
85
|
+
* The boundary-tolerance behavior of the boundary items reproduces the
|
|
86
|
+
* element-boundary semantics of the pre-v5 sentinel-marked pipeline (pinned by
|
|
87
|
+
* the golden corpus and the migration's differential fuzz): boundary-transparent
|
|
88
|
+
* rules skip them, and rules that treat a boundary as an ordinary non-space
|
|
89
|
+
* character do so.
|
|
61
90
|
*/
|
|
62
91
|
interface Item {
|
|
63
92
|
boundary: boolean
|
|
@@ -85,31 +114,53 @@ const QUOTE_CANDIDATE_RE = new RegExp(
|
|
|
85
114
|
)
|
|
86
115
|
|
|
87
116
|
const TERMINAL_SET = new Set<string>(TERMINAL_PUNCTUATION)
|
|
117
|
+
|
|
118
|
+
/** The collapse pass's space alphabet (plain space, tab, NBSP, NNBSP). */
|
|
119
|
+
const COLLAPSIBLE_SPACE_RE = new RegExp(`[${SPACE_CHARS}]`)
|
|
88
120
|
const CLOSING_SET = new Set<string>([RIGHT_SINGLE_QUOTE, RIGHT_DOUBLE_QUOTE])
|
|
89
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Placement alphabet for renders that collapse apostrophes into U+2019: a
|
|
124
|
+
* re-run reads those apostrophes as closing single quotes (in every style but
|
|
125
|
+
* German, which re-derives U+2019 by position), so placement must treat an
|
|
126
|
+
* APOSTROPHE item as part of a closing run for the transform to be a fixed
|
|
127
|
+
* point.
|
|
128
|
+
*/
|
|
129
|
+
const CLOSING_OR_APOSTROPHE_SET = new Set<string>([RIGHT_SINGLE_QUOTE, RIGHT_DOUBLE_QUOTE, MODIFIER_LETTER_APOSTROPHE])
|
|
130
|
+
|
|
90
131
|
/** Chars from `'` ${LSQ} ${RSQ} ${MLA}; \w handled separately. */
|
|
91
132
|
const SINGLE_QUOTE_FAMILY = new Set<string>(["'", LEFT_SINGLE_QUOTE, RIGHT_SINGLE_QUOTE, MODIFIER_LETTER_APOSTROPHE])
|
|
92
133
|
|
|
93
|
-
/** Ending-context chars for single quotes (plus any \s char).
|
|
94
|
-
|
|
134
|
+
/** Ending-context chars for single quotes (plus any \s char). The curly
|
|
135
|
+
* doubles accompany the straight one: `"` curls into one of them in this same
|
|
136
|
+
* pass, and an ending decision must survive the curl. */
|
|
137
|
+
const SINGLE_ENDING_SET = new Set<string>([".", "!", "?", ";", ",", ")", EM_DASH, "-", "]", '"', LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, ELLIPSIS, ...FOLDED_TERMINALS])
|
|
95
138
|
|
|
96
|
-
/** Boundary chars before an opening single quote (plus any \s char).
|
|
97
|
-
|
|
139
|
+
/** Boundary chars before an opening single quote (plus any \s char). A
|
|
140
|
+
* straight double quote belongs with the curly pair: whichever way it curls
|
|
141
|
+
* lands in this set, so deciding as if it already had keeps re-runs (which
|
|
142
|
+
* see the curled glyph) consistent. */
|
|
143
|
+
const SINGLE_OPEN_BEFORE_SET = new Set<string>([LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, '"', EM_DASH, "-", "("])
|
|
98
144
|
|
|
99
145
|
/** Boundary chars before an opening double quote (plus any \s char). */
|
|
100
146
|
const DOUBLE_OPEN_BEFORE_SET = new Set<string>(["(", "/", "[", "{", "-", EM_DASH])
|
|
101
147
|
|
|
102
|
-
/** Ending-context chars that block the opener rule's arm 2 (plus any \s char).
|
|
103
|
-
|
|
148
|
+
/** Ending-context chars that block the opener rule's arm 2 (plus any \s char).
|
|
149
|
+
* ELLIPSIS is deliberately absent: the rule's `...` arm opens, so `…` must too. */
|
|
150
|
+
const DOUBLE_OPEN_ENDING_SET = new Set<string>([")", EM_DASH, ",", "!", "?", ";", ":", ".", "}", ...FOLDED_TERMINALS])
|
|
104
151
|
|
|
105
152
|
/** Chars allowed before an empty double-quote pair (plus any \s char). */
|
|
106
153
|
const DOUBLE_EMPTY_BEFORE_SET = new Set<string>(["(", "[", "{"])
|
|
107
154
|
|
|
108
|
-
/** Chars allowed after an empty double-quote pair (plus any \s char).
|
|
109
|
-
|
|
155
|
+
/** Chars allowed after an empty double-quote pair (plus any \s char).
|
|
156
|
+
* Aligned to contain {@link DOUBLE_CLOSE_AFTER_SET}: a pair the empty-pair
|
|
157
|
+
* rule rejects falls through to opener/closer rules whose after-contexts are
|
|
158
|
+
* the closer set, and the two routes must accept the same shapes or a re-run
|
|
159
|
+
* (which sees the rendered opener, not the whitespace) classifies anew. */
|
|
160
|
+
const DOUBLE_EMPTY_AFTER_SET = new Set<string>([")", "]", "}", ".", "!", "?", ",", ";", ":", "/", "-", "s", EM_DASH, ELLIPSIS, ...FOLDED_TERMINALS])
|
|
110
161
|
|
|
111
162
|
/** Ending-context chars after a closing double quote (plus any \s char). */
|
|
112
|
-
const DOUBLE_CLOSE_AFTER_SET = new Set<string>(["/", ")", ".", ",", ";", EM_DASH, ":", "-", "}", "!", "?", "s"])
|
|
163
|
+
const DOUBLE_CLOSE_AFTER_SET = new Set<string>(["/", ")", ".", ",", ";", EM_DASH, ":", "-", "}", "!", "?", "s", ELLIPSIS, ...FOLDED_TERMINALS])
|
|
113
164
|
|
|
114
165
|
function isLetterItem(items: Item[], index: number): boolean {
|
|
115
166
|
if (index < 0 || index >= items.length) return false
|
|
@@ -125,9 +176,9 @@ function isCharItem(items: Item[], index: number, ch: string): boolean {
|
|
|
125
176
|
|
|
126
177
|
/**
|
|
127
178
|
* Index of the previous item, treating up to `maxBoundaries` boundary items as
|
|
128
|
-
* transparent
|
|
129
|
-
*
|
|
130
|
-
*
|
|
179
|
+
* transparent. Returns -1 at start of text; returns the blocking boundary's
|
|
180
|
+
* index when the budget is exceeded, so character-class tests on the result
|
|
181
|
+
* fail (a boundary item satisfies no character class).
|
|
131
182
|
*/
|
|
132
183
|
function prevIndex(items: Item[], index: number, maxBoundaries: number): number {
|
|
133
184
|
let j = index - 1
|
|
@@ -173,7 +224,7 @@ function buildItems(view: ProseView, style: ActiveQuoteStyle): Item[] {
|
|
|
173
224
|
let b = 0
|
|
174
225
|
// German closers are depth-gated: U+201C/U+2018 close only below an open
|
|
175
226
|
// low-9 quote; orphans fall back to straight-quote candidates re-derived by
|
|
176
|
-
// position (the
|
|
227
|
+
// position (the depth-0 branch).
|
|
177
228
|
let doubleDepth = 0
|
|
178
229
|
let singleDepth = 0
|
|
179
230
|
|
|
@@ -222,7 +273,9 @@ function buildItems(view: ProseView, style: ActiveQuoteStyle): Item[] {
|
|
|
222
273
|
} else if (ch === RIGHT_DOUBLE_QUOTE) {
|
|
223
274
|
// Not used in German typography — re-derive by position.
|
|
224
275
|
mapped = '"'
|
|
225
|
-
} else if (ch === RIGHT_SINGLE_QUOTE) {
|
|
276
|
+
} else if (ch === RIGHT_SINGLE_QUOTE || ch === MODIFIER_LETTER_APOSTROPHE) {
|
|
277
|
+
// U+02BC renders as U+2019, which the next run re-derives by position;
|
|
278
|
+
// re-derive it the same way now so both runs agree.
|
|
226
279
|
mapped = "'"
|
|
227
280
|
}
|
|
228
281
|
if (mapped !== null) {
|
|
@@ -252,6 +305,9 @@ function singleEndingAfter(items: Item[], index: number): boolean {
|
|
|
252
305
|
const j = nextIndex(items, index, 1)
|
|
253
306
|
if (j >= items.length) return true
|
|
254
307
|
const item = items[j]
|
|
308
|
+
// A `!` that folds to `≠` is not the ending it looks like (`≠` is not in
|
|
309
|
+
// the ending set), and the re-run sees the folded glyph.
|
|
310
|
+
if (foldsToNotEqual(items, j)) return false
|
|
255
311
|
return !item.boundary && isSingleEndingChar(item.ch)
|
|
256
312
|
}
|
|
257
313
|
|
|
@@ -269,26 +325,44 @@ function possessiveAfter(items: Item[], index: number): boolean {
|
|
|
269
325
|
}
|
|
270
326
|
|
|
271
327
|
/**
|
|
272
|
-
* `(?<=[^\s“'])` — the closing/possessive lookbehind. A boundary directly
|
|
273
|
-
* before the quote satisfies it (
|
|
328
|
+
* `(?<=[^\s“'"])` — the closing/possessive lookbehind. A boundary directly
|
|
329
|
+
* before the quote satisfies it (a boundary is an ordinary non-space char).
|
|
330
|
+
* A straight double quote is excluded along with `“`: it may curl into an
|
|
331
|
+
* opener later in this same pass, which puts the candidate in exactly the
|
|
332
|
+
* nested-opener position the `“` exclusion covers, and deciding against it
|
|
333
|
+
* now keeps re-runs (which see the curled glyph) consistent.
|
|
274
334
|
*/
|
|
275
335
|
function singleCloserBefore(items: Item[], index: number): boolean {
|
|
276
336
|
if (index === 0) return false
|
|
277
337
|
const prev = items[index - 1]
|
|
278
338
|
if (prev.boundary) return true
|
|
279
|
-
return !SPACE_RE.test(prev.ch) && prev.ch !== LEFT_DOUBLE_QUOTE && prev.ch !== "'"
|
|
339
|
+
return !SPACE_RE.test(prev.ch) && prev.ch !== LEFT_DOUBLE_QUOTE && prev.ch !== "'" && prev.ch !== '"'
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Letter test that treats folded word glyphs as the letters they fold from
|
|
344
|
+
* (`X` → `×`, `st` → `ˢᵗ`); see {@link FOLDED_WORD_CHARS}.
|
|
345
|
+
*/
|
|
346
|
+
function isLetterOrFoldedItem(items: Item[], index: number): boolean {
|
|
347
|
+
if (index < 0 || index >= items.length) return false
|
|
348
|
+
const item = items[index]
|
|
349
|
+
return !item.boundary && (LETTER_RE.test(item.ch) || FOLDED_WORD_CHARS.has(item.ch))
|
|
280
350
|
}
|
|
281
351
|
|
|
282
352
|
/** Letter directly before (no boundary) and letter after (one boundary transparent). */
|
|
283
353
|
function isContractionContext(items: Item[], index: number): boolean {
|
|
284
|
-
return
|
|
354
|
+
return isLetterOrFoldedItem(items, index - 1) && isLetterOrFoldedItem(items, nextIndex(items, index, 1))
|
|
285
355
|
}
|
|
286
356
|
|
|
287
|
-
/**
|
|
357
|
+
/**
|
|
358
|
+
* True iff the item is outside the single-quote/word family (boundaries and
|
|
359
|
+
* text edges qualify). Folded word glyphs count as the word chars they fold
|
|
360
|
+
* from (`3x''` and `3×''` must agree on the empty-pair gate).
|
|
361
|
+
*/
|
|
288
362
|
function outsideSingleQuoteFamily(items: Item[], index: number): boolean {
|
|
289
363
|
if (index < 0 || index >= items.length) return true
|
|
290
364
|
const item = items[index]
|
|
291
|
-
return item.boundary || (!SINGLE_QUOTE_FAMILY.has(item.ch) && !WORD_RE.test(item.ch))
|
|
365
|
+
return item.boundary || (!SINGLE_QUOTE_FAMILY.has(item.ch) && !WORD_RE.test(item.ch) && !FOLDED_WORD_CHARS.has(item.ch))
|
|
292
366
|
}
|
|
293
367
|
|
|
294
368
|
/** Empty (`''`) and whitespace-only (`' '`) single-quote pairs. */
|
|
@@ -330,19 +404,34 @@ function classifyNAbbreviation(items: Item[]): void {
|
|
|
330
404
|
}
|
|
331
405
|
}
|
|
332
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Glyphs the symbol passes fold word characters into (multiplication `x` →
|
|
409
|
+
* `×`, ordinal suffixes → superscripts). Word-gated rules must accept them,
|
|
410
|
+
* or a gate decided on `6x'` flips once a re-run sees `6×'`.
|
|
411
|
+
*/
|
|
412
|
+
const FOLDED_WORD_CHARS = new Set<string>([
|
|
413
|
+
MULTIPLICATION,
|
|
414
|
+
...SUPERSCRIPT_ST, ...SUPERSCRIPT_ND, ...SUPERSCRIPT_RD, ...SUPERSCRIPT_TH,
|
|
415
|
+
])
|
|
416
|
+
|
|
333
417
|
function isWordItem(items: Item[], index: number): boolean {
|
|
334
418
|
if (index < 0 || index >= items.length) return false
|
|
335
419
|
const item = items[index]
|
|
336
|
-
return !item.boundary && WORD_RE.test(item.ch)
|
|
420
|
+
return !item.boundary && (WORD_RE.test(item.ch) || FOLDED_WORD_CHARS.has(item.ch))
|
|
337
421
|
}
|
|
338
422
|
|
|
339
|
-
/**
|
|
340
|
-
|
|
423
|
+
/**
|
|
424
|
+
* Possessive (`dog's`) → APOSTROPHE; then closing single (`'` in ending
|
|
425
|
+
* context) → CLOSE_SINGLE. Returns true when any item changed.
|
|
426
|
+
*/
|
|
427
|
+
function classifyPossessivesAndClosers(items: Item[]): boolean {
|
|
428
|
+
let changed = false
|
|
341
429
|
for (let i = 0; i < items.length; i++) {
|
|
342
430
|
const item = items[i]
|
|
343
431
|
if (item.boundary) continue
|
|
344
432
|
if ((item.ch === "'" || item.ch === RIGHT_SINGLE_QUOTE) && singleCloserBefore(items, i) && possessiveAfter(items, i)) {
|
|
345
433
|
item.ch = MODIFIER_LETTER_APOSTROPHE
|
|
434
|
+
changed = true
|
|
346
435
|
}
|
|
347
436
|
}
|
|
348
437
|
for (let i = 0; i < items.length; i++) {
|
|
@@ -350,8 +439,10 @@ function classifyPossessivesAndClosers(items: Item[]): void {
|
|
|
350
439
|
if (item.boundary || item.ch !== "'") continue
|
|
351
440
|
if (singleCloserBefore(items, i) && singleEndingAfter(items, i)) {
|
|
352
441
|
item.ch = RIGHT_SINGLE_QUOTE
|
|
442
|
+
changed = true
|
|
353
443
|
}
|
|
354
444
|
}
|
|
445
|
+
return changed
|
|
355
446
|
}
|
|
356
447
|
|
|
357
448
|
/** Word-internal contraction: letter + quote + letter → APOSTROPHE. */
|
|
@@ -372,6 +463,11 @@ function isDecadeElision(items: Item[], index: number): boolean {
|
|
|
372
463
|
if (!isDigitItem(items, index + 1) || !isDigitItem(items, index + 2)) return false
|
|
373
464
|
let after = index + 3
|
|
374
465
|
if (isCharItem(items, after, "s")) after++
|
|
466
|
+
// A closing single quote directly after the digits means the number is
|
|
467
|
+
// quoted (`'37'`), not elided — the leading quote is its opener. Only the
|
|
468
|
+
// already-classified closer needs checking: a still-straight quote there
|
|
469
|
+
// halts the closer scan, which elides the leading quote anyway.
|
|
470
|
+
if (isCharItem(items, after, RIGHT_SINGLE_QUOTE)) return false
|
|
375
471
|
return !isLetterOrDigitItem(items, after)
|
|
376
472
|
}
|
|
377
473
|
|
|
@@ -384,7 +480,7 @@ function isDigitItem(items: Item[], index: number): boolean {
|
|
|
384
480
|
function isLetterOrDigitItem(items: Item[], index: number): boolean {
|
|
385
481
|
if (index < 0 || index >= items.length) return false
|
|
386
482
|
const item = items[index]
|
|
387
|
-
return !item.boundary && (LETTER_RE.test(item.ch) || DIGIT_RE.test(item.ch))
|
|
483
|
+
return !item.boundary && (LETTER_RE.test(item.ch) || DIGIT_RE.test(item.ch) || FOLDED_WORD_CHARS.has(item.ch))
|
|
388
484
|
}
|
|
389
485
|
|
|
390
486
|
/** `nʼ ` directly after the quote — a quote leading into an already-classified 'n'. */
|
|
@@ -418,7 +514,7 @@ function hasClosingSingleAhead(items: Item[], index: number): boolean {
|
|
|
418
514
|
* apostrophe ('tis, '90s) unless a closing single quote lies ahead. Decisions
|
|
419
515
|
* are taken against the pre-pass state and applied together, so a quote
|
|
420
516
|
* converted here is still seen as a straight quote by later scans in the same
|
|
421
|
-
* pass (
|
|
517
|
+
* pass (snapshot semantics).
|
|
422
518
|
*/
|
|
423
519
|
function classifyLeadingApostrophes(items: Item[]): void {
|
|
424
520
|
const toConvert: number[] = []
|
|
@@ -440,7 +536,7 @@ function classifyOpeningSingles(items: Item[]): void {
|
|
|
440
536
|
for (let i = 0; i < items.length; i++) {
|
|
441
537
|
const item = items[i]
|
|
442
538
|
if (item.boundary || item.ch !== "'") continue
|
|
443
|
-
// Ahead: a boundary item satisfies \S (
|
|
539
|
+
// Ahead: a boundary item satisfies \S (a boundary is non-space).
|
|
444
540
|
const next = items[i + 1]
|
|
445
541
|
if (next === undefined || (!next.boundary && SPACE_RE.test(next.ch))) continue
|
|
446
542
|
// Behind: up to one boundary, then line start or an opener-context char.
|
|
@@ -480,13 +576,23 @@ function classifyPluralPossessives(items: Item[]): void {
|
|
|
480
576
|
}
|
|
481
577
|
}
|
|
482
578
|
|
|
483
|
-
function classifySingles(items: Item[]): void {
|
|
579
|
+
function classifySingles(items: Item[], style: ActiveQuoteStyle): void {
|
|
484
580
|
classifyEmptySinglePairs(items)
|
|
485
581
|
classifyNAbbreviation(items)
|
|
486
582
|
classifyPossessivesAndClosers(items)
|
|
487
583
|
classifyContractions(items)
|
|
488
584
|
classifyLeadingApostrophes(items)
|
|
489
585
|
classifyOpeningSingles(items)
|
|
586
|
+
// The rules above rewrite neighbors the closer/possessive lookbehind keyed
|
|
587
|
+
// on: in a `''` chain the left quote becomes an apostrophe, which renders
|
|
588
|
+
// as U+2019 — exactly the glyph a re-run reads in that slot, where the
|
|
589
|
+
// straight `'` was excluded. Re-run the closer rule until it settles so the
|
|
590
|
+
// first pass reaches the re-run's reading. German is exempt: it re-derives
|
|
591
|
+
// every rendered single back to a straight `'`, so its re-run keeps the
|
|
592
|
+
// exclusion.
|
|
593
|
+
if (style !== "german") {
|
|
594
|
+
while (classifyPossessivesAndClosers(items)) { /* to fixpoint */ }
|
|
595
|
+
}
|
|
490
596
|
classifyPluralPossessives(items)
|
|
491
597
|
}
|
|
492
598
|
|
|
@@ -525,29 +631,93 @@ function classifyEmptyDoublePairs(items: Item[]): void {
|
|
|
525
631
|
}
|
|
526
632
|
}
|
|
527
633
|
|
|
634
|
+
/**
|
|
635
|
+
* Glyphs of folds that can end immediately before a quote while consuming an
|
|
636
|
+
* interior node boundary (math pairs like `~=` → `≈`, dot triples → `…`,
|
|
637
|
+
* `?`/`!` ligatures; the bare `!` is the `!!` normalization's output). Such a
|
|
638
|
+
* fold collapses the boundary to just before the quote, so a boundary preceded
|
|
639
|
+
* by one of these marks a prefix whose unfolded source character (`=`, `.`,
|
|
640
|
+
* `!`, `?`) blocked the opener rule — and the re-run must block too.
|
|
641
|
+
*/
|
|
642
|
+
const FOLD_STRANDED_PREFIX_CHARS = new Set<string>([
|
|
643
|
+
ELLIPSIS, APPROXIMATE, NOT_EQUAL, LESS_EQUAL, GREATER_EQUAL,
|
|
644
|
+
DOUBLE_QUESTION, QUESTION_EXCLAMATION, EXCLAMATION_QUESTION, "!",
|
|
645
|
+
])
|
|
646
|
+
|
|
528
647
|
/**
|
|
529
648
|
* Opener-position prefix for a straight double quote: line start, an
|
|
530
|
-
* opener-context character, or — unlike single quotes — a bare boundary
|
|
649
|
+
* opener-context character, or — unlike single quotes — a bare boundary
|
|
650
|
+
* (unless the boundary was stranded by a fold; see
|
|
651
|
+
* {@link FOLD_STRANDED_PREFIX_CHARS}).
|
|
531
652
|
*/
|
|
532
653
|
function doubleOpenerPrefixOk(items: Item[], index: number): boolean {
|
|
533
654
|
if (index === 0) return true
|
|
534
655
|
const prev = items[index - 1]
|
|
535
|
-
if (prev.boundary)
|
|
656
|
+
if (prev.boundary) {
|
|
657
|
+
// The stranded-prefix lookbehind reads through every stacked boundary:
|
|
658
|
+
// each fold round can empty another node (`!`|`!`|`""` leaves two stacked
|
|
659
|
+
// boundaries before the quote), and the re-run must keep blocking.
|
|
660
|
+
let beforeIndex = index - 2
|
|
661
|
+
while (beforeIndex >= 0 && items[beforeIndex].boundary) beforeIndex--
|
|
662
|
+
return beforeIndex < 0 || !FOLD_STRANDED_PREFIX_CHARS.has(items[beforeIndex].ch)
|
|
663
|
+
}
|
|
536
664
|
return SPACE_RE.test(prev.ch) || DOUBLE_OPEN_BEFORE_SET.has(prev.ch)
|
|
537
665
|
}
|
|
538
666
|
|
|
539
|
-
|
|
667
|
+
const ELLIPSIS_GAP_SPACE_RE = new RegExp(`[${SPACE_CHARS}]`)
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Index of the dot reachable across one inter-dot gap from the dot at
|
|
671
|
+
* `dotIndex` (the ellipsis pass's gap rule: the gap is nothing, one space
|
|
672
|
+
* character, or one node boundary). -1 when none.
|
|
673
|
+
*/
|
|
674
|
+
function nextEllipsisDotItem(items: Item[], dotIndex: number): number {
|
|
675
|
+
let j = dotIndex + 1
|
|
676
|
+
if (j < items.length && items[j].boundary) {
|
|
677
|
+
// Empty gap with one boundary; a second boundary breaks the gap.
|
|
678
|
+
j++
|
|
679
|
+
return isCharItem(items, j, ".") ? j : -1
|
|
680
|
+
}
|
|
681
|
+
if (j >= items.length) return -1
|
|
682
|
+
if (items[j].ch === ".") return j
|
|
683
|
+
if (ELLIPSIS_GAP_SPACE_RE.test(items[j].ch) && j + 1 < items.length && !items[j + 1].boundary && items[j + 1].ch === ".") {
|
|
684
|
+
return j + 1
|
|
685
|
+
}
|
|
686
|
+
return -1
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* True iff a `...`/`. . .` triple the ellipsis pass folds to `…` begins at
|
|
691
|
+
* `index`. Rules gated on this must decide like they would on `…` itself,
|
|
692
|
+
* since a re-run sees the folded glyph.
|
|
693
|
+
*/
|
|
540
694
|
function isEllipsisDots(items: Item[], index: number): boolean {
|
|
541
|
-
|
|
695
|
+
if (!isCharItem(items, index, ".")) return false
|
|
696
|
+
const second = nextEllipsisDotItem(items, index)
|
|
697
|
+
return second >= 0 && nextEllipsisDotItem(items, second) >= 0
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* `!=` (not followed by another `=`) at `index`: the math-symbol pass folds it
|
|
702
|
+
* to `≠`, which is not an ending character, so ending-gated rules must read
|
|
703
|
+
* the unfolded pair the same way. Mirrors the fold's boundary tolerance: one
|
|
704
|
+
* boundary at the `!`/`=` junction, and the blocking third `=` is seen
|
|
705
|
+
* through at most one boundary.
|
|
706
|
+
*/
|
|
707
|
+
function foldsToNotEqual(items: Item[], index: number): boolean {
|
|
708
|
+
if (!isCharItem(items, index, "!")) return false
|
|
709
|
+
const eq = nextIndex(items, index, 1)
|
|
710
|
+
if (!isCharItem(items, eq, "=")) return false
|
|
711
|
+
return !isCharItem(items, nextIndex(items, eq, 1), "=")
|
|
542
712
|
}
|
|
543
713
|
|
|
544
|
-
/** Opening double quote by following context
|
|
714
|
+
/** Opening double quote by following context. */
|
|
545
715
|
function classifyOpeningDoubles(items: Item[]): void {
|
|
546
716
|
for (let i = 0; i < items.length; i++) {
|
|
547
717
|
const item = items[i]
|
|
548
718
|
if (item.boundary || item.ch !== '"') continue
|
|
549
719
|
if (!doubleOpenerPrefixOk(items, i)) continue
|
|
550
|
-
// Arm 1: a boundary then space/period/comma (the
|
|
720
|
+
// Arm 1: a boundary then space/period/comma (the boundary-start quirk).
|
|
551
721
|
const next = items[i + 1]
|
|
552
722
|
let opens = false
|
|
553
723
|
if (next?.boundary === true) {
|
|
@@ -560,13 +730,30 @@ function classifyOpeningDoubles(items: Item[]): void {
|
|
|
560
730
|
const j = nextIndex(items, i, 1)
|
|
561
731
|
if (j < items.length && !items[j].boundary) {
|
|
562
732
|
const ch = items[j].ch
|
|
563
|
-
opens = isEllipsisDots(items, j) || (
|
|
733
|
+
opens = isEllipsisDots(items, j) || foldsToNotEqual(items, j)
|
|
734
|
+
|| (!SPACE_RE.test(ch) && !DOUBLE_OPEN_ENDING_SET.has(ch))
|
|
564
735
|
}
|
|
565
736
|
}
|
|
566
737
|
if (opens) item.ch = LEFT_DOUBLE_QUOTE
|
|
567
738
|
}
|
|
568
739
|
}
|
|
569
740
|
|
|
741
|
+
/**
|
|
742
|
+
* Opener-position prefix for the quoted-punctuation rule. Unlike
|
|
743
|
+
* {@link doubleOpenerPrefixOk}, boundaries are transparent here: a quote that
|
|
744
|
+
* merely starts a node (e.g. `"` directly after `</a>`) is not in opener
|
|
745
|
+
* position unless the character on the far side of the boundary run is itself
|
|
746
|
+
* opener context — matching how the rule classifies the equivalent plain
|
|
747
|
+
* string.
|
|
748
|
+
*/
|
|
749
|
+
function quotedPunctuationPrefixOk(items: Item[], index: number): boolean {
|
|
750
|
+
let j = index - 1
|
|
751
|
+
while (j >= 0 && items[j].boundary) j--
|
|
752
|
+
if (j < 0) return true
|
|
753
|
+
const ch = items[j].ch
|
|
754
|
+
return SPACE_RE.test(ch) || DOUBLE_OPEN_BEFORE_SET.has(ch)
|
|
755
|
+
}
|
|
756
|
+
|
|
570
757
|
/**
|
|
571
758
|
* Quoted-punctuation openers like `"?"`: an opener-prefixed straight double
|
|
572
759
|
* quote with a closing straight double quote anywhere ahead (at least one
|
|
@@ -579,7 +766,7 @@ function classifyQuotedPunctuationOpeners(items: Item[]): void {
|
|
|
579
766
|
}
|
|
580
767
|
for (let k = 0; k < straightIndices.length; k++) {
|
|
581
768
|
const i = straightIndices[k]
|
|
582
|
-
if (!
|
|
769
|
+
if (!quotedPunctuationPrefixOk(items, i)) continue
|
|
583
770
|
const closer = straightIndices[k + 1]
|
|
584
771
|
if (closer !== undefined && closer >= i + 2) {
|
|
585
772
|
items[i].ch = LEFT_DOUBLE_QUOTE
|
|
@@ -605,17 +792,25 @@ function isDoubleCloseAfterChar(ch: string): boolean {
|
|
|
605
792
|
/**
|
|
606
793
|
* Closing double quote by ending context: a non-space/non-paren character (or
|
|
607
794
|
* a boundary) before it, and an ending character, a boundary, or end of input
|
|
608
|
-
* after it.
|
|
609
|
-
*
|
|
610
|
-
*
|
|
795
|
+
* after it. A consumed span can never contain the next match's before-character
|
|
796
|
+
* (the quote only closes when followed by a non-quote), so no overlap tracking
|
|
797
|
+
* is needed.
|
|
798
|
+
*
|
|
799
|
+
* French: a plain space directly after an opener merges into the opener's
|
|
800
|
+
* rendered NNBSP padding once spaces collapse, so a re-run reads the opener
|
|
801
|
+
* itself in the lookbehind slot — read it that way now.
|
|
611
802
|
*/
|
|
612
|
-
function classifyClosingDoubles(items: Item[]): void {
|
|
803
|
+
function classifyClosingDoubles(items: Item[], style: ActiveQuoteStyle): void {
|
|
613
804
|
for (let i = 1; i < items.length; i++) {
|
|
614
805
|
const item = items[i]
|
|
615
806
|
if (item.boundary || item.ch !== '"') continue
|
|
616
807
|
const before = items[i - 1]
|
|
617
|
-
if (!before.boundary && (SPACE_RE.test(before.ch) || before.ch === "("))
|
|
808
|
+
if (!before.boundary && (SPACE_RE.test(before.ch) || before.ch === "(")) {
|
|
809
|
+
const absorbedIntoOpener = style === "french" && before.ch === " " && isCharItem(items, i - 2, LEFT_DOUBLE_QUOTE)
|
|
810
|
+
if (!absorbedIntoOpener) continue
|
|
811
|
+
}
|
|
618
812
|
const next = items[i + 1]
|
|
813
|
+
if (foldsToNotEqual(items, i + 1)) continue
|
|
619
814
|
if (next === undefined || next.boundary || isDoubleCloseAfterChar(next.ch)) {
|
|
620
815
|
item.ch = RIGHT_DOUBLE_QUOTE
|
|
621
816
|
}
|
|
@@ -644,12 +839,12 @@ function classifySinglesBeforeClosingDoubles(items: Item[]): void {
|
|
|
644
839
|
}
|
|
645
840
|
}
|
|
646
841
|
|
|
647
|
-
function classifyDoubles(items: Item[]): void {
|
|
842
|
+
function classifyDoubles(items: Item[], style: ActiveQuoteStyle): void {
|
|
648
843
|
classifyEmptyDoublePairs(items)
|
|
649
844
|
classifyOpeningDoubles(items)
|
|
650
845
|
classifyQuotedPunctuationOpeners(items)
|
|
651
846
|
classifyBraceOpeners(items)
|
|
652
|
-
classifyClosingDoubles(items)
|
|
847
|
+
classifyClosingDoubles(items, style)
|
|
653
848
|
classifyEndOfInputClosers(items)
|
|
654
849
|
classifySinglesBeforeClosingDoubles(items)
|
|
655
850
|
}
|
|
@@ -663,13 +858,28 @@ function classifyDoubles(items: Item[]): void {
|
|
|
663
858
|
* most one boundary between consecutive closers. Returns the item index just
|
|
664
859
|
* past the run, or -1 when there is no run.
|
|
665
860
|
*/
|
|
666
|
-
|
|
667
|
-
|
|
861
|
+
/**
|
|
862
|
+
* True iff the item at `index` counts as a closing-run member for placement.
|
|
863
|
+
* An APOSTROPHE directly after an `s` is excluded even when the placement
|
|
864
|
+
* alphabet includes apostrophes: the re-run relabels that U+2019 an
|
|
865
|
+
* apostrophe again (the plural-possessive rule keys on the `s`), so
|
|
866
|
+
* punctuation must not move around it ("the boys'." keeps its period
|
|
867
|
+
* outside).
|
|
868
|
+
*/
|
|
869
|
+
function closingRunMemberAt(items: Item[], index: number, closingSet: ReadonlySet<string>): boolean {
|
|
870
|
+
if (index >= items.length || items[index].boundary || !closingSet.has(items[index].ch)) return false
|
|
871
|
+
if (items[index].ch !== MODIFIER_LETTER_APOSTROPHE) return true
|
|
872
|
+
const prev = prevIndex(items, index, 1)
|
|
873
|
+
return !isCharItem(items, prev, "s") && !isCharItem(items, prev, "S")
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function scanClosingRun(items: Item[], start: number, closingSet: ReadonlySet<string>): number {
|
|
877
|
+
if (start >= items.length || items[start].boundary || !closingRunMemberAt(items, start, closingSet)) return -1
|
|
668
878
|
let runEnd = start + 1
|
|
669
879
|
for (;;) {
|
|
670
880
|
let next = runEnd
|
|
671
881
|
if (next < items.length && items[next].boundary) next++
|
|
672
|
-
if (next >= items.length || items[next].boundary || !
|
|
882
|
+
if (next >= items.length || items[next].boundary || !closingRunMemberAt(items, next, closingSet)) break
|
|
673
883
|
runEnd = next + 1
|
|
674
884
|
}
|
|
675
885
|
return runEnd
|
|
@@ -728,38 +938,223 @@ function applyRelocations(order: Item[], moves: Relocation[]): Item[] {
|
|
|
728
938
|
* American style: a period or comma directly after a closing-quote run moves
|
|
729
939
|
* to just before the run, unless the character before the run is terminal
|
|
730
940
|
* punctuation ("Stop!". keeps its period outside). The scan advances one item
|
|
731
|
-
* on every failed position, so a blocked outer run is retried one level in
|
|
732
|
-
* exactly the v4 movePunctuationInside walk.
|
|
941
|
+
* on every failed position, so a blocked outer run is retried one level in.
|
|
733
942
|
*/
|
|
734
|
-
function movePunctuationInside(order: Item[], isMovable: (items: Item[], index: number) => boolean): Item[] {
|
|
735
|
-
// Decisions are taken against the pass-input order (the
|
|
736
|
-
//
|
|
943
|
+
function movePunctuationInside(order: Item[], isMovable: (items: Item[], index: number) => boolean, closingSet: ReadonlySet<string>): Item[] {
|
|
944
|
+
// Decisions are taken against the pass-input order (the scan reads its input
|
|
945
|
+
// order while building the output), then applied together.
|
|
737
946
|
const moves: Relocation[] = []
|
|
947
|
+
// Earliest unresolved straight single quote; remote closer-ahead scans start
|
|
948
|
+
// there, so moves past it are gated below.
|
|
949
|
+
let firstStraightSingle = -1
|
|
950
|
+
for (let i = 0; i < order.length; i++) {
|
|
951
|
+
if (!order[i].boundary && order[i].ch === "'") {
|
|
952
|
+
firstStraightSingle = i
|
|
953
|
+
break
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
const movedFrom = new Set<number>()
|
|
738
957
|
let position = 0
|
|
739
958
|
while (position < order.length) {
|
|
740
959
|
const runStart = order[position].boundary ? position + 1 : position
|
|
741
|
-
const runEnd = scanClosingRun(order, runStart)
|
|
960
|
+
const runEnd = scanClosingRun(order, runStart, closingSet)
|
|
742
961
|
if (runEnd === -1) {
|
|
743
962
|
position++
|
|
744
963
|
continue
|
|
745
964
|
}
|
|
746
965
|
const punctIndex = runEnd < order.length && order[runEnd].boundary ? runEnd + 1 : runEnd
|
|
747
|
-
|
|
748
|
-
|
|
966
|
+
// The terminal lookbehind skips punctuation already relocated by this
|
|
967
|
+
// pass: those items no longer precede the run once the moves apply, and
|
|
968
|
+
// the re-run (which sees them relocated) must reach the same decision.
|
|
969
|
+
// One boundary is transparent: a ligature fold (`?` + `!` → `⁈`) pulls
|
|
970
|
+
// the terminal char across the node edge, and the re-run reads it there.
|
|
971
|
+
let beforeIndex = position - 1
|
|
972
|
+
while (beforeIndex >= 0 && movedFrom.has(beforeIndex)) beforeIndex--
|
|
973
|
+
if (beforeIndex >= 0 && order[beforeIndex].boundary && !(beforeIndex >= 1 && order[beforeIndex - 1].boundary)) {
|
|
974
|
+
// No moved-item skip here: a moved punct is always run-adjacent, and
|
|
975
|
+
// the run-skip above resolves such runs at the boundary item itself.
|
|
976
|
+
beforeIndex--
|
|
977
|
+
}
|
|
978
|
+
const precededByTerminal = beforeIndex >= 0 && !order[beforeIndex].boundary && TERMINAL_SET.has(order[beforeIndex].ch)
|
|
979
|
+
// Gates that read only the run's end hold for every sub-run too, so on
|
|
980
|
+
// those failures the scan skips past the whole run — rescanning each
|
|
981
|
+
// suffix would be quadratic on long closer runs. The position-dependent
|
|
982
|
+
// lookbehinds (terminal char, A-side wall) instead retry one level in,
|
|
983
|
+
// where the preceding item is a closer and can no longer block.
|
|
984
|
+
if (punctIndex >= order.length || order[punctIndex].boundary || !isMovable(order, punctIndex)) {
|
|
985
|
+
position = Math.max(runEnd, position + 1)
|
|
986
|
+
continue
|
|
987
|
+
}
|
|
988
|
+
if (precededByTerminal || moveWallAt(order, prevIndex(order, runStart, 1))) {
|
|
989
|
+
position++
|
|
990
|
+
continue
|
|
991
|
+
}
|
|
992
|
+
if (moveWallAt(order, nextIndex(order, punctIndex, 1))) {
|
|
993
|
+
position = Math.max(runEnd, position + 1)
|
|
994
|
+
continue
|
|
995
|
+
}
|
|
996
|
+
if (insideMoveFlipsContext(order, runStart, punctIndex)) {
|
|
997
|
+
position++
|
|
998
|
+
continue
|
|
999
|
+
}
|
|
1000
|
+
// Moving the punctuation inside strips the run of its ending context: an
|
|
1001
|
+
// unresolved straight single quote earlier in the text whose closer-ahead
|
|
1002
|
+
// scan reads a single closer in this run would stop seeing it as a closer
|
|
1003
|
+
// on the next run (`'5'.0` → `'5.’0`) unless the post-move follower is an
|
|
1004
|
+
// ending context itself. Leave the punctuation in place; the next run
|
|
1005
|
+
// reaches the same state and blocks again.
|
|
1006
|
+
if (firstStraightSingle !== -1 && firstStraightSingle < runStart
|
|
1007
|
+
&& runContainsSingleCloser(order, runStart, runEnd)
|
|
1008
|
+
&& !endingContextAfterMove(order, runEnd, punctIndex)) {
|
|
749
1009
|
position++
|
|
750
1010
|
continue
|
|
751
1011
|
}
|
|
752
1012
|
moves.push({ from: punctIndex, to: runStart, side: "before", anchorOffset: order[runStart].start, anchorBind: "right" })
|
|
1013
|
+
movedFrom.add(punctIndex)
|
|
753
1014
|
position = punctIndex + 1
|
|
754
1015
|
}
|
|
755
1016
|
return applyRelocations(order, moves)
|
|
756
1017
|
}
|
|
757
1018
|
|
|
1019
|
+
/**
|
|
1020
|
+
* Mirror of {@link nextEllipsisDotItem} walking backwards: the dot reachable
|
|
1021
|
+
* across one inter-dot gap before (virtual) position `index`. -1 when none.
|
|
1022
|
+
*/
|
|
1023
|
+
function prevEllipsisDotItem(order: Item[], index: number): number {
|
|
1024
|
+
const j = index - 1
|
|
1025
|
+
if (j >= 0 && order[j].boundary) {
|
|
1026
|
+
return isCharItem(order, j - 1, ".") ? j - 1 : -1
|
|
1027
|
+
}
|
|
1028
|
+
if (j < 0) return -1
|
|
1029
|
+
if (order[j].ch === ".") return j
|
|
1030
|
+
if (ELLIPSIS_GAP_SPACE_RE.test(order[j].ch) && isCharItem(order, j - 1, ".")) return j - 1
|
|
1031
|
+
return -1
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Movable punctuation plus the ellipsis for the outside mover's chain guards:
|
|
1036
|
+
* `…` folds from the dots the guards block on, so it blocks the same way.
|
|
1037
|
+
*/
|
|
1038
|
+
const CHAIN_PUNCT_SET = new Set<string>([".", ",", ELLIPSIS])
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* True iff moving the period at `punctIndex` to just before `order[runStart]`
|
|
1042
|
+
* would make the re-run read its surroundings differently:
|
|
1043
|
+
*
|
|
1044
|
+
* - `..`/`. .` + `.` at the landing: the landed period completes a foldable
|
|
1045
|
+
* dot triple, which the double-opener rule reads as `…`;
|
|
1046
|
+
* - `.` + digit at the removal site: a period directly before a digit blocks
|
|
1047
|
+
* the dash pass's number-range detection, so vacating the slot converts a
|
|
1048
|
+
* range this run left alone.
|
|
1049
|
+
*/
|
|
1050
|
+
function insideMoveFlipsContext(order: Item[], runStart: number, punctIndex: number): boolean {
|
|
1051
|
+
if (order[punctIndex].ch === ".") {
|
|
1052
|
+
const second = prevEllipsisDotItem(order, runStart)
|
|
1053
|
+
if (second >= 0 && prevEllipsisDotItem(order, second) >= 0) return true
|
|
1054
|
+
const afterPunct = nextIndex(order, punctIndex, 1)
|
|
1055
|
+
if (afterPunct < order.length && !order[afterPunct].boundary && DIGIT_RE.test(order[afterPunct].ch)) return true
|
|
1056
|
+
}
|
|
1057
|
+
return false
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** True iff the run [start, end) contains a single-quote closer or apostrophe. */
|
|
1061
|
+
function runContainsSingleCloser(order: Item[], start: number, end: number): boolean {
|
|
1062
|
+
for (let i = start; i < end; i++) {
|
|
1063
|
+
const ch = order[i].ch
|
|
1064
|
+
if (ch === RIGHT_SINGLE_QUOTE || ch === MODIFIER_LETTER_APOSTROPHE) return true
|
|
1065
|
+
}
|
|
1066
|
+
return false
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Mirror of {@link singleEndingAfter} at the run end as the post-move item
|
|
1071
|
+
* sequence reads it: the vacating punctuation leaves its surrounding boundary
|
|
1072
|
+
* items behind (an emptied node stacks them), and they all count against the
|
|
1073
|
+
* lookahead's one-boundary budget.
|
|
1074
|
+
*/
|
|
1075
|
+
function endingContextAfterMove(order: Item[], runEnd: number, punctIndex: number): boolean {
|
|
1076
|
+
let boundaries = 0
|
|
1077
|
+
for (let j = runEnd; j < order.length; j++) {
|
|
1078
|
+
if (j === punctIndex) continue
|
|
1079
|
+
if (order[j].boundary) {
|
|
1080
|
+
boundaries++
|
|
1081
|
+
if (boundaries > 1) return false
|
|
1082
|
+
continue
|
|
1083
|
+
}
|
|
1084
|
+
return endingContextAt(order, j)
|
|
1085
|
+
}
|
|
1086
|
+
return true
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* Mirror of {@link singleEndingAfter}'s target test at an already-resolved,
|
|
1091
|
+
* in-range index (the sole caller bounds it below `order.length`).
|
|
1092
|
+
*/
|
|
1093
|
+
function endingContextAt(order: Item[], index: number): boolean {
|
|
1094
|
+
const item = order[index]
|
|
1095
|
+
if (foldsToNotEqual(order, index)) return false
|
|
1096
|
+
return !item.boundary && isSingleEndingChar(item.ch)
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Characters (or a node boundary) that satisfy an opener-prefix rule. Openers
|
|
1101
|
+
* classify before closers, so a closing quote re-derived with one of these in
|
|
1102
|
+
* its lookbehind slot flips to an opener on the next run. The union of
|
|
1103
|
+
* {@link SINGLE_OPEN_BEFORE_SET} and {@link DOUBLE_OPEN_BEFORE_SET}.
|
|
1104
|
+
*/
|
|
1105
|
+
const OPENER_PREFIX_CHARS = new Set<string>([...SINGLE_OPEN_BEFORE_SET, ...DOUBLE_OPEN_BEFORE_SET])
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* True iff a closing quote would still re-derive as a closer with `prev` (the
|
|
1109
|
+
* item before the punctuation being moved out) directly before it. German
|
|
1110
|
+
* re-derives rendered closers from scratch on the next run (the orphan
|
|
1111
|
+
* fallback): the slot must pass the closer lookbehinds (non-space, non-`(`,
|
|
1112
|
+
* non-quote) and fail the opener prefixes (which preempt the closer rules); a
|
|
1113
|
+
* boundary satisfies the opener prefixes, so it blocks too. A blocked move
|
|
1114
|
+
* simply leaves the punctuation in place — a state the next run reproduces.
|
|
1115
|
+
*/
|
|
1116
|
+
function rederivesAsCloserAfter(prev: Item | undefined): boolean {
|
|
1117
|
+
if (prev === undefined || prev.boundary) return false
|
|
1118
|
+
return !SPACE_RE.test(prev.ch) && prev.ch !== "'" && !OPENER_PREFIX_CHARS.has(prev.ch)
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Characters a later run reads context-sensitively: straight quotes the
|
|
1123
|
+
* classifier leaves for re-reading, and dashes whose conversion rules key on
|
|
1124
|
+
* the adjacent character (`’-2` folds to a minus sign where `.-2` does not).
|
|
1125
|
+
* The folded math operators stand in for the two-character operators they
|
|
1126
|
+
* fold from: a hyphen wall inside `+-` must keep blocking once a re-run sees
|
|
1127
|
+
* `±`.
|
|
1128
|
+
*/
|
|
1129
|
+
const MOVE_WALL_CHARS = new Set<string>([
|
|
1130
|
+
"'", '"', "-", EN_DASH, EM_DASH, MINUS,
|
|
1131
|
+
APPROXIMATE, GREATER_EQUAL, LESS_EQUAL, NOT_EQUAL, PLUS_MINUS,
|
|
1132
|
+
])
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* True iff the item at `index` (one boundary transparent) is a character a
|
|
1136
|
+
* later run reads context-sensitively. Relocating punctuation past such an
|
|
1137
|
+
* item would change the context that run sees, so both movers treat it as a
|
|
1138
|
+
* wall: a blocked move leaves the punctuation where the next run finds — and
|
|
1139
|
+
* blocks — it again.
|
|
1140
|
+
*/
|
|
1141
|
+
function moveWallAt(items: Item[], index: number): boolean {
|
|
1142
|
+
if (index < 0 || index >= items.length) return false
|
|
1143
|
+
const item = items[index]
|
|
1144
|
+
return !item.boundary && MOVE_WALL_CHARS.has(item.ch)
|
|
1145
|
+
}
|
|
1146
|
+
|
|
758
1147
|
/**
|
|
759
1148
|
* British/German/French: a period or comma directly before a closing-quote
|
|
760
1149
|
* run moves past the whole run. No terminal-punctuation guard.
|
|
1150
|
+
*
|
|
1151
|
+
* `spaceBeforePaddedCloser`: French renders CLOSE_DOUBLE with leading NNBSP
|
|
1152
|
+
* padding, and a plain space before the run merges into that padding once
|
|
1153
|
+
* spaces collapse — a re-run reads the punctuation directly adjacent to the
|
|
1154
|
+
* run. Treat one such space as transparent so the first pass moves the
|
|
1155
|
+
* punctuation the way the re-run would.
|
|
761
1156
|
*/
|
|
762
|
-
function movePunctuationOutside(order: Item[], punctChar: string): Item[] {
|
|
1157
|
+
function movePunctuationOutside(order: Item[], punctChar: string, closingSet: ReadonlySet<string>, guardRederivation: boolean, spaceBeforePaddedCloser: boolean): Item[] {
|
|
763
1158
|
const moves: Relocation[] = []
|
|
764
1159
|
let position = 0
|
|
765
1160
|
while (position < order.length) {
|
|
@@ -768,17 +1163,71 @@ function movePunctuationOutside(order: Item[], punctChar: string): Item[] {
|
|
|
768
1163
|
position++
|
|
769
1164
|
continue
|
|
770
1165
|
}
|
|
1166
|
+
if (guardRederivation && !rederivesAsCloserAfter(position > 0 ? order[position - 1] : undefined)) {
|
|
1167
|
+
position++
|
|
1168
|
+
continue
|
|
1169
|
+
}
|
|
1170
|
+
// German re-derives single closers from scratch. Moving the punctuation
|
|
1171
|
+
// past a run whose first closer is a single quote changes what the item
|
|
1172
|
+
// directly before the punctuation reads on the re-run:
|
|
1173
|
+
// - a single closer or apostrophe there loses the ending context its
|
|
1174
|
+
// closer rule needs (the re-derived run closer is a straight `'`, not
|
|
1175
|
+
// an ending character), unwinding to a straight quote;
|
|
1176
|
+
// - an `s` there gains the plural-possessive reading (`s` directly
|
|
1177
|
+
// before the closer), relabeling the closer an apostrophe.
|
|
1178
|
+
// Leave the punctuation in place; the next run blocks here again.
|
|
1179
|
+
const prevCh = position > 0 && !order[position - 1].boundary ? order[position - 1].ch : ""
|
|
1180
|
+
if (guardRederivation
|
|
1181
|
+
&& (prevCh === RIGHT_SINGLE_QUOTE || prevCh === MODIFIER_LETTER_APOSTROPHE || prevCh === "s" || prevCh === "S")
|
|
1182
|
+
&& isCharItem(order, nextIndex(order, position, 1), RIGHT_SINGLE_QUOTE)) {
|
|
1183
|
+
position++
|
|
1184
|
+
continue
|
|
1185
|
+
}
|
|
1186
|
+
if (moveWallAt(order, prevIndex(order, position, 1))) {
|
|
1187
|
+
position++
|
|
1188
|
+
continue
|
|
1189
|
+
}
|
|
1190
|
+
// Another movable punctuation char directly before this one would end up
|
|
1191
|
+
// group-adjacent after the move and migrate on the next run (one char per
|
|
1192
|
+
// run, a cascade); leave chains of movable punctuation in place. The
|
|
1193
|
+
// ellipsis belongs to the chain alphabet: it folds from the dots this
|
|
1194
|
+
// guard blocks on, so the folded glyph must block the same way ("....”"
|
|
1195
|
+
// re-reads as "….”").
|
|
1196
|
+
const prevPunctIndex = prevIndex(order, position, 1)
|
|
1197
|
+
if (prevPunctIndex >= 0 && CHAIN_PUNCT_SET.has(order[prevPunctIndex].ch)) {
|
|
1198
|
+
position++
|
|
1199
|
+
continue
|
|
1200
|
+
}
|
|
771
1201
|
// Quotes group: one or more closers, each preceded by at most one boundary.
|
|
772
1202
|
let last = -1
|
|
773
1203
|
let j = position + 1
|
|
774
1204
|
for (;;) {
|
|
775
1205
|
let k = j
|
|
776
1206
|
if (k < order.length && order[k].boundary) k++
|
|
777
|
-
|
|
1207
|
+
// Any collapsible space char qualifies, not just a plain space: the
|
|
1208
|
+
// collapse pass merges tabs and NBSPs into the closer's NNBSP padding
|
|
1209
|
+
// the same way.
|
|
1210
|
+
if (spaceBeforePaddedCloser && k < order.length && !order[k].boundary
|
|
1211
|
+
&& order[k].ch.length === 1 && COLLAPSIBLE_SPACE_RE.test(order[k].ch)
|
|
1212
|
+
&& isCharItem(order, k + 1, RIGHT_DOUBLE_QUOTE)) {
|
|
1213
|
+
k++
|
|
1214
|
+
}
|
|
1215
|
+
if (k >= order.length || order[k].boundary || !closingRunMemberAt(order, k, closingSet)) break
|
|
778
1216
|
last = k
|
|
779
1217
|
j = k + 1
|
|
780
1218
|
}
|
|
781
|
-
|
|
1219
|
+
const afterRunIndex = nextIndex(order, last === -1 ? position : last, 1)
|
|
1220
|
+
if (last === -1 || moveWallAt(order, afterRunIndex)) {
|
|
1221
|
+
position++
|
|
1222
|
+
continue
|
|
1223
|
+
}
|
|
1224
|
+
// The mirror of the chain guard above: a movable punctuation char directly
|
|
1225
|
+
// after the run is where this one would land (`.'.'` interleaves two
|
|
1226
|
+
// period+closer pairs), re-creating a movable configuration the next run
|
|
1227
|
+
// would move again; leave the punctuation in place. As above, the ellipsis
|
|
1228
|
+
// blocks like the dots it folds from.
|
|
1229
|
+
if (afterRunIndex < order.length && !order[afterRunIndex].boundary
|
|
1230
|
+
&& CHAIN_PUNCT_SET.has(order[afterRunIndex].ch)) {
|
|
782
1231
|
position++
|
|
783
1232
|
continue
|
|
784
1233
|
}
|
|
@@ -788,17 +1237,72 @@ function movePunctuationOutside(order: Item[], punctChar: string): Item[] {
|
|
|
788
1237
|
return applyRelocations(order, moves)
|
|
789
1238
|
}
|
|
790
1239
|
|
|
791
|
-
function applyPunctuationPlacement(items: Item[], style: ActiveQuoteStyle): Item[] {
|
|
1240
|
+
function applyPunctuationPlacement(items: Item[], style: ActiveQuoteStyle, apostrophe: string): Item[] {
|
|
1241
|
+
const closingSet = style !== "german" && apostrophe === RIGHT_SINGLE_QUOTE
|
|
1242
|
+
? CLOSING_OR_APOSTROPHE_SET
|
|
1243
|
+
: CLOSING_SET
|
|
792
1244
|
if (style === "american") {
|
|
793
|
-
|
|
1245
|
+
// Each mover's gates read positions the other mover may vacate (a comma
|
|
1246
|
+
// moving inside exposes a period's run to the terminal lookbehind), so
|
|
1247
|
+
// one round can leave exactly the state a re-run would move again.
|
|
1248
|
+
// Iterate to fixpoint; every move shifts punctuation left past a closer,
|
|
1249
|
+
// so the rounds terminate. `movePunctuationInside` returns its input
|
|
1250
|
+
// array unchanged when it makes no move.
|
|
1251
|
+
let order = items
|
|
1252
|
+
for (;;) {
|
|
1253
|
+
const next = movePunctuationInside(movePunctuationInside(order, isMovablePeriodAt, closingSet), isMovableCommaAt, closingSet)
|
|
1254
|
+
if (next === order) return order
|
|
1255
|
+
order = next
|
|
1256
|
+
}
|
|
794
1257
|
}
|
|
795
|
-
|
|
1258
|
+
const guardRederivation = style === "german"
|
|
1259
|
+
const spaceBeforePaddedCloser = style === "french"
|
|
1260
|
+
return movePunctuationOutside(
|
|
1261
|
+
movePunctuationOutside(items, ".", closingSet, guardRederivation, spaceBeforePaddedCloser),
|
|
1262
|
+
",", closingSet, guardRederivation, spaceBeforePaddedCloser,
|
|
1263
|
+
)
|
|
796
1264
|
}
|
|
797
1265
|
|
|
798
1266
|
// ---------------------------------------------------------------------------
|
|
799
1267
|
// Rendering
|
|
800
1268
|
// ---------------------------------------------------------------------------
|
|
801
1269
|
|
|
1270
|
+
/** A one-char unmoved NBSP/NNBSP item textually adjacent at `offset`. */
|
|
1271
|
+
function isPaddingSpaceItem(item: Item | undefined): item is Item {
|
|
1272
|
+
return item !== undefined && !item.boundary && !item.moved
|
|
1273
|
+
&& item.end - item.start === 1 && (item.ch === NBSP || item.ch === NNBSP)
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* French renders double-quote glyphs with NNBSP padding. A source NBSP/NNBSP
|
|
1278
|
+
* directly adjacent to the glyph would collapse with that padding into one
|
|
1279
|
+
* space whose type wins by priority (NBSP beats NNBSP), which the next run
|
|
1280
|
+
* absorbs as the glyph's padding and re-renders NNBSP — an oscillation.
|
|
1281
|
+
* Absorb the adjacent space into the (single-char, i.e. unpadded) glyph item
|
|
1282
|
+
* now so its render replaces both with the padded form.
|
|
1283
|
+
*/
|
|
1284
|
+
function absorbGuillemetPadding(items: Item[]): void {
|
|
1285
|
+
for (let i = 0; i < items.length; i++) {
|
|
1286
|
+
const item = items[i]
|
|
1287
|
+
if (item.boundary || item.moved || item.end - item.start !== 1) continue
|
|
1288
|
+
if (item.ch === RIGHT_DOUBLE_QUOTE) {
|
|
1289
|
+
const prev = items[i - 1]
|
|
1290
|
+
if (isPaddingSpaceItem(prev) && prev.end === item.start) {
|
|
1291
|
+
item.start = prev.start
|
|
1292
|
+
prev.end = prev.start
|
|
1293
|
+
prev.ch = ""
|
|
1294
|
+
}
|
|
1295
|
+
} else if (item.ch === LEFT_DOUBLE_QUOTE) {
|
|
1296
|
+
const next = items[i + 1]
|
|
1297
|
+
if (isPaddingSpaceItem(next) && next.start === item.end) {
|
|
1298
|
+
item.end = next.end
|
|
1299
|
+
next.start = next.end
|
|
1300
|
+
next.ch = ""
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
802
1306
|
/** Per-locale glyph table. `apostrophe` is U+2019 for niceQuotes, U+02BC for classifyApostrophes. */
|
|
803
1307
|
function renderTable(style: ActiveQuoteStyle, apostrophe: string): Record<QuoteRole, string> {
|
|
804
1308
|
const base: Record<QuoteRole, string> = {
|
|
@@ -837,6 +1341,11 @@ function queueRenderEdits(view: ProseView, items: Item[], table: Record<QuoteRol
|
|
|
837
1341
|
view.replace(item.start, item.end, "")
|
|
838
1342
|
const key = `${item.anchorOffset}|${item.anchorBind}`
|
|
839
1343
|
const existing = insertions.get(key)
|
|
1344
|
+
/* istanbul ignore if -- defensive: the placement guards (terminal
|
|
1345
|
+
lookbehind, movable-punctuation chains) block any second relocation
|
|
1346
|
+
next to an anchor another item already claimed, so merged insertions
|
|
1347
|
+
are unreachable; merging keeps commit() from rejecting duplicate pure
|
|
1348
|
+
insertions should a future rule re-open the path. */
|
|
840
1349
|
if (existing) {
|
|
841
1350
|
existing.text += rendered
|
|
842
1351
|
} else {
|
|
@@ -862,22 +1371,21 @@ function queueRenderEdits(view: ProseView, items: Item[], table: Record<QuoteRol
|
|
|
862
1371
|
|
|
863
1372
|
/**
|
|
864
1373
|
* The quote/apostrophe role classifier: one boundary-aware scan over the
|
|
865
|
-
*
|
|
1374
|
+
* view's text. Commits its edits.
|
|
866
1375
|
*/
|
|
867
1376
|
export function classifyAndRenderQuotes(
|
|
868
|
-
|
|
869
|
-
separator: string,
|
|
1377
|
+
view: ProseView,
|
|
870
1378
|
style: ActiveQuoteStyle,
|
|
871
1379
|
apostrophe: string,
|
|
872
|
-
):
|
|
873
|
-
if (!QUOTE_CANDIDATE_RE.test(
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1380
|
+
): void {
|
|
1381
|
+
if (!QUOTE_CANDIDATE_RE.test(view.text)) return
|
|
1382
|
+
const items = buildItems(view, style)
|
|
1383
|
+
classifySingles(items, style)
|
|
1384
|
+
classifyDoubles(items, style)
|
|
1385
|
+
const placed = applyPunctuationPlacement(items, style, apostrophe)
|
|
1386
|
+
if (style === "french") absorbGuillemetPadding(placed)
|
|
1387
|
+
queueRenderEdits(view, placed, renderTable(style, apostrophe))
|
|
1388
|
+
view.commit()
|
|
881
1389
|
}
|
|
882
1390
|
|
|
883
1391
|
// ---------------------------------------------------------------------------
|
|
@@ -891,6 +1399,20 @@ function feetInchesBefore(items: Item[], digitIndex: number): boolean {
|
|
|
891
1399
|
return j >= 0 && items[j].ch === PRIME
|
|
892
1400
|
}
|
|
893
1401
|
|
|
1402
|
+
/**
|
|
1403
|
+
* Mirrors the closer rules the quote pass applies to a digit-preceded straight
|
|
1404
|
+
* quote: singles close (or turn possessive) per the single ending lookahead;
|
|
1405
|
+
* doubles close before an ending character, a boundary, or end of input. The
|
|
1406
|
+
* lookbehind sides of those rules are always satisfied here because the
|
|
1407
|
+
* candidate has a digit (or a boundary) directly before it.
|
|
1408
|
+
*/
|
|
1409
|
+
function quotePassClosesCandidate(items: Item[], index: number, quote: string): boolean {
|
|
1410
|
+
if (quote === "'") return singleEndingAfterOptionalS(items, index)
|
|
1411
|
+
if (foldsToNotEqual(items, index + 1)) return false
|
|
1412
|
+
const next = items[index + 1]
|
|
1413
|
+
return next === undefined || next.boundary || isDoubleCloseAfterChar(next.ch)
|
|
1414
|
+
}
|
|
1415
|
+
|
|
894
1416
|
/**
|
|
895
1417
|
* The prime/quote disambiguation: a quote candidate after a digit converts to
|
|
896
1418
|
* a prime only when the running quote balance is open-free; contractions are
|
|
@@ -918,6 +1440,15 @@ function convertPrimes(items: Item[]): void {
|
|
|
918
1440
|
item.ch = primeChar
|
|
919
1441
|
continue
|
|
920
1442
|
}
|
|
1443
|
+
// Blocking a candidate is only stable when the quote pass curls it
|
|
1444
|
+
// into a closer: a candidate it cannot close stays straight while
|
|
1445
|
+
// the opener that supplied the balance curls, so the next run would
|
|
1446
|
+
// see an open-free balance and fold the prime then. Fold it now to
|
|
1447
|
+
// keep the transform a fixed point.
|
|
1448
|
+
if (!quotePassClosesCandidate(items, i, quote)) {
|
|
1449
|
+
item.ch = primeChar
|
|
1450
|
+
continue
|
|
1451
|
+
}
|
|
921
1452
|
balance--
|
|
922
1453
|
continue
|
|
923
1454
|
}
|
|
@@ -932,17 +1463,16 @@ function convertPrimes(items: Item[]): void {
|
|
|
932
1463
|
}
|
|
933
1464
|
}
|
|
934
1465
|
|
|
935
|
-
/** primeMarks engine: converts `5'10"` to `5′10″` with quote-balance guards. */
|
|
936
|
-
export function convertPrimeMarks(
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
view.replace(item.start, item.end, item.ch)
|
|
945
|
-
}
|
|
1466
|
+
/** primeMarks engine: converts `5'10"` to `5′10″` with quote-balance guards. Commits its edits. */
|
|
1467
|
+
export function convertPrimeMarks(view: ProseView): void {
|
|
1468
|
+
const text = view.text
|
|
1469
|
+
if (!text.includes("'") && !text.includes('"')) return
|
|
1470
|
+
const items = buildItems(view, "american")
|
|
1471
|
+
convertPrimes(items)
|
|
1472
|
+
for (const item of items) {
|
|
1473
|
+
if (!item.boundary && item.ch !== text[item.start]) {
|
|
1474
|
+
view.replace(item.start, item.end, item.ch)
|
|
946
1475
|
}
|
|
947
|
-
}
|
|
1476
|
+
}
|
|
1477
|
+
view.commit()
|
|
948
1478
|
}
|