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