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