punctilio 4.1.5 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,957 @@
1
+ import { LATIN_LETTERS, TERMINAL_PUNCTUATION, UNICODE_SYMBOLS } from "./constants.js"
2
+ import { buildProseView, type ProseNode, type ProseView } from "./prose-view.js"
3
+
4
+ const {
5
+ EM_DASH,
6
+ LEFT_DOUBLE_QUOTE,
7
+ RIGHT_DOUBLE_QUOTE,
8
+ LEFT_SINGLE_QUOTE,
9
+ RIGHT_SINGLE_QUOTE,
10
+ MODIFIER_LETTER_APOSTROPHE,
11
+ DOUBLE_LOW_9_QUOTE,
12
+ SINGLE_LOW_9_QUOTE,
13
+ LEFT_GUILLEMET,
14
+ RIGHT_GUILLEMET,
15
+ NBSP,
16
+ NNBSP,
17
+ PRIME,
18
+ DOUBLE_PRIME,
19
+ } = UNICODE_SYMBOLS
20
+
21
+ export const PUNCTUATION_STYLES = ["american", "british", "german", "french", "none"] as const
22
+ export type PunctuationStyle = (typeof PUNCTUATION_STYLES)[number]
23
+
24
+ /** Locale style that performs quote work ("none" is handled by the callers). */
25
+ export type ActiveQuoteStyle = Exclude<PunctuationStyle, "none">
26
+
27
+ /**
28
+ * Role assigned to each quote-like character by the classifier. The engine
29
+ * tracks roles through an internal one-character alphabet (the American
30
+ * convention glyphs plus U+02BC for semantic apostrophes); rendering maps the
31
+ * roles to per-locale glyph strings.
32
+ */
33
+ export type QuoteRole =
34
+ | "APOSTROPHE"
35
+ | "CLOSE_DOUBLE"
36
+ | "CLOSE_SINGLE"
37
+ | "DOUBLE_PRIME"
38
+ | "LITERAL"
39
+ | "OPEN_DOUBLE"
40
+ | "OPEN_SINGLE"
41
+ | "PRIME"
42
+
43
+ /** Internal-alphabet character for each non-LITERAL role. */
44
+ const ROLE_BY_CHAR = new Map<string, Exclude<QuoteRole, "LITERAL">>([
45
+ [LEFT_DOUBLE_QUOTE, "OPEN_DOUBLE"],
46
+ [RIGHT_DOUBLE_QUOTE, "CLOSE_DOUBLE"],
47
+ [LEFT_SINGLE_QUOTE, "OPEN_SINGLE"],
48
+ [RIGHT_SINGLE_QUOTE, "CLOSE_SINGLE"],
49
+ [MODIFIER_LETTER_APOSTROPHE, "APOSTROPHE"],
50
+ [PRIME, "PRIME"],
51
+ [DOUBLE_PRIME, "DOUBLE_PRIME"],
52
+ ])
53
+
54
+ /**
55
+ * One element of the classifier's working sequence: either a single clean-text
56
+ * character (occasionally a multi-character span, for guillemets absorbed with
57
+ * their padding) or a zero-width node boundary between source fragments.
58
+ * Boundary items take the place of the v4 sentinel separators: rules that were
59
+ * separator-transparent skip them, rules that treated the sentinel as an
60
+ * ordinary non-space character treat them as such.
61
+ */
62
+ interface Item {
63
+ boundary: boolean
64
+ /** Current internal-alphabet character. Empty for boundary items. */
65
+ ch: string
66
+ /** Original span [start, end) in the view's clean text. */
67
+ start: number
68
+ end: number
69
+ /** Set when punctuation placement relocates this item. */
70
+ moved: boolean
71
+ /** Insertion anchor (clean-text offset and bind side) once moved. */
72
+ anchorOffset: number
73
+ anchorBind: "left" | "right"
74
+ }
75
+
76
+ const LETTER_RE = new RegExp(`[${LATIN_LETTERS}]`)
77
+ const WORD_RE = /\w/
78
+ const SPACE_RE = /\s/
79
+ const DIGIT_RE = /\d/
80
+
81
+ /** Fast path: text without any quote-like character is returned untouched. */
82
+ const QUOTE_CANDIDATE_RE = new RegExp(
83
+ `['"${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}` +
84
+ `${MODIFIER_LETTER_APOSTROPHE}${DOUBLE_LOW_9_QUOTE}${SINGLE_LOW_9_QUOTE}${LEFT_GUILLEMET}${RIGHT_GUILLEMET}]`
85
+ )
86
+
87
+ const TERMINAL_SET = new Set<string>(TERMINAL_PUNCTUATION)
88
+ const CLOSING_SET = new Set<string>([RIGHT_SINGLE_QUOTE, RIGHT_DOUBLE_QUOTE])
89
+
90
+ /** Chars from `'` ${LSQ} ${RSQ} ${MLA}; \w handled separately. */
91
+ const SINGLE_QUOTE_FAMILY = new Set<string>(["'", LEFT_SINGLE_QUOTE, RIGHT_SINGLE_QUOTE, MODIFIER_LETTER_APOSTROPHE])
92
+
93
+ /** Ending-context chars for single quotes (plus any \s char). */
94
+ const SINGLE_ENDING_SET = new Set<string>([".", "!", "?", ";", ",", ")", EM_DASH, "-", "]", '"'])
95
+
96
+ /** Boundary chars before an opening single quote (plus any \s char). */
97
+ const SINGLE_OPEN_BEFORE_SET = new Set<string>([LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, EM_DASH, "-", "("])
98
+
99
+ /** Boundary chars before an opening double quote (plus any \s char). */
100
+ const DOUBLE_OPEN_BEFORE_SET = new Set<string>(["(", "/", "[", "{", "-", EM_DASH])
101
+
102
+ /** Ending-context chars that block the opener rule's arm 2 (plus any \s char). */
103
+ const DOUBLE_OPEN_ENDING_SET = new Set<string>([")", EM_DASH, ",", "!", "?", ";", ":", ".", "}"])
104
+
105
+ /** Chars allowed before an empty double-quote pair (plus any \s char). */
106
+ const DOUBLE_EMPTY_BEFORE_SET = new Set<string>(["(", "[", "{"])
107
+
108
+ /** Chars allowed after an empty double-quote pair (plus any \s char). */
109
+ const DOUBLE_EMPTY_AFTER_SET = new Set<string>([")", "]", "}", ".", "!", "?", ",", ";", ":"])
110
+
111
+ /** Ending-context chars after a closing double quote (plus any \s char). */
112
+ const DOUBLE_CLOSE_AFTER_SET = new Set<string>(["/", ")", ".", ",", ";", EM_DASH, ":", "-", "}", "!", "?", "s"])
113
+
114
+ function isLetterItem(items: Item[], index: number): boolean {
115
+ if (index < 0 || index >= items.length) return false
116
+ const item = items[index]
117
+ return !item.boundary && LETTER_RE.test(item.ch)
118
+ }
119
+
120
+ function isCharItem(items: Item[], index: number, ch: string): boolean {
121
+ if (index < 0 || index >= items.length) return false
122
+ const item = items[index]
123
+ return !item.boundary && item.ch === ch
124
+ }
125
+
126
+ /**
127
+ * Index of the previous item, treating up to `maxBoundaries` boundary items as
128
+ * transparent (the v4 `sep?` slots). Returns -1 at start of text; returns the
129
+ * blocking boundary's index when the budget is exceeded, so character-class
130
+ * tests on the result fail the way the sentinel character did.
131
+ */
132
+ function prevIndex(items: Item[], index: number, maxBoundaries: number): number {
133
+ let j = index - 1
134
+ let skipped = 0
135
+ while (j >= 0 && items[j].boundary) {
136
+ if (skipped === maxBoundaries) return j
137
+ skipped++
138
+ j--
139
+ }
140
+ return j
141
+ }
142
+
143
+ /** Mirror of {@link prevIndex}; returns `items.length` at end of text. */
144
+ function nextIndex(items: Item[], index: number, maxBoundaries: number): number {
145
+ let j = index + 1
146
+ let skipped = 0
147
+ while (j < items.length && items[j].boundary) {
148
+ if (skipped === maxBoundaries) return j
149
+ skipped++
150
+ j++
151
+ }
152
+ return j
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Item construction and locale pre-labeling
157
+ // ---------------------------------------------------------------------------
158
+
159
+ function makeItem(boundary: boolean, ch: string, start: number, end: number): Item {
160
+ return { boundary, ch, start, end, moved: false, anchorOffset: 0, anchorBind: "left" }
161
+ }
162
+
163
+ /**
164
+ * Builds the working item sequence over the view's clean text, applying the
165
+ * locale's pre-label rows: German low-9/depth-gated quotes and French
166
+ * guillemets (with adjacent NBSP/NNBSP padding absorbed) map onto the internal
167
+ * American alphabet so the shared classification rules apply uniformly.
168
+ */
169
+ function buildItems(view: ProseView, style: ActiveQuoteStyle): Item[] {
170
+ const text = view.text
171
+ const boundaries = view.boundaries
172
+ const items: Item[] = []
173
+ let b = 0
174
+ // German closers are depth-gated: U+201C/U+2018 close only below an open
175
+ // low-9 quote; orphans fall back to straight-quote candidates re-derived by
176
+ // position (the v4 normalizeGermanQuotes depth-0 branch).
177
+ let doubleDepth = 0
178
+ let singleDepth = 0
179
+
180
+ let i = 0
181
+ while (i <= text.length) {
182
+ while (b < boundaries.length && boundaries[b] === i) {
183
+ items.push(makeItem(true, "", i, i))
184
+ b++
185
+ }
186
+ if (i === text.length) break
187
+
188
+ const ch = text[i]
189
+ if (style === "french") {
190
+ const boundaryNext = b < boundaries.length && boundaries[b] === i + 1
191
+ const next = text[i + 1]
192
+ if (ch === LEFT_GUILLEMET) {
193
+ const padded = !boundaryNext && (next === NBSP || next === NNBSP)
194
+ items.push(makeItem(false, LEFT_DOUBLE_QUOTE, i, padded ? i + 2 : i + 1))
195
+ i += padded ? 2 : 1
196
+ continue
197
+ }
198
+ if ((ch === NBSP || ch === NNBSP) && !boundaryNext && next === RIGHT_GUILLEMET) {
199
+ items.push(makeItem(false, RIGHT_DOUBLE_QUOTE, i, i + 2))
200
+ i += 2
201
+ continue
202
+ }
203
+ if (ch === RIGHT_GUILLEMET) {
204
+ items.push(makeItem(false, RIGHT_DOUBLE_QUOTE, i, i + 1))
205
+ i++
206
+ continue
207
+ }
208
+ } else if (style === "german") {
209
+ let mapped: string | null = null
210
+ if (ch === DOUBLE_LOW_9_QUOTE) {
211
+ mapped = LEFT_DOUBLE_QUOTE
212
+ doubleDepth++
213
+ } else if (ch === LEFT_DOUBLE_QUOTE) {
214
+ mapped = doubleDepth > 0 ? RIGHT_DOUBLE_QUOTE : '"'
215
+ if (doubleDepth > 0) doubleDepth--
216
+ } else if (ch === SINGLE_LOW_9_QUOTE) {
217
+ mapped = LEFT_SINGLE_QUOTE
218
+ singleDepth++
219
+ } else if (ch === LEFT_SINGLE_QUOTE) {
220
+ mapped = singleDepth > 0 ? RIGHT_SINGLE_QUOTE : "'"
221
+ if (singleDepth > 0) singleDepth--
222
+ } else if (ch === RIGHT_DOUBLE_QUOTE) {
223
+ // Not used in German typography — re-derive by position.
224
+ mapped = '"'
225
+ } else if (ch === RIGHT_SINGLE_QUOTE) {
226
+ mapped = "'"
227
+ }
228
+ if (mapped !== null) {
229
+ items.push(makeItem(false, mapped, i, i + 1))
230
+ i++
231
+ continue
232
+ }
233
+ }
234
+
235
+ items.push(makeItem(false, ch, i, i + 1))
236
+ i++
237
+ }
238
+ return items
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Single-quote classification
243
+ // ---------------------------------------------------------------------------
244
+
245
+ /** True iff the character is a single-quote ending context (or any \s). */
246
+ function isSingleEndingChar(ch: string): boolean {
247
+ return SPACE_RE.test(ch) || SINGLE_ENDING_SET.has(ch)
248
+ }
249
+
250
+ /** `(?=sep?(?:[ending]|$))` at the item after `index`. */
251
+ function singleEndingAfter(items: Item[], index: number): boolean {
252
+ const j = nextIndex(items, index, 1)
253
+ if (j >= items.length) return true
254
+ const item = items[j]
255
+ return !item.boundary && isSingleEndingChar(item.ch)
256
+ }
257
+
258
+ /** `(?=sep?(?:s sep?)?(?:[ending]|$))` — ending context with an optional `s`. */
259
+ function singleEndingAfterOptionalS(items: Item[], index: number): boolean {
260
+ if (singleEndingAfter(items, index)) return true
261
+ const j = nextIndex(items, index, 1)
262
+ return isCharItem(items, j, "s") && singleEndingAfter(items, j)
263
+ }
264
+
265
+ /** `(?=sep?s sep?(?:[ending]|$))` — the possessive lookahead (required `s`). */
266
+ function possessiveAfter(items: Item[], index: number): boolean {
267
+ const j = nextIndex(items, index, 1)
268
+ return isCharItem(items, j, "s") && singleEndingAfter(items, j)
269
+ }
270
+
271
+ /**
272
+ * `(?<=[^\s“'])` — the closing/possessive lookbehind. A boundary directly
273
+ * before the quote satisfies it (the sentinel was an ordinary non-space char).
274
+ */
275
+ function singleCloserBefore(items: Item[], index: number): boolean {
276
+ if (index === 0) return false
277
+ const prev = items[index - 1]
278
+ if (prev.boundary) return true
279
+ return !SPACE_RE.test(prev.ch) && prev.ch !== LEFT_DOUBLE_QUOTE && prev.ch !== "'"
280
+ }
281
+
282
+ /** Letter directly before (no boundary) and letter after (one boundary transparent). */
283
+ function isContractionContext(items: Item[], index: number): boolean {
284
+ return isLetterItem(items, index - 1) && isLetterItem(items, nextIndex(items, index, 1))
285
+ }
286
+
287
+ /** True iff the item is outside the single-quote/word family (boundaries and text edges qualify). */
288
+ function outsideSingleQuoteFamily(items: Item[], index: number): boolean {
289
+ if (index < 0 || index >= items.length) return true
290
+ const item = items[index]
291
+ return item.boundary || (!SINGLE_QUOTE_FAMILY.has(item.ch) && !WORD_RE.test(item.ch))
292
+ }
293
+
294
+ /** Empty (`''`) and whitespace-only (`' '`) single-quote pairs. */
295
+ function classifyEmptySinglePairs(items: Item[]): void {
296
+ for (let i = 0; i < items.length - 1; i++) {
297
+ if (!isCharItem(items, i, "'") || !isCharItem(items, i + 1, "'")) continue
298
+ if (!outsideSingleQuoteFamily(items, i - 1) || !outsideSingleQuoteFamily(items, i + 2)) continue
299
+ items[i].ch = LEFT_SINGLE_QUOTE
300
+ items[i + 1].ch = RIGHT_SINGLE_QUOTE
301
+ i++
302
+ }
303
+ for (let i = 0; i < items.length - 1; i++) {
304
+ if (!isCharItem(items, i, "'")) continue
305
+ let j = i + 1
306
+ while (j < items.length && !items[j].boundary && SPACE_RE.test(items[j].ch)) j++
307
+ if (j === i + 1 || !isCharItem(items, j, "'")) continue
308
+ if (!outsideSingleQuoteFamily(items, i - 1) || !outsideSingleQuoteFamily(items, j + 1)) continue
309
+ items[i].ch = LEFT_SINGLE_QUOTE
310
+ items[j].ch = RIGHT_SINGLE_QUOTE
311
+ i = j
312
+ }
313
+ }
314
+
315
+ /** Rock 'n' Roll: ` 'n' ` between words → semantic apostrophes on both sides. */
316
+ function classifyNAbbreviation(items: Item[]): void {
317
+ for (let i = 0; i + 2 < items.length; i++) {
318
+ const q1 = items[i]
319
+ if (q1.boundary || (q1.ch !== "'" && q1.ch !== RIGHT_SINGLE_QUOTE)) continue
320
+ if (!isCharItem(items, i + 1, "n")) continue
321
+ const q2 = items[i + 2]
322
+ if (q2.boundary || (q2.ch !== "'" && q2.ch !== RIGHT_SINGLE_QUOTE)) continue
323
+ // Behind: literal space, then up to one boundary, then a word char.
324
+ if (!isCharItem(items, i - 1, " ") || !isWordItem(items, prevIndex(items, i - 1, 1))) continue
325
+ // Ahead: literal space, then up to one boundary, then a word char.
326
+ if (!isCharItem(items, i + 3, " ") || !isWordItem(items, nextIndex(items, i + 3, 1))) continue
327
+ q1.ch = MODIFIER_LETTER_APOSTROPHE
328
+ q2.ch = MODIFIER_LETTER_APOSTROPHE
329
+ i += 2
330
+ }
331
+ }
332
+
333
+ function isWordItem(items: Item[], index: number): boolean {
334
+ if (index < 0 || index >= items.length) return false
335
+ const item = items[index]
336
+ return !item.boundary && WORD_RE.test(item.ch)
337
+ }
338
+
339
+ /** Possessive (`dog's`) → APOSTROPHE; then closing single (`'` in ending context) → CLOSE_SINGLE. */
340
+ function classifyPossessivesAndClosers(items: Item[]): void {
341
+ for (let i = 0; i < items.length; i++) {
342
+ const item = items[i]
343
+ if (item.boundary) continue
344
+ if ((item.ch === "'" || item.ch === RIGHT_SINGLE_QUOTE) && singleCloserBefore(items, i) && possessiveAfter(items, i)) {
345
+ item.ch = MODIFIER_LETTER_APOSTROPHE
346
+ }
347
+ }
348
+ for (let i = 0; i < items.length; i++) {
349
+ const item = items[i]
350
+ if (item.boundary || item.ch !== "'") continue
351
+ if (singleCloserBefore(items, i) && singleEndingAfter(items, i)) {
352
+ item.ch = RIGHT_SINGLE_QUOTE
353
+ }
354
+ }
355
+ }
356
+
357
+ /** Word-internal contraction: letter + quote + letter → APOSTROPHE. */
358
+ function classifyContractions(items: Item[]): void {
359
+ for (let i = 0; i < items.length; i++) {
360
+ const item = items[i]
361
+ if (item.boundary) continue
362
+ const ch = item.ch
363
+ if (ch !== "'" && ch !== RIGHT_SINGLE_QUOTE && ch !== MODIFIER_LETTER_APOSTROPHE) continue
364
+ if (isContractionContext(items, i)) {
365
+ item.ch = MODIFIER_LETTER_APOSTROPHE
366
+ }
367
+ }
368
+ }
369
+
370
+ /** `'90s` / `'99` directly after the quote (no boundaries inside the digits). */
371
+ function isDecadeElision(items: Item[], index: number): boolean {
372
+ if (!isDigitItem(items, index + 1) || !isDigitItem(items, index + 2)) return false
373
+ let after = index + 3
374
+ if (isCharItem(items, after, "s")) after++
375
+ return !isLetterOrDigitItem(items, after)
376
+ }
377
+
378
+ function isDigitItem(items: Item[], index: number): boolean {
379
+ if (index < 0 || index >= items.length) return false
380
+ const item = items[index]
381
+ return !item.boundary && DIGIT_RE.test(item.ch)
382
+ }
383
+
384
+ function isLetterOrDigitItem(items: Item[], index: number): boolean {
385
+ if (index < 0 || index >= items.length) return false
386
+ const item = items[index]
387
+ return !item.boundary && (LETTER_RE.test(item.ch) || DIGIT_RE.test(item.ch))
388
+ }
389
+
390
+ /** `nʼ ` directly after the quote — a quote leading into an already-classified 'n'. */
391
+ function isNAbbreviationAhead(items: Item[], index: number): boolean {
392
+ return isCharItem(items, index + 1, "n")
393
+ && isCharItem(items, index + 2, MODIFIER_LETTER_APOSTROPHE)
394
+ && isCharItem(items, index + 3, " ")
395
+ }
396
+
397
+ /**
398
+ * Forward scan for a closing single quote, halting (no closer) at a line
399
+ * break or another single-quote opener. Boundaries are transparent.
400
+ */
401
+ function hasClosingSingleAhead(items: Item[], index: number): boolean {
402
+ for (let j = index + 1; j < items.length; j++) {
403
+ const item = items[j]
404
+ if (item.boundary) continue
405
+ const ch = item.ch
406
+ if (ch === "\n" || ch === LEFT_SINGLE_QUOTE || ch === "'") return false
407
+ if ((ch === RIGHT_SINGLE_QUOTE || ch === MODIFIER_LETTER_APOSTROPHE)
408
+ && !isContractionContext(items, j)
409
+ && singleEndingAfterOptionalS(items, j)) {
410
+ return true
411
+ }
412
+ }
413
+ return false
414
+ }
415
+
416
+ /**
417
+ * Leading elision: a straight quote not preceded by a word char is an
418
+ * apostrophe ('tis, '90s) unless a closing single quote lies ahead. Decisions
419
+ * are taken against the pre-pass state and applied together, so a quote
420
+ * converted here is still seen as a straight quote by later scans in the same
421
+ * pass (matching the v4 snapshot semantics).
422
+ */
423
+ function classifyLeadingApostrophes(items: Item[]): void {
424
+ const toConvert: number[] = []
425
+ for (let i = 0; i < items.length; i++) {
426
+ const item = items[i]
427
+ if (item.boundary || item.ch !== "'") continue
428
+ if (isWordItem(items, i - 1)) continue
429
+ if (isDecadeElision(items, i) || isNAbbreviationAhead(items, i) || !hasClosingSingleAhead(items, i)) {
430
+ toConvert.push(i)
431
+ }
432
+ }
433
+ for (const index of toConvert) {
434
+ items[index].ch = MODIFIER_LETTER_APOSTROPHE
435
+ }
436
+ }
437
+
438
+ /** Opener-position straight single quote → OPEN_SINGLE. */
439
+ function classifyOpeningSingles(items: Item[]): void {
440
+ for (let i = 0; i < items.length; i++) {
441
+ const item = items[i]
442
+ if (item.boundary || item.ch !== "'") continue
443
+ // Ahead: a boundary item satisfies \S (the sentinel was non-space).
444
+ const next = items[i + 1]
445
+ if (next === undefined || (!next.boundary && SPACE_RE.test(next.ch))) continue
446
+ // Behind: up to one boundary, then line start or an opener-context char.
447
+ const j = prevIndex(items, i, 1)
448
+ if (j >= 0) {
449
+ const before = items[j]
450
+ if (before.boundary) continue
451
+ if (!SPACE_RE.test(before.ch) && !SINGLE_OPEN_BEFORE_SET.has(before.ch)) continue
452
+ }
453
+ item.ch = LEFT_SINGLE_QUOTE
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Unmatched CLOSE_SINGLE after s/S → APOSTROPHE (plural possessives), via the
459
+ * advisory open/close balance scan. Boundaries are fully transparent here.
460
+ */
461
+ function classifyPluralPossessives(items: Item[]): void {
462
+ let balance = 0
463
+ for (let i = 0; i < items.length; i++) {
464
+ const item = items[i]
465
+ if (item.boundary) continue
466
+ if (item.ch === LEFT_SINGLE_QUOTE) {
467
+ balance++
468
+ continue
469
+ }
470
+ if (item.ch !== RIGHT_SINGLE_QUOTE) continue
471
+ if (balance > 0) {
472
+ balance--
473
+ continue
474
+ }
475
+ let j = i - 1
476
+ while (j >= 0 && items[j].boundary) j--
477
+ if (j >= 0 && (items[j].ch === "s" || items[j].ch === "S")) {
478
+ item.ch = MODIFIER_LETTER_APOSTROPHE
479
+ }
480
+ }
481
+ }
482
+
483
+ function classifySingles(items: Item[]): void {
484
+ classifyEmptySinglePairs(items)
485
+ classifyNAbbreviation(items)
486
+ classifyPossessivesAndClosers(items)
487
+ classifyContractions(items)
488
+ classifyLeadingApostrophes(items)
489
+ classifyOpeningSingles(items)
490
+ classifyPluralPossessives(items)
491
+ }
492
+
493
+ // ---------------------------------------------------------------------------
494
+ // Double-quote classification
495
+ // ---------------------------------------------------------------------------
496
+
497
+ /** Empty (`""`) and whitespace-only (`" "`) double-quote pairs (no boundaries allowed anywhere in or around the match). */
498
+ function classifyEmptyDoublePairs(items: Item[]): void {
499
+ const beforeOk = (index: number): boolean => {
500
+ if (index < 0) return true
501
+ const item = items[index]
502
+ return !item.boundary && (SPACE_RE.test(item.ch) || DOUBLE_EMPTY_BEFORE_SET.has(item.ch))
503
+ }
504
+ const afterOk = (index: number): boolean => {
505
+ if (index >= items.length) return true
506
+ const item = items[index]
507
+ return !item.boundary && (SPACE_RE.test(item.ch) || DOUBLE_EMPTY_AFTER_SET.has(item.ch))
508
+ }
509
+ for (let i = 0; i < items.length - 1; i++) {
510
+ if (!isCharItem(items, i, '"') || !isCharItem(items, i + 1, '"')) continue
511
+ if (!beforeOk(i - 1) || !afterOk(i + 2)) continue
512
+ items[i].ch = LEFT_DOUBLE_QUOTE
513
+ items[i + 1].ch = RIGHT_DOUBLE_QUOTE
514
+ i++
515
+ }
516
+ for (let i = 0; i < items.length - 1; i++) {
517
+ if (!isCharItem(items, i, '"')) continue
518
+ let j = i + 1
519
+ while (j < items.length && !items[j].boundary && SPACE_RE.test(items[j].ch)) j++
520
+ if (j === i + 1 || !isCharItem(items, j, '"')) continue
521
+ if (!beforeOk(i - 1) || !afterOk(j + 1)) continue
522
+ items[i].ch = LEFT_DOUBLE_QUOTE
523
+ items[j].ch = RIGHT_DOUBLE_QUOTE
524
+ i = j
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Opener-position prefix for a straight double quote: line start, an
530
+ * opener-context character, or — unlike single quotes — a bare boundary.
531
+ */
532
+ function doubleOpenerPrefixOk(items: Item[], index: number): boolean {
533
+ if (index === 0) return true
534
+ const prev = items[index - 1]
535
+ if (prev.boundary) return true
536
+ return SPACE_RE.test(prev.ch) || DOUBLE_OPEN_BEFORE_SET.has(prev.ch)
537
+ }
538
+
539
+ /** Three literal dots with no boundaries between them, starting at `index`. */
540
+ function isEllipsisDots(items: Item[], index: number): boolean {
541
+ return isCharItem(items, index, ".") && isCharItem(items, index + 1, ".") && isCharItem(items, index + 2, ".")
542
+ }
543
+
544
+ /** Opening double quote by following context (the v4 beginningDouble arms). */
545
+ function classifyOpeningDoubles(items: Item[]): void {
546
+ for (let i = 0; i < items.length; i++) {
547
+ const item = items[i]
548
+ if (item.boundary || item.ch !== '"') continue
549
+ if (!doubleOpenerPrefixOk(items, i)) continue
550
+ // Arm 1: a boundary then space/period/comma (the sentinel-start quirk).
551
+ const next = items[i + 1]
552
+ let opens = false
553
+ if (next?.boundary === true) {
554
+ const afterBoundary = items[i + 2]
555
+ opens = afterBoundary !== undefined && !afterBoundary.boundary
556
+ && (afterBoundary.ch === " " || afterBoundary.ch === "." || afterBoundary.ch === ",")
557
+ }
558
+ // Arm 2: up to one boundary, then "..." or a non-ending character.
559
+ if (!opens) {
560
+ const j = nextIndex(items, i, 1)
561
+ if (j < items.length && !items[j].boundary) {
562
+ const ch = items[j].ch
563
+ opens = isEllipsisDots(items, j) || (!SPACE_RE.test(ch) && !DOUBLE_OPEN_ENDING_SET.has(ch))
564
+ }
565
+ }
566
+ if (opens) item.ch = LEFT_DOUBLE_QUOTE
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Quoted-punctuation openers like `"?"`: an opener-prefixed straight double
572
+ * quote with a closing straight double quote anywhere ahead (at least one
573
+ * item between them). Decisions use the pass-start set of straight quotes.
574
+ */
575
+ function classifyQuotedPunctuationOpeners(items: Item[]): void {
576
+ const straightIndices: number[] = []
577
+ for (let i = 0; i < items.length; i++) {
578
+ if (isCharItem(items, i, '"')) straightIndices.push(i)
579
+ }
580
+ for (let k = 0; k < straightIndices.length; k++) {
581
+ const i = straightIndices[k]
582
+ if (!doubleOpenerPrefixOk(items, i)) continue
583
+ const closer = straightIndices[k + 1]
584
+ if (closer !== undefined && closer >= i + 2) {
585
+ items[i].ch = LEFT_DOUBLE_QUOTE
586
+ }
587
+ }
588
+ }
589
+
590
+ /** The `{`-context opener quirk: `{"`, `{ "`, and `{<sep> "`. */
591
+ function classifyBraceOpeners(items: Item[]): void {
592
+ for (let i = 0; i < items.length; i++) {
593
+ const item = items[i]
594
+ if (item.boundary || item.ch !== '"') continue
595
+ const direct = isCharItem(items, i - 1, "{")
596
+ const viaSpace = isCharItem(items, i - 1, " ") && isCharItem(items, prevIndex(items, i - 1, 1), "{")
597
+ if (direct || viaSpace) item.ch = LEFT_DOUBLE_QUOTE
598
+ }
599
+ }
600
+
601
+ function isDoubleCloseAfterChar(ch: string): boolean {
602
+ return SPACE_RE.test(ch) || DOUBLE_CLOSE_AFTER_SET.has(ch)
603
+ }
604
+
605
+ /**
606
+ * Closing double quote by ending context: a non-space/non-paren character (or
607
+ * a boundary) before it, and an ending character, a boundary, or end of input
608
+ * after it. The v4 pass consumed the before-character, but a consumed span
609
+ * can never contain the next match's before-character (the quote only closes
610
+ * when followed by a non-quote), so no overlap tracking is needed.
611
+ */
612
+ function classifyClosingDoubles(items: Item[]): void {
613
+ for (let i = 1; i < items.length; i++) {
614
+ const item = items[i]
615
+ if (item.boundary || item.ch !== '"') continue
616
+ const before = items[i - 1]
617
+ if (!before.boundary && (SPACE_RE.test(before.ch) || before.ch === "(")) continue
618
+ const next = items[i + 1]
619
+ if (next === undefined || next.boundary || isDoubleCloseAfterChar(next.ch)) {
620
+ item.ch = RIGHT_DOUBLE_QUOTE
621
+ }
622
+ }
623
+ }
624
+
625
+ /** End-of-input closer: a straight double quote at the very end (one boundary transparent). */
626
+ function classifyEndOfInputClosers(items: Item[]): void {
627
+ for (let i = 0; i < items.length; i++) {
628
+ const item = items[i]
629
+ if (item.boundary || item.ch !== '"') continue
630
+ if (nextIndex(items, i, 1) >= items.length) {
631
+ item.ch = RIGHT_DOUBLE_QUOTE
632
+ }
633
+ }
634
+ }
635
+
636
+ /** The `'` before CLOSE_DOUBLE quirk: `you.'"` → CLOSE_SINGLE. */
637
+ function classifySinglesBeforeClosingDoubles(items: Item[]): void {
638
+ for (let i = 0; i < items.length; i++) {
639
+ const item = items[i]
640
+ if (item.boundary || item.ch !== "'") continue
641
+ if (isCharItem(items, i + 1, RIGHT_DOUBLE_QUOTE)) {
642
+ item.ch = RIGHT_SINGLE_QUOTE
643
+ }
644
+ }
645
+ }
646
+
647
+ function classifyDoubles(items: Item[]): void {
648
+ classifyEmptyDoublePairs(items)
649
+ classifyOpeningDoubles(items)
650
+ classifyQuotedPunctuationOpeners(items)
651
+ classifyBraceOpeners(items)
652
+ classifyClosingDoubles(items)
653
+ classifyEndOfInputClosers(items)
654
+ classifySinglesBeforeClosingDoubles(items)
655
+ }
656
+
657
+ // ---------------------------------------------------------------------------
658
+ // Punctuation placement on the role stream
659
+ // ---------------------------------------------------------------------------
660
+
661
+ /**
662
+ * Run of consecutive CLOSE_* roles (any depth) starting at `start`, with at
663
+ * most one boundary between consecutive closers. Returns the item index just
664
+ * past the run, or -1 when there is no run.
665
+ */
666
+ function scanClosingRun(items: Item[], start: number): number {
667
+ if (start >= items.length || items[start].boundary || !CLOSING_SET.has(items[start].ch)) return -1
668
+ let runEnd = start + 1
669
+ for (;;) {
670
+ let next = runEnd
671
+ if (next < items.length && items[next].boundary) next++
672
+ if (next >= items.length || items[next].boundary || !CLOSING_SET.has(items[next].ch)) break
673
+ runEnd = next + 1
674
+ }
675
+ return runEnd
676
+ }
677
+
678
+ function isMovablePeriodAt(items: Item[], index: number): boolean {
679
+ return items[index].ch === "." && !isEllipsisDots(items, index)
680
+ }
681
+
682
+ function isMovableCommaAt(items: Item[], index: number): boolean {
683
+ return items[index].ch === ","
684
+ }
685
+
686
+ interface Relocation {
687
+ /** Index of the punctuation item in the pass-input order. */
688
+ from: number
689
+ /** Index of the pass-input item the punctuation lands next to. */
690
+ to: number
691
+ side: "after" | "before"
692
+ anchorOffset: number
693
+ anchorBind: "left" | "right"
694
+ }
695
+
696
+ /**
697
+ * Applies collected relocations in one linear rebuild (per-move splices would
698
+ * make the pass quadratic on punctuation-dense input). Relocation spans are
699
+ * disjoint and ordered, so a single cursor over `moves` suffices.
700
+ */
701
+ function applyRelocations(order: Item[], moves: Relocation[]): Item[] {
702
+ if (moves.length === 0) return order
703
+ for (const move of moves) {
704
+ const item = order[move.from]
705
+ item.moved = true
706
+ item.anchorOffset = move.anchorOffset
707
+ item.anchorBind = move.anchorBind
708
+ }
709
+ const result: Item[] = []
710
+ let m = 0
711
+ for (let i = 0; i < order.length; i++) {
712
+ const move = m < moves.length ? moves[m] : undefined
713
+ if (move?.side === "before" && i === move.to) result.push(order[move.from])
714
+ if (move !== undefined && i === move.from) {
715
+ if (move.side === "before") m++
716
+ continue
717
+ }
718
+ result.push(order[i])
719
+ if (move?.side === "after" && i === move.to) {
720
+ result.push(order[move.from])
721
+ m++
722
+ }
723
+ }
724
+ return result
725
+ }
726
+
727
+ /**
728
+ * American style: a period or comma directly after a closing-quote run moves
729
+ * to just before the run, unless the character before the run is terminal
730
+ * 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.
733
+ */
734
+ function movePunctuationInside(order: Item[], isMovable: (items: Item[], index: number) => boolean): Item[] {
735
+ // Decisions are taken against the pass-input order (the v4 pass scanned its
736
+ // input string while building the output), then applied together.
737
+ const moves: Relocation[] = []
738
+ let position = 0
739
+ while (position < order.length) {
740
+ const runStart = order[position].boundary ? position + 1 : position
741
+ const runEnd = scanClosingRun(order, runStart)
742
+ if (runEnd === -1) {
743
+ position++
744
+ continue
745
+ }
746
+ const punctIndex = runEnd < order.length && order[runEnd].boundary ? runEnd + 1 : runEnd
747
+ const precededByTerminal = position > 0 && !order[position - 1].boundary && TERMINAL_SET.has(order[position - 1].ch)
748
+ if (precededByTerminal || punctIndex >= order.length || order[punctIndex].boundary || !isMovable(order, punctIndex)) {
749
+ position++
750
+ continue
751
+ }
752
+ moves.push({ from: punctIndex, to: runStart, side: "before", anchorOffset: order[runStart].start, anchorBind: "right" })
753
+ position = punctIndex + 1
754
+ }
755
+ return applyRelocations(order, moves)
756
+ }
757
+
758
+ /**
759
+ * British/German/French: a period or comma directly before a closing-quote
760
+ * run moves past the whole run. No terminal-punctuation guard.
761
+ */
762
+ function movePunctuationOutside(order: Item[], punctChar: string): Item[] {
763
+ const moves: Relocation[] = []
764
+ let position = 0
765
+ while (position < order.length) {
766
+ const item = order[position]
767
+ if (item.boundary || item.ch !== punctChar) {
768
+ position++
769
+ continue
770
+ }
771
+ // Quotes group: one or more closers, each preceded by at most one boundary.
772
+ let last = -1
773
+ let j = position + 1
774
+ for (;;) {
775
+ let k = j
776
+ if (k < order.length && order[k].boundary) k++
777
+ if (k >= order.length || order[k].boundary || !CLOSING_SET.has(order[k].ch)) break
778
+ last = k
779
+ j = k + 1
780
+ }
781
+ if (last === -1) {
782
+ position++
783
+ continue
784
+ }
785
+ moves.push({ from: position, to: last, side: "after", anchorOffset: order[last].end, anchorBind: "left" })
786
+ position = last + 1
787
+ }
788
+ return applyRelocations(order, moves)
789
+ }
790
+
791
+ function applyPunctuationPlacement(items: Item[], style: ActiveQuoteStyle): Item[] {
792
+ if (style === "american") {
793
+ return movePunctuationInside(movePunctuationInside(items, isMovablePeriodAt), isMovableCommaAt)
794
+ }
795
+ return movePunctuationOutside(movePunctuationOutside(items, "."), ",")
796
+ }
797
+
798
+ // ---------------------------------------------------------------------------
799
+ // Rendering
800
+ // ---------------------------------------------------------------------------
801
+
802
+ /** Per-locale glyph table. `apostrophe` is U+2019 for niceQuotes, U+02BC for classifyApostrophes. */
803
+ function renderTable(style: ActiveQuoteStyle, apostrophe: string): Record<QuoteRole, string> {
804
+ const base: Record<QuoteRole, string> = {
805
+ APOSTROPHE: apostrophe,
806
+ CLOSE_DOUBLE: RIGHT_DOUBLE_QUOTE,
807
+ CLOSE_SINGLE: RIGHT_SINGLE_QUOTE,
808
+ DOUBLE_PRIME,
809
+ LITERAL: "",
810
+ OPEN_DOUBLE: LEFT_DOUBLE_QUOTE,
811
+ OPEN_SINGLE: LEFT_SINGLE_QUOTE,
812
+ PRIME,
813
+ }
814
+ if (style === "german") {
815
+ base.OPEN_DOUBLE = DOUBLE_LOW_9_QUOTE
816
+ base.CLOSE_DOUBLE = LEFT_DOUBLE_QUOTE
817
+ base.OPEN_SINGLE = SINGLE_LOW_9_QUOTE
818
+ base.CLOSE_SINGLE = LEFT_SINGLE_QUOTE
819
+ } else if (style === "french") {
820
+ base.OPEN_DOUBLE = `${LEFT_GUILLEMET}${NNBSP}`
821
+ base.CLOSE_DOUBLE = `${NNBSP}${RIGHT_GUILLEMET}`
822
+ }
823
+ return base
824
+ }
825
+
826
+ /** Queues replace/delete/insert edits realizing the final item sequence. */
827
+ function queueRenderEdits(view: ProseView, items: Item[], table: Record<QuoteRole, string>): void {
828
+ const text = view.text
829
+ // Moved items anchored at the same offset merge into one insertion, in
830
+ // final item order (ProseView rejects duplicate pure insertions).
831
+ const insertions = new Map<string, { offset: number; bind: "left" | "right"; text: string }>()
832
+ for (const item of items) {
833
+ if (item.boundary) continue
834
+ const role = ROLE_BY_CHAR.get(item.ch)
835
+ const rendered = role === undefined ? item.ch : table[role]
836
+ if (item.moved) {
837
+ view.replace(item.start, item.end, "")
838
+ const key = `${item.anchorOffset}|${item.anchorBind}`
839
+ const existing = insertions.get(key)
840
+ if (existing) {
841
+ existing.text += rendered
842
+ } else {
843
+ insertions.set(key, { offset: item.anchorOffset, bind: item.anchorBind, text: rendered })
844
+ }
845
+ continue
846
+ }
847
+ // Fast path for ordinary characters; pre-labeled items may have changed
848
+ // `ch` without gaining a role (German orphan closers) and must still diff.
849
+ if (role === undefined && item.ch === text[item.start] && item.end - item.start === 1) continue
850
+ if (rendered !== text.slice(item.start, item.end)) {
851
+ view.replace(item.start, item.end, rendered)
852
+ }
853
+ }
854
+ for (const insertion of insertions.values()) {
855
+ view.replace(insertion.offset, insertion.offset, insertion.text, { bind: insertion.bind })
856
+ }
857
+ }
858
+
859
+ // ---------------------------------------------------------------------------
860
+ // Public engine entry points
861
+ // ---------------------------------------------------------------------------
862
+
863
+ function withProseView(markedText: string, separator: string, run: (view: ProseView) => void): string {
864
+ const fragments = separator.length > 0 ? markedText.split(separator) : [markedText]
865
+ const nodes: ProseNode[] = fragments.map((value) => ({ value }))
866
+ const view = buildProseView(nodes)
867
+ run(view)
868
+ view.commit()
869
+ return nodes.map((node) => node.value).join(separator)
870
+ }
871
+
872
+ /**
873
+ * The quote/apostrophe role classifier: one boundary-aware scan over the
874
+ * clean text replaces the v4 ordered regex pipeline.
875
+ */
876
+ export function classifyAndRenderQuotes(
877
+ markedText: string,
878
+ separator: string,
879
+ style: ActiveQuoteStyle,
880
+ apostrophe: string,
881
+ ): string {
882
+ if (!QUOTE_CANDIDATE_RE.test(markedText)) return markedText
883
+ return withProseView(markedText, separator, (view) => {
884
+ const items = buildItems(view, style)
885
+ classifySingles(items)
886
+ classifyDoubles(items)
887
+ const placed = applyPunctuationPlacement(items, style)
888
+ queueRenderEdits(view, placed, renderTable(style, apostrophe))
889
+ })
890
+ }
891
+
892
+ // ---------------------------------------------------------------------------
893
+ // Prime marks
894
+ // ---------------------------------------------------------------------------
895
+
896
+ /** `′` followed by any mix of digits/boundaries up to `digitIndex` (feet-inches like 5′10"). */
897
+ function feetInchesBefore(items: Item[], digitIndex: number): boolean {
898
+ let j = digitIndex - 1
899
+ while (j >= 0 && (items[j].boundary || DIGIT_RE.test(items[j].ch))) j--
900
+ return j >= 0 && items[j].ch === PRIME
901
+ }
902
+
903
+ /**
904
+ * The prime/quote disambiguation: a quote candidate after a digit converts to
905
+ * a prime only when the running quote balance is open-free; contractions are
906
+ * skipped and trailing apostrophes consume balance without converting.
907
+ */
908
+ function convertPrimes(items: Item[]): void {
909
+ const passes: [string, string][] = [
910
+ ["'", PRIME],
911
+ ['"', DOUBLE_PRIME],
912
+ ]
913
+ for (const [quote, primeChar] of passes) {
914
+ let balance = 0
915
+ for (let i = 0; i < items.length; i++) {
916
+ const item = items[i]
917
+ if (item.boundary || item.ch !== quote) continue
918
+ const digitIndex = prevIndex(items, i, 1)
919
+ if (isDigitItem(items, digitIndex) && !isLetterItem(items, i + 1)) {
920
+ // Prime candidate. The after-context check is boundary-blind: a
921
+ // boundary right after the quote always passes it.
922
+ if (balance <= 0) {
923
+ item.ch = primeChar
924
+ continue
925
+ }
926
+ if (primeChar === DOUBLE_PRIME && feetInchesBefore(items, digitIndex)) {
927
+ item.ch = primeChar
928
+ continue
929
+ }
930
+ balance--
931
+ continue
932
+ }
933
+ const letterBefore = isLetterItem(items, prevIndex(items, i, 1))
934
+ if (letterBefore && isLetterItem(items, nextIndex(items, i, 1))) continue
935
+ if (letterBefore) {
936
+ if (balance > 0) balance--
937
+ continue
938
+ }
939
+ balance = balance <= 0 ? 1 : balance - 1
940
+ }
941
+ }
942
+ }
943
+
944
+ /** primeMarks engine: converts `5'10"` to `5′10″` with quote-balance guards. */
945
+ export function convertPrimeMarks(markedText: string, separator: string): string {
946
+ if (!markedText.includes("'") && !markedText.includes('"')) return markedText
947
+ return withProseView(markedText, separator, (view) => {
948
+ const text = view.text
949
+ const items = buildItems(view, "american")
950
+ convertPrimes(items)
951
+ for (const item of items) {
952
+ if (!item.boundary && item.ch !== text[item.start]) {
953
+ view.replace(item.start, item.end, item.ch)
954
+ }
955
+ }
956
+ })
957
+ }