@echogarden/text-segmentation 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/exports/Exports.d.ts +3 -0
  2. package/dist/exports/Exports.d.ts.map +1 -0
  3. package/dist/exports/Exports.js +3 -0
  4. package/dist/exports/Exports.js.map +1 -0
  5. package/dist/{exports → segmentation}/TextSegmentation.d.ts +0 -1
  6. package/dist/segmentation/TextSegmentation.d.ts.map +1 -0
  7. package/dist/segmentation/TextSegmentation.js +575 -0
  8. package/dist/segmentation/TextSegmentation.js.map +1 -0
  9. package/dist/segmentation/WordSequence.d.ts.map +1 -0
  10. package/dist/segmentation/WordSequence.js.map +1 -0
  11. package/dist/tests/Test.js +2 -2
  12. package/dist/tests/Test.js.map +1 -1
  13. package/dist/utilities/Timer.d.ts +7 -3
  14. package/dist/utilities/Timer.d.ts.map +1 -1
  15. package/dist/utilities/Timer.js +41 -39
  16. package/dist/utilities/Timer.js.map +1 -1
  17. package/package.json +4 -4
  18. package/src/exports/Exports.ts +2 -0
  19. package/src/segmentation/TextSegmentation.ts +743 -0
  20. package/src/tests/Test.ts +2 -2
  21. package/src/utilities/Timer.ts +50 -48
  22. package/dist/Test.d.ts +0 -2
  23. package/dist/Test.d.ts.map +0 -1
  24. package/dist/Test.js +0 -120
  25. package/dist/Test.js.map +0 -1
  26. package/dist/exports/TextSegmentation.d.ts.map +0 -1
  27. package/dist/exports/TextSegmentation.js +0 -369
  28. package/dist/exports/TextSegmentation.js.map +0 -1
  29. package/dist/exports/WordSequence.d.ts.map +0 -1
  30. package/dist/exports/WordSequence.js.map +0 -1
  31. package/src/exports/TextSegmentation.ts +0 -527
  32. /package/dist/{exports → segmentation}/WordSequence.d.ts +0 -0
  33. /package/dist/{exports → segmentation}/WordSequence.js +0 -0
  34. /package/src/{exports → segmentation}/WordSequence.ts +0 -0
@@ -0,0 +1,743 @@
1
+ import { buildWordOrNumberPattern as buildWordSplitterPattern, phraseSeparatorRegExp, sentenceSeparatorTrailingPunctuationCharacterRegExp, sentenceSeparatorCharacterRegExp, whitespacePatternRegExp, letterPatternGlobalRegExp, startsWithWhitespacePattern, endsWithWhitespacePattern } from '../patterns/Patterns.js'
2
+ import { cldrSuppressions, additionalSuppressions, leadingApostropheContractionSuppressions, nounSuppressions, tldSuppressions } from '../patterns/Suppressions.js'
3
+ import { eastAsianCharRangesRegExp } from '../patterns/EastAsianCharacterPatterns.js'
4
+ import { WordSequence } from './WordSequence.js'
5
+ import { getShortLanguageCode } from '../utilities/Utilities.js'
6
+
7
+ import { buildRegExp } from 'regexp-composer'
8
+
9
+ ////////////////////////////////////////////////////////////////////////////////////////////////
10
+ // Exported methods
11
+ ////////////////////////////////////////////////////////////////////////////////////////////////
12
+
13
+ // Splits a text string into words and then segments the resulting word sequence into sentences and phrases
14
+ // (this is the main high-level entry point; it is async because splitting may involve ICU postprocessing)
15
+ export async function segmentText(text: string, options?: SegmentationOptions) {
16
+ // Split the text into a sequence of individual words (this may await East Asian postprocessing)
17
+ const wordSequence = await splitToWords(text, options)
18
+
19
+ // Segment the word sequence into segments (paragraphs), sentences, and phrases, and return the result
20
+ return segmentWordSequence(wordSequence)
21
+ }
22
+
23
+ // Segments an already-tokenized word sequence into segments (paragraphs), sentences, and phrases
24
+ // This is the core segmentation algorithm, working purely on the word level
25
+ // (options only control how colons/semicolons are treated when splitting phrases)
26
+ export async function segmentWordSequence(wordSequence: WordSequence, options?: WordSequenceSegmentationOptions) {
27
+ // Fill in any option values the caller didn't provide with their defaults
28
+ options = { ...defaultWordSequenceSegmentationOptions, ...options }
29
+
30
+ // This will hold the start and end word offsets of each paragraph (called a "segment" here)
31
+ const segmentWordRanges: Range[] = []
32
+
33
+ // Step 1: find segment (paragraph) boundaries.
34
+ // A new segment begins right after a newline character that follows at least one non-whitespace word.
35
+ {
36
+ // The word offset at which the current segment starts
37
+ let segmentStartWordOffset = 0
38
+ // Whether at least one non-whitespace word has been seen in the current segment
39
+ let nonWhitespaceWordSeen = false
40
+ // Whether a newline has been seen in the current segment (i.e. we may be at a boundary)
41
+ let newlineSeenInCurrentSegment = false
42
+
43
+ // Walk through every word of the sequence
44
+ for (let wordIndex = 0; wordIndex < wordSequence.length; wordIndex++) {
45
+ // Get the text of the word at the current index
46
+ const word = wordSequence.getWordAt(wordIndex)
47
+
48
+ // If we haven't seen a newline yet, the segment already has content, and this word is a newline, mark the newline as seen
49
+ if (!newlineSeenInCurrentSegment && nonWhitespaceWordSeen && word.endsWith('\n')) {
50
+ newlineSeenInCurrentSegment = true
51
+ }
52
+
53
+ // If a newline was seen, decide whether the segment actually ends at this word
54
+ if (newlineSeenInCurrentSegment) {
55
+ // Look at the word after the current one (or an empty string if the current word is last)
56
+ const nextWord = wordIndex < wordSequence.length - 1 ? wordSequence.getWordAt(wordIndex + 1) : ''
57
+
58
+ // End the segment here if the newline is followed by real content (or by nothing at all)
59
+ if (nextWord === '' || !whitespacePatternRegExp.test(nextWord)) {
60
+ // Record the current segment, ending after the current (newline) word
61
+ segmentWordRanges.push({ start: segmentStartWordOffset, end: wordIndex + 1 })
62
+ // The next segment starts at the word after the newline
63
+ segmentStartWordOffset = wordIndex + 1
64
+
65
+ // Reset the newline flag for the new segment
66
+ newlineSeenInCurrentSegment = false
67
+ }
68
+ }
69
+
70
+ // If no non-whitespace word has been seen yet, check whether the current word is non-whitespace
71
+ if (nonWhitespaceWordSeen === false) {
72
+ nonWhitespaceWordSeen = !whitespacePatternRegExp.test(word)
73
+ }
74
+ }
75
+
76
+ // If the last segment didn't reach the end of the sequence, close it with the remaining words
77
+ if (segmentStartWordOffset < wordSequence.length) {
78
+ segmentWordRanges.push({ start: segmentStartWordOffset, end: wordSequence.length })
79
+ }
80
+ }
81
+
82
+ // These will hold the word offsets of each sentence, and (per segment) the range of sentence indices in it
83
+ const sentenceWordRanges: Range[] = []
84
+ const segmentSentenceRanges: Range[] = []
85
+
86
+ // Step 2: find sentence boundaries inside each segment.
87
+ // A sentence ends at a sentence-separator character (like '.', '!', '?') once it contains a minimum number of letters.
88
+ {
89
+ // A sentence must contain at least this many letters before a separator may end it
90
+ const minimumSentenceLetterCount = 2
91
+
92
+ // Process each segment independently
93
+ for (let segmentIndex = 0; segmentIndex < segmentWordRanges.length; segmentIndex++) {
94
+ // Get the word range of the current segment
95
+ const segmentWordRange = segmentWordRanges[segmentIndex]
96
+
97
+ // Record the segment's first and last word index
98
+ const segmentStartWordIndex = segmentWordRange.start
99
+ const segmentEndWordIndex = segmentWordRange.end
100
+
101
+ // Add a placeholder entry for this segment's sentence range, starting (and initially ending) at the current sentence count
102
+ segmentSentenceRanges.push({ start: sentenceWordRanges.length, end: sentenceWordRanges.length })
103
+
104
+ // Whether a non-whitespace word has been seen in the current sentence
105
+ let nonWhitespaceWordSeen = false
106
+ // The word offset at which the current sentence starts
107
+ let sentenceStartWordOffset = segmentStartWordIndex
108
+ // The number of letters accumulated in the current sentence
109
+ let currentSentenceLetterCount = 0
110
+
111
+ // Walk through the words of this segment
112
+ for (let wordIndex = segmentStartWordIndex; wordIndex < segmentEndWordIndex; wordIndex++) {
113
+ // Get the text of the current word
114
+ const word = wordSequence.getWordAt(wordIndex)
115
+
116
+ // If no non-whitespace word has been seen yet, check whether the current word is non-whitespace
117
+ if (nonWhitespaceWordSeen === false) {
118
+ nonWhitespaceWordSeen = !whitespacePatternRegExp.test(word)
119
+ }
120
+
121
+ // Count letters, but only while the sentence hasn't yet reached the minimum (to save work)
122
+ if (currentSentenceLetterCount < minimumSentenceLetterCount) {
123
+ // Find every letter character in the current word
124
+ const matches = word.matchAll(letterPatternGlobalRegExp)
125
+
126
+ // Count the letters, stopping early once the minimum is reached
127
+ for (const _ of matches) {
128
+ currentSentenceLetterCount += 1
129
+
130
+ if (currentSentenceLetterCount >= minimumSentenceLetterCount) {
131
+ break
132
+ }
133
+ }
134
+ }
135
+
136
+ // End the sentence here if it has content, enough letters, and the current word is a sentence-separator character
137
+ if (nonWhitespaceWordSeen && currentSentenceLetterCount >= minimumSentenceLetterCount && sentenceSeparatorCharacterRegExp.test(word)) {
138
+ // Start scanning at the separator; this index marks where the sentence's trailing punctuation ends
139
+ let trailingSequenceEndIndex = wordIndex
140
+
141
+ // Consume any trailing punctuation words (closing quotes, brackets, etc.) that belong to this sentence
142
+ while (trailingSequenceEndIndex < segmentEndWordIndex) {
143
+ // Get the text of the candidate trailing-punctuation word
144
+ const trailingWord = wordSequence.getWordAt(trailingSequenceEndIndex)
145
+
146
+ // Keep consuming it if it is trailing punctuation; otherwise stop
147
+ if (sentenceSeparatorTrailingPunctuationCharacterRegExp.test(trailingWord)) {
148
+ trailingSequenceEndIndex++
149
+ } else {
150
+ break
151
+ }
152
+ }
153
+
154
+ // Record the completed sentence, from its start offset up to (but not including) the trailing punctuation
155
+ sentenceWordRanges.push({
156
+ start: sentenceStartWordOffset,
157
+ end: trailingSequenceEndIndex
158
+ })
159
+
160
+ // Extend the current segment's sentence range to include the sentence we just recorded
161
+ segmentSentenceRanges[segmentSentenceRanges.length - 1].end += 1
162
+
163
+ // The next sentence starts right after the trailing punctuation
164
+ sentenceStartWordOffset = trailingSequenceEndIndex
165
+ // Reset the letter count for the next sentence
166
+ currentSentenceLetterCount = 0
167
+
168
+ // Skip the words already consumed by the trailing-punctuation scan (the for loop will increment again)
169
+ wordIndex = trailingSequenceEndIndex - 1
170
+ }
171
+ }
172
+
173
+ // If the segment ends with words that don't complete a new sentence, close the final sentence at the segment end
174
+ if (sentenceStartWordOffset < segmentEndWordIndex) {
175
+ sentenceWordRanges.push({ start: sentenceStartWordOffset, end: segmentEndWordIndex })
176
+ segmentSentenceRanges[segmentSentenceRanges.length - 1].end += 1
177
+ }
178
+ }
179
+ }
180
+
181
+ // This will hold the Sentence objects built from the sentence word ranges
182
+ const sentences: Sentence[] = []
183
+
184
+ // Build a Sentence object for every sentence range found above
185
+ for (const wordRange of sentenceWordRanges) {
186
+ // Extract the slice of the word sequence that belongs to this sentence
187
+ const sentenceWordSequence = wordSequence.slice(wordRange.start, wordRange.end)
188
+
189
+ // Create the Sentence for this range and append it to the list
190
+ sentences.push(new Sentence(wordRange, sentenceWordSequence))
191
+ }
192
+
193
+ // Step 3: split each sentence into phrases at phrase-separator characters (commas, colons, semicolons, etc.)
194
+ for (const sentence of sentences) {
195
+ // This will hold the start and end word offsets of each phrase in the current sentence
196
+ const phraseWordRanges: Range[] = []
197
+
198
+ // The word offset just past the end of this sentence
199
+ let sentenceEndWordOffset = sentence.wordRange.end
200
+ // The word offset at which the current phrase starts
201
+ let phraseStartWordOffset = sentence.wordRange.start
202
+
203
+ // Walk through the words of this sentence
204
+ for (let wordIndex = phraseStartWordOffset; wordIndex < sentenceEndWordOffset; wordIndex++) {
205
+ // Get the text of the current word
206
+ const currentWord = wordSequence.getWordAt(wordIndex)
207
+
208
+ // Determine whether this word is a phrase separator:
209
+ // it must match the phrase-separator pattern, and if the option is set, ':' or ';' only count when followed by whitespace
210
+ const isCurrentWordPhraseSeparator =
211
+ phraseSeparatorRegExp.test(currentWord) &&
212
+ (!options.requireSpaceAfterColonsOrSemicolons ||
213
+ (currentWord !== ':' && currentWord !== ';') ||
214
+ whitespacePatternRegExp.test(wordSequence.getWordAt(wordIndex + 1)))
215
+
216
+ // If the current word is a phrase separator, close the current phrase at this word
217
+ if (isCurrentWordPhraseSeparator) {
218
+ // Tracks whether at least one whitespace word was seen while scanning trailing characters
219
+ let whitespaceSeenOnce = false
220
+
221
+ // Include any trailing punctuation (closing quotes, brackets, etc.) in the current phrase
222
+ while (wordIndex < sentenceEndWordOffset - 1) {
223
+ // Get the text of the next word
224
+ const nextWord = wordSequence.getWordAt(wordIndex + 1)
225
+
226
+ // Stop consuming if the next word is not trailing punctuation
227
+ if (!sentenceSeparatorTrailingPunctuationCharacterRegExp.test(nextWord)) {
228
+ break
229
+ }
230
+
231
+ // Remember if this trailing word is whitespace
232
+ if (whitespacePatternRegExp.test(nextWord)) {
233
+ whitespaceSeenOnce = true
234
+ }
235
+
236
+ // A closing double quote that follows whitespace starts the next phrase, so stop before it
237
+ if (nextWord === '"' && whitespaceSeenOnce) {
238
+ break
239
+ }
240
+
241
+ // Otherwise consume this trailing-punctuation word
242
+ wordIndex += 1
243
+ }
244
+
245
+ // Record the phrase, from its start offset through the separator (and any trailing punctuation)
246
+ phraseWordRanges.push({
247
+ start: phraseStartWordOffset,
248
+ end: wordIndex + 1
249
+ })
250
+
251
+ // The next phrase starts right after the current separator
252
+ phraseStartWordOffset = wordIndex + 1
253
+ }
254
+ }
255
+
256
+ // If the sentence ends with words that weren't part of any phrase, close the final phrase at the sentence end
257
+ if (phraseStartWordOffset < sentenceEndWordOffset) {
258
+ phraseWordRanges.push({ start: phraseStartWordOffset, end: sentenceEndWordOffset })
259
+ }
260
+
261
+ // Build a Phrase object for every phrase range and attach it to the sentence
262
+ for (const wordRange of phraseWordRanges) {
263
+ // Extract the slice of the word sequence that belongs to this phrase
264
+ const phraseWordSequence = wordSequence.slice(wordRange.start, wordRange.end)
265
+
266
+ // Create the Phrase for this range and add it to the sentence's phrase list
267
+ sentence.phrases.push(new Phrase(wordRange, phraseWordSequence))
268
+ }
269
+ }
270
+
271
+ // Assemble the final result: the full word sequence, the list of sentences, and the segment-to-sentence mapping
272
+ const result: SegmentationResult = {
273
+ words: wordSequence,
274
+ sentences,
275
+ segmentSentenceRanges,
276
+ }
277
+
278
+ // Return the assembled segmentation result
279
+ return result
280
+ }
281
+
282
+ // Cache of compiled word-splitter regular expressions, keyed by the JSON-serialized options
283
+ // (compiling a regex is expensive, so the same options reuse the same compiled expression)
284
+ const cachedWordSplitterRegExps = new Map<string, RegExp>()
285
+
286
+ // Splits a string of text into a WordSequence of individual words and punctuation marks
287
+ // The returned sequence contains both the matched words and the punctuation found between/beside them
288
+ export async function splitToWords(text: string, options?: SegmentationOptions) {
289
+ // If no options were given, start from an empty options object
290
+ if (!options) {
291
+ options = {}
292
+ }
293
+
294
+ // Fill in any option values the caller didn't provide with their defaults
295
+ options = { ...defaultSegmentationOptions, ...options }
296
+
297
+ // If a language was specified, normalize it to its short base code (e.g. "en-US" becomes "en")
298
+ // so that language-specific data is looked up consistently
299
+ if (options.language) {
300
+ options.language = getShortLanguageCode(options.language)
301
+ }
302
+
303
+ // Serialize the (now normalized) options to JSON so they can be used as a cache key
304
+ const optionsAsJson = JSON.stringify(options)
305
+
306
+ // Look up a previously compiled splitter expression for these exact options
307
+ let wordSplitterRegExp = cachedWordSplitterRegExps.get(optionsAsJson)
308
+
309
+ // If no cached expression exists, compile a new one and store it in the cache
310
+ if (!wordSplitterRegExp) {
311
+ wordSplitterRegExp = buildWordSplitterRegExpForOptions(options)
312
+
313
+ cachedWordSplitterRegExps.set(optionsAsJson, wordSplitterRegExp)
314
+ }
315
+
316
+ // Start with an empty word sequence that will accumulate the results
317
+ let wordSequence = new WordSequence()
318
+
319
+ // Adds the words found in a run of text between two matched words (typically punctuation and whitespace)
320
+ // to the word sequence. Spaces are skipped; every other character becomes its own punctuation word,
321
+ // except that consecutive punctuation characters are grouped into a single word.
322
+ function addPunctuationWordsBetween(startOffset: number, endOffset: number) {
323
+ // Extract the substring that lies between the two given character offsets
324
+ const punctuationWordSubstring = text.substring(startOffset, endOffset)
325
+
326
+ // The current character offset within the source text
327
+ let charOffset = startOffset
328
+ // The character offset at which the current punctuation word starts
329
+ let punctuationWordStartOffset = startOffset
330
+
331
+ // If any characters have been accumulated since the last flush, add them as a single punctuation word
332
+ function addPunctuationWordIfNeeded() {
333
+ // Check whether there are pending characters to add
334
+ if (charOffset > punctuationWordStartOffset) {
335
+ // Extract the pending word text from the source
336
+ const wordText = text.substring(punctuationWordStartOffset, charOffset)
337
+ // Add it to the sequence, marking it as punctuation
338
+ wordSequence.addWord(wordText, punctuationWordStartOffset, true)
339
+
340
+ // The next pending word (if any) starts at the current character offset
341
+ punctuationWordStartOffset = charOffset
342
+ }
343
+ }
344
+
345
+ // Iterate over every character (codepoint) in the gap between words
346
+ for (const char of punctuationWordSubstring) {
347
+ // Spaces are skipped: they just advance the character offset and are not added as words
348
+ if (char === ' ') {
349
+ charOffset += 1
350
+
351
+ continue
352
+ }
353
+
354
+ // Flush any pending punctuation word before the current character
355
+ addPunctuationWordIfNeeded()
356
+
357
+ // Advance the character offset past the current character (char.length handles astral codepoints)
358
+ charOffset += char.length
359
+
360
+ // Flush again so this single character becomes its own punctuation word
361
+ addPunctuationWordIfNeeded()
362
+ }
363
+
364
+ // Flush any remaining pending punctuation word at the end of the gap
365
+ addPunctuationWordIfNeeded()
366
+ }
367
+
368
+ // Find all word matches (as defined by the splitter expression) throughout the text
369
+ const wordMatches = text.matchAll(wordSplitterRegExp)
370
+
371
+ // Track the character offset at which the last matched word ended
372
+ let lastMatchEndOffset = 0
373
+
374
+ // Process each matched word in order of appearance
375
+ if (wordMatches) {
376
+ for (const match of wordMatches) {
377
+ // Read the match's start and end character offsets (available because the regex has the 'd' flag)
378
+ const offsets = match.indices![0]!
379
+ const matchStartOffset = offsets[0]
380
+ const matchEndOffset = offsets[1]
381
+
382
+ // If there is a gap between the previous match and this one, add the gap's punctuation as words
383
+ if (matchStartOffset > lastMatchEndOffset) {
384
+ addPunctuationWordsBetween(lastMatchEndOffset, matchStartOffset)
385
+ }
386
+
387
+ // Extract the matched word's text from the source
388
+ const wordText = text.substring(matchStartOffset, matchEndOffset)
389
+ // Add the matched word to the sequence (it is a real word, not punctuation)
390
+ wordSequence.addWord(wordText, matchStartOffset, false)
391
+
392
+ // Remember where this match ended so gaps after it can be found
393
+ lastMatchEndOffset = matchEndOffset
394
+ }
395
+
396
+ // If the text extends beyond the last match, add the trailing punctuation as words
397
+ addPunctuationWordsBetween(lastMatchEndOffset, text.length)
398
+ }
399
+
400
+ // If East Asian postprocessing is enabled, further split runs of East Asian characters into subwords using ICU word breaking
401
+ if (options.enableEastAsianPostprocessing) {
402
+ wordSequence = await postprocessEastAsianWords(text, wordSequence)
403
+ }
404
+
405
+ // Return the resulting word sequence
406
+ return wordSequence
407
+ }
408
+
409
+ // Add any missing punctuation words to a word sequence
410
+ // Given a word sequence that was built from the source text (with its character offsets) and the source text itself,
411
+ // produce a new word sequence that also includes every punctuation character found between, before, and after the words.
412
+ // Also returns a mapping from each new entry's index back to the original word index it came from
413
+ // (punctuation-only entries are not present as keys in this mapping).
414
+ export function addMissingPunctuationWordsToWordSequence(wordSequence: WordSequence, sourceText: string) {
415
+ // Maps each index in the new (punctuation-included) sequence to the index of the original word it was copied from
416
+ const originalWordsReverseMapping = new Map<number, number>()
417
+
418
+ // The new word sequence that will contain both the original words and the missing punctuation words
419
+ const wordSequenceWithPunctuation = new WordSequence()
420
+
421
+ // Adds entries for every character of a text slice, grouping runs of non-space characters into single punctuation words
422
+ // (spaces are merged into the preceding punctuation word, preserving them for offset correctness)
423
+ function addWordEntriesForTextSlice(textSlice: string, initialCharOffset: number) {
424
+ // The current character offset within the source text
425
+ let charOffset = initialCharOffset
426
+
427
+ // Add entry for every codepoint (this will correctly treat characters beyond BMP)
428
+ for (const char of textSlice) {
429
+ // Compute the source-text offset just past this character
430
+ const charEndOffset = charOffset + char.length
431
+
432
+ // Get the last entry added so far (if any)
433
+ const lastEntry = wordSequenceWithPunctuation.lastEntry
434
+
435
+ // If this is a space and the last entry is a punctuation word that already starts with a space, extend that entry
436
+ if (char === ' ' && lastEntry && lastEntry.isPunctuation && lastEntry.text[0] === ' ') {
437
+ wordSequenceWithPunctuation.lastEntry.text += ' '
438
+ wordSequenceWithPunctuation.lastEntry.endOffset = charEndOffset
439
+ } else {
440
+ // Otherwise, extract this single character from the source and add it as a new punctuation word
441
+ const wordText = sourceText.substring(charOffset, charEndOffset)
442
+
443
+ wordSequenceWithPunctuation.addWord(wordText, charOffset, true)
444
+ }
445
+
446
+ // Advance the character offset past the current character
447
+ charOffset = charEndOffset
448
+ }
449
+ }
450
+
451
+ // Process each original word in order
452
+ for (let wordIndex = 0; wordIndex < wordSequence.length; wordIndex++) {
453
+ // Get the current word entry and the offset where it starts in the source text
454
+ const wordEntry = wordSequence.getEntryAt(wordIndex)
455
+ const wordStartOffset = wordEntry.startOffset
456
+
457
+ // Get the end offset of the previous word (or 0 if this is the first word)
458
+ const previousWordEndOffset = wordIndex > 0 ? wordSequence.entries[wordIndex - 1].endOffset : 0
459
+
460
+ // Add entries for any punctuation characters between the current and previous word (or the start of the text)
461
+ if (previousWordEndOffset !== wordStartOffset) {
462
+ // Extract the slice of source text lying between the two words
463
+ const textSlice = sourceText.substring(previousWordEndOffset, wordStartOffset)
464
+
465
+ // Add entries for the punctuation characters in that slice
466
+ addWordEntriesForTextSlice(textSlice, previousWordEndOffset)
467
+ }
468
+
469
+ // Copy the original word entry into the new sequence
470
+ wordSequenceWithPunctuation.entries.push(wordEntry)
471
+ // Record that this new entry corresponds to the current original word index
472
+ originalWordsReverseMapping.set(wordSequenceWithPunctuation.length - 1, wordIndex)
473
+
474
+ // If last word, add entries for any trailing punctuation characters
475
+ if (wordIndex === wordSequence.length - 1) {
476
+ // If the source text extends beyond the last word, the remainder is trailing punctuation
477
+ if (sourceText.length !== wordEntry.endOffset) {
478
+ // Extract the slice of source text after the last word
479
+ const textSlice = sourceText.substring(wordEntry.endOffset, sourceText.length)
480
+
481
+ // Add entries for the trailing punctuation characters
482
+ addWordEntriesForTextSlice(textSlice, wordEntry.endOffset)
483
+ }
484
+ }
485
+ }
486
+
487
+ // Return the enriched word sequence along with the reverse mapping to the original words
488
+ return { wordSequenceWithPunctuation, originalWordsReverseMapping }
489
+ }
490
+
491
+ ////////////////////////////////////////////////////////////////////////////////////////////////
492
+ // Helper methods
493
+ ////////////////////////////////////////////////////////////////////////////////////////////////
494
+ // Builds a global regular expression that matches words in the given language, using the configured options
495
+ function buildWordSplitterRegExpForOptions(options: SegmentationOptions) {
496
+ // Get the CLDR suppression list (standard abbreviations like "Dr.") for this language, or an empty list if none exists
497
+ const cldrSuppressionsForLang = cldrSuppressions[options.language ?? ''] ?? []
498
+ // Get the extended, project-specific suppression list for this language, or an empty list
499
+ const extendedSuppressionsForLang = additionalSuppressions[options.language ?? ''] ?? []
500
+ // Get the leading-apostrophe contraction suppressions for this language, or an empty list
501
+ const contractionSuppressionsForLang = leadingApostropheContractionSuppressions[options.language ?? ''] ?? []
502
+ // Build a variant of those contractions that uses the typographic apostrophe (') instead of the straight one (')
503
+ const contractionSuppressionsForLangWithSingleQuote = contractionSuppressionsForLang.map(str => str.replaceAll(`'`, `’`))
504
+ // Get any custom suppressions the caller provided, or an empty list
505
+ const customSuppressions = options.customSuppressions ?? []
506
+
507
+ // Combine all suppression sources into one flat list
508
+ let suppressions = [
509
+ ...customSuppressions,
510
+ ...cldrSuppressionsForLang,
511
+ ...extendedSuppressionsForLang,
512
+ ...contractionSuppressionsForLang,
513
+ ...contractionSuppressionsForLangWithSingleQuote,
514
+ ...nounSuppressions,
515
+ ...tldSuppressions,
516
+ ]
517
+
518
+ // Build the word pattern: match any suppression (in original, lowercase, and uppercase forms) or a regular word
519
+ const wordPattern = buildWordSplitterPattern([
520
+ ...suppressions,
521
+ ...suppressions.map(word => word.toLocaleLowerCase()),
522
+ ...suppressions.map(word => word.toLocaleUpperCase()),
523
+ ])
524
+
525
+ // Compile the pattern into a global regular expression (the 'd' flag, added by regexp-composer, gives match indices)
526
+ const wordSplitterRegExp = buildRegExp(wordPattern, { global: true })
527
+
528
+ // Return the compiled regular expression
529
+ return wordSplitterRegExp
530
+ }
531
+
532
+ // Tries to load the ICU segmentation WebAssembly module; returns undefined if it is not installed
533
+ async function getIcuSegmentation() {
534
+ // Attempt to dynamically import the ICU segmentation module
535
+ try {
536
+ const icuSegmentation = await import('@echogarden/icu-segmentation-wasm')
537
+
538
+ // If the import succeeded, return the module
539
+ return icuSegmentation
540
+ } catch {
541
+ // If the import failed (module not available), signal that ICU segmentation is unavailable
542
+ return undefined
543
+ }
544
+ }
545
+
546
+ // Further splits words that contain East Asian characters (Chinese, Japanese, Thai, Khmer) into smaller subwords
547
+ // using ICU's word-break rules, which are much better suited to these scripts than the generic splitter pattern
548
+ async function postprocessEastAsianWords(containingText: string, wordSequence: WordSequence) {
549
+ // Try to load the ICU segmentation module
550
+ const icuSegmentation = await getIcuSegmentation()
551
+
552
+ // If ICU segmentation is unavailable, return the original word sequence unchanged
553
+ if (icuSegmentation === undefined) {
554
+ return wordSequence
555
+ }
556
+
557
+ // Tracks whether the ICU module has been initialized (initialization only needs to happen once)
558
+ let icuInitialized = false
559
+
560
+ // A new word sequence that will hold the postprocessed words
561
+ const newWordSequence = new WordSequence()
562
+
563
+ // Process each word in the original sequence
564
+ for (let wordIndex = 0; wordIndex < wordSequence.length; wordIndex++) {
565
+ // Get the current word entry and the offset where it starts in the source text
566
+ const wordEntry = wordSequence.entries[wordIndex]
567
+ const wordStartOffset = wordEntry.startOffset
568
+
569
+ // Get the text of the current word
570
+ const word = wordSequence.getWordAt(wordIndex)
571
+
572
+ // If the word contains East Asian characters, split it into subwords
573
+ if (eastAsianCharRangesRegExp.test(word)) {
574
+ // Initialize the ICU module on first use
575
+ if (!icuInitialized) {
576
+ await icuSegmentation.initialize()
577
+
578
+ icuInitialized = true
579
+ }
580
+
581
+ // Get the word-break positions within the word (offsets into the word string)
582
+ const wordBreaks = [...icuSegmentation.createWordBreakIterator(word)]
583
+
584
+ // Create a subword for every pair of consecutive break positions
585
+ for (let i = 0; i < wordBreaks.length - 1; i++) {
586
+ // Compute the subword's start and end offsets within the source text
587
+ const subwordStartOffset = wordStartOffset + wordBreaks[i]
588
+ const subwordEndOffset = wordStartOffset + wordBreaks[i + 1]
589
+
590
+ // Extract the subword text from the containing text
591
+ const subwordText = containingText.substring(subwordStartOffset, subwordEndOffset)
592
+
593
+ // Add the subword to the new sequence (it is a real word, not punctuation)
594
+ newWordSequence.addWord(
595
+ subwordText,
596
+ subwordStartOffset,
597
+ false,
598
+ )
599
+ }
600
+ } else {
601
+ // Otherwise, extract the word text from the source and copy it to the new sequence unchanged
602
+ const wordText = containingText.substring(wordEntry.startOffset, wordEntry.endOffset)
603
+
604
+ newWordSequence.addWord(wordText, wordEntry.startOffset, wordEntry.isPunctuation)
605
+ }
606
+ }
607
+
608
+ // Return the postprocessed word sequence
609
+ return newWordSequence
610
+ }
611
+
612
+ // Computes the character ranges of all punctuation in the text.
613
+ // This includes gaps between words, entries that are themselves punctuation, and any leading/trailing text
614
+ // that is not covered by a word entry.
615
+ function getPunctuationRanges(wordSequence: WordSequence, text: string) {
616
+ // This will hold the character ranges of the punctuation found
617
+ const punctuationRanges: Range[] = []
618
+ // Get the list of word entries
619
+ const wordEntries = wordSequence.entries
620
+
621
+ // If the text starts before the first word, the leading part is punctuation
622
+ if (wordEntries[0].startOffset > 0) {
623
+ punctuationRanges.push({ start: 0, end: wordEntries[0].startOffset })
624
+ }
625
+
626
+ // Examine every word entry
627
+ for (let i = 0; i < wordEntries.length; i++) {
628
+ // Get the current entry
629
+ const entry = wordEntries[i]
630
+
631
+ // Get the end offset of the previous entry (or 0 for the first entry)
632
+ const previousEndOffset = wordEntries[i - 1]?.endOffset ?? 0
633
+
634
+ // If there is a gap between the previous entry and this one, that gap is punctuation
635
+ if (entry.startOffset > previousEndOffset) {
636
+ punctuationRanges.push({ start: previousEndOffset, end: entry.startOffset })
637
+ }
638
+
639
+ // If the current entry is itself a punctuation word, its whole range is punctuation
640
+ if (entry.isPunctuation) {
641
+ punctuationRanges.push({ start: entry.startOffset, end: entry.endOffset })
642
+ }
643
+ }
644
+
645
+ {
646
+ // Get the end offset of the last entry (if any)
647
+ const lastEndOffset = wordEntries[wordEntries.length - 1]?.endOffset
648
+
649
+ // If the text extends beyond the last entry, the trailing part is punctuation
650
+ if (lastEndOffset && lastEndOffset < text.length) {
651
+ punctuationRanges.push({ start: lastEndOffset, end: text.length })
652
+ }
653
+ }
654
+
655
+ // Return the collected punctuation ranges
656
+ return punctuationRanges
657
+ }
658
+
659
+
660
+ ////////////////////////////////////////////////////////////////////////////////////////////////
661
+ // Types
662
+ ////////////////////////////////////////////////////////////////////////////////////////////////
663
+
664
+ // Describes the result of segmenting a text: the full word sequence, the detected sentences,
665
+ // and how the sentences map back to the segments (paragraphs) they belong to
666
+ // (segmentSentenceRanges[i] is the range of sentence indices within segment i)
667
+ export interface SegmentationResult {
668
+ words: WordSequence
669
+ sentences: Sentence[]
670
+ segmentSentenceRanges: Range[]
671
+ }
672
+
673
+ // A fragment of text (either a sentence or a phrase): it holds the word range it occupies and its words
674
+ // (this is the shared base class of Sentence and Phrase)
675
+ export class TextFragment {
676
+ // The range of word indices this fragment covers (in the overall word sequence)
677
+ wordRange: Range
678
+ // The words that make up this fragment
679
+ words: WordSequence
680
+
681
+ // Creates a fragment from a word range and the corresponding slice of words
682
+ constructor(wordRange: Range, words: WordSequence) {
683
+ this.wordRange = wordRange
684
+ this.words = words
685
+ }
686
+
687
+ // Returns the fragment's text by concatenating all of its words
688
+ get text() {
689
+ return this.words.text
690
+ }
691
+
692
+ // Returns the character range of this fragment within the source text,
693
+ // from the start of its first word to the end of its last word
694
+ get charRange(): Range {
695
+ return {
696
+ start: this.words.firstEntry.startOffset,
697
+ end: this.words.lastEntry.endOffset
698
+ }
699
+ }
700
+ }
701
+
702
+ // A sentence: a text fragment that additionally contains the phrases it was split into
703
+ // (phrases are filled in during segmentation)
704
+ export class Sentence extends TextFragment {
705
+ // The phrases that make up this sentence
706
+ phrases: Phrase[] = []
707
+ }
708
+
709
+ // A phrase: a text fragment representing one part of a sentence
710
+ // (it has no additional members; it exists to give phrases their own type)
711
+ export class Phrase extends TextFragment {
712
+ }
713
+
714
+ // Represents a range of indices (either word indices or character offsets)
715
+ // with an inclusive start and an exclusive end
716
+ export interface Range {
717
+ start: number
718
+ end: number
719
+ }
720
+
721
+ // Options that control how text is split into words
722
+ export interface SegmentationOptions {
723
+ language?: string
724
+ customSuppressions?: string[]
725
+ enableEastAsianPostprocessing?: boolean
726
+ }
727
+
728
+ // Default options for word splitting: no language, no custom suppressions, and East Asian postprocessing enabled
729
+ export const defaultSegmentationOptions: SegmentationOptions = {
730
+ language: '',
731
+ customSuppressions: [],
732
+ enableEastAsianPostprocessing: true,
733
+ }
734
+
735
+ // Options that control how a word sequence is segmented into sentences and phrases
736
+ export interface WordSequenceSegmentationOptions {
737
+ requireSpaceAfterColonsOrSemicolons: boolean
738
+ }
739
+
740
+ // Default options for word-sequence segmentation: ':' and ';' only count as phrase separators when followed by a space
741
+ export const defaultWordSequenceSegmentationOptions: WordSequenceSegmentationOptions = {
742
+ requireSpaceAfterColonsOrSemicolons: true
743
+ }