/* RTOS adaptation of CrabKeyboard, MIT. Original authors kqakqakqa and crabKun. * Full V5 dictionary, prefix consumption and local preference ordering retained. * New: T9 decoding, paged complete candidates and Unicode-safe editing. */ const GROUPS = ['abc','def','ghi','jkl','mno','pqrs','tuv','wxyz'] // Adjacent QWERTY pairs used by the public 14-key layout. One token per key. export const KEY14_GROUPS = ['qw','er','ty','ui','op','as','df','gh','jk','l','zx','cv','bn','m'] const KEY14_CODES='0123456789abcd' export function code14Of(text){ let result='' for(const ch of String(text||'').toLowerCase()){ const index=KEY14_GROUPS.findIndex(group=>group.indexOf(ch)>=0) if(index<0)return '' result+=KEY14_CODES[index] } return result } export function display14(raw){return (raw.length>6?'… ':'')+Array.from(raw.slice(-6)).map(code=>KEY14_GROUPS[KEY14_CODES.indexOf(code)]||'').join(' ').toUpperCase()} export function digitsOf(text) { let result='' for (const ch of text.toLowerCase()) { const index=GROUPS.findIndex(group=>group.indexOf(ch)>=0) if(index<0)return '' result+=String(index+2) } return result } export function chars(text){return Array.from(String(text||''))} export function contextTail(text){const match=String(text||'').match(/[\u3400-\u9fff]{1,4}$/);return match?match[0]:''} function predictiveBucket(value){let result=0;for(let i=0;i4||row.units!==units))flushRow() if(usedRows+units>4)flushPage() if(!row)row={items:[],slots:0,units} row.items.push({item,span});row.slots+=span if(row.slots===4)flushRow() } flushPage();return pages } export function edit(text,cursor,insertion,maxLength) { const value=chars(text), at=Math.max(0,Math.min(value.length,cursor)) const room=Math.max(0,maxLength-value.length), added=chars(insertion).slice(0,room) return {text:value.slice(0,at).concat(added,value.slice(at)).join(''),cursor:at+added.length,limited:added.lengthlist.reduce((sum,ch)=>sum+(secret?.6:/[MW@#%]/.test(ch)?.95:ch.codePointAt(0)>0x2e7f?1:.6),0) // Keep the cursor in the narrowest round preview, including both ellipses. // The actual cursor is a separate one-pixel element, never a font glyph. const size=()=>width(left)+width(right)+(left.length7&&(left.length||right.length)){ if(left.length&&(width(left)>4.5||!right.length))left.shift() else if(right.length)right.pop() else left.shift() } return {before:(left.length/^(?:[a-z2-9]{1,32}|k14:[0-9a-d]{1,32})$/.test(key)).slice(-100).forEach(key=>{ if(!Array.isArray(input[key]))return result[key]=input[key].filter(x=>x&&typeof x.word==='string'&&chars(x.word).length<=32&&Number.isInteger(x.consume)&&x.consume>0&&x.consume<=32).slice(0,3).map(x=>({word:x.word,consume:x.consume})) }) return result } export function learn(preferences,input,candidate) { const result=sanitizePreferences(preferences) const previous=Array.isArray(result[input])?result[input]:[] delete result[input] result[input]=[{word:candidate.word,consume:candidate.consume}].concat(previous.filter(x=>x.word!==candidate.word||x.consume!==candidate.consume)).slice(0,3) const keys=Object.keys(result) if(keys.length>100)delete result[keys[0]] return result } export function createDictionary(read) { // Small shards and a bounded LRU avoid parsing the whole lexicon on an MCU. const cache=[],pending={};let manifest=null async function load(name) { const existing=cache.find(x=>x.name===name) if(existing)return existing.data if(pending[name])return pending[name] pending[name]=(async()=>{ const text=await read('/assets/ime-dict/'+name+'.json') const data=typeof text==='string'?JSON.parse(text):text const size=typeof text==='string'?text.length:0 cache.push({name,data,size});while(cache.length>4||cache.reduce((sum,x)=>sum+x.size,0)>90000)cache.shift() return data })() try{return await pending[name]}finally{delete pending[name]} } async function metadata(){if(!manifest){const meta=await read('/assets/ime-dict/meta.json');manifest=typeof meta==='string'?JSON.parse(meta):meta}return manifest} async function predict(context){ const tail=contextTail(context);if(!tail)return [] const meta=await metadata(),result=[],seen={} for(let length=tail.length;length>0&&result.length<8;length--){ const prefix=tail.slice(-length),name='a'+predictiveBucket(prefix) if(!meta.association_shards||meta.association_shards.indexOf(name)<0)continue const rows=(await load(name))[prefix]||[] for(const row of rows)if(!seen[row[0]]){seen[row[0]]=true;result.push({word:row[0],pinyin:'',consume:0,frequency:row[1]});if(result.length===8)break} } return result } return { predict, async lookup(raw,t9,preferences,context) { const input=String(raw||'').toLowerCase() const dual=t9==='14',ambiguous=!!t9 if(!(dual?/^[0-9a-d]{1,32}$/:t9?/^[2-9]{1,32}$/:/^[a-z]{1,32}$/).test(input))return [] const digits=ambiguous?input:digitsOf(input) const merged={},frequencies={} function merge(data){Object.assign(merged,data.entries);Object.assign(frequencies,data.frequencies)} await metadata() const names=[] const prefixLength=dual?3:4 for(let length=1;length<=Math.min(prefixLength-1,digits.length);length++)names.push((dual?'ds':'s')+digits.slice(0,length)) if(digits.length>=prefixLength)names.push((dual?'d':'')+digits.slice(0,prefixLength)) for(const name of names)if(manifest.shards.indexOf(name)>=0)try{merge(await load(name))}catch(error){throw new Error('词库读取失败')} const matching=Object.keys(merged).filter(key=>input.indexOf(dual?code14Of(key):t9?digitsOf(key):key)===0) matching.sort((a,b)=>b.length-a.length||(ab?1:0)) const result=[],seen={} for(const key of matching)for(const word of merged[key]){ const identity=word+'@'+key.length if(!seen[identity]){seen[identity]=true;result.push({word,pinyin:key,consume:key.length,frequency:frequencies[word]||0})} } if(input.length>=2){ const mode=dual?'14':t9?'t9':'py',prefix=mode+':'+input,extras=[] if(input.length=0){ for(const row of (await load(name))[prefix]||[])extras.push({word:row[0],pinyin:row[1],frequency:row[2],missing:row[1].length-input.length,consume:input.length,completion:true}) } }else for(const key of Object.keys(merged)){ const code=dual?code14Of(key):t9?digitsOf(key):key if(code.length<=input.length||code.length>input.length+4||code.indexOf(input)!==0)continue for(const word of merged[key])extras.push({word,pinyin:key,frequency:frequencies[word]||0,missing:key.length-input.length,consume:input.length,completion:true}) } extras.sort((a,b)=>b.frequency-a.frequency||a.missing-b.missing) let added=0 for(const item of extras){const identity=item.word+'@'+item.consume;if(!seen[identity]){seen[identity]=true;result.push(item);if(++added===12)break}} } const preferenceKey=dual?'k14:'+input:input const prefs=preferences&&Array.isArray(preferences[preferenceKey])?preferences[preferenceKey]:[] const rank=item=>{const i=prefs.findIndex(x=>x.word===item.word&&x.consume===item.consume);return i<0?100:i} const predictions=await predict(context),contextRank=item=>{const i=predictions.findIndex(x=>x.word===item.word);return i<0?0:8-i} const category=item=>item.completion?1:item.consume===input.length?0:2 // Preserve every exact candidate; complete unfinished syllables before // shorter fallbacks, and use word frequency plus the editor's local context. return result.map((item,index)=>({item,index})).sort((a,b)=>rank(a.item)-rank(b.item)||category(a.item)-category(b.item)||b.item.consume-a.item.consume||contextRank(b.item)-contextRank(a.item)||(b.item.frequency||0)-(a.item.frequency||0)||a.index-b.index).map(x=>x.item) }, clear(){cache.length=0;manifest=null}, get cacheSize(){return cache.length} } }