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 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 };