From b7d98e9d1a877f89ad29cf0f1bd88e82c63f0ae1 Mon Sep 17 00:00:00 2001 From: funkpower Date: Fri, 13 Nov 2026 14:17:44 -0500 Subject: [PATCH 1/3] bookmeta: read MOBI, AZW and AZW3 from the MOBI header and its EXTH records A Kindle book's title, authors, publisher, description, ISBN, ASIN, subjects, date and language come from the EXTH block, with the full name, the Palm database name and the header's locale as fallbacks. The cover image (EXTH 201, or the thumbnail) is returned as Metadata::cover with its media type; it stays out of the JSON record. Every offset is bounds-checked: a damaged header gives an empty record with a note, a broken EXTH block keeps what came before the break, and an encrypted book is still read and noted as drm. Metadata gains description, which the EPUB reader now fills from dc:description as well. Fixtures are a story and cover written for the tests (CC0), converted with calibre to MOBI 6 and AZW3, plus no-EXTH, DRM-flagged and truncated copies. --- crates/bookmeta/README.md | 25 +- crates/bookmeta/src/epub.rs | 11 +- crates/bookmeta/src/lib.rs | 28 + crates/bookmeta/src/mobi.rs | 759 ++++++++++++++++++ crates/bookmeta/tests/fixtures/README.md | 28 + .../bookmeta/tests/fixtures/lantern-cover.jpg | Bin 0 -> 2807 bytes .../bookmeta/tests/fixtures/lantern-drm.mobi | Bin 0 -> 14846 bytes .../tests/fixtures/lantern-no-exth.mobi | Bin 0 -> 14846 bytes .../tests/fixtures/lantern-truncated.mobi | Bin 0 -> 1024 bytes crates/bookmeta/tests/fixtures/lantern.azw3 | Bin 0 -> 16318 bytes crates/bookmeta/tests/fixtures/lantern.html | 5 + crates/bookmeta/tests/fixtures/lantern.mobi | Bin 0 -> 14846 bytes crates/bookmeta/tests/mobi.rs | 102 +++ 13 files changed, 955 insertions(+), 3 deletions(-) create mode 100644 crates/bookmeta/src/mobi.rs create mode 100644 crates/bookmeta/tests/fixtures/README.md create mode 100644 crates/bookmeta/tests/fixtures/lantern-cover.jpg create mode 100644 crates/bookmeta/tests/fixtures/lantern-drm.mobi create mode 100644 crates/bookmeta/tests/fixtures/lantern-no-exth.mobi create mode 100644 crates/bookmeta/tests/fixtures/lantern-truncated.mobi create mode 100644 crates/bookmeta/tests/fixtures/lantern.azw3 create mode 100644 crates/bookmeta/tests/fixtures/lantern.html create mode 100644 crates/bookmeta/tests/fixtures/lantern.mobi create mode 100644 crates/bookmeta/tests/mobi.rs diff --git a/crates/bookmeta/README.md b/crates/bookmeta/README.md index 2234f16..7871bfe 100644 --- a/crates/bookmeta/README.md +++ b/crates/bookmeta/README.md @@ -84,6 +84,7 @@ bookmeta library/*.epub \ |---|---| | EPUB | The package document named by `META-INF/container.xml` — Dublin Core, plus the `` entries Calibre adds for a series | | PDF | The trailer's information dictionary | +| MOBI, AZW, AZW3 | The MOBI header in the first record, and the EXTH records after it | An EPUB whose container is malformed falls back to the first `.opf` in the archive, because a converter that wrote a bad container often still wrote a @@ -94,6 +95,28 @@ both become `isbn`, a UUID becomes `uuid`, anything else `other`. PDF dates are reduced to the day where they parse and kept verbatim where they do not, because a date nobody can read is still evidence. +A Kindle book's title is EXTH 503 where it is there, then the MOBI header's +full name, then the Palm database name (cut to 31 bytes, with a note). Authors +(100) and subjects (105) are every record of their kind, in order; publisher +(101), description (103), date (106) and language (524) the first. EXTH 104 is +an `isbn` when it is shaped like one and `other` when not, and 113 is an +`asin`. A contributor (108) is not an author, the same as an EPUB's +`dc:contributor`, and is not reported. With no EXTH language, the language +comes from the header's locale. Strings are decoded as the header says, +UTF-8 or Windows-1252. A book whose text is encrypted still has its header +read, and carries the note `drm`. + +Every offset in a Kindle book is checked before it is followed: headers that +are cut short or point outside the file give an empty record with a note, and +an EXTH block that breaks off keeps the records before the break. + +## Covers + +`Metadata::cover` is the cover image a Kindle book carries (EXTH 201, or the +thumbnail, 202, when no cover is named), with its media type read off its +first bytes. It is for library callers and is not part of the JSON record, +which is a line of text. + ## Asking a model Some files say nothing useful. `--prompt` writes the question and **sends @@ -147,7 +170,7 @@ if let Some(prompt) = bookmeta::prompt::compose(&meta, Some(opening)) { } ``` -`bookmeta::epub` and `bookmeta::pdf` read one format each, and +`bookmeta::epub`, `bookmeta::pdf` and `bookmeta::mobi` read one format each, and `Metadata::missing` says which fields a record still wants. ## See also diff --git a/crates/bookmeta/src/epub.rs b/crates/bookmeta/src/epub.rs index d434b82..6cda856 100644 --- a/crates/bookmeta/src/epub.rs +++ b/crates/bookmeta/src/epub.rs @@ -85,6 +85,7 @@ fn fill(meta: &mut Metadata, package: &str) { meta.title = first(package, "title"); meta.authors = creators(package); meta.publisher = first(package, "publisher"); + meta.description = first(package, "description"); meta.date = first(package, "date"); meta.language = first(package, "language"); meta.subjects = all(package, "subject"); @@ -179,7 +180,7 @@ fn identifiers(package: &str) -> Vec<(String, String)> { } /// Whether a string is ten or thirteen digits once its hyphens come off. -fn is_isbn(value: &str) -> bool { +pub(crate) fn is_isbn(value: &str) -> bool { let digits = digits_of(value); matches!(digits.len(), 10 | 13) && value @@ -187,7 +188,8 @@ fn is_isbn(value: &str) -> bool { .all(|c| c.is_ascii_digit() || "- xX".contains(c)) } -fn digits_of(value: &str) -> String { +/// The digits of an ISBN, with its check character, and nothing else. +pub(crate) fn digits_of(value: &str) -> String { value .chars() .filter(|c| c.is_ascii_digit() || matches!(c, 'x' | 'X')) @@ -253,6 +255,7 @@ mod tests { Kallistos Ware A Typesetter Faber & Faber + <p>Writings of the Fathers.</p> 1984-09-17 en Orthodox Christianity @@ -275,6 +278,10 @@ mod tests { let meta = read_package(); assert_eq!(meta.title.as_deref(), Some("The Philokalia, Volume 3")); assert_eq!(meta.publisher.as_deref(), Some("Faber & Faber")); + assert_eq!( + meta.description.as_deref(), + Some("

Writings of the Fathers.

") + ); assert_eq!(meta.date.as_deref(), Some("1984-09-17")); assert_eq!(meta.language.as_deref(), Some("en")); } diff --git a/crates/bookmeta/src/lib.rs b/crates/bookmeta/src/lib.rs index 871525c..08ba6d8 100644 --- a/crates/bookmeta/src/lib.rs +++ b/crates/bookmeta/src/lib.rs @@ -14,6 +14,7 @@ //! [`bibfetch`]: https://git.pumpin.online/funkpower/catena pub mod epub; +pub mod mobi; pub mod pdf; pub mod prompt; @@ -30,6 +31,8 @@ pub enum Source { Opf, /// A PDF's information dictionary. PdfInfo, + /// A Kindle book's MOBI header and the EXTH records after it. + Mobi, /// Nothing was found to read. None, } @@ -40,6 +43,7 @@ impl Source { match self { Source::Opf => "opf", Source::PdfInfo => "pdf_info", + Source::Mobi => "mobi", Source::None => "none", } } @@ -64,6 +68,9 @@ pub struct Metadata { /// Who published it. #[serde(skip_serializing_if = "Option::is_none")] pub publisher: Option, + /// The publisher's description of the book, as written: often HTML. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, /// The publication date, in whatever form the file wrote it. #[serde(skip_serializing_if = "Option::is_none")] pub date: Option, @@ -86,6 +93,23 @@ pub struct Metadata { /// What was worth saying about the reading. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub notes: Vec, + /// The cover image the file carries. + /// + /// Not part of the record: a record is a line of text and this is an + /// image. A library caller takes it from here. + #[serde(skip)] + #[schemars(skip)] + pub cover: Option, +} + +/// A cover image, as the file stores it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Cover { + /// What kind of image it is, read off its first bytes: `image/jpeg`, + /// `image/png`, `image/gif` or `image/bmp`. + pub media_type: String, + /// The image itself. + pub bytes: Vec, } fn version() -> u32 { @@ -143,6 +167,7 @@ impl Metadata { self.title.is_none() && self.authors.is_empty() && self.publisher.is_none() + && self.description.is_none() && self.date.is_none() && self.language.is_none() && self.identifiers.is_empty() @@ -171,6 +196,9 @@ pub fn from_bytes(path: &str, bytes: &[u8]) -> Metadata { if bytes.starts_with(b"%PDF") { return pdf::read(path, bytes); } + if mobi::is_mobi(bytes) { + return mobi::read(path, bytes); + } let mut meta = Metadata::empty(path, Source::None); meta.note("not a format this reads metadata out of"); meta diff --git a/crates/bookmeta/src/mobi.rs b/crates/bookmeta/src/mobi.rs new file mode 100644 index 0000000..59471d7 --- /dev/null +++ b/crates/bookmeta/src/mobi.rs @@ -0,0 +1,759 @@ +//! A Kindle book's MOBI header and the EXTH records after it. +//! +//! MOBI, AZW and AZW3 files are Palm databases. The first record opens with a +//! PalmDOC header and a MOBI header, which gives the book's full name and says +//! how its strings are encoded. When a flag in that header is set, an EXTH +//! block follows with everything else: authors, publisher, ISBN, subjects, and +//! which image record is the cover. AZW3 (KF8) keeps the same layout in its +//! first record, so one reader covers all three. +//! +//! Every offset in the file is checked before it is followed. A file whose +//! headers are cut short or written wrongly gives an empty record with a note; +//! an EXTH block that breaks off keeps the records before the break. Neither +//! panics or reads past the end. + +use crate::epub::{digits_of, is_isbn}; +use crate::{Cover, Metadata, Source}; + +/// Where a Palm database says what it holds. +const TYPE_AT: usize = 60; +/// What a Kindle book says there. +const BOOK_TYPE: &[u8] = b"BOOKMOBI"; +/// The Palm database's own name: 32 bytes, NUL-padded. +const NAME_LEN: usize = 32; +/// Where the number of records is written. +const RECORD_COUNT_AT: usize = 76; +/// Where the record list starts: eight bytes per record, offset first. +const RECORD_LIST_AT: usize = 78; + +/// Where the PalmDOC header says whether the text is encrypted. +const ENCRYPTION_AT: usize = 12; +/// Where the MOBI header begins inside the first record. +const MOBI_AT: usize = 16; +/// The MOBI header's own magic. +const MOBI_MAGIC: &[u8] = b"MOBI"; + +// Fields of the MOBI header, as offsets from its start. Each is only there +// when the header's declared length reaches past it. +const HEADER_LENGTH: usize = 4; +const TEXT_ENCODING: usize = 12; +const FULL_NAME_OFFSET: usize = 68; +const FULL_NAME_LENGTH: usize = 72; +const LOCALE: usize = 76; +const FIRST_IMAGE: usize = 92; +const EXTH_FLAGS: usize = 112; + +/// The flag that says an EXTH block follows the MOBI header. +const HAS_EXTH: u32 = 0x40; +/// The EXTH block's magic. +const EXTH_MAGIC: &[u8] = b"EXTH"; + +/// The text encodings a MOBI header names. +const UTF_8: u32 = 65001; +const WINDOWS_1252: u32 = 1252; + +/// What an index or offset says when it points at nothing. +const NONE: u32 = 0xffff_ffff; + +// EXTH record types. +const AUTHOR: u32 = 100; +const PUBLISHER: u32 = 101; +const DESCRIPTION: u32 = 103; +const ISBN: u32 = 104; +const SUBJECT: u32 = 105; +const PUBLISHED: u32 = 106; +const ASIN: u32 = 113; +const COVER_OFFSET: u32 = 201; +const THUMB_OFFSET: u32 = 202; +const UPDATED_TITLE: u32 = 503; +const LANGUAGE: u32 = 524; + +/// Whether these bytes are a Kindle book this module can read. +pub fn is_mobi(bytes: &[u8]) -> bool { + bytes.get(TYPE_AT..TYPE_AT + BOOK_TYPE.len()) == Some(BOOK_TYPE) +} + +/// Read what a MOBI, AZW or AZW3 file says about itself. +pub fn read(path: &str, bytes: &[u8]) -> Metadata { + let mut meta = Metadata::empty(path, Source::Mobi); + if let Err(why) = fill(&mut meta, bytes) { + let mut empty = Metadata::empty(path, Source::None); + empty.note(why); + return empty; + } + if meta.is_empty() { + meta.note("a MOBI header with nothing filled in"); + } + meta +} + +/// The strings an EXTH block carries, before they are placed in a record. +#[derive(Default)] +struct Exth { + authors: Vec, + publisher: Option, + description: Option, + isbn: Option, + subjects: Vec, + published: Option, + asin: Option, + cover: Option, + thumb: Option, + title: Option, + language: Option, +} + +fn fill(meta: &mut Metadata, bytes: &[u8]) -> Result<(), String> { + if !is_mobi(bytes) { + return Err("not a Kindle book: the Palm database type is not BOOKMOBI".into()); + } + let records = records(bytes)?; + let first = record(bytes, &records, 0).ok_or("the first record lies outside the file")?; + + if first.get(MOBI_AT..MOBI_AT + MOBI_MAGIC.len()) != Some(MOBI_MAGIC) { + return Err("no MOBI header in the first record".into()); + } + let header_length = + u32_at(first, MOBI_AT + HEADER_LENGTH).ok_or("the MOBI header is cut short")? as usize; + let header = Header { + record: first, + length: header_length, + }; + + let encoding = header.field(TEXT_ENCODING).unwrap_or(WINDOWS_1252); + if encoding != UTF_8 && encoding != WINDOWS_1252 { + meta.note(format!( + "text encoding {encoding}, which is neither UTF-8 nor Windows-1252; read as Windows-1252" + )); + } + let decode = |raw: &[u8]| text(raw, encoding); + + if u16_at(first, ENCRYPTION_AT).is_some_and(|kind| kind != 0) { + meta.note("drm"); + } + + let exth = if header + .field(EXTH_FLAGS) + .is_some_and(|flags| flags & HAS_EXTH != 0) + { + let at = MOBI_AT + .checked_add(header_length) + .ok_or("the MOBI header length is out of range")?; + exth(first, at, &decode, meta) + } else { + Exth::default() + }; + + let full_name = header + .field(FULL_NAME_OFFSET) + .zip(header.field(FULL_NAME_LENGTH)) + .and_then(|(at, len)| slice(first, at as usize, len as usize)) + .and_then(decode); + meta.title = match (exth.title, full_name) { + (Some(title), _) | (None, Some(title)) => Some(title), + (None, None) => { + let name = palm_name(bytes); + if name.is_some() { + meta.note("title from the Palm database name, which is cut to 31 bytes"); + } + name + } + }; + + meta.authors = exth.authors; + meta.publisher = exth.publisher; + meta.description = exth.description; + meta.date = exth.published; + meta.subjects = exth.subjects; + meta.language = exth.language.or_else(|| { + header + .field(LOCALE) + .and_then(|locale| language_of(locale).map(str::to_string)) + }); + if let Some(isbn) = exth.isbn { + if is_isbn(&isbn) { + meta.identifiers.insert("isbn".into(), digits_of(&isbn)); + } else { + meta.identifiers.insert("other".into(), isbn); + } + } + if let Some(asin) = exth.asin { + meta.identifiers.insert("asin".into(), asin); + } + + if let Some(offset) = exth.cover.or(exth.thumb) { + meta.cover = cover(bytes, &records, header.field(FIRST_IMAGE), offset, meta); + } + Ok(()) +} + +/// The MOBI header, for reading fields that may or may not be in it. +struct Header<'a> { + record: &'a [u8], + length: usize, +} + +impl Header<'_> { + /// A field at `at` bytes into the header, when the header reaches it. + fn field(&self, at: usize) -> Option { + if at.checked_add(4)? > self.length { + return None; + } + u32_at(self.record, MOBI_AT.checked_add(at)?) + } +} + +/// Where each record starts, as the record list says. +fn records(bytes: &[u8]) -> Result, String> { + let count = u16_at(bytes, RECORD_COUNT_AT).ok_or("the Palm database header is cut short")?; + if count == 0 { + return Err("a Palm database with no records".into()); + } + let mut out = Vec::with_capacity(count as usize); + for at in 0..count as usize { + let entry = RECORD_LIST_AT + at * 8; + let Some(offset) = u32_at(bytes, entry) else { + return Err("the record list runs past the end of the file".into()); + }; + out.push(offset as usize); + } + Ok(out) +} + +/// One record's bytes: from its offset to the next record's, or to the end. +fn record<'a>(bytes: &'a [u8], records: &[usize], index: usize) -> Option<&'a [u8]> { + let start = *records.get(index)?; + let end = records.get(index + 1).copied().unwrap_or(bytes.len()); + bytes.get(start..end) +} + +/// Read the EXTH block at `at` inside the first record. +/// +/// A block that ends early keeps what was read before the break, with a note: +/// the records already read are as good as they would have been. +fn exth( + first: &[u8], + at: usize, + decode: &dyn Fn(&[u8]) -> Option, + meta: &mut Metadata, +) -> Exth { + let mut out = Exth::default(); + if at >= first.len() || first.get(at..at + EXTH_MAGIC.len()) != Some(EXTH_MAGIC) { + meta.note("the header says an EXTH block follows, but none does"); + return out; + } + let (Some(length), Some(count)) = (u32_at(first, at + 4), u32_at(first, at + 8)) else { + meta.note("the EXTH block ends early"); + return out; + }; + let end = at.saturating_add(length as usize).min(first.len()); + + let mut cursor = at + 12; + for _ in 0..count { + let (Some(kind), Some(size)) = (u32_at(first, cursor), u32_at(first, cursor + 4)) else { + meta.note("the EXTH block ends early"); + break; + }; + let size = size as usize; + let Some(data) = size + .checked_sub(8) + .and_then(|len| slice(first, cursor + 8, len)) + .filter(|_| cursor.checked_add(size).is_some_and(|stop| stop <= end)) + else { + meta.note("the EXTH block ends early"); + break; + }; + cursor += size; + + match kind { + AUTHOR => out.authors.extend(decode(data)), + SUBJECT => out.subjects.extend(decode(data)), + PUBLISHER => set(&mut out.publisher, decode(data)), + DESCRIPTION => set(&mut out.description, decode(data)), + ISBN => set(&mut out.isbn, decode(data)), + PUBLISHED => set(&mut out.published, decode(data)), + ASIN => set(&mut out.asin, decode(data)), + UPDATED_TITLE => set(&mut out.title, decode(data)), + LANGUAGE => set(&mut out.language, decode(data)), + COVER_OFFSET => set(&mut out.cover, number(data)), + THUMB_OFFSET => set(&mut out.thumb, number(data)), + _ => {} + } + } + out +} + +/// Keep the first value a field is given: a later duplicate is usually a +/// converter writing the same thing twice, and the first is what it meant. +fn set(slot: &mut Option, value: Option) { + if slot.is_none() { + *slot = value; + } +} + +/// A four-byte number record that points somewhere. +fn number(data: &[u8]) -> Option { + let value = u32::from_be_bytes(data.try_into().ok()?); + (value != NONE).then_some(value) +} + +/// The cover image, from the record the header and the EXTH offset name. +fn cover( + bytes: &[u8], + records: &[usize], + first_image: Option, + offset: u32, + meta: &mut Metadata, +) -> Option { + let Some(first_image) = first_image.filter(|&index| index != NONE) else { + meta.note("a cover is named, but the header has no image records"); + return None; + }; + let Some(data) = first_image + .checked_add(offset) + .and_then(|index| record(bytes, records, index as usize)) + else { + meta.note("the cover points past the last record"); + return None; + }; + let Some(media_type) = image_type(data) else { + meta.note("the cover record is not an image"); + return None; + }; + Some(Cover { + media_type: media_type.to_string(), + bytes: data.to_vec(), + }) +} + +/// What kind of image these bytes are, by their magic. +fn image_type(data: &[u8]) -> Option<&'static str> { + if data.starts_with(&[0xff, 0xd8, 0xff]) { + Some("image/jpeg") + } else if data.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("image/png") + } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") { + Some("image/gif") + } else if data.starts_with(b"BM") && data.len() > 14 { + Some("image/bmp") + } else { + None + } +} + +/// The Palm database's name, the last place a title can come from. +/// +/// Converters write the title there with spaces made underscores; it is +/// reported as written. +fn palm_name(bytes: &[u8]) -> Option { + let raw = bytes.get(..NAME_LEN)?; + let raw = raw.split(|&byte| byte == 0).next().unwrap_or(raw); + text(raw, WINDOWS_1252) +} + +/// A string in the book's declared encoding, trimmed; empty is none. +fn text(raw: &[u8], encoding: u32) -> Option { + let decoded: String = match (encoding, std::str::from_utf8(raw)) { + (UTF_8, Ok(text)) => text.to_string(), + (UTF_8, Err(_)) => String::from_utf8_lossy(raw).into_owned(), + _ => raw.iter().map(|&byte| windows_1252(byte)).collect(), + }; + let trimmed = decoded.trim_matches(|c: char| c == '\0' || c.is_whitespace()); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +/// One Windows-1252 byte as a character. +fn windows_1252(byte: u8) -> char { + match byte { + 0x80..=0x9f => HIGH_CONTROLS[(byte - 0x80) as usize], + _ => byte as char, + } +} + +/// The characters Windows-1252 puts where Latin-1 has C1 controls. The five +/// positions it leaves undefined decode to themselves, as browsers do. +const HIGH_CONTROLS: [char; 32] = [ + '\u{20ac}', '\u{81}', '\u{201a}', '\u{192}', '\u{201e}', '\u{2026}', '\u{2020}', '\u{2021}', + '\u{2c6}', '\u{2030}', '\u{160}', '\u{2039}', '\u{152}', '\u{8d}', '\u{17d}', '\u{8f}', + '\u{90}', '\u{2018}', '\u{2019}', '\u{201c}', '\u{201d}', '\u{2022}', '\u{2013}', '\u{2014}', + '\u{2dc}', '\u{2122}', '\u{161}', '\u{203a}', '\u{153}', '\u{9d}', '\u{17e}', '\u{178}', +]; + +/// The language a MOBI locale names, for books with no EXTH language. +/// +/// The low byte is a Windows primary language id; the region above it is +/// dropped, since the file's own language field never carries one either. +fn language_of(locale: u32) -> Option<&'static str> { + Some(match locale & 0xff { + 0x01 => "ar", + 0x02 => "bg", + 0x03 => "ca", + 0x04 => "zh", + 0x05 => "cs", + 0x06 => "da", + 0x07 => "de", + 0x08 => "el", + 0x09 => "en", + 0x0a => "es", + 0x0b => "fi", + 0x0c => "fr", + 0x0d => "he", + 0x0e => "hu", + 0x0f => "is", + 0x10 => "it", + 0x11 => "ja", + 0x12 => "ko", + 0x13 => "nl", + 0x14 => "no", + 0x15 => "pl", + 0x16 => "pt", + 0x18 => "ro", + 0x19 => "ru", + 0x1a => "hr", + 0x1b => "sk", + 0x1d => "sv", + 0x1f => "tr", + 0x22 => "uk", + 0x29 => "fa", + 0x2a => "vi", + 0x36 => "af", + 0x39 => "hi", + _ => return None, + }) +} + +fn slice(bytes: &[u8], at: usize, len: usize) -> Option<&[u8]> { + bytes.get(at..at.checked_add(len)?) +} + +fn u16_at(bytes: &[u8], at: usize) -> Option { + Some(u16::from_be_bytes(slice(bytes, at, 2)?.try_into().ok()?)) +} + +fn u32_at(bytes: &[u8], at: usize) -> Option { + Some(u32::from_be_bytes(slice(bytes, at, 4)?.try_into().ok()?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const JPEG: &[u8] = &[0xff, 0xd8, 0xff, 0xe0, 0, 16, b'J', b'F', b'I', b'F']; + + /// A Kindle book built field by field, so each test can break one thing. + struct Book { + name: &'static [u8], + encoding: u32, + encryption: u16, + locale: u32, + full_name: Vec, + exth: Option)>>, + first_image: u32, + images: Vec>, + } + + impl Default for Book { + fn default() -> Self { + Book { + name: b"A_Test_Book", + encoding: UTF_8, + encryption: 0, + locale: 0x09, + full_name: b"A Test Book".to_vec(), + exth: Some(Vec::new()), + first_image: 1, + images: Vec::new(), + } + } + } + + impl Book { + fn bytes(&self) -> Vec { + const HEADER: usize = 232; + let mut first = vec![0u8; MOBI_AT + HEADER]; + first[ENCRYPTION_AT..ENCRYPTION_AT + 2].copy_from_slice(&self.encryption.to_be_bytes()); + first[MOBI_AT..MOBI_AT + 4].copy_from_slice(MOBI_MAGIC); + let put = |first: &mut Vec, at: usize, value: u32| { + first[MOBI_AT + at..MOBI_AT + at + 4].copy_from_slice(&value.to_be_bytes()); + }; + put(&mut first, HEADER_LENGTH, HEADER as u32); + put(&mut first, TEXT_ENCODING, self.encoding); + put(&mut first, LOCALE, self.locale); + put(&mut first, FIRST_IMAGE, self.first_image); + if let Some(records) = &self.exth { + put(&mut first, EXTH_FLAGS, HAS_EXTH); + let mut body = Vec::new(); + for (kind, data) in records { + body.extend_from_slice(&kind.to_be_bytes()); + body.extend_from_slice(&(data.len() as u32 + 8).to_be_bytes()); + body.extend_from_slice(data); + } + first.extend_from_slice(EXTH_MAGIC); + first.extend_from_slice(&(body.len() as u32 + 12).to_be_bytes()); + first.extend_from_slice(&(records.len() as u32).to_be_bytes()); + first.extend_from_slice(&body); + } + let name_at = first.len(); + first.extend_from_slice(&self.full_name); + put(&mut first, FULL_NAME_OFFSET, name_at as u32); + put(&mut first, FULL_NAME_LENGTH, self.full_name.len() as u32); + + let mut records = vec![first]; + records.extend(self.images.iter().cloned()); + + let mut out = vec![0u8; RECORD_LIST_AT + records.len() * 8]; + out[..self.name.len()].copy_from_slice(self.name); + out[TYPE_AT..TYPE_AT + 8].copy_from_slice(BOOK_TYPE); + out[RECORD_COUNT_AT..RECORD_COUNT_AT + 2] + .copy_from_slice(&(records.len() as u16).to_be_bytes()); + for (at, record) in records.iter().enumerate() { + let offset = out.len() as u32; + let entry = RECORD_LIST_AT + at * 8; + out[entry..entry + 4].copy_from_slice(&offset.to_be_bytes()); + out.extend_from_slice(record); + } + out + } + } + + fn exth(records: &[(u32, &[u8])]) -> Option)>> { + Some(records.iter().map(|(k, v)| (*k, v.to_vec())).collect()) + } + + #[test] + fn the_exth_records_fill_the_record() { + let book = Book { + exth: exth(&[ + (UPDATED_TITLE, b"The Real Title"), + (AUTHOR, b"First Author"), + (AUTHOR, b"Second Author"), + (PUBLISHER, b"A Press"), + (DESCRIPTION, b"What it is about."), + (ISBN, b"978-0-306-40615-7"), + (SUBJECT, b"History"), + (SUBJECT, b"Ships"), + (PUBLISHED, b"1999-01-02"), + (ASIN, b"B000TEST00"), + (LANGUAGE, b"de"), + ]), + ..Book::default() + }; + let meta = read("x.mobi", &book.bytes()); + assert_eq!(meta.source, "mobi"); + assert_eq!(meta.title.as_deref(), Some("The Real Title")); + assert_eq!(meta.authors, ["First Author", "Second Author"]); + assert_eq!(meta.publisher.as_deref(), Some("A Press")); + assert_eq!(meta.description.as_deref(), Some("What it is about.")); + assert_eq!(meta.date.as_deref(), Some("1999-01-02")); + assert_eq!(meta.subjects, ["History", "Ships"]); + assert_eq!(meta.language.as_deref(), Some("de")); + assert_eq!(meta.identifiers["isbn"], "9780306406157"); + assert_eq!(meta.identifiers["asin"], "B000TEST00"); + assert!(meta.notes.is_empty(), "{:?}", meta.notes); + } + + #[test] + fn the_title_falls_back_to_the_full_name_and_then_the_database_name() { + let meta = read("x.mobi", &Book::default().bytes()); + assert_eq!(meta.title.as_deref(), Some("A Test Book")); + + let book = Book { + full_name: Vec::new(), + ..Book::default() + }; + let meta = read("x.mobi", &book.bytes()); + assert_eq!(meta.title.as_deref(), Some("A_Test_Book")); + assert!(meta.notes[0].contains("Palm database name")); + } + + #[test] + fn the_language_falls_back_to_the_locale() { + let book = Book { + locale: 0x040c, + ..Book::default() + }; + assert_eq!( + read("x.mobi", &book.bytes()).language.as_deref(), + Some("fr") + ); + + let book = Book { + locale: 0, + ..Book::default() + }; + assert_eq!(read("x.mobi", &book.bytes()).language, None); + } + + #[test] + fn windows_1252_strings_decode_as_windows_1252() { + let book = Book { + encoding: WINDOWS_1252, + exth: exth(&[ + (AUTHOR, b"Ren\xe9 Ch\x96ar"), + (PUBLISHER, b"\x93Quoted\x94"), + ]), + ..Book::default() + }; + let meta = read("x.mobi", &book.bytes()); + assert_eq!(meta.authors, ["René Ch–ar"]); + assert_eq!(meta.publisher.as_deref(), Some("“Quoted”")); + } + + #[test] + fn the_cover_is_the_image_record_the_offset_names() { + let book = Book { + exth: exth(&[(COVER_OFFSET, &1u32.to_be_bytes())]), + images: vec![b"GIF89a-thumb".to_vec(), JPEG.to_vec()], + ..Book::default() + }; + let cover = read("x.mobi", &book.bytes()).cover.expect("a cover"); + assert_eq!(cover.media_type, "image/jpeg"); + assert_eq!(cover.bytes, JPEG); + } + + #[test] + fn the_thumbnail_stands_in_when_no_cover_is_named() { + let book = Book { + exth: exth(&[(THUMB_OFFSET, &0u32.to_be_bytes())]), + images: vec![b"\x89PNG\r\n\x1a\nrest".to_vec()], + ..Book::default() + }; + let cover = read("x.mobi", &book.bytes()).cover.expect("a cover"); + assert_eq!(cover.media_type, "image/png"); + } + + #[test] + fn a_cover_that_points_nowhere_is_a_note_and_not_a_cover() { + let book = Book { + exth: exth(&[(COVER_OFFSET, &7u32.to_be_bytes())]), + images: vec![JPEG.to_vec()], + ..Book::default() + }; + let meta = read("x.mobi", &book.bytes()); + assert_eq!(meta.cover, None); + assert_eq!(meta.notes, ["the cover points past the last record"]); + + let book = Book { + exth: exth(&[(COVER_OFFSET, &u32::MAX.to_be_bytes())]), + first_image: u32::MAX - 1, + images: vec![JPEG.to_vec()], + ..Book::default() + }; + assert_eq!(read("x.mobi", &book.bytes()).cover, None); + + let book = Book { + exth: exth(&[(COVER_OFFSET, &0u32.to_be_bytes())]), + images: vec![b"not an image".to_vec()], + ..Book::default() + }; + let meta = read("x.mobi", &book.bytes()); + assert_eq!(meta.cover, None); + assert_eq!(meta.notes, ["the cover record is not an image"]); + } + + #[test] + fn an_encrypted_book_still_has_its_header_read() { + let book = Book { + encryption: 2, + exth: exth(&[(AUTHOR, b"Somebody")]), + ..Book::default() + }; + let meta = read("x.azw", &book.bytes()); + assert_eq!(meta.authors, ["Somebody"]); + assert_eq!(meta.notes, ["drm"]); + } + + #[test] + fn an_exth_record_that_overruns_its_block_stops_the_reading() { + let mut bytes = Book { + exth: exth(&[(AUTHOR, b"Kept"), (PUBLISHER, b"Lost")]), + ..Book::default() + } + .bytes(); + // The second record's length, made to reach past the block. + let at = find(&bytes, b"Lost") - 4; + bytes[at..at + 4].copy_from_slice(&0x7fff_ffffu32.to_be_bytes()); + let meta = read("x.mobi", &bytes); + assert_eq!(meta.authors, ["Kept"]); + assert_eq!(meta.publisher, None); + assert!( + meta.notes + .contains(&"the EXTH block ends early".to_string()) + ); + } + + #[test] + fn a_record_length_shorter_than_its_own_header_stops_the_reading() { + let mut bytes = Book { + exth: exth(&[(AUTHOR, b"Kept")]), + ..Book::default() + } + .bytes(); + let at = find(&bytes, b"Kept") - 4; + bytes[at..at + 4].copy_from_slice(&3u32.to_be_bytes()); + let meta = read("x.mobi", &bytes); + assert!(meta.authors.is_empty()); + assert!( + meta.notes + .contains(&"the EXTH block ends early".to_string()) + ); + } + + #[test] + fn what_is_not_a_kindle_book_says_so() { + let meta = read("x.mobi", b"not a palm database at all"); + assert_eq!(meta.source, "none"); + assert!(meta.notes[0].starts_with("not a Kindle book")); + } + + #[test] + fn a_first_record_with_no_mobi_header_is_refused() { + let mut bytes = Book::default().bytes(); + let at = mobi_header(&bytes); + bytes[at] = b'X'; + let meta = read("x.mobi", &bytes); + assert_eq!(meta.source, "none"); + assert_eq!(meta.notes, ["no MOBI header in the first record"]); + } + + #[test] + fn a_header_too_short_for_a_field_does_not_have_that_field() { + let mut bytes = Book::default().bytes(); + let at = mobi_header(&bytes) + HEADER_LENGTH; + // Long enough for the encoding, too short for the full name. + bytes[at..at + 4].copy_from_slice(&24u32.to_be_bytes()); + let meta = read("x.mobi", &bytes); + assert_eq!(meta.title.as_deref(), Some("A_Test_Book")); + assert_eq!(meta.language, None); + } + + #[test] + fn every_truncation_returns_without_panicking() { + let bytes = Book { + exth: exth(&[ + (UPDATED_TITLE, b"Title"), + (AUTHOR, b"Author"), + (COVER_OFFSET, &0u32.to_be_bytes()), + ]), + images: vec![JPEG.to_vec()], + ..Book::default() + } + .bytes(); + for len in 0..=bytes.len() { + let _ = read("x.mobi", &bytes[..len]); + } + } + + /// Where the MOBI header starts: after the record list, so the `MOBI` + /// at the end of the database type is not it. + fn mobi_header(bytes: &[u8]) -> usize { + RECORD_LIST_AT + find(&bytes[RECORD_LIST_AT..], MOBI_MAGIC) + } + + fn find(haystack: &[u8], needle: &[u8]) -> usize { + haystack + .windows(needle.len()) + .position(|window| window == needle) + .expect("present") + } +} diff --git a/crates/bookmeta/tests/fixtures/README.md b/crates/bookmeta/tests/fixtures/README.md new file mode 100644 index 0000000..afe21c1 --- /dev/null +++ b/crates/bookmeta/tests/fixtures/README.md @@ -0,0 +1,28 @@ +# Fixtures + +## MOBI and AZW3 + +`lantern.html` is a two-paragraph story written for these tests, and +`lantern-cover.jpg` a 120×180 cover drawn for them with Pillow. Both are +dedicated to the public domain (CC0 1.0), and so is everything made from them. + +`lantern.mobi` (MOBI 6) and `lantern.azw3` (KF8) were made from them with +calibre 9.14: + +```sh +common=(--title "A Lantern in the Orchard" --authors "Ada Quill & Tomás Reed" + --publisher "Orchard Press" --isbn 9780306406157 --tags "Fiction,Villages" + --language en --pubdate 2001-04-05 --comments "A short story written for testing." + --cover lantern-cover.jpg) +ebook-convert lantern.html lantern.mobi --mobi-file-type old "${common[@]}" +ebook-convert lantern.html lantern.azw3 "${common[@]}" +``` + +The other three are `lantern.mobi` with one thing broken. `r0` is the first +record's offset, the big-endian 32-bit number at byte 78 (136 here). + +| File | Change | +|---|---| +| `lantern-no-exth.mobi` | The EXTH flag (`0x40`) cleared in the MOBI header's flags, at `r0 + 16 + 112` | +| `lantern-drm.mobi` | The PalmDOC encryption type, at `r0 + 12`, set to 2 | +| `lantern-truncated.mobi` | The first 1024 bytes only | diff --git a/crates/bookmeta/tests/fixtures/lantern-cover.jpg b/crates/bookmeta/tests/fixtures/lantern-cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..72358f8dadf13de970b8af35fdfb0347c808d495 GIT binary patch literal 2807 zcmd5-X;f3!7Cs>fLlja-QgI@RAb}tPGHVP-1Bz7)Akd^X-~gfvsUR{vy$ha-rYNFJ zMFjy7Y(X4~L8hos1k_RxK?aSOK&Yq?#zexs?^?Pl;YZhd{(9%Geb!lPpR@P=?)RO2 zkmtxN@P&ulCO3e=0DwV1fOG>_pshuiN6^%sN0_IhqfI0&qmcCUNG3}R7A&(cwX(D@ zU1h$;$z%N*J2!jtRnC4cZeMTp^7gV`&k5vg3-Dljd43Fn(b3T%>5^7bC@Vc}SJ`_0 z&jYy!bTz>*AR3D?1Zuh%tS$zr15~t5Jm${<{u&rHEDo=(p@|laZYcW#s9~^JH5?X? z$KlZ3M^Hb&>EiX4Te+x{*nt{`2g%m)DY=>}Hr%{V@oE`1TC+1eL5uL^0{w-Hj8~e_ zOwDX;?d%;Kom{_Sxw&uLEyurv|@N2stxez#BpB`VV1c8so2$$tuSth7{CZIPm{$V=*f% zR}?bY+S$2zad#cpLV8_?dGdo-^bh-9BY-%%sNhJJBcGO6F+V{t#FTj4M8I{aqB_8L zTBy$ClUd%?o8Za zN+AL?+mjKn9#T*cP+wM5YUrPJD=K{LcA7<^ZOp5wVE6rwk~14N5v1YM4EF;u%KaH_ zhq_Db0++VMMFsE2rUfvFigg`VpT$klZ0Gq3?rv%5NpQM0ZZKZGt-DS zw3R}ekj>&L1fUwHl{|hho0fmn!X@{FP>Qs?@{m!znp@q$^B2V;eI>E_Fr_)#1a=-l zK*xOq{O((^XxQppc=7vK8_MMbKJEIhfIL18nBINq~DA|5emcaYAHk+H4mC9zt6#BDSUeC%l+EnuFcHQkhDb~nf_P*JdzrbWq z`Grk8GLPCM}6k+d>0yoGUuSLL~*n6s+w*Ez;)8_y;m2Eb%)>@t-jvQ#%xI%W7?Yz`D=ZE)s zNhLj6-^V$uC5r2rE*xk$Gek66KL{&$uvs~up6+jE9aVNZE#`V5J4U>4$5Qim$9A8? zo#{TqIQV>QoXU|}94M^Jd=x=%l2#gA8fIuP%*^PVL#9vVY`try?;oH-+o;N3fG+|P zg%VORDjVW-+TeW8VbP;ex6~S@aAc+>(2tJ7U;Rw|@E*E=%J?PJkhTi}JDNvhAJhzw zPcJNB6buBgS(Lk(Y!(^JwmFg21WE=1mR$;l%b~ZW{yU))FfYrdKsJqll$oBuoy-|A z_D2heP`ATggGgpOAyNoVAnQCNJ)@n&g`w?YJ!Rq88WfFmwCs;!e zRiP=(9>SG5{p0Z}NlU93kXwjnNbF4CM4Km=ZK{#y=%HOdr&pNKf7#}Ipz_uUpb#Zhvs+@pmiV@0qa7cp>*{5Es~ZfbCQ*yFLCs!I-Vk&>qfva!8QbB z+X*KhK{B6+cG&YF48m&_LM-||&+$SJxJ6L&V%}7rQ2vwSAY>v&dx~SAQWQVu{g_j- zPn3a4O}QXlgWqF*a@*OGi@M1ies=qnn3zxjkGUbgaX}ocZcJwHa9hqIZ^Qh9X&{~d E0SGf&D*ylh literal 0 HcmV?d00001 diff --git a/crates/bookmeta/tests/fixtures/lantern-drm.mobi b/crates/bookmeta/tests/fixtures/lantern-drm.mobi new file mode 100644 index 0000000000000000000000000000000000000000..d1c508074981f0349ce22c7393811c5cdc71704d GIT binary patch literal 14846 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 b|FyGOrwG1PIg?Z&6Px)T6@d50q$U0hBJee? literal 0 HcmV?d00001 diff --git a/crates/bookmeta/tests/fixtures/lantern-no-exth.mobi b/crates/bookmeta/tests/fixtures/lantern-no-exth.mobi new file mode 100644 index 0000000000000000000000000000000000000000..2505038a33e71bd2f7d017dba116479eb8012ffc GIT binary patch literal 14846 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 Z*_=}Zzp9)~%8-f7{vQ>9_s1p0{tI$_HEaL? literal 0 HcmV?d00001 diff --git a/crates/bookmeta/tests/fixtures/lantern-truncated.mobi b/crates/bookmeta/tests/fixtures/lantern-truncated.mobi new file mode 100644 index 0000000000000000000000000000000000000000..8a37e57f28dfcf4ce58e7f011a637f90989e2d06 GIT binary patch literal 1024 zcmc&y&ui2`7@e$IwX9eX#UjFpp48~XW|L-byHHW8Y?Vd4EwjmVcj`9L$rSPEkAru? zlLx&D{uQ45{ZDvMJePtX`o8R}EEb_RKl0|idEfVCCYfpVuFBjhllOGqvqRPEm~<#j z#-?IzOCBXmS(R*eI?LBO?JJlZsEoZBpHzySdJL{HcKSJZH)9u`f@c^@UV-<4KY?e# zKfrYWwTyYMnKws*x%YmBMCRwW&1Wx8K+pWzVT}7~U%r^KH|~ z(%TYbcx+cSGPcJtz8bCz>Loe#4v+NGt?p%%eMbuW^tQmf#od{3opYQC|8pia`ctzi z$Cv?!TAAe6H}q)ajKkex{msjg-%yJ3L*PgfFNy^bg(3l>ZP0Y?CUzhPiqdz1b5SBy)JUb@ zkHXLolVsJG@nY%+3rQRYX&i(?qV5h5rgEhFMm2ZV<87j+zSQn4gV%tQ7ljCX5&ELh z6=72dJZGyB$q!TvyahfwO(#Nkk9@jwpU3*)aWK`9Pw^g@=yy(2?*m}|w$8JWYO3|V f%CMjaU|Lr+4}Rn{s7Cj>Zq0^b|2|ig{HOR0ylTfx literal 0 HcmV?d00001 diff --git a/crates/bookmeta/tests/fixtures/lantern.azw3 b/crates/bookmeta/tests/fixtures/lantern.azw3 new file mode 100644 index 0000000000000000000000000000000000000000..f6bea333ee04460747111af85bee71d392d34891 GIT binary patch literal 16318 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 QB4tMZa|NI+SyAqP0F`bsTmS$7 literal 0 HcmV?d00001 diff --git a/crates/bookmeta/tests/fixtures/lantern.html b/crates/bookmeta/tests/fixtures/lantern.html new file mode 100644 index 0000000..6d918e9 --- /dev/null +++ b/crates/bookmeta/tests/fixtures/lantern.html @@ -0,0 +1,5 @@ +x +

A Lantern in the Orchard

+

The lantern hung from the lowest branch of the pear tree, and every evening someone lit it. Nobody in the village could say who.

+

Café owners swore it was the miller; the miller swore it was the wind.

+ \ No newline at end of file diff --git a/crates/bookmeta/tests/fixtures/lantern.mobi b/crates/bookmeta/tests/fixtures/lantern.mobi new file mode 100644 index 0000000000000000000000000000000000000000..b4c56efc999ceddcb52d4589fa3c9519260caedc GIT binary patch literal 14846 zcmeHOdpy)z*WY7|`z2;ZbU`8{xf{13IY<{{jHA-&XfQF#jAlk|PX;NakcbXRD3{Yk zmxCe{D!MtjbU{fJ)p1YAn0fY~bLc$p^Ev0d|Ge+#Gka$J*81)1+TXQ*d#{<#`q_GJ zB-2Wi^>>>H@sX0C(BRbvlANs%S(O+> z-MxD@1MNl+sL5ukx>6|oaz98|bDWvJk-n+1zNvu;4rcg5T4}4w^rJIas!SH05vm%@ zpt4vLn(9tELzP8gvZyrQ)vy+Y-+Xp#RTl<@$%GkHNU{!8Zx)r#SA&1Mz#VEp_NDNP z_dr@@YEGt@ns}3Sy-babb&bux2fj7>;Sf32*1wu0RChw+tF(?*) ztqo!nwvn=5OV|LpKcr=b`uYaC`o_BYCT{x17W(?|t22Ftg%A0aA=N{AG%NEh!e4v* zeEynin31CwL(Z?q&*!HHkiN?IXJvldC`d}Xs5GD56bnj#7sUq-sBZu>S+kXtAm2YL zLng#u&r&Q#5C#0tc7;_0Nq~R=0RaL61Ox~O5D*|BKtOiL_Z%RGoy0?qR&tmmXW@UD9tF1U>G{F-!i}} z&>}EK5e9yccj;MMkyQ_kldV-Fog+8JfusI<(|`cpbKYyPV7-Hr13H3Zq0s#h}u#Lh%A1gg_#NgpnweFbokw?1SY%SPmt>%)nLzO(ct| z?ZFsEC1r~(Uwh>?*7eDtx{((nT3kY5p`y|vjTM?&+AEDsOmSxB7Ixp+J2eO?{(SY? zFU2LL*Kd?n*VNv*d#|qkeq&Q}%hPAiU$l0<>FR#_uBZ3?hoRw-Potm5#wWh;=LO@N zW~K$NGc)^UUUG0=Lc+pGVNw3P5JJI_kaEJPWd(R( z2eDp^ClU(k#+@2N{HaaP?5|BM`X9~g_lf;6uT~(1L_p&q<$w)%=a*Avxh+7gV@*kA zRq=86r2FzmnWsw3>XxE{+zJZMwpKh2u{+tLb&&^($9=g`&pgMT@W7kiNza4q-lai2 zuup}%I=8K=n;koG;S{y$dnf;~OEgF4pu;>Cu=ZMEB0 z_gs>(IrPOfKYCDe zoc?wqcfF@d$Alt=XiwXTtqSDEj!8{U5m41s>d*TICvD}&7PHUO-v8BOvi5Wi*XABCEVERA3nzd+1iBgz1Vyn z5PO=)1J>*bEDzi*E+|sll72OqVfnq*>R6M|*OR`EyUe>2);dY_FsAU1v{25jv}RV@ zMN{(9=I~(OUC0wV@UjzD&+=NrC$&r@-Aiw5s%ww7C>&KDtysc(-G?V)^@n!7d~({; zep|~b^(Ol1R#G{A<+!RoY}POBRJjB~zEPDf~XkiW6JM91J5CpswWOD7Lptq3^GT|5!t z7xzz=+j}eQ-XzaW{#_XdP=vDXu3>d9wsrFfJDnQC4RWnRmnmkC#kx12>Gf##xS(jZ zAw}h8;M;`#QI=Qbx!2dZxLuPpl>q>_rY2==AD; zsyJbSepqN7YKpDu9GDRPXfs;F1C+2^^KIHVYeb8GGI8yp4eEyC%aXc+dh6CLAIc-H zS*nqBq(A5A#dh()a5GEUuDdq29_+!B)Q*w6AGs5r+?ChkbW*phGz>0|KM{HXW#jjkc(=PKx|bv`T|@wvQsBMN)ZM-9*j7oRN7Px&uj@g5hL91&Z~UsXJ^1={EA(7 z!kY)SH;jZ;R}PL&DV@Wg>)k=L$KE(ew8wzC^i9t~r5rpDsAl?dud{zE+Tz7N$n{J& zJ~uSQ14%P`!nU!jVT~Y+_ z=gB#}!~xDx39Xz`BZ-ciaLASSxaC>zMx*8pT67c7cAyfdVKe(C+&sZtn&0y`>*1+l zR%cb(yLjz?vDw`-h|Js3C7xXw!oH19-)6T2g*IDt_Oz3A$S}XtvBtjS^2^HtZ#>0dZ7D0)Mp@Wv= z%u<>$3W`Q{r0?Cmuhk0ePsTEvzWZfRUOguF9BjLUA6CzIJZIYRuP70OuqaYU43-+n z@qJ9BxR?<1;${+*gi&Y%hCIg5$e84ob5%qR8-2XG@r~NrWy`(x@aB1Zsgc@;Qr6#p z^I25!r-!+OpZh(_iO1uv2Ij3;vHU1rS&ceMJu%W$m!Dm#@ao5{MmjpD&}Lc@;V+x& z4Dp@L`pj4Tr)`r4B=#IkJZfI8m)0GyQ>)|5X@uR#*Mj}0$5_Ksi6Nw;vWm6yqys7ivJ@18e@a+f8VX7JeaQ-wsPZ-cXbyv z^b~8|v_7{n+%`|E3p-M5@z6w{2MS2#R?j?+urrQA{lnBUspt0yUjW)<0pLO4^gVZfbrkm*-?UDUwQxJe{n7==(CE@mCVc0at#Iv?R5M5KywF&o7}e3;V4! zKYXd*7||l-!&13L075YyKxAVi;z%TriiC0WvE}ZC7faR02cRiB(n>6M0-KT_Ru63WGb(N?6(#m(E`@@3Fnf<}{-2MDERiD%#5S>yE zv=+JjR&bJ}doDWp#-@!9kI{s3 z5{I-d$atjWuy6A=mm@c_H>Wf5HoQj@S6?a1d=rsv=hPR|o{`FAr%Nk+ST2K`Qi*=- zihS102Hc<$1TK zZO8u=95d)L=HFsc(w(*Rb^446;{U!0&TS8@TPyqVxVc?k+{#?xCZF5)3wwt!wY4>) z`h(oo)7ies@ztX??hPs2mp2?$GRj>ild+q1WilP@PvroRsvHRrdL3m8_K$B@aM@3| z=y62!)Zv18pi~5vAB=u$c$!lq`+mIoK55K(ryWas0LKO!_)7^m_XIBHmVrgf5`n zuGtkS16^~;HHgl>xaYkkCJIyQ3rC03E*bCEE-XB|H-Bg>Zn5ET|Hd0TU;Xr^>kw5T zVbLNUK<_^jB+xxIJ7auZwJJ~cJ2$m*^o4JG}QmHFs{d;F?`I{y#*R0^s{avO~Y zn+Vr7+1%YB+n8bze4wu6_I=H~YxM`N|3}1R)B9_yG+ha0ds{xf*Ng-cxHX=O=fE`Q zft>Q3zj@exZq(81H4*M;>?qH}(_3zM4j)q=GkiG7t-n5JuJD7vZTUE9?Cl3nce$?xbM z_FeT&&<#R6pB=rYMUuT)^IP+WN$C9srfZ;AHov-jZheJo(B`mw|F_OXztj-Q2~I61 zkC`6NUDq@!ola=5F~1N9L>-R(rJb$*@9k`G-$X$<|;{Of~R=Rax~Ou88){mMpI0SP%jpk(-U zOa9+>ePP$PUC;LX54B!=91&JXIaTw*v2eH6hpW`X2hXQ|(8^1)#|+?Ymy%=KFaJItH+|<7#nBtj+lG6;3aF`-?kh)`L4g~kkv?8zGTBF{(?p@CuUGWC0}@; z+B>5>L6!A7b9^H+cu%cJuv=#+H=C?e)eVo*DKpIo^pcnDdyv1^G@9-~QhYlPEVB}d z{k`j@U#lWATg-lB7i?KSFu^}c|Ez>me_=IqYHjsqb{#yx_Fg1yqd5(|k~xL05>KG@ zo9$n$Ub*BkQRO^wJIRk~8u7$oy;erB;z@ta*jI~;t`syYUA|oREBy8F6iq($bbILD zQ+aU!I~dEdjJa}i`ndaQ!C9`td*QIri3IP&fbC2k|LsBek0}4G!|68<`S%WX({(T~ ckN?`)tWyNvs+>uxkcrLwj|#y11ZjzX1F#P@tN;K2 literal 0 HcmV?d00001 diff --git a/crates/bookmeta/tests/mobi.rs b/crates/bookmeta/tests/mobi.rs new file mode 100644 index 0000000..628bc3e --- /dev/null +++ b/crates/bookmeta/tests/mobi.rs @@ -0,0 +1,102 @@ +//! MOBI and AZW3 files as a converter writes them, and as they break. + +use std::path::{Path, PathBuf}; + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) +} + +fn read(name: &str) -> bookmeta::Metadata { + bookmeta::read(&fixture(name)) +} + +/// What the two converted fixtures were given, and so must say. +fn says_what_it_was_given(meta: &bookmeta::Metadata) { + assert_eq!(meta.source, "mobi"); + assert_eq!(meta.title.as_deref(), Some("A Lantern in the Orchard")); + assert_eq!(meta.authors, ["Ada Quill", "Tomás Reed"]); + assert_eq!(meta.publisher.as_deref(), Some("Orchard Press")); + assert_eq!( + meta.description.as_deref(), + Some("A short story written for testing.") + ); + assert_eq!(meta.date.as_deref(), Some("2001-04-05T04:00:00+00:00")); + assert_eq!(meta.language.as_deref(), Some("en")); + assert_eq!(meta.subjects, ["Fiction", "Villages"]); + assert_eq!(meta.identifiers["isbn"], "9780306406157"); + assert!(meta.identifiers.contains_key("asin")); + + let cover = meta.cover.as_ref().expect("a cover"); + assert_eq!(cover.media_type, "image/jpeg"); + let original = std::fs::read(fixture("lantern-cover.jpg")).expect("the cover fixture"); + assert_eq!(cover.bytes, original); +} + +#[test] +fn a_mobi_6_file_says_what_it_was_given() { + let meta = read("lantern.mobi"); + says_what_it_was_given(&meta); + assert!(meta.notes.is_empty(), "{:?}", meta.notes); +} + +#[test] +fn an_azw3_file_says_what_it_was_given() { + let meta = read("lantern.azw3"); + says_what_it_was_given(&meta); + assert!(meta.notes.is_empty(), "{:?}", meta.notes); +} + +#[test] +fn a_file_with_no_exth_still_has_its_full_name_and_locale() { + let meta = read("lantern-no-exth.mobi"); + assert_eq!(meta.source, "mobi"); + assert_eq!(meta.title.as_deref(), Some("A Lantern in the Orchard")); + assert_eq!(meta.language.as_deref(), Some("en")); + assert!(meta.authors.is_empty()); + assert!(meta.identifiers.is_empty()); + assert_eq!(meta.cover, None); +} + +#[test] +fn a_truncated_file_is_an_empty_record_with_a_reason() { + let meta = read("lantern-truncated.mobi"); + assert_eq!(meta.source, "none"); + assert!(meta.is_empty()); + assert_eq!(meta.notes, ["the first record lies outside the file"]); +} + +#[test] +fn a_drm_flagged_file_has_its_metadata_and_says_drm() { + let meta = read("lantern-drm.mobi"); + says_what_it_was_given(&meta); + assert_eq!(meta.notes, ["drm"]); +} + +#[test] +fn every_truncation_of_the_mobi_fixture_returns() { + let bytes = std::fs::read(fixture("lantern.mobi")).expect("the fixture"); + let first_record_end = u32::from_be_bytes(bytes[86..90].try_into().unwrap()) as usize; + for len in 0..=bytes.len() { + let meta = bookmeta::from_bytes("cut.mobi", &bytes[..len]); + if len < first_record_end { + assert!(meta.is_empty(), "cut at {len}: {meta:?}"); + } + } +} + +#[test] +fn every_single_byte_corruption_of_the_header_returns() { + let bytes = std::fs::read(fixture("lantern.mobi")).expect("the fixture"); + // The Palm header, the record list, and the whole of the first record: + // every offset the reader follows is in there. + let first_record_end = u32::from_be_bytes(bytes[86..90].try_into().unwrap()) as usize; + for at in 0..first_record_end { + for value in [0x00, 0x7f, 0xff] { + let mut broken = bytes.clone(); + broken[at] = value; + let _ = bookmeta::from_bytes("broken.mobi", &broken); + } + } +} From 130324bbe0ff0cecc7a1c468f8f9828a881d5f7a Mon Sep 17 00:00:00 2001 From: funkpower Date: Fri, 13 Nov 2026 14:51:43 -0500 Subject: [PATCH 2/3] bookmeta: file EXTH 113 by shape, and fall back to the first image record for a cover EXTH 113 is an ASIN only when it is ten letters and digits; a UUID goes under uuid, as the EPUB reader files it, and anything else under other. With no EXTH 201 or 202, the first record from the first image index that is a JPEG, PNG or GIF is the cover, stopping at the KF8 boundary (EXTH 121). --- crates/bookmeta/src/epub.rs | 7 ++- crates/bookmeta/src/mobi.rs | 102 ++++++++++++++++++++++++++++++++-- crates/bookmeta/tests/mobi.rs | 46 ++++++++++++++- 3 files changed, 148 insertions(+), 7 deletions(-) diff --git a/crates/bookmeta/src/epub.rs b/crates/bookmeta/src/epub.rs index 6cda856..1ee9916 100644 --- a/crates/bookmeta/src/epub.rs +++ b/crates/bookmeta/src/epub.rs @@ -167,7 +167,7 @@ fn identifiers(package: &str) -> Vec<(String, String)> { ("doi", value.to_string()) } else if is_isbn(value) { ("isbn", digits_of(value)) - } else if lower.len() == 36 && lower.matches('-').count() == 4 { + } else if is_uuid(&lower) { ("uuid", lower.clone()) } else { ("other", value.to_string()) @@ -188,6 +188,11 @@ pub(crate) fn is_isbn(value: &str) -> bool { .all(|c| c.is_ascii_digit() || "- xX".contains(c)) } +/// Whether a value is shaped like a UUID: 36 characters with four hyphens. +pub(crate) fn is_uuid(value: &str) -> bool { + value.len() == 36 && value.matches('-').count() == 4 +} + /// The digits of an ISBN, with its check character, and nothing else. pub(crate) fn digits_of(value: &str) -> String { value diff --git a/crates/bookmeta/src/mobi.rs b/crates/bookmeta/src/mobi.rs index 59471d7..95d8843 100644 --- a/crates/bookmeta/src/mobi.rs +++ b/crates/bookmeta/src/mobi.rs @@ -12,7 +12,7 @@ //! an EXTH block that breaks off keeps the records before the break. Neither //! panics or reads past the end. -use crate::epub::{digits_of, is_isbn}; +use crate::epub::{digits_of, is_isbn, is_uuid}; use crate::{Cover, Metadata, Source}; /// Where a Palm database says what it holds. @@ -65,6 +65,7 @@ const PUBLISHED: u32 = 106; const ASIN: u32 = 113; const COVER_OFFSET: u32 = 201; const THUMB_OFFSET: u32 = 202; +const KF8_BOUNDARY: u32 = 121; const UPDATED_TITLE: u32 = 503; const LANGUAGE: u32 = 524; @@ -99,6 +100,7 @@ struct Exth { asin: Option, cover: Option, thumb: Option, + kf8_boundary: Option, title: Option, language: Option, } @@ -177,12 +179,24 @@ fn fill(meta: &mut Metadata, bytes: &[u8]) -> Result<(), String> { meta.identifiers.insert("other".into(), isbn); } } - if let Some(asin) = exth.asin { - meta.identifiers.insert("asin".into(), asin); + // EXTH 113 holds an ASIN when the book was made for Amazon, and calibre's + // own book UUID when it was converted without one. + if let Some(value) = exth.asin { + let lower = value.to_lowercase(); + if is_uuid(&lower) { + meta.identifiers.insert("uuid".into(), lower); + } else if is_asin(&value) { + meta.identifiers.insert("asin".into(), value); + } else { + meta.identifiers.entry("other".into()).or_insert(value); + } } + let first_image = header.field(FIRST_IMAGE); if let Some(offset) = exth.cover.or(exth.thumb) { - meta.cover = cover(bytes, &records, header.field(FIRST_IMAGE), offset, meta); + meta.cover = cover(bytes, &records, first_image, offset, meta); + } else { + meta.cover = first_image_record(bytes, &records, first_image, exth.kf8_boundary); } Ok(()) } @@ -277,6 +291,7 @@ fn exth( LANGUAGE => set(&mut out.language, decode(data)), COVER_OFFSET => set(&mut out.cover, number(data)), THUMB_OFFSET => set(&mut out.thumb, number(data)), + KF8_BOUNDARY => set(&mut out.kf8_boundary, number(data)), _ => {} } } @@ -326,6 +341,34 @@ fn cover( }) } +/// The cover when the file names none: calibre takes the first image record. +/// +/// The search runs from the first image record to the end of the MOBI 6 +/// records, the KF8 boundary when the file is a joint one, and skips records +/// that are not an image. +fn first_image_record( + bytes: &[u8], + records: &[usize], + first_image: Option, + boundary: Option, +) -> Option { + let first = first_image.filter(|&index| index != NONE)?; + let stop = boundary.map_or(records.len(), |at| (at as usize).min(records.len())); + (first as usize..stop).find_map(|index| { + let data = record(bytes, records, index)?; + let media_type = image_type(data).filter(|kind| *kind != "image/bmp")?; + Some(Cover { + media_type: media_type.to_string(), + bytes: data.to_vec(), + }) + }) +} + +/// An ASIN is ten letters and digits; an ISBN-10 is one too. +fn is_asin(value: &str) -> bool { + value.len() == 10 && value.chars().all(|c| c.is_ascii_alphanumeric()) +} + /// What kind of image these bytes are, by their magic. fn image_type(data: &[u8]) -> Option<&'static str> { if data.starts_with(&[0xff, 0xd8, 0xff]) { @@ -622,6 +665,57 @@ mod tests { assert_eq!(cover.media_type, "image/png"); } + #[test] + fn with_no_cover_named_the_first_real_image_record_is_the_cover() { + let book = Book { + images: vec![ + b"not an image".to_vec(), + JPEG.to_vec(), + b"GIF89a-later".to_vec(), + ], + ..Book::default() + }; + let cover = read("x.mobi", &book.bytes()).cover.expect("a cover"); + assert_eq!(cover.media_type, "image/jpeg"); + assert_eq!(cover.bytes, JPEG); + + let book = Book { + images: vec![b"not an image".to_vec()], + ..Book::default() + }; + assert_eq!(read("x.mobi", &book.bytes()).cover, None); + } + + #[test] + fn the_first_image_search_stops_at_the_kf8_boundary() { + let book = Book { + exth: exth(&[(121, &2u32.to_be_bytes())]), + images: vec![b"text".to_vec(), JPEG.to_vec()], + ..Book::default() + }; + // Record 1 is not an image, and record 2 is past the boundary. + assert_eq!(read("x.mobi", &book.bytes()).cover, None); + } + + #[test] + fn exth_113_is_an_asin_a_uuid_or_something_else() { + let id = |value: &[u8]| { + let book = Book { + exth: exth(&[(ASIN, value)]), + ..Book::default() + }; + read("x.mobi", &book.bytes()).identifiers + }; + assert_eq!(id(b"B01A7RJGIW")["asin"], "B01A7RJGIW"); + assert_eq!(id(b"0306406152")["asin"], "0306406152"); + let uuid = id(b"69AE65CA-B644-499F-A78C-139771C7140A"); + assert_eq!(uuid["uuid"], "69ae65ca-b644-499f-a78c-139771c7140a"); + assert_eq!(uuid.get("asin"), None); + let other = id(b"2AW6TL2JJEYYGXCOV3PWN6ZOYUORXAVX"); + assert_eq!(other["other"], "2AW6TL2JJEYYGXCOV3PWN6ZOYUORXAVX"); + assert_eq!(other.get("asin"), None); + } + #[test] fn a_cover_that_points_nowhere_is_a_note_and_not_a_cover() { let book = Book { diff --git a/crates/bookmeta/tests/mobi.rs b/crates/bookmeta/tests/mobi.rs index 628bc3e..161eab3 100644 --- a/crates/bookmeta/tests/mobi.rs +++ b/crates/bookmeta/tests/mobi.rs @@ -26,7 +26,8 @@ fn says_what_it_was_given(meta: &bookmeta::Metadata) { assert_eq!(meta.language.as_deref(), Some("en")); assert_eq!(meta.subjects, ["Fiction", "Villages"]); assert_eq!(meta.identifiers["isbn"], "9780306406157"); - assert!(meta.identifiers.contains_key("asin")); + assert_eq!(meta.identifiers.get("asin"), None); + assert_eq!(meta.identifiers["uuid"].len(), 36); let cover = meta.cover.as_ref().expect("a cover"); assert_eq!(cover.media_type, "image/jpeg"); @@ -48,6 +49,45 @@ fn an_azw3_file_says_what_it_was_given() { assert!(meta.notes.is_empty(), "{:?}", meta.notes); } +#[test] +fn exth_113_uuid_is_not_an_asin() { + for name in ["lantern.mobi", "lantern.azw3"] { + let meta = read(name); + assert_eq!(meta.identifiers.get("asin"), None, "{name}"); + assert!(meta.identifiers.contains_key("uuid"), "{name}"); + } +} + +/// `lantern.mobi` with its cover and thumbnail offsets (EXTH 201 and 202) +/// retyped to an unused record type, so the file names no cover. +fn lantern_naming_no_cover() -> Vec { + let mut bytes = std::fs::read(fixture("lantern.mobi")).expect("the fixture"); + let exth = bytes.windows(4).position(|w| w == b"EXTH").expect("EXTH"); + let count = u32::from_be_bytes(bytes[exth + 8..exth + 12].try_into().unwrap()); + let mut cursor = exth + 12; + let mut retyped = 0; + for _ in 0..count { + let kind = u32::from_be_bytes(bytes[cursor..cursor + 4].try_into().unwrap()); + let size = u32::from_be_bytes(bytes[cursor + 4..cursor + 8].try_into().unwrap()); + if kind == 201 || kind == 202 { + bytes[cursor..cursor + 4].copy_from_slice(&999u32.to_be_bytes()); + retyped += 1; + } + cursor += size as usize; + } + assert!(retyped > 0, "the fixture names a cover"); + bytes +} + +#[test] +fn a_file_naming_no_cover_gets_its_first_image_record() { + let meta = bookmeta::from_bytes("x.mobi", &lantern_naming_no_cover()); + let cover = meta.cover.expect("the first image record"); + assert_eq!(cover.media_type, "image/jpeg"); + let original = std::fs::read(fixture("lantern-cover.jpg")).expect("the cover fixture"); + assert_eq!(cover.bytes, original); +} + #[test] fn a_file_with_no_exth_still_has_its_full_name_and_locale() { let meta = read("lantern-no-exth.mobi"); @@ -56,7 +96,9 @@ fn a_file_with_no_exth_still_has_its_full_name_and_locale() { assert_eq!(meta.language.as_deref(), Some("en")); assert!(meta.authors.is_empty()); assert!(meta.identifiers.is_empty()); - assert_eq!(meta.cover, None); + // No EXTH names no cover, so the first image record stands in for one. + let cover = meta.cover.expect("the first image record"); + assert_eq!(cover.media_type, "image/jpeg"); } #[test] From a105b1c9aae028603312add6b7caf83104ea93a7 Mon Sep 17 00:00:00 2001 From: funkpower Date: Fri, 13 Nov 2026 15:22:04 -0500 Subject: [PATCH 3/3] bookmeta: with no cover named, take only the record at first_image When a MOBI or AZW3 file names no cover (no EXTH 201 or 202), the cover is the record at the first image index if it is a JPEG, PNG or GIF, and nothing otherwise, as calibre does (metadata/mobi.py:524-532). The walk to the next image is gone: a later record may be an inline illustration, and kindlegen puts a font or RESC record first. EXTH 121 is no longer read, which also stops a stale KF8 boundary below first_image from hiding the cover. --- crates/bookmeta/src/mobi.rs | 72 +++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 26 deletions(-) diff --git a/crates/bookmeta/src/mobi.rs b/crates/bookmeta/src/mobi.rs index 95d8843..be6533a 100644 --- a/crates/bookmeta/src/mobi.rs +++ b/crates/bookmeta/src/mobi.rs @@ -65,7 +65,6 @@ const PUBLISHED: u32 = 106; const ASIN: u32 = 113; const COVER_OFFSET: u32 = 201; const THUMB_OFFSET: u32 = 202; -const KF8_BOUNDARY: u32 = 121; const UPDATED_TITLE: u32 = 503; const LANGUAGE: u32 = 524; @@ -100,7 +99,6 @@ struct Exth { asin: Option, cover: Option, thumb: Option, - kf8_boundary: Option, title: Option, language: Option, } @@ -196,7 +194,7 @@ fn fill(meta: &mut Metadata, bytes: &[u8]) -> Result<(), String> { if let Some(offset) = exth.cover.or(exth.thumb) { meta.cover = cover(bytes, &records, first_image, offset, meta); } else { - meta.cover = first_image_record(bytes, &records, first_image, exth.kf8_boundary); + meta.cover = first_image_record(bytes, &records, first_image); } Ok(()) } @@ -291,7 +289,6 @@ fn exth( LANGUAGE => set(&mut out.language, decode(data)), COVER_OFFSET => set(&mut out.cover, number(data)), THUMB_OFFSET => set(&mut out.thumb, number(data)), - KF8_BOUNDARY => set(&mut out.kf8_boundary, number(data)), _ => {} } } @@ -341,26 +338,19 @@ fn cover( }) } -/// The cover when the file names none: calibre takes the first image record. +/// The cover when the file names none: calibre takes the record at the first +/// image index, and only that one. /// -/// The search runs from the first image record to the end of the MOBI 6 -/// records, the KF8 boundary when the file is a joint one, and skips records -/// that are not an image. -fn first_image_record( - bytes: &[u8], - records: &[usize], - first_image: Option, - boundary: Option, -) -> Option { +/// When that record is not a JPEG, PNG or GIF there is no cover. The search +/// does not walk on to the next image, because a later record may be an inline +/// illustration, and kindlegen puts a font or a RESC record first. +fn first_image_record(bytes: &[u8], records: &[usize], first_image: Option) -> Option { let first = first_image.filter(|&index| index != NONE)?; - let stop = boundary.map_or(records.len(), |at| (at as usize).min(records.len())); - (first as usize..stop).find_map(|index| { - let data = record(bytes, records, index)?; - let media_type = image_type(data).filter(|kind| *kind != "image/bmp")?; - Some(Cover { - media_type: media_type.to_string(), - bytes: data.to_vec(), - }) + let data = record(bytes, records, first as usize)?; + let media_type = image_type(data).filter(|kind| *kind != "image/bmp")?; + Some(Cover { + media_type: media_type.to_string(), + bytes: data.to_vec(), }) } @@ -666,7 +656,17 @@ mod tests { } #[test] - fn with_no_cover_named_the_first_real_image_record_is_the_cover() { + fn with_no_cover_named_only_the_record_at_first_image_can_be_the_cover() { + let book = Book { + images: vec![JPEG.to_vec(), b"GIF89a-later".to_vec()], + ..Book::default() + }; + let cover = read("x.mobi", &book.bytes()).cover.expect("a cover"); + assert_eq!(cover.media_type, "image/jpeg"); + assert_eq!(cover.bytes, JPEG); + + // The record at first_image is not an image: no cover, even though a + // later record is one. let book = Book { images: vec![ b"not an image".to_vec(), @@ -675,9 +675,7 @@ mod tests { ], ..Book::default() }; - let cover = read("x.mobi", &book.bytes()).cover.expect("a cover"); - assert_eq!(cover.media_type, "image/jpeg"); - assert_eq!(cover.bytes, JPEG); + assert_eq!(read("x.mobi", &book.bytes()).cover, None); let book = Book { images: vec![b"not an image".to_vec()], @@ -686,6 +684,28 @@ mod tests { assert_eq!(read("x.mobi", &book.bytes()).cover, None); } + #[test] + fn a_font_at_first_image_is_not_skipped_past_to_an_inline_image() { + // No EXTH 201 or 202. The first resource record is a font, as in + // kindlegen output; calibre reads only that record and finds no cover. + let book = Book { + images: vec![b"FONT\0\0\0\x18".to_vec(), JPEG.to_vec()], + ..Book::default() + }; + assert_eq!(read("x.mobi", &book.bytes()).cover, None); + } + + #[test] + fn a_stale_kf8_boundary_below_first_image_does_not_hide_the_cover() { + let book = Book { + exth: exth(&[(121, &1u32.to_be_bytes())]), + first_image: 2, + images: vec![b"text".to_vec(), JPEG.to_vec()], + ..Book::default() + }; + assert!(read("x.mobi", &book.bytes()).cover.is_some()); + } + #[test] fn the_first_image_search_stops_at_the_kf8_boundary() { let book = Book {