mobi格式解析

mobi格式或者azw格式,是亚马逊的私有格式,由于kindle不支持epub,所以准备研究一下mobi

背景知识

mobi原本是mobipacket,后来被亚马逊收购,就成了私有规范,目前网上没有完整的规范文档。亚马逊在mobi的基础上加上drm也就是版权信息就成了azw格式,后续又参考epub,加入更多功能,成了azw3格式,两种格式又叫kf6和kf8

基础结构

mobi是采用PDB格式封装,这个封装的意思可以理解成数据保存方式,一般格式规范有两部分,一是包括什么数据,二是数据存储方式。例如epub、docx这些就采用zip格式作为封装格式

文件头(PDBHeader)

文件开头的78个字节(byte),是文件的一些基础元数据,包括像是文件日期,文件类型等等。这里需要注意一些关键性字段

日期

在第36、40、44(offset,下同)索引的unit32字段,这三个字段都是时间相关字段,存储的都是秒时间戳,只是起始时间不一定是1970年。具体规则如下

如果拿到时间戳数字,其最高位(bit,下同)是1,这就是个无符号unit32,代表时间从1904年开始,否则从1970年开始;在我得到的mobi格式样例来看,都是从1970年开始,1904可能是其他文件类型在使用。这里给出一个判断时间的样例代码

1
2
3
4
5
6
7
fn do_time_format(value: u32) -> String {
if value & 0x80000000 == 0x80000000 {
crate::common::do_time_display((value & 0x7fffffff) as u64, 1904)
} else {
crate::common::time_display(value as u64)
}
}

顺便一提,在mobi格式中,有一些规则跟数据长度息息相关,如果规范里规定了是2个字节,那么一定不要使用更大的数据格式——比如int——来存储,也要小心语言可能存在的自动向上转型

magic

魔法值,或者魔术值,一般用来区分文件格式,绝大多数文件格式都会在文件开头几个字节里放几个固定字节,比如java里的COFFIE,jpeg的IFEF。

mobi的magic值相对较远,在第52和56索引,分为两个字段,一共八个字节,固定值为BOOKMOBI,虽然规范分成两个字段,实际使用时不用管这个。另外azw3和mobi使用相同的magic,所以还需要判断第34索引,mobi值为6,azw3为8,也就是KF8

书本名

从第0索引开始32个字节代表文件名,通常也是书本名,但是问题是32个字节不一定够存下书名,二是calibre生成的mobi这里也是ascii,如果是中文书,这里就是拼音字母了,所以这32个字节建议直接抛弃,后续字节中有可阅读的书本名称

record

除了存储文件元数据的文件头外,剩下的字节被切割成一个个的record,每个record的长度不一定相同,具体有多少个record,文件头中也有描述;这里只介绍已知用得上的几种record

书本信息

头信息

在第0个record中,分成了两部分,第0到16(左闭右开)索引是文件存储方式相关信息,比如压缩方式,文本长度,record数量,本文取名MOBIDOCHeader。具体用处后面再行描述。

从第16索引开始,后面的就是MOBIHeader,存储了一些record信息,比如文字编码方式,书本名称位置,第一张图片位置等等,一共二百多个字节的头信息中,存在相当多的unknown字段,可能是为了扩展预留字段,也可能是因为私有格式,反解析的时候实在找不到用处。

书本名

在header的第84和88索引中记录了书本名的offset和length,注意这个offset是相对于record0的offset,不是从文件开头计算的。关于长度也有一番表述,如果只是读取的话,什么拼接0字节之类的,如果只是读取的话,不用管这些,直接定位到offset,然后读取length长度,按照编码方式解析即可

元信息

在record0的第128索引,指代是否存在EXTHrecord,这是书本的元数据信息,就是作者、出版社这些,这是个可能存在的record,当exth_flags & 0x40 == 0x40时才有exth

exth本身是不定长度的,除了record本身的信息外,其余的字节数又被分成一个个的小record,每个record通过type区分含义,比如作者、出版社、出版日期这类。根据具体情况不同,同一type可能有多个值。注意这里又有一份书本名。此外需要注意的就是封面图

封面

封面有普通封面和缩略图封面,注意二者都是可选值,不是一定有的。具体的值是offset。

注意这个offset不是字节位置,而是record索引,在我们解析文件头的时候,获取到有多少个record,以及他们的offset2,这个offset2就是相对于文件头的字节索引了。

另外在record0中还有一个字段是第一张图片的offset,当然也是record索引。

所以这里需要将两个offset相加(所有图片的操作都要加上这个第一张图片的offset),从而找到字节索引,然后直接读取相应长度(读取record offset时会有length)的字节,就是图片本身了。

注意这里是不存在图片的元数据信息的,也就是目前并不知道图片的文件名,也不知道到底有多少图片,不像epub格式会把所有的文件都列出来,想要获取所有图片,还需要读取文本信息才行

文本

文本的解析最为复杂,首先是文本被拆分到一个个的record里,每个record长度最大为4096,这个数字在文件头(MOBIDOCHeader)中也有记录,然后每个record还有特殊压缩编码,还有尾padding

一共有多少个record,在MOBIDocHeader中有记录,假设为 record_count,此时遍历[1, record_count],注意,左右均为闭区间;

当拿到每个record的字节后,需要去除尾部的padding字节,具体有几个字节由以下规则确定

tail padding

MOBIHeader中第240索引,长度4字节字段flag,flag虽然是4个字节,也就是unit32,但是实际使用是unit16,也就是只有低16bit有用

从低十六位的最高位开始循环,如果该位(bit)为1,则进入后续流程,注意最后一位(bit)不参与循环,伪代码如下:

1
2
3
4
5
for(j = 15;j>0;j--){
if (flag & ( 1 << j ) >=1 ){
// do some thing
}
}

每次循环中,将record的最后四个字节拿出来,从倒数第四个字节开始,如果该字节& 0b1000_0000 >= 1,则重置计数,否则将低七位左移合并,伪代码如下

1
2
3
4
5
6
7
value=0
for(byte in bytes){
if byte & 0b1000_0000 {
value = 0
}
value = (value << 7 ) | ( byte & 0b111_1111)
}

最终得到的value代表尾部需要去掉的长度,record去掉尾部数据后再次进行循环

结束循环后,如果 flag & 1 == 1,则还需要去除长度,伪代码如下:

1
2
let length = (data[data.length - 1] & 0b11)+1
data = data.subarr(0, -length);

uncompress

解压缩,这里根据MOBIDOCHeader中关于压缩信息的字段值不同,有三种方案,一是不压缩,也就不解压,二是PalmDOC compression(LZ77),三是HUFF/CDIC compression,因为我手头只有第二种的样本,暂时就只解析第二种

LZ77是为了减小体积。

遍历record的每一个字节,

  • 如果 byte = 0, copy it
  • 如果 byte <=8,copy从下一个字节开始共计byte个字节,同时迭代往前byte个长度
  • 如果 byte <= 0b111_1111,copy it
  • 如果 byte <= 0b1011_1111,将当前字节和下一个字节连起来,右移三位后取低11位(distance);取下一个字节低三位后加3(length),循环length次,每次将当前结果的倒数第distance个字节再添加到结果里,注意这里结果的长度在变,所以每次循环添加的不一定是同一个值
  • 都不符合,添加一个32,再添加 byte ^ 0b1000_0000

最终拿到的结果即每个record的值,再把每个record相连,按照指定编码解码,即可拿到文本信息

html

解析之后的文本信息是一个相对标准html文档,只有一个head和body。

章节分页

body中包含<mbp:pagebreak/>,代表一个章节的结束,其中第0个章节可能是目录导航,其他都是普通的html片段。

将文本分节,每节包括文本,开始字节数(start),结束字节数(end),这里的字节数是相对于文本开头,不是文件开头

分节的时候要注意,因为后续使用的都是字节数,而utf8一个字符使用的字节数是不固定的,所以不能在编解码后做分节,必须使用解压缩完之后的数据,这就成了一个基本的子串查找,可以上力扣刷题了


head中会有特殊标签guide#reference指向toc目录,样例如下

1
2
3
4
5
<head>
<guide>
<reference type="toc" title="Table of Contents" filepos=0002387139 />
</guide>
</head>

使用filepos在章节中查找,end值大于filepos的即为对应章节,就我手头的样本,导航会被放到最后一个章节里,这个章节的意思实际是给阅读器使用的,格式固定,样本里甚至还给了宽高都是0,避免被阅读器渲染出来被读者看到,开头第0个章节那个是书籍自定义的导航,用来给读者看的,样式规范多样化,阅读器无法识别,在epub格式中也有类似的设计。

这里给一个样例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<mbp:pagebreak/>
<p height="1em" width="0pt" align="center">
<font size="7">
<b>Table of Contents</b>
</font>
</p>
<p height="1em" width="-19pt">
<a filepos=0000005452>第一卷</a>
</p>
<blockquote height="0pt" width="0pt">
<a filepos=0000005452>插图</a>
</blockquote>
<blockquote height="0pt" width="0pt">
<a filepos=0000007756>第一章</a>
</blockquote>
<blockquote height="0pt" width="0pt">
<a filepos=0000052866>第二章</a>
</blockquote>
<mbp:pagebreak/>

图片

每一个img标签都会被赋予一个recindex属性,代表的是record从1开始的索引,同时务必加上第一张图片的offset。

如果有字体等资源文件,也是相同的方案,只是读取到的值处理不一样,因为我手头没有这种样本,暂时不做研究

目录

前文提到,文本章节中有目录导航,此外,当MOBIHeader中的第244索引值不等于0xffffffff,mobi存在INDX类型record,这里的解析方式较为复杂,而且缺失了目录层级信息,不建议使用该处数据,所以暂时先不解析了。

参考资料


mobi格式解析
http://blog.inkroom.cn/2024/08/26/2V0D1S7.html
作者
inkbox
发布于
2024年8月26日
许可协议