@pith-hash/pith-unicode 0.0.0 → 0.1.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/index.js +288 -1
- package/package.json +33 -14
- package/prebuilds/darwin-arm64/libpith_unicode.dylib +0 -0
- package/prebuilds/linux-x64/libpith_unicode.so +0 -0
- package/prebuilds/win32-x64/pith_unicode.dll +0 -0
- package/reference.json +3453 -0
- package/README.md +0 -2
package/index.js
CHANGED
|
@@ -1 +1,288 @@
|
|
|
1
|
-
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 pith-hash
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* pith-unicode SDK: Unicode NFC/NFD normalisation through koffi.
|
|
7
|
+
*
|
|
8
|
+
* Every function validates its input as UTF-8 inside the Rust core and
|
|
9
|
+
* returns a fresh Buffer — the handed-out cdylib buffer is copied and
|
|
10
|
+
* released before returning. Invalid UTF-8 throws an `FfiError` with
|
|
11
|
+
* `status === -2`; it is never a crash.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const koffi = require("koffi");
|
|
15
|
+
const fs = require("node:fs");
|
|
16
|
+
const path = require("node:path");
|
|
17
|
+
|
|
18
|
+
const STATUS_OK = 0;
|
|
19
|
+
const STATUS_INVALID = -1;
|
|
20
|
+
const STATUS_REJECTED = -2;
|
|
21
|
+
|
|
22
|
+
/** Normalization form codes (UAX #15), the FFI `form` argument encoding. */
|
|
23
|
+
const FORM_NFC = 1;
|
|
24
|
+
const FORM_NFD = 2;
|
|
25
|
+
const FORM_NFKC = 3;
|
|
26
|
+
const FORM_NFKD = 4;
|
|
27
|
+
|
|
28
|
+
/** Accepted spellings of a normalization form: the codes above or their names. */
|
|
29
|
+
const NORMALIZATION_FORMS = { NFC: FORM_NFC, NFD: FORM_NFD, NFKC: FORM_NFKC, NFKD: FORM_NFKD };
|
|
30
|
+
|
|
31
|
+
/** Every cdylib file name cargo may drop into the build directory, per platform. */
|
|
32
|
+
const CDYLIB_NAMES = ["pith_unicode.dll", "libpith_unicode.so", "libpith_unicode.dylib"];
|
|
33
|
+
|
|
34
|
+
const PKG_ROOT = path.join(__dirname);
|
|
35
|
+
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
|
36
|
+
|
|
37
|
+
/** Backs the data pointer for empty inputs: the FFI contract treats a
|
|
38
|
+
* NULL data pointer as a caller bug (status -1), so an empty Buffer
|
|
39
|
+
* must hand over a valid non-null pointer that reads as zero bytes. */
|
|
40
|
+
const EMPTY_SCRATCH = Buffer.alloc(1);
|
|
41
|
+
|
|
42
|
+
/** FfiError: a non-zero status code came back from the cdylib. */
|
|
43
|
+
class FfiError extends Error {
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} op the FFI operation name
|
|
46
|
+
* @param {number} status the raw status code
|
|
47
|
+
*/
|
|
48
|
+
constructor(op, status) {
|
|
49
|
+
const kind = { [STATUS_INVALID]: "invalid argument", [STATUS_REJECTED]: "input rejected" }[status] ?? "unknown failure";
|
|
50
|
+
super(`${op} failed: ${kind} (status ${status})`);
|
|
51
|
+
this.name = "FfiError";
|
|
52
|
+
/** The raw status code the FFI returned. */
|
|
53
|
+
this.status = status;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Locates the cdylib through the suite's discovery chain.
|
|
59
|
+
* @returns {string} an absolute path to the cdylib file
|
|
60
|
+
* @throws {Error} when nothing is found
|
|
61
|
+
*/
|
|
62
|
+
function findCdylib() {
|
|
63
|
+
const explicit = process.env.PITH_CDYLIB;
|
|
64
|
+
if (explicit && fs.statSync(explicit, { throwIfNoEntry: false })?.isFile()) {
|
|
65
|
+
return path.resolve(explicit);
|
|
66
|
+
}
|
|
67
|
+
/** @type {string[]} */
|
|
68
|
+
const dirs = [];
|
|
69
|
+
const envDir = process.env.PITH_CDYLIB_DIR;
|
|
70
|
+
if (envDir) {
|
|
71
|
+
dirs.push(envDir);
|
|
72
|
+
if (!path.isAbsolute(envDir)) {
|
|
73
|
+
dirs.push(path.join(REPO_ROOT, envDir));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const osArch = `${process.platform}-${process.arch}`;
|
|
77
|
+
dirs.push(path.join(PKG_ROOT, "prebuilds", osArch));
|
|
78
|
+
dirs.push(path.join(PKG_ROOT, "prebuilds"));
|
|
79
|
+
dirs.push(path.join(REPO_ROOT, "target", "release"));
|
|
80
|
+
for (const dir of dirs) {
|
|
81
|
+
for (const name of CDYLIB_NAMES) {
|
|
82
|
+
const p = path.join(dir, name);
|
|
83
|
+
if (fs.statSync(p, { throwIfNoEntry: false })?.isFile()) return p;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
throw new Error(
|
|
87
|
+
"no pith-unicode cdylib found (searched PITH_CDYLIB, PITH_CDYLIB_DIR, prebuilds/ and <repo>/target/release); " +
|
|
88
|
+
"run `cargo build --release` first",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let cached = undefined;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Loads the cdylib and binds the exported symbols (lazily, once).
|
|
96
|
+
* Exported so the null-pointer refusal tests can drive the raw
|
|
97
|
+
* binding with a null data pointer.
|
|
98
|
+
*
|
|
99
|
+
* @returns {{nfc: Function, nfd: Function, free: Function}}
|
|
100
|
+
*/
|
|
101
|
+
function loadLibrary() {
|
|
102
|
+
if (cached) return cached;
|
|
103
|
+
const lib = koffi.load(findCdylib());
|
|
104
|
+
// One shared prototype shape: nfc, nfd, nfkc, nfkd and both folds have
|
|
105
|
+
// identical C signatures.
|
|
106
|
+
const bind = (name) =>
|
|
107
|
+
lib.func(name, "int32_t", [
|
|
108
|
+
"const uint8_t *",
|
|
109
|
+
"size_t",
|
|
110
|
+
koffi.out(koffi.pointer("void *")),
|
|
111
|
+
koffi.out(koffi.pointer("size_t")),
|
|
112
|
+
]);
|
|
113
|
+
const nfc = bind("pith_unicode_nfc");
|
|
114
|
+
const nfd = bind("pith_unicode_nfd");
|
|
115
|
+
const nfkc = bind("pith_unicode_nfkc");
|
|
116
|
+
const nfkd = bind("pith_unicode_nfkd");
|
|
117
|
+
const casefold = bind("pith_unicode_casefold");
|
|
118
|
+
const casefoldSimple = bind("pith_unicode_casefold_simple");
|
|
119
|
+
const free = lib.func("void pith_unicode_free(void *ptr, size_t len)");
|
|
120
|
+
const isNormalized = lib.func("pith_unicode_is_normalized", "int32_t", [
|
|
121
|
+
"uint32_t", // normalization form code
|
|
122
|
+
"const uint8_t *", // data
|
|
123
|
+
"size_t", // len
|
|
124
|
+
koffi.out(koffi.pointer("uint8_t")), // the 0/1 answer
|
|
125
|
+
]);
|
|
126
|
+
cached = { nfc, nfd, nfkc, nfkd, casefold, casefoldSimple, free, isNormalized };
|
|
127
|
+
return cached;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Resolves a normalization form given as a code or name.
|
|
131
|
+
*
|
|
132
|
+
* @param {number|string} form the form code (FORM_NFC…) or name ("NFC"…)
|
|
133
|
+
* @returns {number} the FFI form code
|
|
134
|
+
*/
|
|
135
|
+
function formCode(form) {
|
|
136
|
+
if (typeof form === "string") {
|
|
137
|
+
const code = NORMALIZATION_FORMS[form.toUpperCase()];
|
|
138
|
+
if (code === undefined) {
|
|
139
|
+
throw new TypeError(`unknown normalization form ${form}`);
|
|
140
|
+
}
|
|
141
|
+
return code;
|
|
142
|
+
}
|
|
143
|
+
if (Object.values(NORMALIZATION_FORMS).includes(form)) {
|
|
144
|
+
return form;
|
|
145
|
+
}
|
|
146
|
+
throw new TypeError(`unknown normalization form code ${form}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The loadLibrary() key of each form's buffer op. */
|
|
150
|
+
const FORM_KEYS = { [FORM_NFC]: "nfc", [FORM_NFD]: "nfd", [FORM_NFKC]: "nfkc", [FORM_NFKD]: "nfkd" };
|
|
151
|
+
|
|
152
|
+
/** Runs one buffer-handed-out op and copies the handed-out cdylib buffer
|
|
153
|
+
* into a fresh Buffer before releasing it.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} key the loadLibrary() binding key (for errors)
|
|
156
|
+
* @param {Buffer} data the UTF-8 bytes to transform
|
|
157
|
+
* @returns {Buffer} the transformed UTF-8 bytes
|
|
158
|
+
* @throws {FfiError} with `status === -2` for bytes that are not valid UTF-8
|
|
159
|
+
*/
|
|
160
|
+
function bufferOp(key, data) {
|
|
161
|
+
if (!Buffer.isBuffer(data)) {
|
|
162
|
+
throw new TypeError("data must be a Buffer");
|
|
163
|
+
}
|
|
164
|
+
const lib = loadLibrary();
|
|
165
|
+
const op = lib[key];
|
|
166
|
+
const out = [null];
|
|
167
|
+
const outLen = [0];
|
|
168
|
+
// koffi hands a NULL pointer for a zero-length Buffer, but the FFI
|
|
169
|
+
// contract treats NULL as a caller bug (status -1); empty input must
|
|
170
|
+
// read as zero bytes off a valid non-null pointer instead.
|
|
171
|
+
const ptr = data.length > 0 ? data : EMPTY_SCRATCH;
|
|
172
|
+
const status = op(ptr, data.length, out, outLen);
|
|
173
|
+
if (status !== STATUS_OK) {
|
|
174
|
+
throw new FfiError(key, status);
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
// koffi.decode hands back a Uint8Array view over the external
|
|
178
|
+
// buffer; copy it into a Buffer before the cdylib buffer is freed.
|
|
179
|
+
return Buffer.from(koffi.decode(out[0], "uint8_t", Number(outLen[0])));
|
|
180
|
+
} finally {
|
|
181
|
+
lib.free(out[0], Number(outLen[0]));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Normalizes `data` to Unicode Normalization Form C (canonical
|
|
187
|
+
* composition) and returns the UTF-8 result as a fresh Buffer.
|
|
188
|
+
*
|
|
189
|
+
* @param {Buffer} data the UTF-8 bytes to normalize
|
|
190
|
+
* @returns {Buffer} the NFC-normalized bytes (empty in → empty out)
|
|
191
|
+
* @throws {FfiError} with `status === -2` for invalid UTF-8
|
|
192
|
+
*/
|
|
193
|
+
function nfc(data) {
|
|
194
|
+
return bufferOp("nfc", data);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Normalizes `data` to Unicode Normalization Form D (canonical
|
|
199
|
+
* decomposition), with the same contract as `nfc`.
|
|
200
|
+
*/
|
|
201
|
+
function nfd(data) {
|
|
202
|
+
return bufferOp("nfd", data);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Normalizes `data` to Unicode Normalization Form KC (compatibility
|
|
207
|
+
* composition), with the same contract as `nfc`.
|
|
208
|
+
*/
|
|
209
|
+
function nfkc(data) {
|
|
210
|
+
return bufferOp("nfkc", data);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Normalizes `data` to Unicode Normalization Form KD (compatibility
|
|
215
|
+
* decomposition), with the same contract as `nfc`.
|
|
216
|
+
*/
|
|
217
|
+
function nfkd(data) {
|
|
218
|
+
return bufferOp("nfkd", data);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Normalizes `data` to `form` — a NORMALIZATION_FORMS name ("NFC", "NFD",
|
|
223
|
+
* "NFKC", "NFKD", any case) or one of the FORM_NFC…FORM_NFKD codes — with
|
|
224
|
+
* the same contract as `nfc`.
|
|
225
|
+
*/
|
|
226
|
+
function normalize(form, data) {
|
|
227
|
+
return bufferOp(FORM_KEYS[formCode(form)], data);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Answers whether `data` is already in normalization `form` — the exact
|
|
232
|
+
* UAX #15 quick-check answer, never a pessimistic approximation.
|
|
233
|
+
*
|
|
234
|
+
* @throws {TypeError} for an unknown form
|
|
235
|
+
* @throws {FfiError} with `status === -2` for invalid UTF-8
|
|
236
|
+
*/
|
|
237
|
+
function isNormalized(form, data) {
|
|
238
|
+
if (!Buffer.isBuffer(data)) {
|
|
239
|
+
throw new TypeError("data must be a Buffer");
|
|
240
|
+
}
|
|
241
|
+
const lib = loadLibrary();
|
|
242
|
+
const out = [0xff];
|
|
243
|
+
const status = lib.isNormalized(formCode(form), data.length > 0 ? data : EMPTY_SCRATCH, data.length, out);
|
|
244
|
+
if (status !== STATUS_OK) {
|
|
245
|
+
throw new FfiError("pith_unicode_is_normalized", status);
|
|
246
|
+
}
|
|
247
|
+
return out[0] === 1;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Folds `data` to its full case folding (UAX #44 statuses `C` and `F`;
|
|
252
|
+
* mappings may expand, e.g. ß → ss). The Turkic `T` entries are locale
|
|
253
|
+
* data and are not applied. Same contract as `nfc`.
|
|
254
|
+
*/
|
|
255
|
+
function casefold(data) {
|
|
256
|
+
return bufferOp("casefold", data);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Folds `data` to its simple case folding (statuses `C` and `S`): a
|
|
261
|
+
* strict one-to-one mapping (ẞ → ß, ß → itself).
|
|
262
|
+
*/
|
|
263
|
+
function casefoldSimple(data) {
|
|
264
|
+
return bufferOp("casefoldSimple", data);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = {
|
|
268
|
+
STATUS_OK,
|
|
269
|
+
STATUS_INVALID,
|
|
270
|
+
STATUS_REJECTED,
|
|
271
|
+
CDYLIB_NAMES,
|
|
272
|
+
FORM_NFC,
|
|
273
|
+
FORM_NFD,
|
|
274
|
+
FORM_NFKC,
|
|
275
|
+
FORM_NFKD,
|
|
276
|
+
NORMALIZATION_FORMS,
|
|
277
|
+
FfiError,
|
|
278
|
+
findCdylib,
|
|
279
|
+
loadLibrary,
|
|
280
|
+
nfc,
|
|
281
|
+
nfd,
|
|
282
|
+
nfkc,
|
|
283
|
+
nfkd,
|
|
284
|
+
normalize,
|
|
285
|
+
isNormalized,
|
|
286
|
+
casefold,
|
|
287
|
+
casefoldSimple,
|
|
288
|
+
};
|
package/package.json
CHANGED
|
@@ -1,14 +1,33 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@pith-hash/pith-unicode",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@pith-hash/pith-unicode",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Unicode NFC/NFD normalisation SDK via the pith-unicode Rust cdylib (koffi, prebuilt)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.js",
|
|
9
|
+
"prebuilds/",
|
|
10
|
+
"reference.json"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node --test"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"koffi": "^2.12.0"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/pith-hash/pith-unicode.git",
|
|
24
|
+
"directory": "sdk/node"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"unicode",
|
|
28
|
+
"nfc",
|
|
29
|
+
"nfd",
|
|
30
|
+
"normalization",
|
|
31
|
+
"hashing"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|