punctilio 5.5.2 → 5.5.4

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.
@@ -1,4 +1,4 @@
1
- import { CLOSING_BRACKETS, FOLDED_WORD_CHARS, LATIN_LETTER_RE, OPENING_BRACKETS, QUOTE_GLYPHS, SPACE_CHAR_RE, TERMINAL_PUNCTUATION, UNICODE_SYMBOLS, WORD_RE } from "./constants.js"
1
+ import { CLOSING_BRACKETS, FOLDED_TERMINALS, FOLDED_WORD_CHARS, FRACTION_GLYPHS, LATIN_LETTER_RE, OPENING_BRACKETS, QUOTE_GLYPHS, SPACE_CHAR_RE, TERMINAL_PUNCTUATION, UNICODE_SYMBOLS, WORD_RE } from "./constants.js"
2
2
  import type { ProseView } from "./prose-view.js"
3
3
 
4
4
  const {
@@ -24,8 +24,6 @@ const {
24
24
  DOUBLE_QUESTION,
25
25
  QUESTION_EXCLAMATION,
26
26
  EXCLAMATION_QUESTION,
27
- DOUBLE_EXCLAMATION,
28
- INTERROBANG,
29
27
  APPROXIMATE,
30
28
  GREATER_EQUAL,
31
29
  LESS_EQUAL,
@@ -33,14 +31,6 @@ const {
33
31
  PLUS_MINUS,
34
32
  } = UNICODE_SYMBOLS
35
33
 
36
- /**
37
- * Glyphs the symbol passes fold `?`/`!` runs and `...` into. The quote rules
38
- * run before those passes, so every ending-context set that contains the
39
- * source punctuation must contain the folded glyph too — otherwise a decision
40
- * gated on `?!` flips once a re-run sees `⁈`.
41
- */
42
- const FOLDED_TERMINALS = [DOUBLE_QUESTION, QUESTION_EXCLAMATION, EXCLAMATION_QUESTION, DOUBLE_EXCLAMATION, INTERROBANG] as const
43
-
44
34
  export const PUNCTUATION_STYLES = ["american", "british", "german", "french", "none"] as const
45
35
  export type PunctuationStyle = (typeof PUNCTUATION_STYLES)[number]
46
36
 
@@ -106,8 +96,9 @@ interface Item {
106
96
  */
107
97
  frozenCloser?: boolean
108
98
  /**
109
- * Set on a German item whose curly source glyph was re-derived to a straight
110
- * candidate by position. If classification assigns it no role, it renders as
99
+ * Set on a German item whose curly glyph was re-derived to a straight
100
+ * candidate by position: the source glyph, or the one the previous round's
101
+ * rendering gives it. If classification assigns it no role, it renders as
111
102
  * this glyph: the style never downgrades curly text to straight.
112
103
  */
113
104
  curlySource?: string
@@ -117,6 +108,7 @@ interface Item {
117
108
  }
118
109
 
119
110
  const SPACE_RE = /\s/
111
+ const FRACTION_GLYPH_SET: ReadonlySet<string> = new Set(FRACTION_GLYPHS)
120
112
  const DIGIT_RE = /\d/
121
113
 
122
114
  /** Fast path: text without any quote-like character is returned untouched. */
@@ -185,7 +177,9 @@ function makeItemTester(test: (ch: string) => boolean): (items: Item[], index: n
185
177
 
186
178
  const isLetterItem = makeItemTester((ch) => LATIN_LETTER_RE.test(ch))
187
179
  const isDigitItem = makeItemTester((ch) => DIGIT_RE.test(ch))
188
- const isWordItem = makeItemTester((ch) => WORD_RE.test(ch) || FOLDED_WORD_CHARS.has(ch))
180
+ /** Word test that reads a vulgar fraction glyph as the digit it folds from
181
+ * (`7/8'B` and `⅞'B` must agree on the leading-apostrophe gate). */
182
+ const isWordItem = makeItemTester((ch) => WORD_RE.test(ch) || FRACTION_GLYPH_SET.has(ch) || FOLDED_WORD_CHARS.has(ch))
189
183
  const isLetterOrDigitItem = makeItemTester((ch) => LATIN_LETTER_RE.test(ch) || DIGIT_RE.test(ch) || FOLDED_WORD_CHARS.has(ch))
190
184
  /** Letter test that treats folded word glyphs as the letters they fold from
191
185
  * (`X` → `×`, `st` → `ˢᵗ`); see {@link FOLDED_WORD_CHARS}. */
@@ -197,6 +191,23 @@ function isCharItem(items: Item[], index: number, ch: string): boolean {
197
191
  return !item.boundary && item.ch === ch
198
192
  }
199
193
 
194
+ /**
195
+ * French only: whether the run of space items from `start` (walking by `step`)
196
+ * ends at a `glyph` item and collapses to one space once an unpadded glyph's
197
+ * padding absorbs the NBSP/NNBSP beside it. A re-run reads that space between
198
+ * the straight quote and the padded glyph, so the first run must too.
199
+ */
200
+ function spaceRunReadsAsSingle(items: Item[], start: number, step: 1 | -1, glyph: string): boolean {
201
+ let index = start
202
+ while (index >= 0 && index < items.length && !items[index].boundary && SPACE_CHAR_RE.test(items[index].ch)) index += step
203
+ const runLength = (index - start) * step
204
+ if (!isCharItem(items, index, glyph)) return false
205
+ // A glyph the source already pads absorbs nothing further.
206
+ const glyphSide = items[index - step].ch
207
+ const absorbs = items[index].end - items[index].start === 1 && (glyphSide === NBSP || glyphSide === NNBSP)
208
+ return runLength === 1 || (runLength === 2 && absorbs)
209
+ }
210
+
200
211
  /**
201
212
  * Index of the previous item, treating up to `maxBoundaries` boundary items as
202
213
  * transparent. Returns -1 at start of text; returns the blocking boundary's
@@ -234,6 +245,50 @@ function makeItem(boundary: boolean, ch: string, start: number, end: number): It
234
245
  return { boundary, ch, start, end, moved: false, anchorOffset: 0, anchorBind: "left" }
235
246
  }
236
247
 
248
+ /** Count of open German low-9 quotes, per kind. */
249
+ interface GermanDepth {
250
+ double: number
251
+ single: number
252
+ }
253
+
254
+ /**
255
+ * The internal-alphabet reading of a glyph in German text, or null when German
256
+ * leaves the glyph alone. Closers are depth-gated: U+201C/U+2018 close only
257
+ * below an open low-9 quote; orphans and the glyphs German does not use fall
258
+ * back to straight-quote candidates re-derived by position.
259
+ */
260
+ function readGermanGlyph(glyph: string, afterDigit: boolean, depth: GermanDepth): string | null {
261
+ if (glyph === DOUBLE_LOW_9_QUOTE) {
262
+ depth.double++
263
+ return LEFT_DOUBLE_QUOTE
264
+ }
265
+ if (glyph === LEFT_DOUBLE_QUOTE) {
266
+ if (depth.double === 0) return '"'
267
+ depth.double--
268
+ return RIGHT_DOUBLE_QUOTE
269
+ }
270
+ if (glyph === SINGLE_LOW_9_QUOTE) {
271
+ depth.single++
272
+ return LEFT_SINGLE_QUOTE
273
+ }
274
+ if (glyph === LEFT_SINGLE_QUOTE) {
275
+ if (depth.single === 0) return "'"
276
+ depth.single--
277
+ return RIGHT_SINGLE_QUOTE
278
+ }
279
+ // Not used in German typography; re-derive by position.
280
+ if (glyph === RIGHT_DOUBLE_QUOTE) return '"'
281
+ if (glyph === RIGHT_SINGLE_QUOTE || glyph === MODIFIER_LETTER_APOSTROPHE) {
282
+ // U+02BC and U+2019 read alike: the next run re-derives the rendered U+2019
283
+ // by position, so re-derive here too. Directly after a digit the mark is a
284
+ // separator (Swiss "5’000"), so keep the apostrophe role: a straight quote
285
+ // there would reach the next run's prime pass ("5′000"), destroying the
286
+ // separator and the fixed point.
287
+ return afterDigit ? MODIFIER_LETTER_APOSTROPHE : "'"
288
+ }
289
+ return null
290
+ }
291
+
237
292
  /**
238
293
  * Builds the working item sequence over the view's clean text, applying the
239
294
  * locale's pre-label rows: German low-9/depth-gated quotes and French
@@ -245,11 +300,7 @@ function buildItems(view: ProseView, style: ActiveQuoteStyle): Item[] {
245
300
  const boundaries = view.boundaries
246
301
  const items: Item[] = []
247
302
  let b = 0
248
- // German closers are depth-gated: U+201C/U+2018 close only below an open
249
- // low-9 quote; orphans fall back to straight-quote candidates re-derived by
250
- // position (the depth-0 branch).
251
- let doubleDepth = 0
252
- let singleDepth = 0
303
+ const germanDepth: GermanDepth = { double: 0, single: 0 }
253
304
 
254
305
  let i = 0
255
306
  while (i <= text.length) {
@@ -280,32 +331,7 @@ function buildItems(view: ProseView, style: ActiveQuoteStyle): Item[] {
280
331
  continue
281
332
  }
282
333
  } else if (style === "german") {
283
- let mapped: string | null = null
284
- if (ch === DOUBLE_LOW_9_QUOTE) {
285
- mapped = LEFT_DOUBLE_QUOTE
286
- doubleDepth++
287
- } else if (ch === LEFT_DOUBLE_QUOTE) {
288
- mapped = doubleDepth > 0 ? RIGHT_DOUBLE_QUOTE : '"'
289
- if (doubleDepth > 0) doubleDepth--
290
- } else if (ch === SINGLE_LOW_9_QUOTE) {
291
- mapped = LEFT_SINGLE_QUOTE
292
- singleDepth++
293
- } else if (ch === LEFT_SINGLE_QUOTE) {
294
- mapped = singleDepth > 0 ? RIGHT_SINGLE_QUOTE : "'"
295
- if (singleDepth > 0) singleDepth--
296
- } else if (ch === RIGHT_DOUBLE_QUOTE) {
297
- // Not used in German typography — re-derive by position.
298
- mapped = '"'
299
- } else if (ch === RIGHT_SINGLE_QUOTE || ch === MODIFIER_LETTER_APOSTROPHE) {
300
- // U+02BC renders as U+2019, which the next run re-derives by position;
301
- // re-derive it the same way now so both runs agree. Directly after a
302
- // digit the mark is a separator (Swiss "5’000"), never a German quote
303
- // glyph: keep the apostrophe role, which renders U+2019 unchanged.
304
- // Re-deriving to a straight quote there hands correct typography to
305
- // the next run's prime pass ("5'000" → "5′000") — a destroyed
306
- // separator and a broken fixed point.
307
- mapped = DIGIT_RE.test(text[i - 1] ?? "") ? MODIFIER_LETTER_APOSTROPHE : "'"
308
- }
334
+ const mapped = readGermanGlyph(ch, DIGIT_RE.test(text[i - 1] ?? ""), germanDepth)
309
335
  if (mapped !== null) {
310
336
  const item = makeItem(false, mapped, i, i + 1)
311
337
  if (mapped === '"' || mapped === "'") item.curlySource = ch
@@ -527,13 +553,16 @@ function classifyLeadingApostrophes(items: Item[]): void {
527
553
  }
528
554
 
529
555
  /** Opener-position straight single quote → OPEN_SINGLE. */
530
- function classifyOpeningSingles(items: Item[]): void {
556
+ function classifyOpeningSingles(items: Item[], style: ActiveQuoteStyle): void {
531
557
  for (let i = 0; i < items.length; i++) {
532
558
  const item = items[i]
533
559
  if (item.boundary || item.ch !== "'") continue
534
- // Ahead: a boundary item satisfies \S (a boundary is non-space).
560
+ // Ahead: a boundary item satisfies \S (a boundary is non-space), and a
561
+ // French space the closer's padding absorbs reads as none on a re-run.
535
562
  const next = items[i + 1]
536
- if (next === undefined || (!next.boundary && SPACE_RE.test(next.ch))) continue
563
+ const spaceAhead = next !== undefined && !next.boundary && SPACE_RE.test(next.ch)
564
+ const blockedBySpace = spaceAhead && !(style === "french" && spaceRunReadsAsSingle(items, i + 1, 1, RIGHT_DOUBLE_QUOTE))
565
+ if (next === undefined || blockedBySpace) continue
537
566
  // Behind: up to one boundary, then line start or an opener-context char.
538
567
  const j = prevIndex(items, i, 1)
539
568
  if (j >= 0) {
@@ -583,7 +612,7 @@ function classifySingles(items: Item[], style: ActiveQuoteStyle): void {
583
612
  classifyPossessivesAndClosers(items)
584
613
  classifyContractions(items)
585
614
  classifyLeadingApostrophes(items)
586
- classifyOpeningSingles(items)
615
+ classifyOpeningSingles(items, style)
587
616
  // The rules above rewrite neighbors the closer/possessive lookbehind keyed
588
617
  // on: in a `''` chain the left quote becomes an apostrophe, which renders
589
618
  // as U+2019 — exactly the glyph a re-run reads in that slot, where the
@@ -771,7 +800,7 @@ function classifyOpeningDoubles(items: Item[], style: ActiveQuoteStyle): void {
771
800
  const j = nextIndex(items, i, 1)
772
801
  if (j < items.length && !items[j].boundary) {
773
802
  const ch = items[j].ch
774
- const absorbedIntoCloser = style === "french" && ch === " " && isCharItem(items, j + 1, RIGHT_DOUBLE_QUOTE)
803
+ const absorbedIntoCloser = style === "french" && spaceRunReadsAsSingle(items, j, 1, RIGHT_DOUBLE_QUOTE)
775
804
  opens = isEllipsisDots(items, j) || foldsToNotEqual(items, j)
776
805
  || (!SPACE_RE.test(ch) && !DOUBLE_OPEN_ENDING_SET.has(ch))
777
806
  || absorbedIntoCloser
@@ -880,7 +909,7 @@ function classifyClosingDoubles(items: Item[], style: ActiveQuoteStyle): void {
880
909
  if (item.boundary || item.ch !== '"') continue
881
910
  const before = items[i - 1]
882
911
  if (!before.boundary && (SPACE_RE.test(before.ch) || OPEN_BRACKETS.has(before.ch))) {
883
- const absorbedIntoOpener = style === "french" && before.ch === " " && isCharItem(items, i - 2, LEFT_DOUBLE_QUOTE)
912
+ const absorbedIntoOpener = style === "french" && spaceRunReadsAsSingle(items, i - 1, -1, LEFT_DOUBLE_QUOTE)
884
913
  if (!absorbedIntoOpener) continue
885
914
  }
886
915
  const next = items[i + 1]
@@ -913,29 +942,6 @@ function classifySinglesBeforeClosingDoubles(items: Item[]): void {
913
942
  }
914
943
  }
915
944
 
916
- /**
917
- * German reads its rendered glyphs by depth: a U+201C below an open „ closes
918
- * it. A curly orphan that kept its source glyph (see {@link Item.curlySource})
919
- * below a quote opened in this same pass is therefore that quote's closer on
920
- * the next run; label it one now so both runs agree. Singles need no
921
- * counterpart: a single opener is only recognized when a closer follows it
922
- * with no straight single in between, and an orphan U+2018 is one.
923
- */
924
- function closeCurlyOrphansBelowOpeners(items: Item[]): void {
925
- let depth = 0
926
- for (const item of items) {
927
- if (item.boundary) continue
928
- if (item.ch === LEFT_DOUBLE_QUOTE) {
929
- depth++
930
- } else if (item.ch === RIGHT_DOUBLE_QUOTE) {
931
- if (depth > 0) depth--
932
- } else if (item.ch === '"' && item.curlySource === LEFT_DOUBLE_QUOTE && depth > 0) {
933
- item.ch = RIGHT_DOUBLE_QUOTE
934
- depth--
935
- }
936
- }
937
- }
938
-
939
945
  function classifyDoubles(items: Item[], style: ActiveQuoteStyle): void {
940
946
  classifyEmptyDoublePairs(items, style)
941
947
  classifyOpeningDoubles(items, style)
@@ -1122,7 +1128,7 @@ function movePunctuationInside(order: Item[], isMovable: (items: Item[], index:
1122
1128
  // suffix would be quadratic on long closer runs. The position-dependent
1123
1129
  // lookbehinds (terminal char, A-side wall) instead retry one level in,
1124
1130
  // where the preceding item is a closer and can no longer block.
1125
- if (punctIndex >= order.length || order[punctIndex].boundary || !isMovable(order, punctIndex)) {
1131
+ if (punctIndex >= order.length || order[punctIndex].boundary || isWholeNodeText(order, punctIndex) || !isMovable(order, punctIndex)) {
1126
1132
  position = Math.max(runEnd, position + 1)
1127
1133
  continue
1128
1134
  }
@@ -1199,7 +1205,10 @@ const CHAIN_PUNCT_SET = new Set<string>([".", ",", ELLIPSIS])
1199
1205
  * dot triple, which the double-opener rule reads as `…`;
1200
1206
  * - `.` + digit at the removal site: a period directly before a digit blocks
1201
1207
  * the dash pass's number-range detection, so vacating the slot converts a
1202
- * range this run left alone.
1208
+ * range this run left alone;
1209
+ * - a closer directly after a still-straight `'` + two digits: that adjacency
1210
+ * is what keeps {@link isDecadeElision} from reading the quote as an
1211
+ * elision, so the re-run of `.'47,’` would turn it into `’`.
1203
1212
  */
1204
1213
  function insideMoveFlipsContext(order: Item[], runStart: number, punctIndex: number): boolean {
1205
1214
  if (order[punctIndex].ch === ".") {
@@ -1208,7 +1217,15 @@ function insideMoveFlipsContext(order: Item[], runStart: number, punctIndex: num
1208
1217
  const afterPunct = nextIndex(order, punctIndex, 1)
1209
1218
  if (afterPunct < order.length && !order[afterPunct].boundary && DIGIT_RE.test(order[afterPunct].ch)) return true
1210
1219
  }
1211
- return false
1220
+ return closesStraightQuotedDigits(order, runStart)
1221
+ }
1222
+
1223
+ /** `'` + two digits, then the single closer at `runStart`. */
1224
+ function closesStraightQuotedDigits(order: Item[], runStart: number): boolean {
1225
+ return isCharItem(order, runStart, RIGHT_SINGLE_QUOTE)
1226
+ && isDigitItem(order, runStart - 1)
1227
+ && isDigitItem(order, runStart - 2)
1228
+ && isCharItem(order, runStart - 3, "'")
1212
1229
  }
1213
1230
 
1214
1231
  /** True iff the run [start, end) contains a single-quote closer or apostrophe. */
@@ -1222,9 +1239,8 @@ function runContainsSingleCloser(order: Item[], start: number, end: number): boo
1222
1239
 
1223
1240
  /**
1224
1241
  * Mirror of {@link singleEndingAfter} at the run end as the post-move item
1225
- * sequence reads it: the vacating punctuation leaves its surrounding boundary
1226
- * items behind (an emptied node stacks them), and they all count against the
1227
- * lookahead's one-boundary budget.
1242
+ * sequence reads it: the vacating punctuation leaves the boundary items around
1243
+ * it behind, and they all count against the lookahead's one-boundary budget.
1228
1244
  */
1229
1245
  function endingContextAfterMove(order: Item[], runEnd: number, punctIndex: number): boolean {
1230
1246
  let boundaries = 0
@@ -1298,6 +1314,16 @@ function moveWallAt(items: Item[], index: number): boolean {
1298
1314
  return !item.boundary && MOVE_WALL_CHARS.has(item.ch)
1299
1315
  }
1300
1316
 
1317
+ /**
1318
+ * True iff the item at `index` is the only text of its node: a boundary or the
1319
+ * view edge on each side. Moving it would empty the node, and an emptied inline
1320
+ * mark (`_,_`) serializes as bare delimiters that the next run reads as literal
1321
+ * text.
1322
+ */
1323
+ function isWholeNodeText(order: Item[], index: number): boolean {
1324
+ return (order[index - 1]?.boundary ?? true) && (order[index + 1]?.boundary ?? true)
1325
+ }
1326
+
1301
1327
  /**
1302
1328
  * British/German/French: a period or comma directly before a closing-quote
1303
1329
  * run moves past the whole run. No terminal-punctuation guard.
@@ -1313,7 +1339,7 @@ function movePunctuationOutside(order: Item[], punctChar: string, closingSet: Re
1313
1339
  let position = 0
1314
1340
  while (position < order.length) {
1315
1341
  const item = order[position]
1316
- if (item.boundary || item.ch !== punctChar) {
1342
+ if (item.boundary || item.ch !== punctChar || isWholeNodeText(order, position)) {
1317
1343
  position++
1318
1344
  continue
1319
1345
  }
@@ -1361,10 +1387,8 @@ function movePunctuationOutside(order: Item[], punctChar: string, closingSet: Re
1361
1387
  // Any collapsible space char qualifies, not just a plain space: the
1362
1388
  // collapse pass merges tabs and NBSPs into the closer's NNBSP padding
1363
1389
  // the same way.
1364
- if (spaceBeforePaddedCloser && k < order.length && !order[k].boundary
1365
- && order[k].ch.length === 1 && SPACE_CHAR_RE.test(order[k].ch)
1366
- && isCharItem(order, k + 1, RIGHT_DOUBLE_QUOTE)) {
1367
- k++
1390
+ if (spaceBeforePaddedCloser && spaceRunReadsAsSingle(order, k, 1, RIGHT_DOUBLE_QUOTE)) {
1391
+ while (SPACE_CHAR_RE.test(order[k].ch)) k++
1368
1392
  }
1369
1393
  if (k >= order.length || order[k].boundary || !closingRunMemberAt(order, k, closingSet)) break
1370
1394
  last = k
@@ -1421,31 +1445,49 @@ function applyPunctuationPlacement(items: Item[], style: ActiveQuoteStyle, apost
1421
1445
  // Rendering
1422
1446
  // ---------------------------------------------------------------------------
1423
1447
 
1424
- /** A one-char unmoved NBSP/NNBSP item textually adjacent at `offset`. */
1448
+ /** A one-char unmoved NBSP/NNBSP item. */
1425
1449
  function isPaddingSpaceItem(item: Item | undefined): item is Item {
1426
1450
  return item !== undefined && !item.boundary && !item.moved
1427
1451
  && item.end - item.start === 1 && (item.ch === NBSP || item.ch === NNBSP)
1428
1452
  }
1429
1453
 
1454
+ /**
1455
+ * The closer at `closerIndex` takes the padding space directly before it, or
1456
+ * deletes it when moved punctuation leaves them textually apart. A padding
1457
+ * space further left in the same run, textually apart from the next member,
1458
+ * is deleted too: the collapse pass would merge it with the closer's padding
1459
+ * into an NBSP, which a re-run absorbs as the padding and renders NNBSP.
1460
+ */
1461
+ function absorbSpaceRunBeforeCloser(items: Item[], closerIndex: number): void {
1462
+ const closer = items[closerIndex]
1463
+ for (let j = closerIndex - 1; j >= 0; j--) {
1464
+ const member = items[j]
1465
+ if (member.boundary || member.moved || member.end - member.start !== 1 || !SPACE_CHAR_RE.test(member.ch)) return
1466
+ const gapAfter = member.end !== items[j + 1].start
1467
+ if (isPaddingSpaceItem(member) && (j === closerIndex - 1 || gapAfter)) {
1468
+ if (!gapAfter) {
1469
+ closer.start = member.start
1470
+ member.end = member.start
1471
+ }
1472
+ member.ch = ""
1473
+ }
1474
+ }
1475
+ }
1476
+
1430
1477
  /**
1431
1478
  * French renders double-quote glyphs with NNBSP padding. A source NBSP/NNBSP
1432
- * directly adjacent to the glyph would collapse with that padding into one
1433
- * space whose type wins by priority (NBSP beats NNBSP), which the next run
1434
- * absorbs as the glyph's padding and re-renders NNBSP — an oscillation.
1435
- * Absorb the adjacent space into the (single-char, i.e. unpadded) glyph item
1436
- * now so its render replaces both with the padded form.
1479
+ * adjacent to the glyph in the placed order would collapse with that padding
1480
+ * into one space whose type wins by priority (NBSP beats NNBSP), which the next
1481
+ * run absorbs as the glyph's padding and re-renders NNBSP, an oscillation.
1482
+ * Absorb the space into the (single-char, i.e. unpadded) glyph item now so its
1483
+ * render replaces both with the padded form.
1437
1484
  */
1438
1485
  function absorbGuillemetPadding(items: Item[]): void {
1439
1486
  for (let i = 0; i < items.length; i++) {
1440
1487
  const item = items[i]
1441
1488
  if (item.boundary || item.moved || item.end - item.start !== 1) continue
1442
1489
  if (item.ch === RIGHT_DOUBLE_QUOTE) {
1443
- const prev = items[i - 1]
1444
- if (isPaddingSpaceItem(prev) && prev.end === item.start) {
1445
- item.start = prev.start
1446
- prev.end = prev.start
1447
- prev.ch = ""
1448
- }
1490
+ absorbSpaceRunBeforeCloser(items, i)
1449
1491
  } else if (item.ch === LEFT_DOUBLE_QUOTE) {
1450
1492
  const next = items[i + 1]
1451
1493
  if (isPaddingSpaceItem(next) && next.start === item.end) {
@@ -1490,7 +1532,7 @@ function queueRenderEdits(view: ProseView, items: Item[], table: Record<QuoteRol
1490
1532
  for (const item of items) {
1491
1533
  if (item.boundary) continue
1492
1534
  const role = ROLE_BY_CHAR.get(item.ch)
1493
- const rendered = role === undefined ? item.ch : table[role]
1535
+ const rendered = role === undefined ? (item.curlySource ?? item.ch) : table[role]
1494
1536
  if (item.moved) {
1495
1537
  view.replace(item.start, item.end, "")
1496
1538
  const key = `${item.anchorOffset}|${item.anchorBind}`
@@ -1507,10 +1549,9 @@ function queueRenderEdits(view: ProseView, items: Item[], table: Record<QuoteRol
1507
1549
  }
1508
1550
  continue
1509
1551
  }
1510
- if (role === undefined && item.curlySource !== undefined) continue
1511
1552
  // Fast path for ordinary characters; pre-labeled items may have changed
1512
1553
  // `ch` without gaining a role (German orphan closers) and must still diff.
1513
- if (role === undefined && item.ch === text[item.start] && item.end - item.start === 1) continue
1554
+ if (role === undefined && item.curlySource === undefined && item.ch === text[item.start] && item.end - item.start === 1) continue
1514
1555
  if (rendered !== text.slice(item.start, item.end)) {
1515
1556
  view.replace(item.start, item.end, rendered)
1516
1557
  }
@@ -1524,6 +1565,61 @@ function queueRenderEdits(view: ProseView, items: Item[], table: Record<QuoteRol
1524
1565
  // Public engine entry points
1525
1566
  // ---------------------------------------------------------------------------
1526
1567
 
1568
+ /**
1569
+ * The items the next run builds from this pass's rendering. German reads its
1570
+ * rendered glyphs by depth, and re-derives most of them to straight candidates
1571
+ * classified by position, so the next run's readings depend on the roles this
1572
+ * pass assigned to everything before them. The next run's prime pass turns
1573
+ * the digit-adjacent straight quotes this pass leaves straight into primes,
1574
+ * so they are primes here too.
1575
+ */
1576
+ function rereadGermanRendering(items: Item[], text: string, apostrophe: string): Item[] {
1577
+ const rendered = renderTable("german", apostrophe)
1578
+ const depth: GermanDepth = { double: 0, single: 0 }
1579
+ const reread = items.map((item) => {
1580
+ if (item.boundary) return makeItem(true, "", item.start, item.end)
1581
+ const role = ROLE_BY_CHAR.get(item.ch)
1582
+ const glyph = role === undefined ? (item.curlySource ?? item.ch) : rendered[role]
1583
+ const mapped = readGermanGlyph(glyph, DIGIT_RE.test(text[item.start - 1] ?? ""), depth)
1584
+ const next = makeItem(false, mapped ?? item.ch, item.start, item.end)
1585
+ next.moved = item.moved
1586
+ next.anchorOffset = item.anchorOffset
1587
+ next.anchorBind = item.anchorBind
1588
+ if (mapped === '"' || mapped === "'") next.curlySource = glyph
1589
+ return next
1590
+ })
1591
+ convertPrimes(reread, new Set())
1592
+ return reread
1593
+ }
1594
+
1595
+ /** Runs the single- then double-quote classification rules over `items`. */
1596
+ function classifyBuiltItems(items: Item[], style: ActiveQuoteStyle): void {
1597
+ classifySingles(items, style)
1598
+ classifyDoubles(items, style)
1599
+ }
1600
+
1601
+ // Loop guard only: reaching a fixed point is expected within a few rounds. The
1602
+ // worst case found so far takes 4 (`„“„” „(” ““‚`).
1603
+ const MAX_GERMAN_REREADS = 8
1604
+
1605
+ /**
1606
+ * Repeats German's classification over its own rereadings until a round
1607
+ * assigns the roles it started from: the next run starts from the first fixed
1608
+ * point.
1609
+ */
1610
+ function settleGermanRoles(items: Item[], text: string, apostrophe: string): Item[] {
1611
+ for (let round = 0; round < MAX_GERMAN_REREADS; round++) {
1612
+ const reread = rereadGermanRendering(items, text, apostrophe)
1613
+ classifyBuiltItems(reread, "german")
1614
+ const settled = reread.every((item, index) => item.ch === items[index].ch)
1615
+ items = reread
1616
+ if (settled) return items
1617
+ }
1618
+ /* istanbul ignore next -- defensive: no input found settles in more than 4
1619
+ rounds; a cap hit means the reread no longer converges. */
1620
+ throw new Error(`German quote classification did not settle within ${MAX_GERMAN_REREADS} rereads`)
1621
+ }
1622
+
1527
1623
  /**
1528
1624
  * The quote/apostrophe role classifier: one boundary-aware scan over the
1529
1625
  * view's text. Commits its edits.
@@ -1534,18 +1630,25 @@ export function classifyAndRenderQuotes(
1534
1630
  apostrophe: string,
1535
1631
  ): void {
1536
1632
  if (!QUOTE_CANDIDATE_RE.test(view.text)) return
1537
- const items = buildItems(view, style)
1538
- classifySingles(items, style)
1539
- classifyDoubles(items, style)
1540
- if (style === "german") closeCurlyOrphansBelowOpeners(items)
1541
- const placed = applyPunctuationPlacement(items, style, apostrophe)
1633
+ let items = buildItems(view, style)
1634
+ classifyBuiltItems(items, style)
1635
+ if (style === "german") items = settleGermanRoles(items, view.text, apostrophe)
1636
+ let placed = applyPunctuationPlacement(items, style, apostrophe)
1637
+ if (style === "german" && placed !== items) placed = settleGermanRoles(placed, view.text, apostrophe)
1542
1638
  // Placement can slide a closer past punctuation until it sits directly after
1543
1639
  // an s/S (british/french move the closer left, out of "dogs.'" → "dogs'."),
1544
1640
  // turning it into a plural possessive the pre-placement scan couldn't see. A
1545
1641
  // re-run over the final order relabels it, so the first transform matches what
1546
1642
  // a re-run reads (classifyApostrophes "dogs.'" → "dogsʼ." in one pass).
1547
1643
  classifyPluralPossessives(placed, style)
1548
- if (style === "french") absorbGuillemetPadding(placed)
1644
+ if (style === "french") {
1645
+ // Punctuation moved out from between a straight quote's trailing space and
1646
+ // a closer leaves the closer directly after that space, which the opener
1647
+ // rules read as the closer's padding. Re-run them on the placed order.
1648
+ classifyOpeningSingles(placed, style)
1649
+ classifyOpeningDoubles(placed, style)
1650
+ absorbGuillemetPadding(placed)
1651
+ }
1549
1652
  queueRenderEdits(view, placed, renderTable(style, apostrophe))
1550
1653
  view.commit()
1551
1654
  }
@@ -1589,7 +1692,7 @@ function convertPrimes(items: Item[], swissSeparators: ReadonlySet<number>): voi
1589
1692
  let balance = 0
1590
1693
  for (let i = 0; i < items.length; i++) {
1591
1694
  const item = items[i]
1592
- if (item.boundary || item.ch !== quote) continue
1695
+ if (item.boundary || item.ch !== quote || item.curlySource !== undefined) continue
1593
1696
  // A Swiss/Liechtenstein digit-group separator (5'000) is an apostrophe,
1594
1697
  // not a foot/minute prime; curl it and leave the quote balance untouched.
1595
1698
  if (quote === "'" && swissSeparators.has(item.start)) {
package/src/symbols.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { cachedRegExp, LATIN_LETTER_RE, LATIN_OR_DIGIT, MAX_BOUNDARY_SEPARATORS, SPACE_CHAR_RE, UNICODE_SYMBOLS, WORD_RE } from "./constants.js"
1
+ import { cachedRegExp, FRACTION_GLYPHS, LATIN_LETTER_RE, LATIN_OR_DIGIT, MAX_BOUNDARY_SEPARATORS, OPERATOR_SPACE, SPACE_CHAR_RE, UNICODE_SYMBOLS, WORD_RE } from "./constants.js"
2
2
  import { boundaryCountAt, exceedsSingleBoundary, firstInteriorBoundary, interiorBoundariesWithin, makeProsePass, overInput, type ProseView, replaceAllInView } from "./prose-view.js"
3
+ import { isDashChar } from "./dashes.js"
3
4
  import { convertPrimeMarks } from "./quote-classifier.js"
4
5
 
5
6
  export interface SymbolOptions {
@@ -78,6 +79,16 @@ function ellipsisNextDot(view: ProseView, text: string, afterDot: number): numbe
78
79
  return -1
79
80
  }
80
81
 
82
+ /**
83
+ * Whether folding `[from, to)` would absorb a node boundary into the glyph and
84
+ * leave a dash directly after it. The dash pass keyed on the unfolded text, where
85
+ * a character sat between the boundary and the dash; the fold removes that
86
+ * character, so a re-run would read the dash as boundary-led and convert it.
87
+ */
88
+ function foldStrandsDash(view: ProseView, from: number, to: number): boolean {
89
+ return firstInteriorBoundary(view, from, to) >= 0 && isDashChar(view.text[to]) && !view.hasBoundary(to)
90
+ }
91
+
81
92
  /**
82
93
  * Fold `...`, `. . .`, and the cross-boundary variants into `…`, scanning
83
94
  * left-to-right so a blocked leftmost triple still lets the next valid triple
@@ -92,7 +103,7 @@ function ellipsisFoldDots(view: ProseView): void {
92
103
  const secondDot = ellipsisNextDot(view, text, i + 1)
93
104
  if (secondDot < 0) { i++; continue }
94
105
  const thirdDot = ellipsisNextDot(view, text, secondDot + 1)
95
- if (thirdDot < 0) { i++; continue }
106
+ if (thirdDot < 0 || foldStrandsDash(view, i, thirdDot + 1)) { i++; continue }
96
107
  view.replace(i, thirdDot + 1, ELLIPSIS)
97
108
  i = thirdDot + 1
98
109
  }
@@ -147,10 +158,6 @@ const DIGIT_SUFFIX = `(?:${PRIME_ALT}|${UNIT_ALT})?`
147
158
  // (1e5x3 — the `e` is a letter) and model SKUs (Surface5x3, RTX3060x2);
148
159
  // signed exponents (1e-5x3, 3.5E+10x2) are unambiguously scientific, their
149
160
  // operand is preceded by a sign, and they convert.
150
- //
151
- // Operator spacing is horizontal whitespace only: an operator across a line
152
- // break is a list marker (`Step 1\n* 2 cups`), not multiplication.
153
- const OPERATOR_SPACE = "[^\\S\\n\\v\\f\\r\\u2028\\u2029]*"
154
161
  const MULTIPLICATION_CHAIN = `(?<firstNum>\\d+${DIGIT_SUFFIX})(?<rest>(?:${OPERATOR_SPACE}[xX*]${OPERATOR_SPACE}\\d+${DIGIT_SUFFIX})+)`
155
162
  const MULTIPLICATION_SEGMENT = `(?<spaceBefore>${OPERATOR_SPACE})[xX*](?<spaceAfter>${OPERATOR_SPACE})(?<num>\\d+${DIGIT_SUFFIX})`
156
163
 
@@ -505,12 +512,7 @@ const BOUNDARY_CONTEXT_COST = 2
505
512
  // Vulgar fraction glyphs (½, ¾, …). A fraction folds from `n/m`, which the path
506
513
  // heuristic below reads as a path context — so the glyph must read the same way,
507
514
  // or `1/2(tm)` (blocked) would convert once fractions strips the slash to `½(tm)`.
508
- const FRACTION_GLYPH_RE = new RegExp(
509
- `[${Object.entries(UNICODE_SYMBOLS)
510
- .filter(([key]) => key.startsWith("FRACTION_"))
511
- .map(([, glyph]) => glyph)
512
- .join("")}]`,
513
- )
515
+ const FRACTION_GLYPH_RE = new RegExp(`[${FRACTION_GLYPHS}]`)
514
516
 
515
517
  const isPathContext = (before: string): boolean => {
516
518
  const parts = before.split(/\s+/)
@@ -967,7 +969,7 @@ function punctuationLigaturesOverView(view: ProseView): void {
967
969
  end = afterSep + 1
968
970
  consumed++
969
971
  }
970
- if (consumed === 0) { i++; continue }
972
+ if (consumed === 0 || foldStrandsDash(view, i, end)) { i++; continue }
971
973
  view.replace(i, end, replacement)
972
974
  i = end
973
975
  }