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]