punctilio 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/symbols.ts ADDED
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Symbol transformations: ellipses, multiplication, math symbols, arrows.
3
+ */
4
+
5
+ import escapeStringRegexp from "escape-string-regexp"
6
+ import { UNICODE_SYMBOLS, ESCAPED_DEFAULT_SEPARATOR, LATIN_LETTERS, wordBoundaryEnd, SPACE_CHARS, spaceBoundaryStart, spaceBoundaryEnd } from "./constants.js"
7
+
8
+ export interface SymbolOptions {
9
+ /** Boundary marker for HTML element boundaries. Default: "\uE000" */
10
+ separator?: string
11
+ /** Include arrow transforms (-> → →). Default: true */
12
+ includeArrows?: boolean
13
+ }
14
+
15
+ const {
16
+ ELLIPSIS,
17
+ MULTIPLICATION,
18
+ NOT_EQUAL,
19
+ PLUS_MINUS,
20
+ COPYRIGHT,
21
+ REGISTERED,
22
+ TRADEMARK,
23
+ DEGREE,
24
+ ARROW_RIGHT,
25
+ ARROW_LEFT,
26
+ ARROW_LEFT_RIGHT,
27
+ APPROXIMATE,
28
+ LESS_EQUAL,
29
+ GREATER_EQUAL,
30
+ NBSP,
31
+ PRIME,
32
+ DOUBLE_PRIME,
33
+ SUPERSCRIPT_ST,
34
+ SUPERSCRIPT_ND,
35
+ SUPERSCRIPT_RD,
36
+ SUPERSCRIPT_TH,
37
+ DOUBLE_QUESTION,
38
+ QUESTION_EXCLAMATION,
39
+ EXCLAMATION_QUESTION,
40
+ } = UNICODE_SYMBOLS
41
+
42
+ /** Convert "..." or ". . ." to "…". */
43
+ export function ellipsis(text: string, options: SymbolOptions = {}): string {
44
+ const chr = options.separator
45
+ ? escapeStringRegexp(options.separator)
46
+ : ESCAPED_DEFAULT_SEPARATOR
47
+
48
+ // Convert consecutive or spaced dots: ... or . . . → …
49
+ // Captures preserve any separators between dots
50
+ const pattern = new RegExp(`\\.[${SPACE_CHARS}]?(${chr})?\\.([${SPACE_CHARS}]?)(${chr})?\\.`, "g")
51
+ text = text.replace(pattern, (_match, sep1, _space, sep2) => {
52
+ return ELLIPSIS + (sep1 || "") + (sep2 || "")
53
+ })
54
+
55
+ text = text.replace(new RegExp(`${ELLIPSIS}(?=[${LATIN_LETTERS}\\d])`, "gu"), `${ELLIPSIS} `)
56
+
57
+ return text
58
+ }
59
+
60
+ /** Convert "5x5" to "5×5". Skips hex (0x5F). */
61
+ export function multiplication(text: string, options: SymbolOptions = {}): string {
62
+ const chr = options.separator
63
+ ? escapeStringRegexp(options.separator)
64
+ : ESCAPED_DEFAULT_SEPARATOR
65
+
66
+ // Match entire multiplication chains in one pass: "5 x 5 x 5" or "5x5x5"
67
+ // Pattern matches: digit(s), then one or more (operator, digit(s)) groups
68
+ const chainPattern = new RegExp(
69
+ `(\\d+)((?:${chr}?\\s*[xX*]\\s*${chr}?\\d+)+)`,
70
+ "g"
71
+ )
72
+
73
+ text = text.replace(chainPattern, (match, firstNum, rest) => {
74
+ // Skip hexadecimal: 0x... or 0X...
75
+ if (firstNum === "0" && /^x/i.test(rest)) return match
76
+
77
+ // Replace all operators in the chain, preserving spacing
78
+ const converted = rest.replace(
79
+ new RegExp(`(${chr}?)(\\s*)[xX*](\\s*)(${chr}?)`, "g"),
80
+ (_: string, pre: string, spaceBefore: string, spaceAfter: string, post: string) => {
81
+ const space = spaceBefore || spaceAfter ? " " : ""
82
+ return `${pre}${space}${MULTIPLICATION}${space}${post}`
83
+ }
84
+ )
85
+ return `${firstNum}${converted}`
86
+ })
87
+
88
+ // Trailing multiplier: 5x (followed by word boundary - space, punctuation, etc.)
89
+ const wbe = wordBoundaryEnd(chr)
90
+ const trailingPattern = new RegExp(`(?<num>\\d+${chr}?)[xX*]${wbe}`, "g")
91
+ text = text.replace(trailingPattern, (match, num: string) => {
92
+ // Skip if this looks like start of hexadecimal
93
+ if (num === "0") return match
94
+ return `${num}${MULTIPLICATION}`
95
+ })
96
+
97
+ return text
98
+ }
99
+
100
+ /** Math symbol replacement map: ASCII → Unicode */
101
+ const MATH_SYMBOL_MAP: [RegExp, string][] = [
102
+ [/!=/g, NOT_EQUAL],
103
+ [/\+\/-/g, PLUS_MINUS],
104
+ [/\+-/g, PLUS_MINUS],
105
+ [/<=/g, LESS_EQUAL],
106
+ [/>=/g, GREATER_EQUAL],
107
+ [/~=/g, APPROXIMATE],
108
+ [/=~/g, APPROXIMATE],
109
+ ]
110
+
111
+ /** Convert !=, <=, >=, +/-, ~= to Unicode equivalents. */
112
+ export function mathSymbols(text: string): string {
113
+ for (const [pattern, replacement] of MATH_SYMBOL_MAP) {
114
+ text = text.replace(pattern, replacement)
115
+ }
116
+ return text
117
+ }
118
+
119
+ /** Legal symbol replacement map: ASCII → Unicode */
120
+ const LEGAL_SYMBOL_MAP: [RegExp, string][] = [
121
+ [/\(c\)/gi, COPYRIGHT],
122
+ [/\(r\)/gi, REGISTERED],
123
+ [/\(tm\)/gi, TRADEMARK],
124
+ ]
125
+
126
+ /** Convert (c), (r), (tm) to ©, ®, ™. */
127
+ export function legalSymbols(text: string): string {
128
+ for (const [pattern, replacement] of LEGAL_SYMBOL_MAP) {
129
+ text = text.replace(pattern, replacement)
130
+ }
131
+ return text
132
+ }
133
+
134
+ /** Arrow pattern map: arrow shape → Unicode symbol */
135
+ const ARROW_MAP: [string, string][] = [
136
+ [`<-+${"%CHR%"}?>`, ARROW_LEFT_RIGHT], // Bidirectional: <-> or <-->
137
+ ["-+>", ARROW_RIGHT], // Right: -> or -->
138
+ ["<-+", ARROW_LEFT], // Left: <- or <--
139
+ ]
140
+
141
+ /** Convert -> and <-> to arrows. */
142
+ export function arrows(text: string, options: SymbolOptions = {}): string {
143
+ const chr = options.separator
144
+ ? escapeStringRegexp(options.separator)
145
+ : ESCAPED_DEFAULT_SEPARATOR
146
+
147
+ const start = spaceBoundaryStart(chr)
148
+ const end = spaceBoundaryEnd(chr)
149
+
150
+ for (const [arrowPattern, replacement] of ARROW_MAP) {
151
+ const pattern = new RegExp(`${start}${arrowPattern.replace("%CHR%", chr)}${end}`, "g")
152
+ text = text.replace(pattern, replacement)
153
+ }
154
+
155
+ return text
156
+ }
157
+
158
+ /** Convert "20 C" or "20 F" to "20 °C" or "20 °F". Only matches uppercase C/F. */
159
+ export function degrees(text: string, options: SymbolOptions = {}): string {
160
+ const chr = options.separator
161
+ ? escapeStringRegexp(options.separator)
162
+ : ESCAPED_DEFAULT_SEPARATOR
163
+
164
+ // Temperature with optional space before C or F (uppercase only)
165
+ // Handles separator between digit and unit
166
+ // Uses marker-aware boundary to avoid false matches like "20C\uE000elsius"
167
+ const wbe = wordBoundaryEnd(chr)
168
+ return text.replace(
169
+ new RegExp(`(?<num>\\d${chr}?) ?(?<unit>[CF])${wbe}`, "g"),
170
+ (_, num, unit) => `${num} ${DEGREE}${unit}`
171
+ )
172
+ }
173
+
174
+ /** Convert 5'10" to 5′10″ (prime marks). Call before smart quotes. */
175
+ export function primeMarks(text: string, options: SymbolOptions = {}): string {
176
+ const chr = options.separator
177
+ ? escapeStringRegexp(options.separator)
178
+ : ESCAPED_DEFAULT_SEPARATOR
179
+
180
+ // Convert quotes to primes using quote balancing
181
+ // Only convert if quotes are balanced (even count before) to avoid converting closing quotes
182
+ // Examples: 5' → 5′ ✓, 12" → 12″ ✓, 'Term 1' → 'Term 1' ✓, "Term 1" → "Term 1" ✓
183
+ const quotePrimePairs = [
184
+ ["'", PRIME],
185
+ ['"', DOUBLE_PRIME],
186
+ ]
187
+
188
+ for (const [quote, prime] of quotePrimePairs) {
189
+ const pattern = new RegExp(
190
+ `(?<digit>\\d)(?<sep>${chr}?)${quote}(?<afterSep>${chr}?)(?![${LATIN_LETTERS}])`,
191
+ "g"
192
+ )
193
+ const quoteCountPattern = new RegExp(escapeStringRegexp(quote), "g")
194
+ text = text.replace(pattern, (match, digit, sep, afterSep, offset) => {
195
+ const textBefore = text.slice(0, offset)
196
+ const quoteCount = (textBefore.match(quoteCountPattern) || []).length
197
+ if (quoteCount % 2 === 0) {
198
+ return `${digit}${sep}${prime}${afterSep}`
199
+ }
200
+ return match
201
+ })
202
+ }
203
+
204
+ // Feet-inches pattern: convert " after ′ + digit (e.g., 5′10" → 5′10″)
205
+ const feetInchesPattern = new RegExp(`(?<primeAndNum>${PRIME}${chr}?\\d${chr}?)"`, "g")
206
+ text = text.replace(feetInchesPattern, `$<primeAndNum>${DOUBLE_PRIME}`)
207
+
208
+ return text
209
+ }
210
+
211
+ const FRACTION_MAP: Record<string, string> = {
212
+ "1/4": UNICODE_SYMBOLS.FRACTION_1_4,
213
+ "1/2": UNICODE_SYMBOLS.FRACTION_1_2,
214
+ "3/4": UNICODE_SYMBOLS.FRACTION_3_4,
215
+ "1/3": UNICODE_SYMBOLS.FRACTION_1_3,
216
+ "2/3": UNICODE_SYMBOLS.FRACTION_2_3,
217
+ "1/5": UNICODE_SYMBOLS.FRACTION_1_5,
218
+ "2/5": UNICODE_SYMBOLS.FRACTION_2_5,
219
+ "3/5": UNICODE_SYMBOLS.FRACTION_3_5,
220
+ "4/5": UNICODE_SYMBOLS.FRACTION_4_5,
221
+ "1/6": UNICODE_SYMBOLS.FRACTION_1_6,
222
+ "5/6": UNICODE_SYMBOLS.FRACTION_5_6,
223
+ "1/8": UNICODE_SYMBOLS.FRACTION_1_8,
224
+ "3/8": UNICODE_SYMBOLS.FRACTION_3_8,
225
+ "5/8": UNICODE_SYMBOLS.FRACTION_5_8,
226
+ "7/8": UNICODE_SYMBOLS.FRACTION_7_8,
227
+ }
228
+
229
+ /** Convert 1/2, 1/4, etc. to ½, ¼, etc. */
230
+ export function fractions(text: string, options: SymbolOptions = {}): string {
231
+ const chr = options.separator
232
+ ? escapeStringRegexp(options.separator)
233
+ : ESCAPED_DEFAULT_SEPARATOR
234
+
235
+ for (const [ascii, unicode] of Object.entries(FRACTION_MAP)) {
236
+ // Negative lookbehind/lookahead: ensures fraction is not part of a larger number or path
237
+ // - (?<![/.\d]) prevents matching after slashes, dots, or digits
238
+ // - (?![/\d]|\.\d) prevents matching before slashes, digits, or decimal points
239
+ // Named captures preserve separators before and after the slash
240
+ const [numerator, denominator] = ascii.split("/")
241
+ const pattern = new RegExp(
242
+ `(?<![/\\.\\d])${numerator}(?<sepBefore>${chr}?)/(?<sepAfter>${chr}?)${denominator}(?![/\\d]|\\.\\d)`,
243
+ "g"
244
+ )
245
+ // Preserve separators around the fraction Unicode character
246
+ text = text.replace(pattern, `$<sepBefore>${unicode}$<sepAfter>`)
247
+ }
248
+
249
+ return text
250
+ }
251
+
252
+ const ORDINAL_MAP: Record<string, string> = {
253
+ st: SUPERSCRIPT_ST,
254
+ nd: SUPERSCRIPT_ND,
255
+ rd: SUPERSCRIPT_RD,
256
+ th: SUPERSCRIPT_TH,
257
+ }
258
+
259
+ /** Convert 1st, 2nd, 3rd, 4th to superscript ordinals. */
260
+ export function superscriptOrdinal(text: string, options: SymbolOptions = {}): string {
261
+ const chr = options.separator
262
+ ? escapeStringRegexp(options.separator)
263
+ : ESCAPED_DEFAULT_SEPARATOR
264
+
265
+ // Match number + optional separator + ordinal suffix at word boundary
266
+ // Use case-insensitive matching for the suffix
267
+ // Uses marker-aware boundary to avoid false matches like "1st\uE000ly"
268
+ const wbe = wordBoundaryEnd(chr)
269
+ const pattern = new RegExp(
270
+ `(?<num>\\d${chr}?)(?<suffix>st|nd|rd|th)${wbe}`,
271
+ "gi"
272
+ )
273
+
274
+ return text.replace(pattern, (_match, num, suffix) => {
275
+ const superscriptSuffix = ORDINAL_MAP[suffix.toLowerCase()]
276
+ return num + superscriptSuffix
277
+ })
278
+ }
279
+
280
+ /** Collapse multiple spaces to single space. Prefers nbsp if any nbsp is present. */
281
+ export function collapseSpaces(text: string): string {
282
+ return text.replace(new RegExp(`[${SPACE_CHARS}]{2,}`, "g"), (match) => {
283
+ // If any nbsp is present, prefer nbsp (more likely to be intentional)
284
+ return match.includes(NBSP) ? NBSP : " "
285
+ })
286
+ }
287
+
288
+ /**
289
+ * Punctuation ligature map: [first char, repeated char, replacement]
290
+ * Pattern: first(sep)?repeated(?:sep?repeated)* → replacement
291
+ * Order matters: handle mixed punctuation first, then repeated
292
+ */
293
+ const PUNCTUATION_LIGATURE_MAP: [string, string, string][] = [
294
+ ["\\?", "!", QUESTION_EXCLAMATION], // ?!+ → ⁈
295
+ ["!", "\\?", EXCLAMATION_QUESTION], // !?+ → ⁉
296
+ ["\\?", "\\?", DOUBLE_QUESTION], // ??+ → ⁇
297
+ ["!", "!", "!"], // !!+ → ! (normalize)
298
+ ]
299
+
300
+ /** Convert ?? to ⁇, ?! to ⁈, !? to ⁉. Poor font support, disabled by default. */
301
+ export function punctuationLigatures(text: string, options: SymbolOptions = {}): string {
302
+ const chr = options.separator
303
+ ? escapeStringRegexp(options.separator)
304
+ : ESCAPED_DEFAULT_SEPARATOR
305
+
306
+ for (const [first, repeated, replacement] of PUNCTUATION_LIGATURE_MAP) {
307
+ const pattern = new RegExp(`${first}(${chr})?${repeated}(?:${chr}?${repeated})*`, "g")
308
+ text = text.replace(pattern, (_match, sep) => replacement + (sep || ""))
309
+ }
310
+
311
+ return text
312
+ }
313
+
314
+ /** Apply all symbol transforms. degrees/fractions excluded (too aggressive). */
315
+ export function symbolTransform(text: string, options: SymbolOptions = {}): string {
316
+ text = ellipsis(text, options)
317
+ text = multiplication(text, options)
318
+ text = mathSymbols(text)
319
+ text = legalSymbols(text)
320
+ if (options.includeArrows !== false) {
321
+ text = arrows(text, options)
322
+ }
323
+ return text
324
+ }