@stll/text-normalize 0.0.1-placeholder.0 → 0.1.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/README.md CHANGED
@@ -1 +1,81 @@
1
- Placeholder for @stll/text-normalize; real releases are published from https://github.com/stella/stella.
1
+ # @stll/text-normalize
2
+
3
+ Search match-key text normalization. Folds orthographic variants so a
4
+ search query matches text regardless of how it was typed.
5
+
6
+ The normalized output is a **match key**: it is lossy and intended only
7
+ for indexing and query comparison. Never store or display it in place of
8
+ the original text.
9
+
10
+ ## Arabic
11
+
12
+ `arabicNormalize` folds the orthographic variants that make Arabic
13
+ search miss otherwise-identical words:
14
+
15
+ - alef variants and alef-wasla (`أ إ آ ٱ`) to bare alef `ا`
16
+ - waw-hamza `ؤ` to `و`, yeh-hamza `ئ` to `ي`, standalone hamza `ء` dropped
17
+ - teh marbuta `ة` to heh `ه`
18
+ - alef maksura `ى` to yeh `ي`
19
+ - tashkeel (harakat), superscript alef, and tatweel removed
20
+ - Arabic-Indic and Extended Arabic-Indic digits to ASCII
21
+
22
+ It also runs NFKC (folding presentation forms), locale-stable ASCII case
23
+ folding, and whitespace collapse, so it is a safe pass-through for
24
+ non-Arabic scripts.
25
+
26
+ The fold tables are vendored from Lucene's `ArabicNormalizer` (Apache-2.0)
27
+ and cross-checked against CAMeL Tools (MIT), extended for the classes
28
+ Lucene omits.
29
+
30
+ ```ts
31
+ import { arabicNormalize } from "@stll/text-normalize";
32
+
33
+ arabicNormalize("أحمد") === arabicNormalize("احمد"); // true
34
+ ```
35
+
36
+ The same fold must be reproduced by the PostgreSQL `arabic_normalize()`
37
+ function used in contacts trigram expression indexes and search predicates;
38
+ the golden vectors in `src/normalize.test.ts` pin the shared contract.
39
+
40
+ ## Diacritics
41
+
42
+ `stripDiacritics` decomposes with NFD and removes every combining mark
43
+ (`\p{Diacritic}`, so marks beyond the U+0300–U+036F block are covered
44
+ too). Use it for accent-insensitive search keys.
45
+
46
+ `foldToAscii` keeps that strip as its base and adds the folds a
47
+ decomposition cannot express, so it matches PostgreSQL `unaccent()` for
48
+ every Latin-script character for which the extension declares a rule:
49
+ `ł` to `l`, `đ` to `d`, `ø` to `o`, `ß` to `ss`, `æ` to `ae`, and the
50
+ rest of the table in `src/ascii-fold-table.ts`. Use it wherever the
51
+ counterpart is an `unaccent`-folded index (or a Tantivy `ascii_folding`
52
+ one); a plain mark strip leaves `ł` standing and under-matches the
53
+ lexemes the index actually stored. Non-Latin scripts are out of contract
54
+ and pass through with their marks stripped.
55
+
56
+ `stripDiacriticsForSlug` is the same strip but decomposes with NFKD, so
57
+ compatibility characters (ligatures, full-width forms, superscripts) fold
58
+ to their base before a `[a-z0-9]` slug filter runs. Slugs are persisted,
59
+ public URL segments; this variant pins the exact form existing slugs were
60
+ generated with.
61
+
62
+ ## Substring matching with highlight offsets
63
+
64
+ `foldSearchMatchText` is the client-side match key for interactive
65
+ filtering: NFKD, the `unaccent()`-parity ASCII folds, mark strip, Arabic
66
+ folds, lowercase — `capek` matches `Čapek` and `wroclaw` matches
67
+ `Wrocław`, both ways. `findSearchMatchRanges` finds a folded query in
68
+ folded content and returns ranges into the **original** string, so
69
+ highlights wrap the text the user sees even where folding changes string
70
+ length; it accepts content pre-folded with `foldSearchMatchTextWithOffsets`
71
+ so repeated queries against the same text fold it once.
72
+
73
+ ## Spaced letters
74
+
75
+ `collapseSpacedLetters` joins letter-spaced court headings
76
+ (`r o z h o d o l :` to `rozhodol:`) back into searchable words. It
77
+ requires at least four spaced letters, so Czech/Slovak single-letter word
78
+ lists (`a b c`) are left intact. `spacedLetterRunRegex()` returns the
79
+ underlying matcher for callers that need per-match offsets (find-in-page
80
+ highlighting). Index-time and query-time collapse share this one
81
+ threshold so a heading collapses identically on both sides.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Arabic orthographic fold tables for SEARCH match-key normalization.
3
+ *
4
+ * Vendored from Lucene's ArabicNormalizer (Apache-2.0) and cross-checked
5
+ * against CAMeL Tools (MIT), then extended for the classes Lucene omits:
6
+ * alef-wasla, waw/yeh/standalone hamza, superscript alef, and Arabic-Indic
7
+ * digits. Fold directions (alef variants to bare alef, teh-marbuta to heh,
8
+ * alef-maksura to yeh) are the settled Lucene/CAMeL consensus.
9
+ *
10
+ * These folds are lossy match-key transforms: never apply them to stored
11
+ * or displayed text, only to search keys.
12
+ */
13
+ export declare const ARABIC_LETTER_FOLDS: Readonly<Record<string, string>>;
14
+ export declare const ARABIC_REMOVED: readonly string[];
15
+ export declare const ARABIC_DIGIT_FOLDS: Readonly<Record<string, string>>;
16
+ /**
17
+ * Apply the Arabic letter, digit, and removal folds codepoint by
18
+ * codepoint. Non-Arabic characters pass through unchanged.
19
+ *
20
+ * Deliberately does not normalize. Unicode normalization is whole-string
21
+ * work — NFKC composes across codepoint boundaries — so it belongs to
22
+ * the caller, before the fold: `arabicNormalize` applies NFKC to the
23
+ * whole string and then calls this. Folding is a per-codepoint table
24
+ * lookup and stays that way.
25
+ */
26
+ export declare const applyArabicFolds: (text: string) => string;
27
+ export type FoldedText = {
28
+ sourceEndIndex: number[];
29
+ text: string;
30
+ truncated: boolean;
31
+ sourceIndex: number[];
32
+ };
33
+ type ApplyArabicFoldsWithOffsetsOptions = {
34
+ maxFoldedUnits?: number;
35
+ };
36
+ /**
37
+ * Fold Arabic variants and return an offset map, so a caller matching
38
+ * against the folded text (find-in-page, search previews) can slice the
39
+ * original at the right positions.
40
+ *
41
+ * Not simply `applyArabicFolds` plus offsets. This variant additionally
42
+ * normalizes NFKC one character at a time, because it is meant for raw
43
+ * document text: Arabic PDFs carry presentation forms (`ﺍﺣﻤﺪ`) and
44
+ * ligatures (`ﷲ`, `ﷺ`) that must expand to canonical letters to be
45
+ * searchable at all. Per character, not whole string, is what keeps
46
+ * every unit of an expansion pointing back at the one source character
47
+ * it came from — whole-string NFKC composes across boundaries and would
48
+ * describe positions the map no longer has. The `maxFoldedUnits` budget
49
+ * exists for the same reason: one ligature can expand to eighteen
50
+ * characters.
51
+ *
52
+ * The consequence is a deliberate asymmetry with `applyArabicFolds`,
53
+ * which never normalizes. On a compatibility character the two disagree:
54
+ *
55
+ * applyArabicFolds("fi") === "fi"
56
+ * applyArabicFoldsWithOffsets("fi") === "fi"
57
+ *
58
+ * They agree on NFKC-normalized input, and that is the state both sides
59
+ * of a comparison are in wherever the two are used together: the API's
60
+ * search highlighter folds its source through `normalizeSourceWithMappings`
61
+ * and each candidate through `arabicNormalize`, and both apply
62
+ * whole-string NFKC before either fold runs. Feeding raw text to one side
63
+ * and normalized text to the other is what would misplace a highlight, so
64
+ * normalize both or neither.
65
+ */
66
+ export declare const applyArabicFoldsWithOffsets: (input: string, options?: ApplyArabicFoldsWithOffsetsOptions) => FoldedText;
67
+ export {};
package/dist/arabic.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Arabic orthographic fold tables for SEARCH match-key normalization.
3
+ *
4
+ * Vendored from Lucene's ArabicNormalizer (Apache-2.0) and cross-checked
5
+ * against CAMeL Tools (MIT), then extended for the classes Lucene omits:
6
+ * alef-wasla, waw/yeh/standalone hamza, superscript alef, and Arabic-Indic
7
+ * digits. Fold directions (alef variants to bare alef, teh-marbuta to heh,
8
+ * alef-maksura to yeh) are the settled Lucene/CAMeL consensus.
9
+ *
10
+ * These folds are lossy match-key transforms: never apply them to stored
11
+ * or displayed text, only to search keys.
12
+ */
13
+ // One-to-one letter folds (source codepoint to target codepoint).
14
+ export const ARABIC_LETTER_FOLDS = {
15
+ آ: "ا", // آ alef madda -> ا alef
16
+ أ: "ا", // أ alef hamza above -> ا alef
17
+ إ: "ا", // إ alef hamza below -> ا alef
18
+ ٱ: "ا", // ٱ alef wasla -> ا alef
19
+ ؤ: "و", // ؤ waw hamza -> و waw
20
+ ئ: "ي", // ئ yeh hamza -> ي yeh
21
+ ة: "ه", // ة teh marbuta -> ه heh
22
+ ى: "ي", // ى alef maksura -> ي yeh
23
+ };
24
+ // Codepoints folded to nothing (removed). Tatweel, the eight harakat
25
+ // (U+064B–U+0652), decomposed hamza/madda marks, superscript alef, and
26
+ // standalone hamza.
27
+ export const ARABIC_REMOVED = [
28
+ "ء", // ء standalone hamza
29
+ "ـ", // ـ tatweel / kashida
30
+ "ً", // fathatan
31
+ "ٌ", // dammatan
32
+ "ٍ", // kasratan
33
+ "َ", // fatha
34
+ "ُ", // damma
35
+ "ِ", // kasra
36
+ "ّ", // shadda
37
+ "ْ", // sukun
38
+ "ٓ", // madda above
39
+ "ٔ", // hamza above
40
+ "ٕ", // hamza below
41
+ "ٰ", // superscript alef
42
+ ];
43
+ // Arabic-Indic (U+0660–0669) and Extended Arabic-Indic (U+06F0–06F9)
44
+ // digits to ASCII. Same mapping as @stll/stdnum's internal digit table;
45
+ // inlined here to avoid depending on another package's private util.
46
+ export const ARABIC_DIGIT_FOLDS = {
47
+ "٠": "0",
48
+ "١": "1",
49
+ "٢": "2",
50
+ "٣": "3",
51
+ "٤": "4",
52
+ "٥": "5",
53
+ "٦": "6",
54
+ "٧": "7",
55
+ "٨": "8",
56
+ "٩": "9",
57
+ "۰": "0",
58
+ "۱": "1",
59
+ "۲": "2",
60
+ "۳": "3",
61
+ "۴": "4",
62
+ "۵": "5",
63
+ "۶": "6",
64
+ "۷": "7",
65
+ "۸": "8",
66
+ "۹": "9",
67
+ };
68
+ const FOLD_MAP = new Map([
69
+ ...Object.entries(ARABIC_LETTER_FOLDS),
70
+ ...Object.entries(ARABIC_DIGIT_FOLDS),
71
+ ...ARABIC_REMOVED.map((char) => [char, ""]),
72
+ ]);
73
+ /**
74
+ * Apply the Arabic letter, digit, and removal folds codepoint by
75
+ * codepoint. Non-Arabic characters pass through unchanged.
76
+ *
77
+ * Deliberately does not normalize. Unicode normalization is whole-string
78
+ * work — NFKC composes across codepoint boundaries — so it belongs to
79
+ * the caller, before the fold: `arabicNormalize` applies NFKC to the
80
+ * whole string and then calls this. Folding is a per-codepoint table
81
+ * lookup and stays that way.
82
+ */
83
+ export const applyArabicFolds = (text) => {
84
+ // Fast path for the single-code-unit calls made character-by-character
85
+ // when building offset maps; avoids an array allocation per character.
86
+ if (text.length === 1) {
87
+ return FOLD_MAP.get(text) ?? text;
88
+ }
89
+ const out = [];
90
+ for (const char of text) {
91
+ out.push(FOLD_MAP.get(char) ?? char);
92
+ }
93
+ return out.join("");
94
+ };
95
+ /**
96
+ * Fold Arabic variants and return an offset map, so a caller matching
97
+ * against the folded text (find-in-page, search previews) can slice the
98
+ * original at the right positions.
99
+ *
100
+ * Not simply `applyArabicFolds` plus offsets. This variant additionally
101
+ * normalizes NFKC one character at a time, because it is meant for raw
102
+ * document text: Arabic PDFs carry presentation forms (`ﺍﺣﻤﺪ`) and
103
+ * ligatures (`ﷲ`, `ﷺ`) that must expand to canonical letters to be
104
+ * searchable at all. Per character, not whole string, is what keeps
105
+ * every unit of an expansion pointing back at the one source character
106
+ * it came from — whole-string NFKC composes across boundaries and would
107
+ * describe positions the map no longer has. The `maxFoldedUnits` budget
108
+ * exists for the same reason: one ligature can expand to eighteen
109
+ * characters.
110
+ *
111
+ * The consequence is a deliberate asymmetry with `applyArabicFolds`,
112
+ * which never normalizes. On a compatibility character the two disagree:
113
+ *
114
+ * applyArabicFolds("fi") === "fi"
115
+ * applyArabicFoldsWithOffsets("fi") === "fi"
116
+ *
117
+ * They agree on NFKC-normalized input, and that is the state both sides
118
+ * of a comparison are in wherever the two are used together: the API's
119
+ * search highlighter folds its source through `normalizeSourceWithMappings`
120
+ * and each candidate through `arabicNormalize`, and both apply
121
+ * whole-string NFKC before either fold runs. Feeding raw text to one side
122
+ * and normalized text to the other is what would misplace a highlight, so
123
+ * normalize both or neither.
124
+ */
125
+ export const applyArabicFoldsWithOffsets = (input, options = {}) => {
126
+ const parts = [];
127
+ const sourceEndIndex = [];
128
+ const sourceIndex = [];
129
+ const maxFoldedUnits = options.maxFoldedUnits ?? Number.POSITIVE_INFINITY;
130
+ let foldedUnits = 0;
131
+ let originalUnit = 0;
132
+ let truncated = false;
133
+ for (const char of input) {
134
+ const replacement = applyArabicFolds(char.normalize("NFKC"));
135
+ if (foldedUnits + replacement.length > maxFoldedUnits) {
136
+ truncated = true;
137
+ break;
138
+ }
139
+ const originalEnd = originalUnit + char.length;
140
+ parts.push(replacement);
141
+ // One offset entry per UTF-16 code unit of the replacement; folds are
142
+ // BMP, but an unfolded astral passthrough spans two code units.
143
+ let unit = 0;
144
+ while (unit < replacement.length) {
145
+ sourceIndex.push(originalUnit);
146
+ sourceEndIndex.push(originalEnd);
147
+ unit += 1;
148
+ }
149
+ foldedUnits += replacement.length;
150
+ originalUnit = originalEnd;
151
+ }
152
+ sourceIndex.push(originalUnit);
153
+ sourceEndIndex.push(originalUnit);
154
+ return { sourceEndIndex, text: parts.join(""), truncated, sourceIndex };
155
+ };
156
+ //# sourceMappingURL=arabic.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"arabic.js","sourceRoot":"","sources":["../src/arabic.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Latin-script fold table: the characters PostgreSQL `unaccent()` folds that
3
+ * NFD plus combining-mark removal cannot reach, either because the letter has
4
+ * no canonical decomposition (`ł`, `đ`, `ø`, `ß`) or because Unicode
5
+ * classifies it as a diacritic and a mark strip deletes it outright (`ʰ`, `ᴬ`).
6
+ *
7
+ * Generated from the extension's own rules, so the two sides cannot drift by
8
+ * hand:
9
+ *
10
+ * ```sql
11
+ * SELECT cp, unaccent(chr(cp))
12
+ * FROM generate_series(1, 196607) AS cp
13
+ * WHERE NOT (cp BETWEEN 55296 AND 57343)
14
+ * AND unaccent(chr(cp)) IS DISTINCT FROM chr(cp);
15
+ * ```
16
+ *
17
+ * A row becomes an entry below when its character is `\p{Script=Latin}` and its
18
+ * `unaccent` output differs from NFD plus mark removal. Non-Latin scripts stay
19
+ * out of the contract and pass through unchanged; see `foldToAscii`.
20
+ *
21
+ * `apps/api/src/lib/search/ascii-fold-unaccent-parity.db.test.ts` re-runs the
22
+ * query against a live PostgreSQL and asserts both directions, so an extension
23
+ * upgrade that adds, drops, or retargets a rule fails CI instead of silently
24
+ * splitting the query-side fold from the indexed lexemes.
25
+ *
26
+ * Keys are single code points; values are ASCII, which is what makes
27
+ * `foldToAscii` idempotent.
28
+ */
29
+ export declare const ASCII_FOLD_TABLE: Readonly<Record<string, string>>;