Skip to main content

inputx_pinyin_wasm/
lib.rs

1//! WASM bindings for `inputx-pinyin`. Exposes a small, JS-friendly API
2//! that browser and Node consumers can import via `wasm-pack` output.
3//!
4//! Build:
5//!     wasm-pack build crates/inputx-pinyin-wasm --target web --release
6//!
7//! Default build bakes the bootstrap dict (1.7 KB) — the full 15 MB
8//! `pinyin.fst` is too large for typical wasm bundles. Item 33 of the
9//! workspace ROADMAP is the proper streaming-load story; until then,
10//! pass `--no-default-features` (which propagates to the lib crate) to
11//! bake the full dict.
12//!
13//! Usage (JS):
14//! ```js
15//! import init, { PinyinEngine } from "@goliapkg/pinyin";
16//! await init();
17//! const eng = new PinyinEngine();
18//! const candidates = eng.lookup("zhongguo");      // ["中国", ...]
19//! eng.recordPick("zhongguo", "中国");              // user picked candidate
20//! const state = eng.exportL0();                    // structured object
21//! localStorage.setItem("pinyin-l0", JSON.stringify(state));
22//! // later:
23//! eng.importL0(JSON.parse(localStorage.getItem("pinyin-l0")));
24//! ```
25
26use wasm_bindgen::prelude::*;
27
28use inputx_pinyin::{L0Snapshot, PinyinDict, char_to_pinyin};
29
30/// Pinyin engine wrapping the embedded FST dictionary plus a per-instance
31/// L0 layer (in-memory; persistence is up to the host via `exportL0` /
32/// `importL0`).
33#[wasm_bindgen]
34pub struct PinyinEngine {
35    dict: PinyinDict,
36}
37
38#[wasm_bindgen]
39impl PinyinEngine {
40    /// Construct from the embedded FST. Cheap (validates the FST header
41    /// and initializes empty L0).
42    #[wasm_bindgen(constructor)]
43    pub fn new() -> PinyinEngine {
44        Self {
45            dict: PinyinDict::embedded(),
46        }
47    }
48
49    /// Total number of `(pinyin, word)` entries in the embedded FST.
50    #[wasm_bindgen(getter)]
51    pub fn len(&self) -> usize {
52        self.dict.len()
53    }
54
55    /// `true` iff the embedded FST has zero entries.
56    #[wasm_bindgen(getter, js_name = isEmpty)]
57    pub fn is_empty(&self) -> bool {
58        self.dict.is_empty()
59    }
60
61    /// Words exactly matching `pinyin` (lowercase a–z), L0/freq ranked.
62    pub fn lookup(&self, pinyin: &str) -> js_sys::Array {
63        let arr = js_sys::Array::new();
64        for word in self.dict.lookup(pinyin) {
65            arr.push(&JsValue::from_str(&word));
66        }
67        arr
68    }
69
70    /// `{pinyin, word}` pairs whose pinyin starts with `prefix`.
71    #[wasm_bindgen(js_name = prefix)]
72    pub fn prefix_lookup(&self, prefix: &str) -> js_sys::Array {
73        let arr = js_sys::Array::new();
74        for (pinyin, word) in self.dict.prefix(prefix) {
75            let obj = js_sys::Object::new();
76            let _ = js_sys::Reflect::set(&obj, &"pinyin".into(), &JsValue::from_str(&pinyin));
77            let _ = js_sys::Reflect::set(&obj, &"word".into(), &JsValue::from_str(&word));
78            arr.push(&obj);
79        }
80        arr
81    }
82
83    /// Pinyin readings for a single Han character (`""` if unknown).
84    pub fn encode(&self, ch: &str) -> js_sys::Array {
85        let arr = js_sys::Array::new();
86        let Some(c) = ch.chars().next() else {
87            return arr;
88        };
89        for r in char_to_pinyin(c) {
90            arr.push(&JsValue::from_str(&r));
91        }
92        arr
93    }
94
95    // -------------------------------------------------------------------
96    // L0 mutation — host calls these to drive learning. All counter logic
97    // lives inside `inputx-pinyin`; the host only signals events.
98    // -------------------------------------------------------------------
99
100    /// Tell the dictionary that the user just committed `word` for
101    /// `pinyin`. Returns `true` if this call caused an auto-promotion to
102    /// L0.
103    #[wasm_bindgen(js_name = recordPick)]
104    pub fn record_pick(&self, pinyin: &str, word: &str) -> bool {
105        self.dict.record_pick(pinyin, word)
106    }
107
108    /// Force-pin a word as L0 default for `pinyin` without going through
109    /// the pick counter. Returns `true` if `(pinyin, word)` exists in L1.
110    pub fn pin(&self, pinyin: &str, word: &str) -> bool {
111        self.dict.pin(pinyin, word)
112    }
113
114    /// Drop the L0 pin AND any pending pick counters for `pinyin`.
115    pub fn forget(&self, pinyin: &str) -> bool {
116        self.dict.forget(pinyin)
117    }
118
119    /// Number of L0 pinned pinyins.
120    #[wasm_bindgen(js_name = l0PinCount, getter)]
121    pub fn l0_pin_count(&self) -> usize {
122        self.dict.l0_pin_count()
123    }
124
125    /// Number of distinct (pinyin, word) pairs with pending pick counters.
126    #[wasm_bindgen(js_name = l0PendingCount, getter)]
127    pub fn l0_pending_count(&self) -> usize {
128        self.dict.l0_pending_count()
129    }
130
131    /// Snapshot the L0 state as a JS object:
132    /// ```js
133    /// { pins: [[pinyin, word], ...],
134    ///   pickCounts: [[pinyin, word, n], ...] }
135    /// ```
136    /// Caller should `JSON.stringify` this for persistence.
137    #[wasm_bindgen(js_name = exportL0)]
138    pub fn export_l0(&self) -> JsValue {
139        let snap = self.dict.export_l0();
140        let obj = js_sys::Object::new();
141
142        let pins = js_sys::Array::new();
143        for (p, w) in &snap.pins {
144            let pair = js_sys::Array::new();
145            pair.push(&JsValue::from_str(p));
146            pair.push(&JsValue::from_str(w));
147            pins.push(&pair);
148        }
149        let _ = js_sys::Reflect::set(&obj, &"pins".into(), &pins);
150
151        let counts = js_sys::Array::new();
152        for (p, w, n) in &snap.pick_counts {
153            let triple = js_sys::Array::new();
154            triple.push(&JsValue::from_str(p));
155            triple.push(&JsValue::from_str(w));
156            triple.push(&JsValue::from_f64(*n as f64));
157            counts.push(&triple);
158        }
159        let _ = js_sys::Reflect::set(&obj, &"pickCounts".into(), &counts);
160
161        obj.into()
162    }
163
164    /// Replace the L0 state from a JS object with the same shape produced
165    /// by `exportL0`. Entries whose `(pinyin, word)` no longer exist in
166    /// L1 are silently dropped. Returns the count of accepted pins.
167    #[wasm_bindgen(js_name = importL0)]
168    pub fn import_l0(&self, state: JsValue) -> usize {
169        let Some(obj) = state.dyn_ref::<js_sys::Object>() else {
170            return 0;
171        };
172        let snap = parse_snapshot(obj);
173        self.dict.import_l0(snap)
174    }
175}
176
177impl Default for PinyinEngine {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183fn parse_snapshot(obj: &js_sys::Object) -> L0Snapshot {
184    let pins = js_sys::Reflect::get(obj, &"pins".into())
185        .ok()
186        .and_then(|v| v.dyn_into::<js_sys::Array>().ok())
187        .map(|arr| {
188            arr.iter()
189                .filter_map(|pair| {
190                    let pair: js_sys::Array = pair.dyn_into().ok()?;
191                    let p = pair.get(0).as_string()?;
192                    let w = pair.get(1).as_string()?;
193                    Some((p, w))
194                })
195                .collect()
196        })
197        .unwrap_or_default();
198
199    let pick_counts = js_sys::Reflect::get(obj, &"pickCounts".into())
200        .ok()
201        .and_then(|v| v.dyn_into::<js_sys::Array>().ok())
202        .map(|arr| {
203            arr.iter()
204                .filter_map(|triple| {
205                    let triple: js_sys::Array = triple.dyn_into().ok()?;
206                    let p = triple.get(0).as_string()?;
207                    let w = triple.get(1).as_string()?;
208                    let n = triple.get(2).as_f64()? as u32;
209                    Some((p, w, n))
210                })
211                .collect()
212        })
213        .unwrap_or_default();
214
215    L0Snapshot { pins, pick_counts }
216}