persian-normalize 1.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/LICENSE +21 -0
- package/README.md +145 -0
- package/package.json +56 -0
- package/src/index.d.ts +62 -0
- package/src/index.js +216 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Qatreh (https://qatrehai.ir)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# persian-normalize
|
|
2
|
+
|
|
3
|
+
Make Persian and Arabic-script text comparable. Zero dependencies, ESM, TypeScript types included.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install persian-normalize
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## The problem
|
|
10
|
+
|
|
11
|
+
In Persian, one word can be written several ways that look identical to a reader and are completely different to a computer:
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
const a = "چتبات"; // with a zero-width non-joiner (U+200C)
|
|
15
|
+
const b = "چت بات"; // with an ordinary space
|
|
16
|
+
|
|
17
|
+
a === b // false
|
|
18
|
+
a.length === b.length // true ← the trap: length tells you nothing
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
None of these variants is a spelling mistake. All appear in published Persian, and all are encoded distinctly in Unicode:
|
|
22
|
+
|
|
23
|
+
| Variation | Example |
|
|
24
|
+
|---|---|
|
|
25
|
+
| Zero-width non-joiner | `میرود` · `می رود` · `میرود` |
|
|
26
|
+
| Persian yeh vs Arabic yeh | `ی` (U+06CC) vs `ي` (U+064A) |
|
|
27
|
+
| Persian keheh vs Arabic kaf | `ک` (U+06A9) vs `ك` (U+0643) |
|
|
28
|
+
| Heh vs teh marbuta | `شرکه` vs `شرکة` |
|
|
29
|
+
| Three digit sets | `۱۴۰۳` · `١٤٠٣` · `1403` |
|
|
30
|
+
| Diacritics and tatweel | `مَدرسه` · `مدـــرسه` |
|
|
31
|
+
|
|
32
|
+
A system that recognises one form of `چتبات` fails three users in four — and it fails **silently**, because nothing crashes. It just returns the wrong answer.
|
|
33
|
+
|
|
34
|
+
## Two jobs, opposite settings
|
|
35
|
+
|
|
36
|
+
The library separates the two things people constantly conflate.
|
|
37
|
+
|
|
38
|
+
### `cleanText` — for text you store or display
|
|
39
|
+
|
|
40
|
+
Repairs what a wrong keyboard layout produced, drops decoration, tidies spacing. **Keeps the text readable.**
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
import { cleanText } from "persian-normalize";
|
|
44
|
+
|
|
45
|
+
cleanText("ميكنم") // "میکنم" Arabic yeh and kaf repaired
|
|
46
|
+
cleanText("شركة") // "شرکه" teh marbuta fixed
|
|
47
|
+
cleanText("مَدرسه") // "مدرسه" harakat dropped
|
|
48
|
+
cleanText("١٤٠٣") // "۱۴۰۳" Arabic-Indic digits to Persian
|
|
49
|
+
cleanText("آموزش") // "آموزش" the madda is correct Persian — untouched
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The ZWNJ survives, because deleting it turns `میرود` into `میرود`, which is a different and worse spelling.
|
|
53
|
+
|
|
54
|
+
### `foldForSearch` — for text you compare
|
|
55
|
+
|
|
56
|
+
Flattens every variant into one form. The output is for matching, not for reading.
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
import { foldForSearch, equals } from "persian-normalize";
|
|
60
|
+
|
|
61
|
+
foldForSearch("چتبات") // "چت بات"
|
|
62
|
+
foldForSearch("۱۴۰۳") // "1403"
|
|
63
|
+
foldForSearch("سلام، دنیا!") // "سلام دنیا"
|
|
64
|
+
|
|
65
|
+
equals("چتبات", "چت بات") // true
|
|
66
|
+
equals("شرکة", "شرکه") // true
|
|
67
|
+
equals("۱۴۰۳", "1403") // true
|
|
68
|
+
equals("آموزش", "سازمان") // false — different words stay different
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**The ZWNJ becomes a space, not nothing.** Delete it and `چتبات` becomes `چتبات`, which still does not equal `چت بات`. Turn it into a space and both spellings arrive at the same two tokens.
|
|
72
|
+
|
|
73
|
+
## Matching without the substring trap
|
|
74
|
+
|
|
75
|
+
The other half of the problem. Persian attaches prefixes and suffixes freely, so a plain `includes()` finds words that are not there:
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
"آموزش سازمانی دارید؟".includes("زمان") // true — «زمان» hides inside «سازمانی»
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
That one line routes a question about *enterprise training* to a page about *project timelines*, and the answer reads perfectly well. Nobody notices.
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
import { containsWord, tokenize } from "persian-normalize";
|
|
85
|
+
|
|
86
|
+
containsWord("آموزش سازمانی دارید؟", "زمان") // false ✓
|
|
87
|
+
containsWord("دوره آموزشی ما", "آموزش") // true — long enough to prefix-match
|
|
88
|
+
containsWord("قیمت چتبات چقدر است", "چت بات") // true — phrase, across spellings
|
|
89
|
+
|
|
90
|
+
tokenize("چتبات سازمانی!") // ["چت", "بات", "سازمانی"]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Only the final token of a phrase may match by prefix, and only when it is at least `minPrefix` characters (default 5) — so `آموزش` still matches `آموزشی`, while `زمان` no longer matches `سازمانی`.
|
|
94
|
+
|
|
95
|
+
## API
|
|
96
|
+
|
|
97
|
+
| Function | Purpose |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `cleanText(s, opts?)` | Repair for storage or display |
|
|
100
|
+
| `foldForSearch(s, opts?)` | Flatten for comparison |
|
|
101
|
+
| `equals(a, b)` | Compare ignoring spelling variation |
|
|
102
|
+
| `tokenize(s)` | Folded tokens |
|
|
103
|
+
| `containsWord(haystack, needle, opts?)` | Whole-word or phrase match |
|
|
104
|
+
| `ZWNJ` | The U+200C character |
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
cleanText(input, {
|
|
108
|
+
digitsToLatin?: boolean, // ۱۲۳ -> 123 default false
|
|
109
|
+
arabicDigitsToPersian?: boolean, // ١٢٣ -> ۱۲۳ default true
|
|
110
|
+
diacritics?: boolean, // strip harakat default true
|
|
111
|
+
collapseSpaces?: boolean, // default true
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
foldForSearch(input, { keepPunctuation?: boolean }) // default false
|
|
115
|
+
containsWord(haystack, needle, { minPrefix?: number }) // default 5
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Notes
|
|
119
|
+
|
|
120
|
+
**This is not `String.prototype.normalize()`.** Unicode NFC/NFKC compose and decompose characters, but Persian yeh and Arabic yeh are *separate letters with separate meanings*, not two encodings of one character. Unicode will not merge them, and it should not. Script folding is an application-level decision.
|
|
121
|
+
|
|
122
|
+
**Normalize your reference strings too**, at start-up. A keyword list typed by a developer on one layout and a query typed by a user on another will otherwise never meet, however good the folding is on the input side.
|
|
123
|
+
|
|
124
|
+
**It is not only chatbots.** Anywhere Persian text is compared: product search, customer-name lookup, address matching, deduplication. In a database, two spellings of one company name remain two separate records.
|
|
125
|
+
|
|
126
|
+
## Tests
|
|
127
|
+
|
|
128
|
+
Zero dependencies, including dev ones.
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
node --test test/
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Every case came from a real failure in a production Persian assistant, which is why the negative assertions — the things that must *not* fold together — carry as much weight as the positive ones.
|
|
135
|
+
|
|
136
|
+
## Background
|
|
137
|
+
|
|
138
|
+
The reasoning behind each step, and how the failures were found:
|
|
139
|
+
[Why Your Persian Chatbot Answers the Wrong Question](https://qatrehai.ir/blog/persian-chatbot-text-normalization-en)
|
|
140
|
+
|
|
141
|
+
## Licence
|
|
142
|
+
|
|
143
|
+
MIT © [Qatreh](https://qatrehai.ir) — an AI team in Karaj, Iran.
|
|
144
|
+
|
|
145
|
+
Found a variant this misses? Please open an issue — that is exactly the contribution this needs.
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "persian-normalize",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Normalize Persian and Arabic-script text. Fold ZWNJ, Arabic yeh/kaf, three digit sets and diacritics into one comparable form — or clean text for storage without destroying it.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"module": "./src/index.js",
|
|
8
|
+
"types": "./src/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.d.ts",
|
|
12
|
+
"import": "./src/index.js",
|
|
13
|
+
"default": "./src/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test test/"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"persian",
|
|
28
|
+
"farsi",
|
|
29
|
+
"arabic",
|
|
30
|
+
"normalize",
|
|
31
|
+
"normalization",
|
|
32
|
+
"zwnj",
|
|
33
|
+
"half-space",
|
|
34
|
+
"nim-fasele",
|
|
35
|
+
"rtl",
|
|
36
|
+
"unicode",
|
|
37
|
+
"text",
|
|
38
|
+
"search",
|
|
39
|
+
"nlp",
|
|
40
|
+
"tokenize",
|
|
41
|
+
"diacritics",
|
|
42
|
+
"persian-digits",
|
|
43
|
+
"arabic-digits",
|
|
44
|
+
"i18n"
|
|
45
|
+
],
|
|
46
|
+
"author": "Qatreh <qatreh.ai@gmail.com> (https://qatrehai.ir)",
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"homepage": "https://qatrehai.ir/blog/persian-chatbot-text-normalization-en",
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/qatrehai/persian-normalize.git"
|
|
52
|
+
},
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/qatrehai/persian-normalize/issues"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Zero-width non-joiner (U+200C) — invisible, meaningful, and the usual culprit. */
|
|
2
|
+
export declare const ZWNJ: "";
|
|
3
|
+
|
|
4
|
+
export interface CleanTextOptions {
|
|
5
|
+
/** ۱۲۳ -> 123. Off by default: Persian text usually wants Persian digits. */
|
|
6
|
+
digitsToLatin?: boolean;
|
|
7
|
+
/** ١٢٣ -> ۱۲۳. On by default; the Arabic-Indic set is a keyboard accident. */
|
|
8
|
+
arabicDigitsToPersian?: boolean;
|
|
9
|
+
/** Strip harakat and tatweel. On by default. */
|
|
10
|
+
diacritics?: boolean;
|
|
11
|
+
/** Collapse runs of spaces without disturbing the ZWNJ. On by default. */
|
|
12
|
+
collapseSpaces?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FoldOptions {
|
|
16
|
+
/** Keep punctuation instead of collapsing it to whitespace. */
|
|
17
|
+
keepPunctuation?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ContainsOptions {
|
|
21
|
+
/** Shortest token allowed to match by prefix. Default 5. */
|
|
22
|
+
minPrefix?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Repair text for storage or display. Keeps the ZWNJ, punctuation and case, so
|
|
27
|
+
* the result still reads as the language it came from.
|
|
28
|
+
*/
|
|
29
|
+
export declare function cleanText(input: string, options?: CleanTextOptions): string;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Fold text into one comparable form for matching. Not meant to be read back:
|
|
33
|
+
* letter variants, all three digit sets, case and punctuation are flattened.
|
|
34
|
+
*/
|
|
35
|
+
export declare function foldForSearch(input: string, options?: FoldOptions): string;
|
|
36
|
+
|
|
37
|
+
/** Do two strings mean the same thing, ignoring spelling variation? */
|
|
38
|
+
export declare function equals(a: string, b: string): boolean;
|
|
39
|
+
|
|
40
|
+
/** Split folded text into tokens. */
|
|
41
|
+
export declare function tokenize(input: string): string[];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Does `haystack` contain `needle` as whole words? The needle may be a phrase;
|
|
45
|
+
* its tokens must appear in order and adjacent. Only the final token may match
|
|
46
|
+
* by prefix, and only when it is at least `minPrefix` characters.
|
|
47
|
+
*/
|
|
48
|
+
export declare function containsWord(
|
|
49
|
+
haystack: string,
|
|
50
|
+
needle: string,
|
|
51
|
+
options?: ContainsOptions
|
|
52
|
+
): boolean;
|
|
53
|
+
|
|
54
|
+
declare const _default: {
|
|
55
|
+
cleanText: typeof cleanText;
|
|
56
|
+
foldForSearch: typeof foldForSearch;
|
|
57
|
+
equals: typeof equals;
|
|
58
|
+
tokenize: typeof tokenize;
|
|
59
|
+
containsWord: typeof containsWord;
|
|
60
|
+
ZWNJ: typeof ZWNJ;
|
|
61
|
+
};
|
|
62
|
+
export default _default;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* persian-normalize — make Persian and Arabic-script text comparable.
|
|
3
|
+
*
|
|
4
|
+
* Two jobs that are constantly confused, and that need opposite settings:
|
|
5
|
+
*
|
|
6
|
+
* cleanText(s) Repair text you are going to STORE or DISPLAY. Fixes the
|
|
7
|
+
* characters a wrong keyboard layout produces, drops
|
|
8
|
+
* decoration, tidies spacing — and keeps the text readable.
|
|
9
|
+
* The zero-width non-joiner survives, because «میرود» without
|
|
10
|
+
* it is «میرود», which is a different and worse spelling.
|
|
11
|
+
*
|
|
12
|
+
* foldForSearch(s) Reduce text you are going to COMPARE to a single form.
|
|
13
|
+
* Folds every variant together, ASCII digits, no
|
|
14
|
+
* punctuation, no case. The output is not meant to be read.
|
|
15
|
+
*
|
|
16
|
+
* Using cleanText for matching leaves «چتبات» and «چت بات» unequal. Using
|
|
17
|
+
* foldForSearch for storage destroys the writing. Pick by what you are doing.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Zero-width non-joiner: invisible, meaningful, and the usual culprit. */
|
|
21
|
+
export const ZWNJ = "";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Characters an Arabic keyboard produces where Persian expects its own letter.
|
|
25
|
+
* These are always safe to repair: no Persian word wants the Arabic form.
|
|
26
|
+
*/
|
|
27
|
+
const KEYBOARD_FIXES = {
|
|
28
|
+
"ي": "ی", // ARABIC YEH ي -> ی
|
|
29
|
+
"ى": "ی", // ALEF MAKSURA ى -> ی
|
|
30
|
+
"ك": "ک", // ARABIC KAF ك -> ک
|
|
31
|
+
"ة": "ه", // TEH MARBUTA ة -> ه
|
|
32
|
+
"ۀ": "ه", // HEH WITH YEH ABOVE ۀ -> ه
|
|
33
|
+
"ۍ": "ی", // YEH WITH TAIL
|
|
34
|
+
"ـ": "", // TATWEEL — decoration only
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Further folds that lose information, so they belong to search only.
|
|
39
|
+
* «آب» and «اب» are different words; collapsing them is right for matching
|
|
40
|
+
* and wrong for storage.
|
|
41
|
+
*/
|
|
42
|
+
const SEARCH_FOLDS = {
|
|
43
|
+
"آ": "ا", // ALEF WITH MADDA آ -> ا
|
|
44
|
+
"أ": "ا", // ALEF WITH HAMZA ABOVE أ -> ا
|
|
45
|
+
"إ": "ا", // ALEF WITH HAMZA BELOW إ -> ا
|
|
46
|
+
"ٱ": "ا", // ALEF WASLA
|
|
47
|
+
"ؤ": "و", // WAW WITH HAMZA ؤ -> و
|
|
48
|
+
"ئ": "ی", // YEH WITH HAMZA ئ -> ی
|
|
49
|
+
"ء": "", // HAMZA on its own
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Harakat, sukun, dagger alef — optional vowel marks that survive pasting. */
|
|
53
|
+
const DIACRITICS = /[ً-ْٰٓ-ٕٖ-ٟۖ-ۭ]/g;
|
|
54
|
+
|
|
55
|
+
/** Bidi controls and other invisibles that break comparison silently. */
|
|
56
|
+
const INVISIBLES = /[--]/g;
|
|
57
|
+
|
|
58
|
+
const PERSIAN_DIGITS = /[۰-۹]/g; // ۰-۹
|
|
59
|
+
const ARABIC_DIGITS = /[٠-٩]/g; // ٠-٩
|
|
60
|
+
|
|
61
|
+
const mapChars = (input, table) => {
|
|
62
|
+
let out = "";
|
|
63
|
+
for (const ch of input) out += ch in table ? table[ch] : ch;
|
|
64
|
+
return out;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Repair text for storage or display.
|
|
69
|
+
*
|
|
70
|
+
* Keeps the ZWNJ, keeps punctuation, keeps letter case, and leaves Persian
|
|
71
|
+
* digits as Persian digits unless asked otherwise — the result should still
|
|
72
|
+
* read as the language it came from.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} input
|
|
75
|
+
* @param {object} [options]
|
|
76
|
+
* @param {boolean} [options.digitsToLatin=false] ۱۲۳ -> 123
|
|
77
|
+
* @param {boolean} [options.arabicDigitsToPersian=true] ١٢٣ -> ۱۲۳
|
|
78
|
+
* @param {boolean} [options.diacritics=true] strip harakat and tatweel
|
|
79
|
+
* @param {boolean} [options.collapseSpaces=true]
|
|
80
|
+
* @returns {string}
|
|
81
|
+
*/
|
|
82
|
+
export function cleanText(input, options = {}) {
|
|
83
|
+
if (typeof input !== "string") return "";
|
|
84
|
+
const {
|
|
85
|
+
digitsToLatin = false,
|
|
86
|
+
arabicDigitsToPersian = true,
|
|
87
|
+
diacritics = true,
|
|
88
|
+
collapseSpaces = true,
|
|
89
|
+
} = options;
|
|
90
|
+
|
|
91
|
+
let s = input.replace(INVISIBLES, "");
|
|
92
|
+
if (diacritics) s = s.replace(DIACRITICS, "");
|
|
93
|
+
s = mapChars(s, KEYBOARD_FIXES);
|
|
94
|
+
|
|
95
|
+
if (digitsToLatin) {
|
|
96
|
+
s = s.replace(PERSIAN_DIGITS, (d) => String(d.charCodeAt(0) - 0x06f0))
|
|
97
|
+
.replace(ARABIC_DIGITS, (d) => String(d.charCodeAt(0) - 0x0660));
|
|
98
|
+
} else if (arabicDigitsToPersian) {
|
|
99
|
+
s = s.replace(ARABIC_DIGITS, (d) => String.fromCharCode(d.charCodeAt(0) - 0x0660 + 0x06f0));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (collapseSpaces) {
|
|
103
|
+
// Collapse runs of ordinary spaces, but never touch the ZWNJ, and never
|
|
104
|
+
// let a space sit next to one — that pair renders as a double gap.
|
|
105
|
+
s = s.replace(/[ \t]+/g, " ")
|
|
106
|
+
.replace(new RegExp(`\\s*${ZWNJ}\\s*`, "g"), ZWNJ)
|
|
107
|
+
.replace(/ *\n */g, "\n")
|
|
108
|
+
.trim();
|
|
109
|
+
}
|
|
110
|
+
return s;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Fold text into one comparable form.
|
|
115
|
+
*
|
|
116
|
+
* The output is for matching, not for reading. Everything that can vary is
|
|
117
|
+
* flattened: letter variants, all three digit sets, case, punctuation.
|
|
118
|
+
*
|
|
119
|
+
* The ZWNJ becomes a SPACE rather than being deleted. Delete it and «چتبات»
|
|
120
|
+
* becomes «چتبات», which still does not equal «چت بات»; turn it into a space
|
|
121
|
+
* and both spellings arrive at the same two tokens.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} input
|
|
124
|
+
* @param {object} [options]
|
|
125
|
+
* @param {boolean} [options.keepPunctuation=false]
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
export function foldForSearch(input, options = {}) {
|
|
129
|
+
if (typeof input !== "string") return "";
|
|
130
|
+
const { keepPunctuation = false } = options;
|
|
131
|
+
|
|
132
|
+
let s = input
|
|
133
|
+
.replace(/&(?:amp|lt|gt|quot|nbsp|#x27|#39);/g, " ")
|
|
134
|
+
.replace(DIACRITICS, "");
|
|
135
|
+
|
|
136
|
+
// Every invisible, the ZWNJ included, becomes a space.
|
|
137
|
+
s = s.replace(new RegExp(`[${ZWNJ}--]`, "g"), " ");
|
|
138
|
+
|
|
139
|
+
s = mapChars(s, KEYBOARD_FIXES);
|
|
140
|
+
s = mapChars(s, SEARCH_FOLDS);
|
|
141
|
+
|
|
142
|
+
s = s.replace(PERSIAN_DIGITS, (d) => String(d.charCodeAt(0) - 0x06f0))
|
|
143
|
+
.replace(ARABIC_DIGITS, (d) => String(d.charCodeAt(0) - 0x0660))
|
|
144
|
+
.toLowerCase();
|
|
145
|
+
|
|
146
|
+
if (!keepPunctuation) s = s.replace(/[^\p{L}\p{N}]+/gu, " ");
|
|
147
|
+
return s.replace(/\s+/g, " ").trim();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Do two strings mean the same thing, ignoring spelling variation?
|
|
152
|
+
* @param {string} a
|
|
153
|
+
* @param {string} b
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
export function equals(a, b) {
|
|
157
|
+
return foldForSearch(a) === foldForSearch(b);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Split folded text into tokens.
|
|
162
|
+
*
|
|
163
|
+
* Compare at token level rather than by substring. `"آموزش سازمانی".includes("زمان")`
|
|
164
|
+
* is true — «زمان» hides inside «سازمانی» — and that one mistake routes a
|
|
165
|
+
* question about enterprise training to a page about project timelines.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} input
|
|
168
|
+
* @returns {string[]}
|
|
169
|
+
*/
|
|
170
|
+
export function tokenize(input) {
|
|
171
|
+
const folded = foldForSearch(input);
|
|
172
|
+
return folded ? folded.split(" ") : [];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Does `haystack` contain `needle` as whole words?
|
|
177
|
+
*
|
|
178
|
+
* The needle may be a phrase. That is not a nicety: «چت بات» is two tokens and
|
|
179
|
+
* «چتبات» is one string, and a term-matching function that only handles single
|
|
180
|
+
* words cannot answer the most common question anyone asks of Persian text.
|
|
181
|
+
* The tokens must appear in order and adjacent.
|
|
182
|
+
*
|
|
183
|
+
* Only the final token may match by prefix, and only when it is long enough
|
|
184
|
+
* that a shared opening cannot be coincidental — so «آموزش» still matches
|
|
185
|
+
* «آموزشی», while «زمان» no longer matches «سازمانی».
|
|
186
|
+
*
|
|
187
|
+
* @param {string} haystack
|
|
188
|
+
* @param {string} needle single word or phrase
|
|
189
|
+
* @param {object} [options]
|
|
190
|
+
* @param {number} [options.minPrefix=5] shortest token allowed to prefix-match
|
|
191
|
+
* @returns {boolean}
|
|
192
|
+
*/
|
|
193
|
+
export function containsWord(haystack, needle, options = {}) {
|
|
194
|
+
const { minPrefix = 5 } = options;
|
|
195
|
+
const target = tokenize(needle);
|
|
196
|
+
if (!target.length) return false;
|
|
197
|
+
const words = tokenize(haystack);
|
|
198
|
+
if (target.length > words.length) return false;
|
|
199
|
+
|
|
200
|
+
for (let i = 0; i <= words.length - target.length; i++) {
|
|
201
|
+
let matched = true;
|
|
202
|
+
for (let j = 0; j < target.length; j++) {
|
|
203
|
+
const word = words[i + j];
|
|
204
|
+
const tok = target[j];
|
|
205
|
+
if (word === tok) continue;
|
|
206
|
+
const isLast = j === target.length - 1;
|
|
207
|
+
if (isLast && tok.length >= minPrefix && word.startsWith(tok)) continue;
|
|
208
|
+
matched = false;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
if (matched) return true;
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export default { cleanText, foldForSearch, equals, tokenize, containsWord, ZWNJ };
|