use std::collections::BTreeMap;
use crate::palmdb::PalmDb;
use crate::{FormatError, Result};
pub const COMPRESSION_HUFF_CDIC: u16 = 17480;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DrmBlock {
pub ptr: u32,
pub count: u32,
pub size: u32,
pub flags: u32,
}
#[derive(Debug, Clone)]
pub struct MobiHeader {
pub compression: u16,
pub text_record_count: u16,
pub encryption_type: u16,
pub is_textread: bool,
pub mobi_length: u32,
pub mobi_version: u32,
pub codepage: u32,
pub exth_flag: u32,
pub drm: DrmBlock,
pub extra_data_flags: u16,
pub exth: BTreeMap<u32, Vec<u8>>,
}
fn be_u16(data: &[u8], off: usize) -> Result<u16> {
data.get(off..off + 2)
.map(|b| u16::from_be_bytes([b[0], b[1]]))
.ok_or(FormatError::Truncated(off + 2))
}
fn be_u32(data: &[u8], off: usize) -> Result<u32> {
data.get(off..off + 4)
.map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
.ok_or(FormatError::Truncated(off + 4))
}
impl MobiHeader {
pub fn parse(record0: &[u8], type_creator: &[u8; 8]) -> Result<MobiHeader> {
let is_textread = match type_creator {
b"BOOKMOBI" => false,
b"TEXtREAd" => true,
other => {
return Err(FormatError::BadMagic(
String::from_utf8_lossy(other).into_owned(),
))
}
};
let compression = be_u16(record0, 0x00)?;
let text_record_count = be_u16(record0, 0x08)?;
let encryption_type = be_u16(record0, 0x0C)?;
let mut header = MobiHeader {
compression,
text_record_count,
encryption_type,
is_textread,
mobi_length: 0,
mobi_version: 0,
codepage: 0,
exth_flag: 0,
drm: DrmBlock::default(),
extra_data_flags: 0,
exth: BTreeMap::new(),
};
if is_textread {
return Ok(header);
}
header.mobi_length = be_u32(record0, 0x14)?;
header.codepage = be_u32(record0, 0x1C)?;
header.mobi_version = be_u32(record0, 0x68)?;
header.exth_flag = be_u32(record0, 0x80)?;
if record0.len() >= 0xB8 {
header.drm = DrmBlock {
ptr: be_u32(record0, 0xA8)?,
count: be_u32(record0, 0xAC)?,
size: be_u32(record0, 0xB0)?,
flags: be_u32(record0, 0xB4)?,
};
}
if header.mobi_length >= 0xE4 && header.mobi_version >= 5 {
let mut flags = be_u16(record0, 0xF2)?;
if header.compression != COMPRESSION_HUFF_CDIC {
flags &= 0xFFFE;
}
header.extra_data_flags = flags;
}
if header.exth_flag & 0x40 != 0 {
let start = 16usize.saturating_add(header.mobi_length as usize);
if let Some(exth) = record0.get(start..) {
header.exth = parse_exth(exth)?;
}
}
Ok(header)
}
pub fn from_image(data: &[u8]) -> Result<MobiHeader> {
let db = PalmDb::parse(data)?;
let record0 = db
.record(data, 0)
.ok_or_else(|| FormatError::Invalid("missing record 0".into()))?;
MobiHeader::parse(record0, &db.type_creator)
}
pub fn book_title(&self, record0: &[u8], db_name: &[u8]) -> String {
let mut title: &[u8] = b"";
if !self.is_textread {
if let Some(exth503) = self.exth.get(&503) {
title = exth503;
} else if let (Ok(toff), Ok(tlen)) = (be_u32(record0, 0x54), be_u32(record0, 0x58)) {
let (start, end) = (toff as usize, toff as usize + tlen as usize);
if let Some(slice) = record0.get(start..end) {
title = slice;
}
}
}
if title.is_empty() {
let name = &db_name[..db_name.len().min(32)];
title = match name.iter().position(|&b| b == 0) {
Some(nul) => &name[..nul],
None => name,
};
}
decode_title(title, self.codepage)
}
pub fn pid_meta(&self) -> (Vec<u8>, Vec<u8>) {
let rec209 = self.exth.get(&209).cloned().unwrap_or_default();
let mut token = Vec::new();
let mut i = 0;
while i + 5 <= rec209.len() {
let key =
u32::from_be_bytes([rec209[i + 1], rec209[i + 2], rec209[i + 3], rec209[i + 4]]);
if let Some(value) = self.exth.get(&key) {
token.extend_from_slice(value);
}
i += 5;
}
(rec209, token)
}
}
fn decode_title(bytes: &[u8], codepage: u32) -> String {
if codepage == 65001 {
String::from_utf8_lossy(bytes).into_owned()
} else {
bytes.iter().map(|&b| b as char).collect()
}
}
fn parse_exth(exth: &[u8]) -> Result<BTreeMap<u32, Vec<u8>>> {
let mut map = BTreeMap::new();
if exth.len() < 12 || &exth[0..4] != b"EXTH" {
return Ok(map);
}
let nitems = be_u32(exth, 8)?;
let mut pos = 12usize;
for _ in 0..nitems {
let rtype = be_u32(exth, pos)?;
let size = be_u32(exth, pos + 4)? as usize;
if size < 8 || pos + size > exth.len() {
return Err(FormatError::Invalid("EXTH record overruns block".into()));
}
map.insert(rtype, exth[pos + 8..pos + size].to_vec());
pos += size;
}
Ok(map)
}
#[cfg(test)]
mod tests {
use super::*;
fn build_exth(items: &[(u32, &[u8])]) -> Vec<u8> {
let mut records = Vec::new();
for (rtype, content) in items {
let size = 8 + content.len();
records.extend_from_slice(&rtype.to_be_bytes());
records.extend_from_slice(&(size as u32).to_be_bytes());
records.extend_from_slice(content);
}
let mut exth = Vec::new();
exth.extend_from_slice(b"EXTH");
exth.extend_from_slice(&((12 + records.len()) as u32).to_be_bytes());
exth.extend_from_slice(&(items.len() as u32).to_be_bytes());
exth.extend_from_slice(&records);
exth
}
fn build_record0(compression: u16, mobi_version: u32, exth: &[u8]) -> Vec<u8> {
let mobi_length: u32 = 0xE4;
let mut r = vec![0u8; 16 + mobi_length as usize];
r[0x00..0x02].copy_from_slice(&compression.to_be_bytes());
r[0x08..0x0A].copy_from_slice(&7u16.to_be_bytes()); r[0x0C..0x0E].copy_from_slice(&2u16.to_be_bytes()); r[0x10..0x14].copy_from_slice(b"MOBI");
r[0x14..0x18].copy_from_slice(&mobi_length.to_be_bytes());
r[0x1C..0x20].copy_from_slice(&65001u32.to_be_bytes()); r[0x68..0x6C].copy_from_slice(&mobi_version.to_be_bytes());
r[0x80..0x84].copy_from_slice(&0x40u32.to_be_bytes()); r[0xA8..0xAC].copy_from_slice(&0x0400u32.to_be_bytes()); r[0xAC..0xB0].copy_from_slice(&1u32.to_be_bytes()); r[0xB0..0xB4].copy_from_slice(&0x30u32.to_be_bytes()); r[0xB4..0xB8].copy_from_slice(&0u32.to_be_bytes()); r[0xF2..0xF4].copy_from_slice(&0x0003u16.to_be_bytes()); r.extend_from_slice(exth);
r
}
#[test]
fn parses_bookmobi_record0_and_exth() {
let mut rec209 = vec![0u8]; rec209.extend_from_slice(&300u32.to_be_bytes());
let exth = build_exth(&[(209, &rec209), (300, b"TOKEN"), (503, b"Title")]);
let record0 = build_record0(2, 6, &exth);
let h = MobiHeader::parse(&record0, b"BOOKMOBI").unwrap();
assert_eq!(h.compression, 2);
assert_eq!(h.text_record_count, 7);
assert_eq!(h.encryption_type, 2);
assert!(!h.is_textread);
assert_eq!(h.mobi_length, 0xE4);
assert_eq!(h.mobi_version, 6);
assert_eq!(h.codepage, 65001);
assert_eq!(h.exth_flag & 0x40, 0x40);
assert_eq!(
h.drm,
DrmBlock {
ptr: 0x0400,
count: 1,
size: 0x30,
flags: 0
}
);
assert_eq!(h.extra_data_flags, 0x0002);
assert_eq!(
h.exth.get(&503).map(|v| v.as_slice()),
Some(b"Title".as_slice())
);
let (rec, token) = h.pid_meta();
assert_eq!(rec, rec209);
assert_eq!(token, b"TOKEN");
}
#[test]
fn huff_cdic_keeps_low_bit() {
let exth = build_exth(&[(300, b"x")]);
let record0 = build_record0(COMPRESSION_HUFF_CDIC, 6, &exth);
let h = MobiHeader::parse(&record0, b"BOOKMOBI").unwrap();
assert_eq!(h.extra_data_flags, 0x0003);
}
#[test]
fn textread_stops_after_palmdoc_fields() {
let mut r = vec![0u8; 16];
r[0x00..0x02].copy_from_slice(&1u16.to_be_bytes());
r[0x08..0x0A].copy_from_slice(&3u16.to_be_bytes());
r[0x0C..0x0E].copy_from_slice(&0u16.to_be_bytes());
let h = MobiHeader::parse(&r, b"TEXtREAd").unwrap();
assert!(h.is_textread);
assert_eq!(h.text_record_count, 3);
assert_eq!(h.mobi_length, 0);
assert!(h.exth.is_empty());
}
#[test]
fn book_title_prefers_exth_503() {
let exth = build_exth(&[(503, b"The Real Title")]);
let record0 = build_record0(2, 6, &exth);
let h = MobiHeader::parse(&record0, b"BOOKMOBI").unwrap();
assert_eq!(h.book_title(&record0, b"PALMNAME\0"), "The Real Title");
}
#[test]
fn book_title_falls_back_to_full_name_offset() {
let title = b"Full Name Field";
let exth = build_exth(&[(300, b"x")]); let mut record0 = build_record0(2, 6, &exth);
let toff = record0.len() as u32;
record0.extend_from_slice(title);
record0[0x54..0x58].copy_from_slice(&toff.to_be_bytes());
record0[0x58..0x5c].copy_from_slice(&(title.len() as u32).to_be_bytes());
let h = MobiHeader::parse(&record0, b"BOOKMOBI").unwrap();
assert_eq!(h.book_title(&record0, b"PALMNAME\0"), "Full Name Field");
}
#[test]
fn book_title_falls_back_to_db_name() {
let mut r = vec![0u8; 16];
r[0x0C..0x0E].copy_from_slice(&0u16.to_be_bytes());
let h = MobiHeader::parse(&r, b"TEXtREAd").unwrap();
let mut db_name = b"My Palm Book".to_vec();
db_name.resize(32, 0); assert_eq!(h.book_title(&r, &db_name), "My Palm Book");
}
#[test]
fn book_title_utf8_codepage() {
let exth = build_exth(&[(503, "Café ☕".as_bytes())]);
let record0 = build_record0(2, 6, &exth); let h = MobiHeader::parse(&record0, b"BOOKMOBI").unwrap();
assert_eq!(h.book_title(&record0, b""), "Café ☕");
}
#[test]
fn rejects_bad_magic() {
let r = vec![0u8; 16];
assert!(matches!(
MobiHeader::parse(&r, b"DUMMY\0\0\0"),
Err(FormatError::BadMagic(_))
));
}
}