From 130324bbe0ff0cecc7a1c468f8f9828a881d5f7a Mon Sep 17 00:00:00 2001 From: funkpower Date: Fri, 13 Nov 2026 14:51:43 -0500 Subject: [PATCH] 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]