我经常有在终端查单词的需求,之前使用的是自己写的网络爬虫,原理是构造网址 “https://www.youdao.com/result?word={}&lang=en" 并访问,再通过解析 Html 文件获得单词解释。一个朋友说他用的是 sdcv ,星际译王的终端版本,可以支持离线词典。我也时常脱机工作,对离线词典的需求也不小,所以打算自己写一个支持 StarDict 格式离线词典的软件。
代码仓库:https://github.com/vaaandark/rmall
StarDict 格式
以朗道英汉字典为例,在解压下载得到的压缩包之后可以看到其目录结构为:
langdao-ec-gb
├── langdao-ec-gb.dict.dz
├── langdao-ec-gb.idx
└── langdao-ec-gb.ifo
1 directory, 3 files
其中有三种文件:.dict.dz
是压缩后的字典文件,.idx
是索引文件,.ifo
保存了该字典的信息。
.ifo 文件
首先可以看一下 langdao-ec-gb.ifo
文件:
StarDict's dict ifo file
version=2.4.2
wordcount=435468
idxfilesize=10651674
bookname=朗道英汉字典5.0
author=上海朗道电脑科技发展有限公司
description=罗小辉破解文件格式,胡正制作转换程序。
date=2003.08.26
sametypesequence=m
在查找相关资料后可以看到这些字段的填写要求:
bookname= // required
wordcount= // required
synwordcount= // required if ".syn" file exists.
idxfilesize= // required
idxoffsetbits= // New in 3.0.0
author=
email=
website=
description= // You can use <br> for new line.
date=
sametypesequence= // very important.
dicttype=
最为重要的是 version
字段,因为如今只支持 2.4.2
和 3.0.0
两个版本,而这两个版本索引文件格式又略有不同。
.idx 文件
再看看 .idx
的索引文件,StarDict 的文档说它是由很多条目(entries)组成,每个条目由一个不定长的单词字符串加两个数字组成。其中字符串以 \0
结尾,两个数字分别为该单词在字典文件中的偏移(offset)和大小(size)。
上文提到了不同版本的索引文件略有不同,在 2.4.2
中数字是 32 位的无符号整型,在 3.0.0
中数字是 64 位的无符号整型。
所以我可以先用 Lua 脚本大致看一下它的结构:
#!/usr/bin/env lua
-- Usage: ./idx.lua path/to/idx
-- or version 3.0.0
-- ./idx.lua path/to/idx -3
local f = arg[1]
local pattern = arg[2] == '-3' and ">zI8I8" or ">zI4I4"
f = assert(io.open(f, "rb"))
local contents = f:read("a")
local now
local word, offset, size
while not now or now < #contents do
word, offset, size, now = string.unpack(pattern, contents, now)
print(string.format("%-50s | %8d | %4d", word, offset, size))
end
f:close()
运行这个小工具可以看到:
$ ./idx.lua ~/.config/rmall/00-langdao-ec-gb/langdao-ec-gb.idx | head
a | 0 | 132
A and B agglutinogens | 132 | 24
A AND NOT B gate | 156 | 19
A as well as B | 175 | 28
A B. S. pill | 203 | 45
a back number | 248 | 29
a bad actor | 277 | 20
a bad egg | 297 | 6
a bad hat | 303 | 6
a bad job | 309 | 15
.dict.dz 文件
这个文件本质是由 gzip 压缩后的文件,所以只需使用 gzip -d
解压之后就可以看到它其实是一个文本文件。
当我们需要查词时,根据上文提到的索引文件中的 offset
和 size
就直接知道了单词释义的位置。
小插曲
使用 Rust 读取 .idx
使用 Rust 读取二进制文件并不是件很难的事情,但是由于有两种版本的 StarDict 文件,如果使用两个函数分别解析就很麻烦,所以需要使用 Rust 的泛型特性。
首先弄明白需求,需求就是该函数同时支持读取 4 个字节和 8 个字节的数字,同时要满足读取不定长的字符串。
由于使用了函数 from_be_bytes
来通过大端序读入,它是 u32
和 u64
等数字类型的方法。
u32::from_be_bytes
的函数签名是:
pub const fn from_be_bytes(bytes: [u8; 4]) -> u32
u64::from_be_bytes
的函数签名是:
pub const fn from_be_bytes(bytes: [u8; 8]) -> u64
遗憾地,由于这个函数签名的原因,我没有想到只使用一个泛型参数的方法,只能使用两个,一个是类型 T ,一个是表示数组长度的常量 N 。
更加遗憾地,标准库中的 from_be_bytes
不属于任何一个 trait ,想要将 u32
和 u64
这样能从字节读入的数据抽象到一起还并不简单。好在经过 Google 找到了一个叫做 eio
的 crate ,它有着 trait FromBytes
:
pub trait FromBytes<const N: usize> {
fn from_be_bytes(bytes: [u8; N]) -> Self;
fn from_le_bytes(bytes: [u8; N]) -> Self;
}
所以我的 read_bytes
函数终于成型了:
fn read_bytes<'a, const N: usize, T>(path: PathBuf) -> Result<Vec<(String, usize, usize)>>
where
T: FromBytes<N> + TryInto<usize>,
<T as TryInto<usize>>::Error: Debug,
{
let f = File::open(path).map_err(|_| Error::CannotOpenIdxFile)?;
let mut f = BufReader::new(f);
let mut items: Vec<_> = Vec::new();
let mut buf: Vec<u8> = Vec::new();
while let Ok(n) = f.read_until(0, &mut buf) {
if n == 0 {
break;
}
buf.pop();
let mut word = String::new();
buf.iter().for_each(|x| word.push(*x as char));
buf.clear();
let mut b = [0; N];
f.read(&mut b).map_err(|_| Error::IdxFileParsingError)?;
let offset = T::from_be_bytes(b).try_into().unwrap();
let mut b = [0; N];
f.read(&mut b).map_err(|_| Error::IdxFileParsingError)?;
let size = T::from_be_bytes(b).try_into().unwrap();
items.push((word, offset, size))
}
Ok(items)
}
在这里将 u32
和 u64
类型 try_into
成 usize ,使用了 unwrap()
,因此需要 <T as TryInto<usize>>::Error: Debug
,而实际上这里的转换是并不会 panic 的。
关于「字典序」的乌龙
字典序是指按照单词出现在字典的顺序进行排序的方法。
学过离散数学的都知道字典序是一种全序,学过任何一种编程语言的也知道,在编程语言中比较两个字符串的大小一般都是利用字典序。
在 C 语言中,strcmp()
函数就是对每个字符的大小进行比较,如果当前字符相同就比较下一个,直到比较出来大小。
这样的严格按 ASCII 表的字典序其实不是现实世界中字典的样子。举个例子,单词 China 和 china 这两个单词如果按照编程语言的字典序来排绝不会在相邻的地方。因为 C 是 103 而 c 是 143 ,对以 c(C) 开头的单词排序,一定是所有以大写 C 开头的单词排列完成后再是以小写 c 开头的单词,这和我们经常看到的英文词典实际情况不同。
因此现实世界中的「字典序」应该是先忽略大小写进行排序,相同单词再考虑大小写,这样能保证只是大小写不同的单词会在一起。
在一开始写查找单词的时候,我直接使用 binary_search_by_key
来查找:
fn lookup(&'a self, word: &str) -> &'a str {
if let Ok(pos) = self.idx.items.binary_search_by_key(&word, |x| &x.0) {
let (_, offset, size) = self.idx.items[pos];
self.dict.get(offset, size)
} else {
panic!();
}
}
这样做的后果就是查不到大部分的单词,我还以为是 .idx
文件不规范,但我仔细思考后发现了「字典序」的乌龙后就改变了写法:
pub fn lookup(&'a self, word: &str) -> Result<&'a str> {
if let Ok(pos) = self.idx.items.binary_search_by(|probe| {
probe
.0
.to_lowercase()
.cmp(&word.to_lowercase())
.then(probe.0.as_str().cmp(&word))
}) {
let (_, offset, size) = self.idx.items[pos];
Ok(self.dict.get(offset, size))
} else {
Err(Error::WordNotFound(self.ifo.bookname.to_string()))
}
}
多字典支持
之前暂时没有在程序中集成多字典,但是可以通过 Shell 脚本实现类似的效果:
#!/bin/bash
word="$1"
for i in langdao-ec-gb oxford-gb cdict-gb kdic-computer-gb; do
if rmall lookup -l "$HOME/.config/rmall/$i" "$word"; then
exit 0
fi
done
rmall lookup "$word"
查词时就会按照相应的离线词典优先级查找,如果都没找到则使用电子词典。
现在集成了多字典功能,可以将词典目录分别命名为 00-XXX
, 01-YYY
, …, 99-ZZZ
这样的格式来实现优先级。
$ rmall -L terraria
Error: WordNotFound("朗道英汉字典5.0")
Error: WordNotFound("牛津现代英汉双解词典")
Error: WordNotFound("CDICT5英汉辞典")
Error: WordNotFound("计算机词汇")
terraria
英 / tɛˈrɛːrɪə / 美 / tɛˈrɛːrɪə /
泰拉瑞亚(游戏名)
Xbox Live ArcadeMarch 27, 2013PlayStation VitaLate Fall 2013iOSAugust 29, 2013http://www.terrariaonline.