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