文件
crab-input-rtos/com.sydxky.rtos.inputmethod/src/common/ime-engine.js
T

171 行
9.6 KiB
JavaScript

/* 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;i<value.length;i++)result=(result*31+value.charCodeAt(i))&65535;return (result%256+256).toString(16).slice(1)}
// Four columns and four row units. Phrases take more width/height, never ellipsis.
export function candidatePages(candidates) {
const pages=[];let rows=[],usedRows=0,row=null
function flushRow(){if(row){rows.push(row);usedRows+=row.units;row=null}}
function flushPage(){flushRow();if(rows.length)pages.push(rows);rows=[];usedRows=0}
for(const item of candidates){
const length=chars(item.word).length
const span=length===1?1:length<=4?2:4
const units=span===4?Math.max(1,Math.ceil(length/12)):1
if(row&&(row.slots+span>4||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.length<chars(insertion).length}
}
export function erase(text,cursor) {
const value=chars(text),at=Math.max(0,Math.min(value.length,cursor))
if(at)value.splice(at-1,1)
return {text:value.join(''),cursor:Math.max(0,at-1)}
}
export function previewParts(text,cursor,secret) {
const value=chars(text),at=Math.max(0,Math.min(value.length,cursor))
const left=value.slice(Math.max(0,at-15),at),right=value.slice(at,at+7)
const width=list=>list.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.length<at?1:0)+(right.length<value.length-at?1:0)
while(size()>7&&(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<at?'…':'')+(secret?'•'.repeat(left.length):left.join('')),after:(secret?'•'.repeat(right.length):right.join(''))+(right.length<value.length-at?'…':'')}
}
export function preview(text,cursor,secret) {const parts=previewParts(text,cursor,secret);return parts.before+'│'+parts.after}
export function sanitizePreferences(input) {
const result={}
if(!input||typeof input!=='object'||Array.isArray(input))return result
Object.keys(input).filter(key=>/^(?:[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||(a<b?-1:a>b?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<prefixLength){
const name='c'+predictiveBucket(prefix)
if(manifest.completion_shards&&manifest.completion_shards.indexOf(name)>=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}
}
}