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.
Files changed (65) hide show
  1. package/README.md +37 -23
  2. package/dist/constants.d.ts +0 -27
  3. package/dist/constants.d.ts.map +1 -1
  4. package/dist/constants.js +0 -40
  5. package/dist/constants.js.map +1 -1
  6. package/dist/dashes.d.ts +11 -7
  7. package/dist/dashes.d.ts.map +1 -1
  8. package/dist/dashes.js +423 -284
  9. package/dist/dashes.js.map +1 -1
  10. package/dist/index.d.ts +14 -118
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +65 -116
  13. package/dist/index.js.map +1 -1
  14. package/dist/markdown.js +1 -1
  15. package/dist/markdown.js.map +1 -1
  16. package/dist/nbsp.d.ts +19 -13
  17. package/dist/nbsp.d.ts.map +1 -1
  18. package/dist/nbsp.js +214 -69
  19. package/dist/nbsp.js.map +1 -1
  20. package/dist/prose-view.d.ts +48 -7
  21. package/dist/prose-view.d.ts.map +1 -1
  22. package/dist/prose-view.js +163 -27
  23. package/dist/prose-view.js.map +1 -1
  24. package/dist/quote-classifier.d.ts +5 -4
  25. package/dist/quote-classifier.d.ts.map +1 -1
  26. package/dist/quote-classifier.js +601 -88
  27. package/dist/quote-classifier.js.map +1 -1
  28. package/dist/quotes.d.ts +12 -4
  29. package/dist/quotes.d.ts.map +1 -1
  30. package/dist/quotes.js +24 -27
  31. package/dist/quotes.js.map +1 -1
  32. package/dist/rehype.d.ts +53 -8
  33. package/dist/rehype.d.ts.map +1 -1
  34. package/dist/rehype.js +105 -62
  35. package/dist/rehype.js.map +1 -1
  36. package/dist/remark.d.ts +1 -1
  37. package/dist/remark.d.ts.map +1 -1
  38. package/dist/remark.js +6 -9
  39. package/dist/remark.js.map +1 -1
  40. package/dist/symbols.d.ts +25 -14
  41. package/dist/symbols.d.ts.map +1 -1
  42. package/dist/symbols.js +812 -220
  43. package/dist/symbols.js.map +1 -1
  44. package/dist/transform-options.d.ts +97 -0
  45. package/dist/transform-options.d.ts.map +1 -0
  46. package/dist/transform-options.js +34 -0
  47. package/dist/transform-options.js.map +1 -0
  48. package/dist/utils.d.ts +0 -16
  49. package/dist/utils.d.ts.map +1 -1
  50. package/dist/utils.js +0 -56
  51. package/dist/utils.js.map +1 -1
  52. package/package.json +8 -3
  53. package/src/constants.ts +0 -46
  54. package/src/dashes.ts +450 -269
  55. package/src/index.ts +70 -291
  56. package/src/markdown.ts +1 -1
  57. package/src/nbsp.ts +255 -78
  58. package/src/prose-view.ts +234 -39
  59. package/src/quote-classifier.ts +618 -88
  60. package/src/quotes.ts +36 -19
  61. package/src/rehype.ts +169 -88
  62. package/src/remark.ts +6 -11
  63. package/src/symbols.ts +814 -251
  64. package/src/transform-options.ts +143 -0
  65. package/src/utils.ts +0 -82
@@ -1,6 +1,12 @@
1
- import { LATIN_LETTERS, TERMINAL_PUNCTUATION, UNICODE_SYMBOLS } from "./constants.js";
2
- import { withProseView } 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;
1
+ import { LATIN_LETTERS, SPACE_CHARS, TERMINAL_PUNCTUATION, UNICODE_SYMBOLS } from "./constants.js";
2
+ const { EM_DASH, EN_DASH, MINUS, 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, ELLIPSIS, DOUBLE_QUESTION, QUESTION_EXCLAMATION, EXCLAMATION_QUESTION, DOUBLE_EXCLAMATION, INTERROBANG, MULTIPLICATION, APPROXIMATE, GREATER_EQUAL, LESS_EQUAL, NOT_EQUAL, PLUS_MINUS, SUPERSCRIPT_ST, SUPERSCRIPT_ND, SUPERSCRIPT_RD, SUPERSCRIPT_TH, } = UNICODE_SYMBOLS;
3
+ /**
4
+ * Glyphs the symbol passes fold `?`/`!` runs and `...` into. The quote rules
5
+ * run before those passes, so every ending-context set that contains the
6
+ * source punctuation must contain the folded glyph too — otherwise a decision
7
+ * gated on `?!` flips once a re-run sees `⁈`.
8
+ */
9
+ const FOLDED_TERMINALS = [DOUBLE_QUESTION, QUESTION_EXCLAMATION, EXCLAMATION_QUESTION, DOUBLE_EXCLAMATION, INTERROBANG];
4
10
  export const PUNCTUATION_STYLES = ["american", "british", "german", "french", "none"];
5
11
  /** Internal-alphabet character for each non-LITERAL role. */
6
12
  const ROLE_BY_CHAR = new Map([
@@ -20,23 +26,43 @@ const DIGIT_RE = /\d/;
20
26
  const QUOTE_CANDIDATE_RE = new RegExp(`['"${LEFT_SINGLE_QUOTE}${RIGHT_SINGLE_QUOTE}${LEFT_DOUBLE_QUOTE}${RIGHT_DOUBLE_QUOTE}` +
21
27
  `${MODIFIER_LETTER_APOSTROPHE}${DOUBLE_LOW_9_QUOTE}${SINGLE_LOW_9_QUOTE}${LEFT_GUILLEMET}${RIGHT_GUILLEMET}]`);
22
28
  const TERMINAL_SET = new Set(TERMINAL_PUNCTUATION);
29
+ /** The collapse pass's space alphabet (plain space, tab, NBSP, NNBSP). */
30
+ const COLLAPSIBLE_SPACE_RE = new RegExp(`[${SPACE_CHARS}]`);
23
31
  const CLOSING_SET = new Set([RIGHT_SINGLE_QUOTE, RIGHT_DOUBLE_QUOTE]);
32
+ /**
33
+ * Placement alphabet for renders that collapse apostrophes into U+2019: a
34
+ * re-run reads those apostrophes as closing single quotes (in every style but
35
+ * German, which re-derives U+2019 by position), so placement must treat an
36
+ * APOSTROPHE item as part of a closing run for the transform to be a fixed
37
+ * point.
38
+ */
39
+ const CLOSING_OR_APOSTROPHE_SET = new Set([RIGHT_SINGLE_QUOTE, RIGHT_DOUBLE_QUOTE, MODIFIER_LETTER_APOSTROPHE]);
24
40
  /** Chars from `'` ${LSQ} ${RSQ} ${MLA}; \w handled separately. */
25
41
  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, "-", "("]);
42
+ /** Ending-context chars for single quotes (plus any \s char). The curly
43
+ * doubles accompany the straight one: `"` curls into one of them in this same
44
+ * pass, and an ending decision must survive the curl. */
45
+ const SINGLE_ENDING_SET = new Set([".", "!", "?", ";", ",", ")", EM_DASH, "-", "]", '"', LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, ELLIPSIS, ...FOLDED_TERMINALS]);
46
+ /** Boundary chars before an opening single quote (plus any \s char). A
47
+ * straight double quote belongs with the curly pair: whichever way it curls
48
+ * lands in this set, so deciding as if it already had keeps re-runs (which
49
+ * see the curled glyph) consistent. */
50
+ const SINGLE_OPEN_BEFORE_SET = new Set([LEFT_DOUBLE_QUOTE, RIGHT_DOUBLE_QUOTE, '"', EM_DASH, "-", "("]);
30
51
  /** Boundary chars before an opening double quote (plus any \s char). */
31
52
  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, ",", "!", "?", ";", ":", ".", "}"]);
53
+ /** Ending-context chars that block the opener rule's arm 2 (plus any \s char).
54
+ * ELLIPSIS is deliberately absent: the rule's `...` arm opens, so `…` must too. */
55
+ const DOUBLE_OPEN_ENDING_SET = new Set([")", EM_DASH, ",", "!", "?", ";", ":", ".", "}", ...FOLDED_TERMINALS]);
34
56
  /** Chars allowed before an empty double-quote pair (plus any \s char). */
35
57
  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([")", "]", "}", ".", "!", "?", ",", ";", ":"]);
58
+ /** Chars allowed after an empty double-quote pair (plus any \s char).
59
+ * Aligned to contain {@link DOUBLE_CLOSE_AFTER_SET}: a pair the empty-pair
60
+ * rule rejects falls through to opener/closer rules whose after-contexts are
61
+ * the closer set, and the two routes must accept the same shapes or a re-run
62
+ * (which sees the rendered opener, not the whitespace) classifies anew. */
63
+ const DOUBLE_EMPTY_AFTER_SET = new Set([")", "]", "}", ".", "!", "?", ",", ";", ":", "/", "-", "s", EM_DASH, ELLIPSIS, ...FOLDED_TERMINALS]);
38
64
  /** Ending-context chars after a closing double quote (plus any \s char). */
39
- const DOUBLE_CLOSE_AFTER_SET = new Set(["/", ")", ".", ",", ";", EM_DASH, ":", "-", "}", "!", "?", "s"]);
65
+ const DOUBLE_CLOSE_AFTER_SET = new Set(["/", ")", ".", ",", ";", EM_DASH, ":", "-", "}", "!", "?", "s", ELLIPSIS, ...FOLDED_TERMINALS]);
40
66
  function isLetterItem(items, index) {
41
67
  if (index < 0 || index >= items.length)
42
68
  return false;
@@ -51,9 +77,9 @@ function isCharItem(items, index, ch) {
51
77
  }
52
78
  /**
53
79
  * 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.
80
+ * transparent. Returns -1 at start of text; returns the blocking boundary's
81
+ * index when the budget is exceeded, so character-class tests on the result
82
+ * fail (a boundary item satisfies no character class).
57
83
  */
58
84
  function prevIndex(items, index, maxBoundaries) {
59
85
  let j = index - 1;
@@ -97,7 +123,7 @@ function buildItems(view, style) {
97
123
  let b = 0;
98
124
  // German closers are depth-gated: U+201C/U+2018 close only below an open
99
125
  // low-9 quote; orphans fall back to straight-quote candidates re-derived by
100
- // position (the v4 normalizeGermanQuotes depth-0 branch).
126
+ // position (the depth-0 branch).
101
127
  let doubleDepth = 0;
102
128
  let singleDepth = 0;
103
129
  let i = 0;
@@ -153,7 +179,9 @@ function buildItems(view, style) {
153
179
  // Not used in German typography — re-derive by position.
154
180
  mapped = '"';
155
181
  }
156
- else if (ch === RIGHT_SINGLE_QUOTE) {
182
+ else if (ch === RIGHT_SINGLE_QUOTE || ch === MODIFIER_LETTER_APOSTROPHE) {
183
+ // U+02BC renders as U+2019, which the next run re-derives by position;
184
+ // re-derive it the same way now so both runs agree.
157
185
  mapped = "'";
158
186
  }
159
187
  if (mapped !== null) {
@@ -180,6 +208,10 @@ function singleEndingAfter(items, index) {
180
208
  if (j >= items.length)
181
209
  return true;
182
210
  const item = items[j];
211
+ // A `!` that folds to `≠` is not the ending it looks like (`≠` is not in
212
+ // the ending set), and the re-run sees the folded glyph.
213
+ if (foldsToNotEqual(items, j))
214
+ return false;
183
215
  return !item.boundary && isSingleEndingChar(item.ch);
184
216
  }
185
217
  /** `(?=sep?(?:s sep?)?(?:[ending]|$))` — ending context with an optional `s`. */
@@ -195,8 +227,12 @@ function possessiveAfter(items, index) {
195
227
  return isCharItem(items, j, "s") && singleEndingAfter(items, j);
196
228
  }
197
229
  /**
198
- * `(?<=[^\s“'])` — the closing/possessive lookbehind. A boundary directly
199
- * before the quote satisfies it (the sentinel was an ordinary non-space char).
230
+ * `(?<=[^\s“'"])` — the closing/possessive lookbehind. A boundary directly
231
+ * before the quote satisfies it (a boundary is an ordinary non-space char).
232
+ * A straight double quote is excluded along with `“`: it may curl into an
233
+ * opener later in this same pass, which puts the candidate in exactly the
234
+ * nested-opener position the `“` exclusion covers, and deciding against it
235
+ * now keeps re-runs (which see the curled glyph) consistent.
200
236
  */
201
237
  function singleCloserBefore(items, index) {
202
238
  if (index === 0)
@@ -204,18 +240,32 @@ function singleCloserBefore(items, index) {
204
240
  const prev = items[index - 1];
205
241
  if (prev.boundary)
206
242
  return true;
207
- return !SPACE_RE.test(prev.ch) && prev.ch !== LEFT_DOUBLE_QUOTE && prev.ch !== "'";
243
+ return !SPACE_RE.test(prev.ch) && prev.ch !== LEFT_DOUBLE_QUOTE && prev.ch !== "'" && prev.ch !== '"';
244
+ }
245
+ /**
246
+ * Letter test that treats folded word glyphs as the letters they fold from
247
+ * (`X` → `×`, `st` → `ˢᵗ`); see {@link FOLDED_WORD_CHARS}.
248
+ */
249
+ function isLetterOrFoldedItem(items, index) {
250
+ if (index < 0 || index >= items.length)
251
+ return false;
252
+ const item = items[index];
253
+ return !item.boundary && (LETTER_RE.test(item.ch) || FOLDED_WORD_CHARS.has(item.ch));
208
254
  }
209
255
  /** Letter directly before (no boundary) and letter after (one boundary transparent). */
210
256
  function isContractionContext(items, index) {
211
- return isLetterItem(items, index - 1) && isLetterItem(items, nextIndex(items, index, 1));
257
+ return isLetterOrFoldedItem(items, index - 1) && isLetterOrFoldedItem(items, nextIndex(items, index, 1));
212
258
  }
213
- /** True iff the item is outside the single-quote/word family (boundaries and text edges qualify). */
259
+ /**
260
+ * True iff the item is outside the single-quote/word family (boundaries and
261
+ * text edges qualify). Folded word glyphs count as the word chars they fold
262
+ * from (`3x''` and `3×''` must agree on the empty-pair gate).
263
+ */
214
264
  function outsideSingleQuoteFamily(items, index) {
215
265
  if (index < 0 || index >= items.length)
216
266
  return true;
217
267
  const item = items[index];
218
- return item.boundary || (!SINGLE_QUOTE_FAMILY.has(item.ch) && !WORD_RE.test(item.ch));
268
+ return item.boundary || (!SINGLE_QUOTE_FAMILY.has(item.ch) && !WORD_RE.test(item.ch) && !FOLDED_WORD_CHARS.has(item.ch));
219
269
  }
220
270
  /** Empty (`''`) and whitespace-only (`' '`) single-quote pairs. */
221
271
  function classifyEmptySinglePairs(items) {
@@ -265,20 +315,34 @@ function classifyNAbbreviation(items) {
265
315
  i += 2;
266
316
  }
267
317
  }
318
+ /**
319
+ * Glyphs the symbol passes fold word characters into (multiplication `x` →
320
+ * `×`, ordinal suffixes → superscripts). Word-gated rules must accept them,
321
+ * or a gate decided on `6x'` flips once a re-run sees `6×'`.
322
+ */
323
+ const FOLDED_WORD_CHARS = new Set([
324
+ MULTIPLICATION,
325
+ ...SUPERSCRIPT_ST, ...SUPERSCRIPT_ND, ...SUPERSCRIPT_RD, ...SUPERSCRIPT_TH,
326
+ ]);
268
327
  function isWordItem(items, index) {
269
328
  if (index < 0 || index >= items.length)
270
329
  return false;
271
330
  const item = items[index];
272
- return !item.boundary && WORD_RE.test(item.ch);
331
+ return !item.boundary && (WORD_RE.test(item.ch) || FOLDED_WORD_CHARS.has(item.ch));
273
332
  }
274
- /** Possessive (`dog's`) → APOSTROPHE; then closing single (`'` in ending context) → CLOSE_SINGLE. */
333
+ /**
334
+ * Possessive (`dog's`) → APOSTROPHE; then closing single (`'` in ending
335
+ * context) → CLOSE_SINGLE. Returns true when any item changed.
336
+ */
275
337
  function classifyPossessivesAndClosers(items) {
338
+ let changed = false;
276
339
  for (let i = 0; i < items.length; i++) {
277
340
  const item = items[i];
278
341
  if (item.boundary)
279
342
  continue;
280
343
  if ((item.ch === "'" || item.ch === RIGHT_SINGLE_QUOTE) && singleCloserBefore(items, i) && possessiveAfter(items, i)) {
281
344
  item.ch = MODIFIER_LETTER_APOSTROPHE;
345
+ changed = true;
282
346
  }
283
347
  }
284
348
  for (let i = 0; i < items.length; i++) {
@@ -287,8 +351,10 @@ function classifyPossessivesAndClosers(items) {
287
351
  continue;
288
352
  if (singleCloserBefore(items, i) && singleEndingAfter(items, i)) {
289
353
  item.ch = RIGHT_SINGLE_QUOTE;
354
+ changed = true;
290
355
  }
291
356
  }
357
+ return changed;
292
358
  }
293
359
  /** Word-internal contraction: letter + quote + letter → APOSTROPHE. */
294
360
  function classifyContractions(items) {
@@ -311,6 +377,12 @@ function isDecadeElision(items, index) {
311
377
  let after = index + 3;
312
378
  if (isCharItem(items, after, "s"))
313
379
  after++;
380
+ // A closing single quote directly after the digits means the number is
381
+ // quoted (`'37'`), not elided — the leading quote is its opener. Only the
382
+ // already-classified closer needs checking: a still-straight quote there
383
+ // halts the closer scan, which elides the leading quote anyway.
384
+ if (isCharItem(items, after, RIGHT_SINGLE_QUOTE))
385
+ return false;
314
386
  return !isLetterOrDigitItem(items, after);
315
387
  }
316
388
  function isDigitItem(items, index) {
@@ -323,7 +395,7 @@ function isLetterOrDigitItem(items, index) {
323
395
  if (index < 0 || index >= items.length)
324
396
  return false;
325
397
  const item = items[index];
326
- return !item.boundary && (LETTER_RE.test(item.ch) || DIGIT_RE.test(item.ch));
398
+ return !item.boundary && (LETTER_RE.test(item.ch) || DIGIT_RE.test(item.ch) || FOLDED_WORD_CHARS.has(item.ch));
327
399
  }
328
400
  /** `nʼ ` directly after the quote — a quote leading into an already-classified 'n'. */
329
401
  function isNAbbreviationAhead(items, index) {
@@ -356,7 +428,7 @@ function hasClosingSingleAhead(items, index) {
356
428
  * apostrophe ('tis, '90s) unless a closing single quote lies ahead. Decisions
357
429
  * are taken against the pre-pass state and applied together, so a quote
358
430
  * converted here is still seen as a straight quote by later scans in the same
359
- * pass (matching the v4 snapshot semantics).
431
+ * pass (snapshot semantics).
360
432
  */
361
433
  function classifyLeadingApostrophes(items) {
362
434
  const toConvert = [];
@@ -380,7 +452,7 @@ function classifyOpeningSingles(items) {
380
452
  const item = items[i];
381
453
  if (item.boundary || item.ch !== "'")
382
454
  continue;
383
- // Ahead: a boundary item satisfies \S (the sentinel was non-space).
455
+ // Ahead: a boundary item satisfies \S (a boundary is non-space).
384
456
  const next = items[i + 1];
385
457
  if (next === undefined || (!next.boundary && SPACE_RE.test(next.ch)))
386
458
  continue;
@@ -424,13 +496,23 @@ function classifyPluralPossessives(items) {
424
496
  }
425
497
  }
426
498
  }
427
- function classifySingles(items) {
499
+ function classifySingles(items, style) {
428
500
  classifyEmptySinglePairs(items);
429
501
  classifyNAbbreviation(items);
430
502
  classifyPossessivesAndClosers(items);
431
503
  classifyContractions(items);
432
504
  classifyLeadingApostrophes(items);
433
505
  classifyOpeningSingles(items);
506
+ // The rules above rewrite neighbors the closer/possessive lookbehind keyed
507
+ // on: in a `''` chain the left quote becomes an apostrophe, which renders
508
+ // as U+2019 — exactly the glyph a re-run reads in that slot, where the
509
+ // straight `'` was excluded. Re-run the closer rule until it settles so the
510
+ // first pass reaches the re-run's reading. German is exempt: it re-derives
511
+ // every rendered single back to a straight `'`, so its re-run keeps the
512
+ // exclusion.
513
+ if (style !== "german") {
514
+ while (classifyPossessivesAndClosers(items)) { /* to fixpoint */ }
515
+ }
434
516
  classifyPluralPossessives(items);
435
517
  }
436
518
  // ---------------------------------------------------------------------------
@@ -474,23 +556,88 @@ function classifyEmptyDoublePairs(items) {
474
556
  i = j;
475
557
  }
476
558
  }
559
+ /**
560
+ * Glyphs of folds that can end immediately before a quote while consuming an
561
+ * interior node boundary (math pairs like `~=` → `≈`, dot triples → `…`,
562
+ * `?`/`!` ligatures; the bare `!` is the `!!` normalization's output). Such a
563
+ * fold collapses the boundary to just before the quote, so a boundary preceded
564
+ * by one of these marks a prefix whose unfolded source character (`=`, `.`,
565
+ * `!`, `?`) blocked the opener rule — and the re-run must block too.
566
+ */
567
+ const FOLD_STRANDED_PREFIX_CHARS = new Set([
568
+ ELLIPSIS, APPROXIMATE, NOT_EQUAL, LESS_EQUAL, GREATER_EQUAL,
569
+ DOUBLE_QUESTION, QUESTION_EXCLAMATION, EXCLAMATION_QUESTION, "!",
570
+ ]);
477
571
  /**
478
572
  * Opener-position prefix for a straight double quote: line start, an
479
- * opener-context character, or — unlike single quotes — a bare boundary.
573
+ * opener-context character, or — unlike single quotes — a bare boundary
574
+ * (unless the boundary was stranded by a fold; see
575
+ * {@link FOLD_STRANDED_PREFIX_CHARS}).
480
576
  */
481
577
  function doubleOpenerPrefixOk(items, index) {
482
578
  if (index === 0)
483
579
  return true;
484
580
  const prev = items[index - 1];
485
- if (prev.boundary)
486
- return true;
581
+ if (prev.boundary) {
582
+ // The stranded-prefix lookbehind reads through every stacked boundary:
583
+ // each fold round can empty another node (`!`|`!`|`""` leaves two stacked
584
+ // boundaries before the quote), and the re-run must keep blocking.
585
+ let beforeIndex = index - 2;
586
+ while (beforeIndex >= 0 && items[beforeIndex].boundary)
587
+ beforeIndex--;
588
+ return beforeIndex < 0 || !FOLD_STRANDED_PREFIX_CHARS.has(items[beforeIndex].ch);
589
+ }
487
590
  return SPACE_RE.test(prev.ch) || DOUBLE_OPEN_BEFORE_SET.has(prev.ch);
488
591
  }
489
- /** Three literal dots with no boundaries between them, starting at `index`. */
592
+ const ELLIPSIS_GAP_SPACE_RE = new RegExp(`[${SPACE_CHARS}]`);
593
+ /**
594
+ * Index of the dot reachable across one inter-dot gap from the dot at
595
+ * `dotIndex` (the ellipsis pass's gap rule: the gap is nothing, one space
596
+ * character, or one node boundary). -1 when none.
597
+ */
598
+ function nextEllipsisDotItem(items, dotIndex) {
599
+ let j = dotIndex + 1;
600
+ if (j < items.length && items[j].boundary) {
601
+ // Empty gap with one boundary; a second boundary breaks the gap.
602
+ j++;
603
+ return isCharItem(items, j, ".") ? j : -1;
604
+ }
605
+ if (j >= items.length)
606
+ return -1;
607
+ if (items[j].ch === ".")
608
+ return j;
609
+ if (ELLIPSIS_GAP_SPACE_RE.test(items[j].ch) && j + 1 < items.length && !items[j + 1].boundary && items[j + 1].ch === ".") {
610
+ return j + 1;
611
+ }
612
+ return -1;
613
+ }
614
+ /**
615
+ * True iff a `...`/`. . .` triple the ellipsis pass folds to `…` begins at
616
+ * `index`. Rules gated on this must decide like they would on `…` itself,
617
+ * since a re-run sees the folded glyph.
618
+ */
490
619
  function isEllipsisDots(items, index) {
491
- return isCharItem(items, index, ".") && isCharItem(items, index + 1, ".") && isCharItem(items, index + 2, ".");
620
+ if (!isCharItem(items, index, "."))
621
+ return false;
622
+ const second = nextEllipsisDotItem(items, index);
623
+ return second >= 0 && nextEllipsisDotItem(items, second) >= 0;
492
624
  }
493
- /** Opening double quote by following context (the v4 beginningDouble arms). */
625
+ /**
626
+ * `!=` (not followed by another `=`) at `index`: the math-symbol pass folds it
627
+ * to `≠`, which is not an ending character, so ending-gated rules must read
628
+ * the unfolded pair the same way. Mirrors the fold's boundary tolerance: one
629
+ * boundary at the `!`/`=` junction, and the blocking third `=` is seen
630
+ * through at most one boundary.
631
+ */
632
+ function foldsToNotEqual(items, index) {
633
+ if (!isCharItem(items, index, "!"))
634
+ return false;
635
+ const eq = nextIndex(items, index, 1);
636
+ if (!isCharItem(items, eq, "="))
637
+ return false;
638
+ return !isCharItem(items, nextIndex(items, eq, 1), "=");
639
+ }
640
+ /** Opening double quote by following context. */
494
641
  function classifyOpeningDoubles(items) {
495
642
  for (let i = 0; i < items.length; i++) {
496
643
  const item = items[i];
@@ -498,7 +645,7 @@ function classifyOpeningDoubles(items) {
498
645
  continue;
499
646
  if (!doubleOpenerPrefixOk(items, i))
500
647
  continue;
501
- // Arm 1: a boundary then space/period/comma (the sentinel-start quirk).
648
+ // Arm 1: a boundary then space/period/comma (the boundary-start quirk).
502
649
  const next = items[i + 1];
503
650
  let opens = false;
504
651
  if (next?.boundary === true) {
@@ -511,13 +658,31 @@ function classifyOpeningDoubles(items) {
511
658
  const j = nextIndex(items, i, 1);
512
659
  if (j < items.length && !items[j].boundary) {
513
660
  const ch = items[j].ch;
514
- opens = isEllipsisDots(items, j) || (!SPACE_RE.test(ch) && !DOUBLE_OPEN_ENDING_SET.has(ch));
661
+ opens = isEllipsisDots(items, j) || foldsToNotEqual(items, j)
662
+ || (!SPACE_RE.test(ch) && !DOUBLE_OPEN_ENDING_SET.has(ch));
515
663
  }
516
664
  }
517
665
  if (opens)
518
666
  item.ch = LEFT_DOUBLE_QUOTE;
519
667
  }
520
668
  }
669
+ /**
670
+ * Opener-position prefix for the quoted-punctuation rule. Unlike
671
+ * {@link doubleOpenerPrefixOk}, boundaries are transparent here: a quote that
672
+ * merely starts a node (e.g. `"` directly after `</a>`) is not in opener
673
+ * position unless the character on the far side of the boundary run is itself
674
+ * opener context — matching how the rule classifies the equivalent plain
675
+ * string.
676
+ */
677
+ function quotedPunctuationPrefixOk(items, index) {
678
+ let j = index - 1;
679
+ while (j >= 0 && items[j].boundary)
680
+ j--;
681
+ if (j < 0)
682
+ return true;
683
+ const ch = items[j].ch;
684
+ return SPACE_RE.test(ch) || DOUBLE_OPEN_BEFORE_SET.has(ch);
685
+ }
521
686
  /**
522
687
  * Quoted-punctuation openers like `"?"`: an opener-prefixed straight double
523
688
  * quote with a closing straight double quote anywhere ahead (at least one
@@ -531,7 +696,7 @@ function classifyQuotedPunctuationOpeners(items) {
531
696
  }
532
697
  for (let k = 0; k < straightIndices.length; k++) {
533
698
  const i = straightIndices[k];
534
- if (!doubleOpenerPrefixOk(items, i))
699
+ if (!quotedPunctuationPrefixOk(items, i))
535
700
  continue;
536
701
  const closer = straightIndices[k + 1];
537
702
  if (closer !== undefined && closer >= i + 2) {
@@ -557,19 +722,28 @@ function isDoubleCloseAfterChar(ch) {
557
722
  /**
558
723
  * Closing double quote by ending context: a non-space/non-paren character (or
559
724
  * 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.
725
+ * after it. A consumed span can never contain the next match's before-character
726
+ * (the quote only closes when followed by a non-quote), so no overlap tracking
727
+ * is needed.
728
+ *
729
+ * French: a plain space directly after an opener merges into the opener's
730
+ * rendered NNBSP padding once spaces collapse, so a re-run reads the opener
731
+ * itself in the lookbehind slot — read it that way now.
563
732
  */
564
- function classifyClosingDoubles(items) {
733
+ function classifyClosingDoubles(items, style) {
565
734
  for (let i = 1; i < items.length; i++) {
566
735
  const item = items[i];
567
736
  if (item.boundary || item.ch !== '"')
568
737
  continue;
569
738
  const before = items[i - 1];
570
- if (!before.boundary && (SPACE_RE.test(before.ch) || before.ch === "("))
571
- continue;
739
+ if (!before.boundary && (SPACE_RE.test(before.ch) || before.ch === "(")) {
740
+ const absorbedIntoOpener = style === "french" && before.ch === " " && isCharItem(items, i - 2, LEFT_DOUBLE_QUOTE);
741
+ if (!absorbedIntoOpener)
742
+ continue;
743
+ }
572
744
  const next = items[i + 1];
745
+ if (foldsToNotEqual(items, i + 1))
746
+ continue;
573
747
  if (next === undefined || next.boundary || isDoubleCloseAfterChar(next.ch)) {
574
748
  item.ch = RIGHT_DOUBLE_QUOTE;
575
749
  }
@@ -597,12 +771,12 @@ function classifySinglesBeforeClosingDoubles(items) {
597
771
  }
598
772
  }
599
773
  }
600
- function classifyDoubles(items) {
774
+ function classifyDoubles(items, style) {
601
775
  classifyEmptyDoublePairs(items);
602
776
  classifyOpeningDoubles(items);
603
777
  classifyQuotedPunctuationOpeners(items);
604
778
  classifyBraceOpeners(items);
605
- classifyClosingDoubles(items);
779
+ classifyClosingDoubles(items, style);
606
780
  classifyEndOfInputClosers(items);
607
781
  classifySinglesBeforeClosingDoubles(items);
608
782
  }
@@ -614,15 +788,31 @@ function classifyDoubles(items) {
614
788
  * most one boundary between consecutive closers. Returns the item index just
615
789
  * past the run, or -1 when there is no run.
616
790
  */
617
- function scanClosingRun(items, start) {
618
- if (start >= items.length || items[start].boundary || !CLOSING_SET.has(items[start].ch))
791
+ /**
792
+ * True iff the item at `index` counts as a closing-run member for placement.
793
+ * An APOSTROPHE directly after an `s` is excluded even when the placement
794
+ * alphabet includes apostrophes: the re-run relabels that U+2019 an
795
+ * apostrophe again (the plural-possessive rule keys on the `s`), so
796
+ * punctuation must not move around it ("the boys'." keeps its period
797
+ * outside).
798
+ */
799
+ function closingRunMemberAt(items, index, closingSet) {
800
+ if (index >= items.length || items[index].boundary || !closingSet.has(items[index].ch))
801
+ return false;
802
+ if (items[index].ch !== MODIFIER_LETTER_APOSTROPHE)
803
+ return true;
804
+ const prev = prevIndex(items, index, 1);
805
+ return !isCharItem(items, prev, "s") && !isCharItem(items, prev, "S");
806
+ }
807
+ function scanClosingRun(items, start, closingSet) {
808
+ if (start >= items.length || items[start].boundary || !closingRunMemberAt(items, start, closingSet))
619
809
  return -1;
620
810
  let runEnd = start + 1;
621
811
  for (;;) {
622
812
  let next = runEnd;
623
813
  if (next < items.length && items[next].boundary)
624
814
  next++;
625
- if (next >= items.length || items[next].boundary || !CLOSING_SET.has(items[next].ch))
815
+ if (next >= items.length || items[next].boundary || !closingRunMemberAt(items, next, closingSet))
626
816
  break;
627
817
  runEnd = next + 1;
628
818
  }
@@ -671,37 +861,224 @@ function applyRelocations(order, moves) {
671
861
  * American style: a period or comma directly after a closing-quote run moves
672
862
  * to just before the run, unless the character before the run is terminal
673
863
  * 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.
864
+ * on every failed position, so a blocked outer run is retried one level in.
676
865
  */
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.
866
+ function movePunctuationInside(order, isMovable, closingSet) {
867
+ // Decisions are taken against the pass-input order (the scan reads its input
868
+ // order while building the output), then applied together.
680
869
  const moves = [];
870
+ // Earliest unresolved straight single quote; remote closer-ahead scans start
871
+ // there, so moves past it are gated below.
872
+ let firstStraightSingle = -1;
873
+ for (let i = 0; i < order.length; i++) {
874
+ if (!order[i].boundary && order[i].ch === "'") {
875
+ firstStraightSingle = i;
876
+ break;
877
+ }
878
+ }
879
+ const movedFrom = new Set();
681
880
  let position = 0;
682
881
  while (position < order.length) {
683
882
  const runStart = order[position].boundary ? position + 1 : position;
684
- const runEnd = scanClosingRun(order, runStart);
883
+ const runEnd = scanClosingRun(order, runStart, closingSet);
685
884
  if (runEnd === -1) {
686
885
  position++;
687
886
  continue;
688
887
  }
689
888
  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)) {
889
+ // The terminal lookbehind skips punctuation already relocated by this
890
+ // pass: those items no longer precede the run once the moves apply, and
891
+ // the re-run (which sees them relocated) must reach the same decision.
892
+ // One boundary is transparent: a ligature fold (`?` + `!` → `⁈`) pulls
893
+ // the terminal char across the node edge, and the re-run reads it there.
894
+ let beforeIndex = position - 1;
895
+ while (beforeIndex >= 0 && movedFrom.has(beforeIndex))
896
+ beforeIndex--;
897
+ if (beforeIndex >= 0 && order[beforeIndex].boundary && !(beforeIndex >= 1 && order[beforeIndex - 1].boundary)) {
898
+ // No moved-item skip here: a moved punct is always run-adjacent, and
899
+ // the run-skip above resolves such runs at the boundary item itself.
900
+ beforeIndex--;
901
+ }
902
+ const precededByTerminal = beforeIndex >= 0 && !order[beforeIndex].boundary && TERMINAL_SET.has(order[beforeIndex].ch);
903
+ // Gates that read only the run's end hold for every sub-run too, so on
904
+ // those failures the scan skips past the whole run — rescanning each
905
+ // suffix would be quadratic on long closer runs. The position-dependent
906
+ // lookbehinds (terminal char, A-side wall) instead retry one level in,
907
+ // where the preceding item is a closer and can no longer block.
908
+ if (punctIndex >= order.length || order[punctIndex].boundary || !isMovable(order, punctIndex)) {
909
+ position = Math.max(runEnd, position + 1);
910
+ continue;
911
+ }
912
+ if (precededByTerminal || moveWallAt(order, prevIndex(order, runStart, 1))) {
913
+ position++;
914
+ continue;
915
+ }
916
+ if (moveWallAt(order, nextIndex(order, punctIndex, 1))) {
917
+ position = Math.max(runEnd, position + 1);
918
+ continue;
919
+ }
920
+ if (insideMoveFlipsContext(order, runStart, punctIndex)) {
921
+ position++;
922
+ continue;
923
+ }
924
+ // Moving the punctuation inside strips the run of its ending context: an
925
+ // unresolved straight single quote earlier in the text whose closer-ahead
926
+ // scan reads a single closer in this run would stop seeing it as a closer
927
+ // on the next run (`'5'.0` → `'5.’0`) unless the post-move follower is an
928
+ // ending context itself. Leave the punctuation in place; the next run
929
+ // reaches the same state and blocks again.
930
+ if (firstStraightSingle !== -1 && firstStraightSingle < runStart
931
+ && runContainsSingleCloser(order, runStart, runEnd)
932
+ && !endingContextAfterMove(order, runEnd, punctIndex)) {
692
933
  position++;
693
934
  continue;
694
935
  }
695
936
  moves.push({ from: punctIndex, to: runStart, side: "before", anchorOffset: order[runStart].start, anchorBind: "right" });
937
+ movedFrom.add(punctIndex);
696
938
  position = punctIndex + 1;
697
939
  }
698
940
  return applyRelocations(order, moves);
699
941
  }
942
+ /**
943
+ * Mirror of {@link nextEllipsisDotItem} walking backwards: the dot reachable
944
+ * across one inter-dot gap before (virtual) position `index`. -1 when none.
945
+ */
946
+ function prevEllipsisDotItem(order, index) {
947
+ const j = index - 1;
948
+ if (j >= 0 && order[j].boundary) {
949
+ return isCharItem(order, j - 1, ".") ? j - 1 : -1;
950
+ }
951
+ if (j < 0)
952
+ return -1;
953
+ if (order[j].ch === ".")
954
+ return j;
955
+ if (ELLIPSIS_GAP_SPACE_RE.test(order[j].ch) && isCharItem(order, j - 1, "."))
956
+ return j - 1;
957
+ return -1;
958
+ }
959
+ /**
960
+ * Movable punctuation plus the ellipsis for the outside mover's chain guards:
961
+ * `…` folds from the dots the guards block on, so it blocks the same way.
962
+ */
963
+ const CHAIN_PUNCT_SET = new Set([".", ",", ELLIPSIS]);
964
+ /**
965
+ * True iff moving the period at `punctIndex` to just before `order[runStart]`
966
+ * would make the re-run read its surroundings differently:
967
+ *
968
+ * - `..`/`. .` + `.` at the landing: the landed period completes a foldable
969
+ * dot triple, which the double-opener rule reads as `…`;
970
+ * - `.` + digit at the removal site: a period directly before a digit blocks
971
+ * the dash pass's number-range detection, so vacating the slot converts a
972
+ * range this run left alone.
973
+ */
974
+ function insideMoveFlipsContext(order, runStart, punctIndex) {
975
+ if (order[punctIndex].ch === ".") {
976
+ const second = prevEllipsisDotItem(order, runStart);
977
+ if (second >= 0 && prevEllipsisDotItem(order, second) >= 0)
978
+ return true;
979
+ const afterPunct = nextIndex(order, punctIndex, 1);
980
+ if (afterPunct < order.length && !order[afterPunct].boundary && DIGIT_RE.test(order[afterPunct].ch))
981
+ return true;
982
+ }
983
+ return false;
984
+ }
985
+ /** True iff the run [start, end) contains a single-quote closer or apostrophe. */
986
+ function runContainsSingleCloser(order, start, end) {
987
+ for (let i = start; i < end; i++) {
988
+ const ch = order[i].ch;
989
+ if (ch === RIGHT_SINGLE_QUOTE || ch === MODIFIER_LETTER_APOSTROPHE)
990
+ return true;
991
+ }
992
+ return false;
993
+ }
994
+ /**
995
+ * Mirror of {@link singleEndingAfter} at the run end as the post-move item
996
+ * sequence reads it: the vacating punctuation leaves its surrounding boundary
997
+ * items behind (an emptied node stacks them), and they all count against the
998
+ * lookahead's one-boundary budget.
999
+ */
1000
+ function endingContextAfterMove(order, runEnd, punctIndex) {
1001
+ let boundaries = 0;
1002
+ for (let j = runEnd; j < order.length; j++) {
1003
+ if (j === punctIndex)
1004
+ continue;
1005
+ if (order[j].boundary) {
1006
+ boundaries++;
1007
+ if (boundaries > 1)
1008
+ return false;
1009
+ continue;
1010
+ }
1011
+ return endingContextAt(order, j);
1012
+ }
1013
+ return true;
1014
+ }
1015
+ /**
1016
+ * Mirror of {@link singleEndingAfter}'s target test at an already-resolved,
1017
+ * in-range index (the sole caller bounds it below `order.length`).
1018
+ */
1019
+ function endingContextAt(order, index) {
1020
+ const item = order[index];
1021
+ if (foldsToNotEqual(order, index))
1022
+ return false;
1023
+ return !item.boundary && isSingleEndingChar(item.ch);
1024
+ }
1025
+ /**
1026
+ * Characters (or a node boundary) that satisfy an opener-prefix rule. Openers
1027
+ * classify before closers, so a closing quote re-derived with one of these in
1028
+ * its lookbehind slot flips to an opener on the next run. The union of
1029
+ * {@link SINGLE_OPEN_BEFORE_SET} and {@link DOUBLE_OPEN_BEFORE_SET}.
1030
+ */
1031
+ const OPENER_PREFIX_CHARS = new Set([...SINGLE_OPEN_BEFORE_SET, ...DOUBLE_OPEN_BEFORE_SET]);
1032
+ /**
1033
+ * True iff a closing quote would still re-derive as a closer with `prev` (the
1034
+ * item before the punctuation being moved out) directly before it. German
1035
+ * re-derives rendered closers from scratch on the next run (the orphan
1036
+ * fallback): the slot must pass the closer lookbehinds (non-space, non-`(`,
1037
+ * non-quote) and fail the opener prefixes (which preempt the closer rules); a
1038
+ * boundary satisfies the opener prefixes, so it blocks too. A blocked move
1039
+ * simply leaves the punctuation in place — a state the next run reproduces.
1040
+ */
1041
+ function rederivesAsCloserAfter(prev) {
1042
+ if (prev === undefined || prev.boundary)
1043
+ return false;
1044
+ return !SPACE_RE.test(prev.ch) && prev.ch !== "'" && !OPENER_PREFIX_CHARS.has(prev.ch);
1045
+ }
1046
+ /**
1047
+ * Characters a later run reads context-sensitively: straight quotes the
1048
+ * classifier leaves for re-reading, and dashes whose conversion rules key on
1049
+ * the adjacent character (`’-2` folds to a minus sign where `.-2` does not).
1050
+ * The folded math operators stand in for the two-character operators they
1051
+ * fold from: a hyphen wall inside `+-` must keep blocking once a re-run sees
1052
+ * `±`.
1053
+ */
1054
+ const MOVE_WALL_CHARS = new Set([
1055
+ "'", '"', "-", EN_DASH, EM_DASH, MINUS,
1056
+ APPROXIMATE, GREATER_EQUAL, LESS_EQUAL, NOT_EQUAL, PLUS_MINUS,
1057
+ ]);
1058
+ /**
1059
+ * True iff the item at `index` (one boundary transparent) is a character a
1060
+ * later run reads context-sensitively. Relocating punctuation past such an
1061
+ * item would change the context that run sees, so both movers treat it as a
1062
+ * wall: a blocked move leaves the punctuation where the next run finds — and
1063
+ * blocks — it again.
1064
+ */
1065
+ function moveWallAt(items, index) {
1066
+ if (index < 0 || index >= items.length)
1067
+ return false;
1068
+ const item = items[index];
1069
+ return !item.boundary && MOVE_WALL_CHARS.has(item.ch);
1070
+ }
700
1071
  /**
701
1072
  * British/German/French: a period or comma directly before a closing-quote
702
1073
  * run moves past the whole run. No terminal-punctuation guard.
1074
+ *
1075
+ * `spaceBeforePaddedCloser`: French renders CLOSE_DOUBLE with leading NNBSP
1076
+ * padding, and a plain space before the run merges into that padding once
1077
+ * spaces collapse — a re-run reads the punctuation directly adjacent to the
1078
+ * run. Treat one such space as transparent so the first pass moves the
1079
+ * punctuation the way the re-run would.
703
1080
  */
704
- function movePunctuationOutside(order, punctChar) {
1081
+ function movePunctuationOutside(order, punctChar, closingSet, guardRederivation, spaceBeforePaddedCloser) {
705
1082
  const moves = [];
706
1083
  let position = 0;
707
1084
  while (position < order.length) {
@@ -710,6 +1087,41 @@ function movePunctuationOutside(order, punctChar) {
710
1087
  position++;
711
1088
  continue;
712
1089
  }
1090
+ if (guardRederivation && !rederivesAsCloserAfter(position > 0 ? order[position - 1] : undefined)) {
1091
+ position++;
1092
+ continue;
1093
+ }
1094
+ // German re-derives single closers from scratch. Moving the punctuation
1095
+ // past a run whose first closer is a single quote changes what the item
1096
+ // directly before the punctuation reads on the re-run:
1097
+ // - a single closer or apostrophe there loses the ending context its
1098
+ // closer rule needs (the re-derived run closer is a straight `'`, not
1099
+ // an ending character), unwinding to a straight quote;
1100
+ // - an `s` there gains the plural-possessive reading (`s` directly
1101
+ // before the closer), relabeling the closer an apostrophe.
1102
+ // Leave the punctuation in place; the next run blocks here again.
1103
+ const prevCh = position > 0 && !order[position - 1].boundary ? order[position - 1].ch : "";
1104
+ if (guardRederivation
1105
+ && (prevCh === RIGHT_SINGLE_QUOTE || prevCh === MODIFIER_LETTER_APOSTROPHE || prevCh === "s" || prevCh === "S")
1106
+ && isCharItem(order, nextIndex(order, position, 1), RIGHT_SINGLE_QUOTE)) {
1107
+ position++;
1108
+ continue;
1109
+ }
1110
+ if (moveWallAt(order, prevIndex(order, position, 1))) {
1111
+ position++;
1112
+ continue;
1113
+ }
1114
+ // Another movable punctuation char directly before this one would end up
1115
+ // group-adjacent after the move and migrate on the next run (one char per
1116
+ // run, a cascade); leave chains of movable punctuation in place. The
1117
+ // ellipsis belongs to the chain alphabet: it folds from the dots this
1118
+ // guard blocks on, so the folded glyph must block the same way ("....”"
1119
+ // re-reads as "….”").
1120
+ const prevPunctIndex = prevIndex(order, position, 1);
1121
+ if (prevPunctIndex >= 0 && CHAIN_PUNCT_SET.has(order[prevPunctIndex].ch)) {
1122
+ position++;
1123
+ continue;
1124
+ }
713
1125
  // Quotes group: one or more closers, each preceded by at most one boundary.
714
1126
  let last = -1;
715
1127
  let j = position + 1;
@@ -717,12 +1129,31 @@ function movePunctuationOutside(order, punctChar) {
717
1129
  let k = j;
718
1130
  if (k < order.length && order[k].boundary)
719
1131
  k++;
720
- if (k >= order.length || order[k].boundary || !CLOSING_SET.has(order[k].ch))
1132
+ // Any collapsible space char qualifies, not just a plain space: the
1133
+ // collapse pass merges tabs and NBSPs into the closer's NNBSP padding
1134
+ // the same way.
1135
+ if (spaceBeforePaddedCloser && k < order.length && !order[k].boundary
1136
+ && order[k].ch.length === 1 && COLLAPSIBLE_SPACE_RE.test(order[k].ch)
1137
+ && isCharItem(order, k + 1, RIGHT_DOUBLE_QUOTE)) {
1138
+ k++;
1139
+ }
1140
+ if (k >= order.length || order[k].boundary || !closingRunMemberAt(order, k, closingSet))
721
1141
  break;
722
1142
  last = k;
723
1143
  j = k + 1;
724
1144
  }
725
- if (last === -1) {
1145
+ const afterRunIndex = nextIndex(order, last === -1 ? position : last, 1);
1146
+ if (last === -1 || moveWallAt(order, afterRunIndex)) {
1147
+ position++;
1148
+ continue;
1149
+ }
1150
+ // The mirror of the chain guard above: a movable punctuation char directly
1151
+ // after the run is where this one would land (`.'.'` interleaves two
1152
+ // period+closer pairs), re-creating a movable configuration the next run
1153
+ // would move again; leave the punctuation in place. As above, the ellipsis
1154
+ // blocks like the dots it folds from.
1155
+ if (afterRunIndex < order.length && !order[afterRunIndex].boundary
1156
+ && CHAIN_PUNCT_SET.has(order[afterRunIndex].ch)) {
726
1157
  position++;
727
1158
  continue;
728
1159
  }
@@ -731,15 +1162,68 @@ function movePunctuationOutside(order, punctChar) {
731
1162
  }
732
1163
  return applyRelocations(order, moves);
733
1164
  }
734
- function applyPunctuationPlacement(items, style) {
1165
+ function applyPunctuationPlacement(items, style, apostrophe) {
1166
+ const closingSet = style !== "german" && apostrophe === RIGHT_SINGLE_QUOTE
1167
+ ? CLOSING_OR_APOSTROPHE_SET
1168
+ : CLOSING_SET;
735
1169
  if (style === "american") {
736
- return movePunctuationInside(movePunctuationInside(items, isMovablePeriodAt), isMovableCommaAt);
1170
+ // Each mover's gates read positions the other mover may vacate (a comma
1171
+ // moving inside exposes a period's run to the terminal lookbehind), so
1172
+ // one round can leave exactly the state a re-run would move again.
1173
+ // Iterate to fixpoint; every move shifts punctuation left past a closer,
1174
+ // so the rounds terminate. `movePunctuationInside` returns its input
1175
+ // array unchanged when it makes no move.
1176
+ let order = items;
1177
+ for (;;) {
1178
+ const next = movePunctuationInside(movePunctuationInside(order, isMovablePeriodAt, closingSet), isMovableCommaAt, closingSet);
1179
+ if (next === order)
1180
+ return order;
1181
+ order = next;
1182
+ }
737
1183
  }
738
- return movePunctuationOutside(movePunctuationOutside(items, "."), ",");
1184
+ const guardRederivation = style === "german";
1185
+ const spaceBeforePaddedCloser = style === "french";
1186
+ return movePunctuationOutside(movePunctuationOutside(items, ".", closingSet, guardRederivation, spaceBeforePaddedCloser), ",", closingSet, guardRederivation, spaceBeforePaddedCloser);
739
1187
  }
740
1188
  // ---------------------------------------------------------------------------
741
1189
  // Rendering
742
1190
  // ---------------------------------------------------------------------------
1191
+ /** A one-char unmoved NBSP/NNBSP item textually adjacent at `offset`. */
1192
+ function isPaddingSpaceItem(item) {
1193
+ return item !== undefined && !item.boundary && !item.moved
1194
+ && item.end - item.start === 1 && (item.ch === NBSP || item.ch === NNBSP);
1195
+ }
1196
+ /**
1197
+ * French renders double-quote glyphs with NNBSP padding. A source NBSP/NNBSP
1198
+ * directly adjacent to the glyph would collapse with that padding into one
1199
+ * space whose type wins by priority (NBSP beats NNBSP), which the next run
1200
+ * absorbs as the glyph's padding and re-renders NNBSP — an oscillation.
1201
+ * Absorb the adjacent space into the (single-char, i.e. unpadded) glyph item
1202
+ * now so its render replaces both with the padded form.
1203
+ */
1204
+ function absorbGuillemetPadding(items) {
1205
+ for (let i = 0; i < items.length; i++) {
1206
+ const item = items[i];
1207
+ if (item.boundary || item.moved || item.end - item.start !== 1)
1208
+ continue;
1209
+ if (item.ch === RIGHT_DOUBLE_QUOTE) {
1210
+ const prev = items[i - 1];
1211
+ if (isPaddingSpaceItem(prev) && prev.end === item.start) {
1212
+ item.start = prev.start;
1213
+ prev.end = prev.start;
1214
+ prev.ch = "";
1215
+ }
1216
+ }
1217
+ else if (item.ch === LEFT_DOUBLE_QUOTE) {
1218
+ const next = items[i + 1];
1219
+ if (isPaddingSpaceItem(next) && next.start === item.end) {
1220
+ item.end = next.end;
1221
+ next.start = next.end;
1222
+ next.ch = "";
1223
+ }
1224
+ }
1225
+ }
1226
+ }
743
1227
  /** Per-locale glyph table. `apostrophe` is U+2019 for niceQuotes, U+02BC for classifyApostrophes. */
744
1228
  function renderTable(style, apostrophe) {
745
1229
  const base = {
@@ -779,6 +1263,11 @@ function queueRenderEdits(view, items, table) {
779
1263
  view.replace(item.start, item.end, "");
780
1264
  const key = `${item.anchorOffset}|${item.anchorBind}`;
781
1265
  const existing = insertions.get(key);
1266
+ /* istanbul ignore if -- defensive: the placement guards (terminal
1267
+ lookbehind, movable-punctuation chains) block any second relocation
1268
+ next to an anchor another item already claimed, so merged insertions
1269
+ are unreachable; merging keeps commit() from rejecting duplicate pure
1270
+ insertions should a future rule re-open the path. */
782
1271
  if (existing) {
783
1272
  existing.text += rendered;
784
1273
  }
@@ -804,18 +1293,19 @@ function queueRenderEdits(view, items, table) {
804
1293
  // ---------------------------------------------------------------------------
805
1294
  /**
806
1295
  * The quote/apostrophe role classifier: one boundary-aware scan over the
807
- * clean text replaces the v4 ordered regex pipeline.
808
- */
809
- export function classifyAndRenderQuotes(markedText, separator, style, apostrophe) {
810
- if (!QUOTE_CANDIDATE_RE.test(markedText))
811
- return markedText;
812
- return withProseView(markedText, separator, (view) => {
813
- const items = buildItems(view, style);
814
- classifySingles(items);
815
- classifyDoubles(items);
816
- const placed = applyPunctuationPlacement(items, style);
817
- queueRenderEdits(view, placed, renderTable(style, apostrophe));
818
- });
1296
+ * view's text. Commits its edits.
1297
+ */
1298
+ export function classifyAndRenderQuotes(view, style, apostrophe) {
1299
+ if (!QUOTE_CANDIDATE_RE.test(view.text))
1300
+ return;
1301
+ const items = buildItems(view, style);
1302
+ classifySingles(items, style);
1303
+ classifyDoubles(items, style);
1304
+ const placed = applyPunctuationPlacement(items, style, apostrophe);
1305
+ if (style === "french")
1306
+ absorbGuillemetPadding(placed);
1307
+ queueRenderEdits(view, placed, renderTable(style, apostrophe));
1308
+ view.commit();
819
1309
  }
820
1310
  // ---------------------------------------------------------------------------
821
1311
  // Prime marks
@@ -827,6 +1317,21 @@ function feetInchesBefore(items, digitIndex) {
827
1317
  j--;
828
1318
  return j >= 0 && items[j].ch === PRIME;
829
1319
  }
1320
+ /**
1321
+ * Mirrors the closer rules the quote pass applies to a digit-preceded straight
1322
+ * quote: singles close (or turn possessive) per the single ending lookahead;
1323
+ * doubles close before an ending character, a boundary, or end of input. The
1324
+ * lookbehind sides of those rules are always satisfied here because the
1325
+ * candidate has a digit (or a boundary) directly before it.
1326
+ */
1327
+ function quotePassClosesCandidate(items, index, quote) {
1328
+ if (quote === "'")
1329
+ return singleEndingAfterOptionalS(items, index);
1330
+ if (foldsToNotEqual(items, index + 1))
1331
+ return false;
1332
+ const next = items[index + 1];
1333
+ return next === undefined || next.boundary || isDoubleCloseAfterChar(next.ch);
1334
+ }
830
1335
  /**
831
1336
  * The prime/quote disambiguation: a quote candidate after a digit converts to
832
1337
  * a prime only when the running quote balance is open-free; contractions are
@@ -855,6 +1360,15 @@ function convertPrimes(items) {
855
1360
  item.ch = primeChar;
856
1361
  continue;
857
1362
  }
1363
+ // Blocking a candidate is only stable when the quote pass curls it
1364
+ // into a closer: a candidate it cannot close stays straight while
1365
+ // the opener that supplied the balance curls, so the next run would
1366
+ // see an open-free balance and fold the prime then. Fold it now to
1367
+ // keep the transform a fixed point.
1368
+ if (!quotePassClosesCandidate(items, i, quote)) {
1369
+ item.ch = primeChar;
1370
+ continue;
1371
+ }
858
1372
  balance--;
859
1373
  continue;
860
1374
  }
@@ -870,19 +1384,18 @@ function convertPrimes(items) {
870
1384
  }
871
1385
  }
872
1386
  }
873
- /** primeMarks engine: converts `5'10"` to `5′10″` with quote-balance guards. */
874
- export function convertPrimeMarks(markedText, separator) {
875
- if (!markedText.includes("'") && !markedText.includes('"'))
876
- return markedText;
877
- return withProseView(markedText, separator, (view) => {
878
- const text = view.text;
879
- const items = buildItems(view, "american");
880
- convertPrimes(items);
881
- for (const item of items) {
882
- if (!item.boundary && item.ch !== text[item.start]) {
883
- view.replace(item.start, item.end, item.ch);
884
- }
1387
+ /** primeMarks engine: converts `5'10"` to `5′10″` with quote-balance guards. Commits its edits. */
1388
+ export function convertPrimeMarks(view) {
1389
+ const text = view.text;
1390
+ if (!text.includes("'") && !text.includes('"'))
1391
+ return;
1392
+ const items = buildItems(view, "american");
1393
+ convertPrimes(items);
1394
+ for (const item of items) {
1395
+ if (!item.boundary && item.ch !== text[item.start]) {
1396
+ view.replace(item.start, item.end, item.ch);
885
1397
  }
886
- });
1398
+ }
1399
+ view.commit();
887
1400
  }
888
1401
  //# sourceMappingURL=quote-classifier.js.map