我经常有在终端查单词的需求,之前使用的是自己写的网络爬虫,原理是构造网址 “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.23.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 解压之后就可以看到它其实是一个文本文件。

当我们需要查词时,根据上文提到的索引文件中的 offsetsize 就直接知道了单词释义的位置。

小插曲

使用 Rust 读取 .idx

使用 Rust 读取二进制文件并不是件很难的事情,但是由于有两种版本的 StarDict 文件,如果使用两个函数分别解析就很麻烦,所以需要使用 Rust 的泛型特性。

首先弄明白需求,需求就是该函数同时支持读取 4 个字节和 8 个字节的数字,同时要满足读取不定长的字符串。

由于使用了函数 from_be_bytes 来通过大端序读入,它是 u32u64 等数字类型的方法。

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 ,想要将 u32u64 这样能从字节读入的数据抽象到一起还并不简单。好在经过 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)
}

在这里将 u32u64 类型 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.

参考资料

StarDict 字典格式