重新精簡Rime小狼毫輸入法字庫

重新精簡Rime小狼毫輸入法字庫

輸入法的詞庫(本文特指單字而非詞組,下稱字庫)不能太小,否則容易打不出想要打的字。但同時輸入法字庫也不應該太大,否則在打字的時候,會有過多生僻的候選字,導致打字效率變低,甚至容易選錯字。Rime小狼毫輸入法的字庫,一般是非常大的,其中包含了很多中日韓統一表意文字擴展區的漢字,我不需要這麼多的生僻字,所以決定對字庫進行精簡。

早在之前,我已經做過這樣的嘗試,但是當時我是對字庫文件進行直接的刪減,刪減到了GB2312的程度。這樣的做法會使得偶爾打生僻字時不方便,缺少可維護性。而且當時為了打出自己偏好的繁體字字形,直接修改了八股文essay.txt,導致一些詞組的輸入出現了問題。所以趁着這個機會,我決定改用另一種更加合理、更加方便維護的方法。而這也要得益於人工智慧大語言模型,在之前,如果不懂Lua編程,那麼將寸步難行。而自從有了人工智慧大語言模型,特別是深度求索這種免費的工具之後,就算不懂編程,也可以讓其代勞,而我們只需要清晰提出自己的需求便可以。網路上亦有網友一針見血地表示,之前Rime輸入法的配置門檻之高一直為人所詬病,很多不懂編程的人面對代碼手足無措,而那些略懂代碼的初學者,雖然能夠完成基本的配置,但是一旦遇到高階配置,也無法得心應手。而人工智慧大語言模型問世之後,Rime輸入法的這些壁壘就被打破了,配置難這個缺點也可以說是因此而消失了。不得不感嘆人工智慧大語言模型真是一項偉大的發明,功在當代,利在千秋。

這次的字庫精簡功能使用Lua來實現,最開始的時候,網友hchunhui有一個librime-lua項目,裡面就有一個生僻字過濾器,用於過濾中日韓統一表意文字擴展區的漢字,不過這個版本的功能較少,滿足不了我的這兩個需求:一是使用快捷鍵控制生僻字過濾器開關,二是無法給一些較常用但又不幸位於擴展區的漢字開綠燈。偶然之間,我又發現了另一網友riverscn的rime-forge項目,這個Lua程式實現了我所需要的快捷鍵開關和例外字功能。說實話在搜尋引擎大海撈針的情況下,能找到剛好如此稀世珍寶實屬不易,同時也非常幸運,可謂是雪中送炭。不過這裡面還加了一個通用規範漢字表,所以有點冗長,我決定在此基礎上進行精簡。在深度求索的幫助下,修改後的版本如下。

./lua/charset_cjkext.lua
-- 基於 https://github.com/riverscn/rime-forge/blob/main/lua/charset.lua
-- 自定义增补字,总是保留
local extra = {
0x30EDE, -- 𰻞 biang
}

-- CJK 统一表意文字各区块范围
local charset = {
["CJK"] = { first = 0x4E00, last = 0x9FFF },
["ExtA"] = { first = 0x3400, last = 0x4DBF },
["ExtB"] = { first = 0x20000, last = 0x2A6DF },
["ExtC"] = { first = 0x2A700, last = 0x2B73F },
["ExtD"] = { first = 0x2B740, last = 0x2B81F },
["ExtE"] = { first = 0x2B820, last = 0x2CEAF },
["ExtF"] = { first = 0x2CEB0, last = 0x2EBEF },
["Compat"] = { first = 0x2F800, last = 0x2FA1F }, -- 兼容补充
["CompatIdeo"] = { first = 0xF900, last = 0xFAFF }, -- 兼容主区
["ExtG"] = { first = 0x30000, last = 0x3134F },
["ExtH"] = { first = 0x31350, last = 0x323AF },
["ExtI"] = { first = 0x2EBF0, last = 0x2EE5F },
}

-- 检查整个字符串是否全部满足 single_filter 条件
local function exists(single_filter, text)
for i in utf8.codes(text) do
local c = utf8.codepoint(text, i)
if not single_filter(c) then
return false
end
end
return true
end

-- 返回判断 codepoint 是否属于指定字符集的函数
local function is_charset(s)
return function(c)
return c >= charset[s].first and c <= charset[s].last
end
end

-- 判断是否为扩展汉字(包括所有非基本区)
local function is_cjk_ext(c)
return is_charset("ExtA")(c)
or is_charset("ExtB")(c)
or is_charset("ExtC")(c)
or is_charset("ExtD")(c)
or is_charset("ExtE")(c)
or is_charset("ExtF")(c)
or is_charset("Compat")(c)
or is_charset("CompatIdeo")(c)
or is_charset("ExtG")(c)
or is_charset("ExtH")(c)
or is_charset("ExtI")(c)
end

-- 检查是否属于自定义增补字
local function is_extra(c)
for _, v in pairs(extra) do
if v == c then
return true
end
end
return false
end

-- 主过滤器
local function charset_filter(input, env)
local b_extended_charset = env.engine.context:get_option("extended_charset")

for cand in input:iter() do
-- 保留条件:
-- 1. 已开启扩展字符集选项
-- 2. 或候选不含任何 CJK 扩展/兼容区汉字
-- 3. 或候选属于自定义增补字列表
if b_extended_charset
or not exists(is_cjk_ext, cand.text)
or exists(is_extra, cand.text) then
yield(cand)
end
end
end

return {
filter = charset_filter
}

此版本為溫和版本,而我需要一個激進版本,將字庫縮減到GB2312的級別,當然這個GB2312單單只是GB2312,範圍肯定是要再擴大些,例如還要收錄繁體字以及一些表外的常用字。因此,最適合的做法就是創建一個gb2312.txt這樣的文本檔案,然後往裡面寫入需要保留的漢字,一字一行。當然我剛剛已經說過,範圍要擴大些,所以這個文本檔將是幾個字表的並集。而關於這些官方的字表,rime-aca/character_set這個項目已經有適合計算機閱讀的檔案。我將按照以下的順序進行並集處理,同時進行去重,去重的方式是使用Sublime Text編輯器的EditPermute LinesUnique功能。

  1. GB2312
  2. GB12345
  3. BIG5之常用漢字(兙兛兞兝兡兣嗧瓩糎一乙丁七乃……驪鬱鸛鸞籲)
  4. 香港小學學習字詞表
  5. 香港常用字字形表
  6. 通用规范汉字表之一级二级字表
  7. 康熙部首(乛罒癶覀龺亅匸夊禸艸襾辵靑)

如此便可以滿足日常打字需求,同時最大程度精簡字庫,避免打字時出現過多重碼。此外,我還需要一個白名單作為補充,命名為user_han.txt。準備好兩個檔案之後,便開始修改Lua程式。同時要考慮到這兩個檔案中為了人類可讀性,會增加一些註釋,所以應該只設置單行單字為有效數據,其他的則視為無效數據,即註釋。

./lua/charset.lua
--[[
charset_filter: 滤除非白名单内的汉字,且拒绝所有兼容区汉字
白名单由 gb2312.txt 和 user_han.txt(可选)合并而成
]]

local allowed_han = {}
do
local appdata = os.getenv("APPDATA")
local base_path = appdata .. "\\Rime\\lua\\"

-- 加载主字表 gb2312.txt
local file = io.open(base_path .. "gb2312.txt", "r")
if file then
for line in file:lines() do
line = line:match("^%s*(.-)%s*$")
if line and utf8.len(line) == 1 then
local cp = utf8.codepoint(line)
if cp then
allowed_han[cp] = true
end
end
end
file:close()
end

-- 加载用户自定义字表 user_han.txt(可选)
local user_file = io.open(base_path .. "user_han.txt", "r")
if user_file then
for line in user_file:lines() do
line = line:match("^%s*(.-)%s*$")
if line and utf8.len(line) == 1 then
local cp = utf8.codepoint(line)
if cp then
allowed_han[cp] = true
end
end
end
user_file:close()
end
end

-- 判断是否为普通 CJK 汉字(不含兼容区)
local function is_cjk_char(c)
return (c >= 0x4E00 and c <= 0x9FFF) or
(c >= 0x3400 and c <= 0x4DBF) or
(c >= 0x20000 and c <= 0x2A6DF) or
(c >= 0x2A700 and c <= 0x2B73F) or
(c >= 0x2B740 and c <= 0x2B81F) or
(c >= 0x2B820 and c <= 0x2CEAF) or
(c >= 0x2CEB0 and c <= 0x2EBEF) or
(c >= 0x30000 and c <= 0x3134F)
end

-- 判断是否为 CJK 兼容汉字(两个区:U+F900-FAFF 和 U+2F800-2FA1F)
local function is_compat_han(c)
return (c >= 0xF900 and c <= 0xFAFF) or
(c >= 0x2F800 and c <= 0x2FA1F)
end

local function charset_filter(input, env)
local b_extended = env.engine.context:get_option("extended_charset")

for cand in input:iter() do
if b_extended then
yield(cand)
else
local valid = true
for _, cp in utf8.codes(cand.text) do
-- 兼容区汉字一律拒绝
if is_compat_han(cp) then
valid = false
break
end
-- 普通汉字:不在白名单则拒绝
if is_cjk_char(cp) and not allowed_han[cp] then
valid = false
break
end
-- 非汉字字符忽略,继续检查
end
if valid then
yield(cand)
end
end
end
end

return { filter = charset_filter }

隨後,我們需要在Rime用戶資料夾目錄下創建一個rime.lua,內容為:

./rime.lua
local charset = require("charset")
charset_filter = charset.filter

最後,在輸入方案的客製化檔案中加入相關的開關與引擎。這裡截取新增的部分示例如下:

./<scheme>.custom.yaml
# 開關名稱由lua中的local b_extended = env.engine.context:get_option("extended_charset")定義
switches:
- <此處省略其他開關>
- name: extended_charset
reset: 0

engine/filters:
- <此處省略其他引擎之濾鏡>
- lua_filter@charset_filter

key_binder/bindings/+:
- {accept: "Control+Shift+C", toggle: extended_charset, when: always}

這樣子就可以滿足平時只用類似於GB2312的小字庫進行打字,然後有需要打生僻字時,再按快捷鍵臨時切換到大字庫。而事實上Rime自帶的輸入方案的字庫,很多都是包含了擴展區的大字庫,例如我所用的大千注音輸入方案,其上游的地球拼音字庫就包含了擴展區的字,甚至可以稱為超大字庫。而擴展區的字我很少用到,因此這裡還可以再進一步,對字庫原檔案進行硬刪減,刪除擴展區的漢字,僅保留基本區的漢字。這裡可以用這個Python程式:

del_dict_cjkext.py
#!/usr/bin/env python3
"""
过滤 Rime 词库,保留 CJK 基本区汉字条目,并额外保留 user_han.txt 中指定的字符。
例外字符列表从 %APPDATA%/Rime/lua/user_han.txt 加载(一行一个汉字)。
用法:python filter_cjk_basic.py 输入文件 输出文件
"""

import sys
import os

# 内置默认例外集合(当 user_han.txt 不存在时使用)
DEFAULT_EXCEPTIONS = {
'〇',

}

def load_exceptions_from_file():
"""从 %APPDATA%/Rime/lua/user_han.txt 加载例外字符集合。
如果文件存在且可读,则使用文件内容;否则回退到内置默认集合并给出警告。
"""
# 展开 %APPDATA% 并构建路径(使用正斜杠避免转义问题)
appdata = os.environ.get('APPDATA', '')
if not appdata:
print("警告: 无法获取 APPDATA 环境变量,使用当前目录下的 user_han.txt")
file_path = 'user_han.txt'
else:
file_path = os.path.join(appdata, 'Rime', 'lua', 'user_han.txt')

if not os.path.isfile(file_path):
print(f"警告: 未找到例外字符文件 '{file_path}',将使用内置默认例外集合。")
print(f" 你可以创建该文件(每行一个汉字)来定制例外字符列表。")
return DEFAULT_EXCEPTIONS

try:
with open(file_path, 'r', encoding='utf-8') as f:
chars = set()
for line in f:
line = line.strip()
if line:
chars.add(line[0]) # 只取第一个字符(防止行内有杂物)
if not chars:
print(f"警告: 文件 '{file_path}' 为空,将使用内置默认例外集合。")
return DEFAULT_EXCEPTIONS
return chars
except Exception as e:
print(f"错误: 读取文件 '{file_path}' 失败: {e},将使用内置默认例外集合。")
return DEFAULT_EXCEPTIONS

def is_cjk_basic(char: str) -> bool:
"""判断单个字符是否属于 CJK 统一表意文字基本区块"""
if len(char) != 1:
return False
cp = ord(char)
return 0x4E00 <= cp <= 0x9FFF

def should_keep(char: str, exceptions: set) -> bool:
"""判断该汉字是否应该保留(基本区或例外列表)"""
return is_cjk_basic(char) or char in exceptions

def process_line(line: str, exceptions: set) -> str | None:
"""如果该行第一个非空字符是应保留的汉字,返回原行(去掉尾部换行),否则返回 None"""
stripped_line = line.rstrip('\n\r')
if not stripped_line:
return None
lstrip_line = stripped_line.lstrip()
if not lstrip_line:
return None
first_char = lstrip_line[0]
if should_keep(first_char, exceptions):
return stripped_line
return None

def main():
if len(sys.argv) != 3:
print(f"用法: {sys.argv[0]} 输入文件 输出文件")
sys.exit(1)

input_path = sys.argv[1]
output_path = sys.argv[2]

exceptions = load_exceptions_from_file()
print(f"已加载 {len(exceptions)} 个例外字符。")

kept = 0
deleted = 0
blank = 0

with open(input_path, 'r', encoding='utf-8-sig') as fin, \
open(output_path, 'w', encoding='utf-8') as fout:
for line in fin:
processed = process_line(line, exceptions)
if processed is not None:
fout.write(processed + '\n')
kept += 1
else:
if line.strip() == '':
blank += 1
else:
deleted += 1

print(f"处理完成,结果保存在: {output_path}")
print(f"保留: {kept} 行")
print(f"删除: {deleted} 行")
print(f"空行: {blank} 行(已跳过)")

if __name__ == '__main__':
main()

※ Cover image: Rime

Comments