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.
- package/README.md +1 -1
- package/dist/constants.d.ts +13 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +19 -0
- package/dist/constants.js.map +1 -1
- package/dist/dashes.d.ts +1 -0
- package/dist/dashes.d.ts.map +1 -1
- package/dist/dashes.js +38 -24
- package/dist/dashes.js.map +1 -1
- package/dist/markdown.d.ts +6 -0
- package/dist/markdown.d.ts.map +1 -1
- package/dist/markdown.js +25 -12
- package/dist/markdown.js.map +1 -1
- package/dist/nbsp.d.ts.map +1 -1
- package/dist/nbsp.js +14 -4
- package/dist/nbsp.js.map +1 -1
- package/dist/quote-classifier.d.ts.map +1 -1
- package/dist/quote-classifier.js +203 -114
- package/dist/quote-classifier.js.map +1 -1
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +14 -11
- package/dist/symbols.js.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +22 -0
- package/src/dashes.ts +37 -26
- package/src/markdown.ts +36 -18
- package/src/nbsp.ts +14 -4
- package/src/quote-classifier.ts +210 -107
- package/src/symbols.ts +15 -13
package/src/quote-classifier.ts
CHANGED
|
@@ -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
|
|
110
|
-
* candidate by position
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" &&
|
|
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" &&
|
|
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
|
|
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
|
|
1226
|
-
*
|
|
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
|
|
1365
|
-
|
|
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
|
|
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
|
-
*
|
|
1433
|
-
* space whose type wins by priority (NBSP beats NNBSP), which the next
|
|
1434
|
-
* absorbs as the glyph's padding and re-renders NNBSP
|
|
1435
|
-
* Absorb the
|
|
1436
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
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")
|
|
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
|
}
|