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).
This commit is contained in:
funkpower committed 2026-11-13 14:51:43 -05:00
1 parent b7d98e9d1a
commit 130324bbe0
3 files changed
+148 -7

No files matched your search

+6 -1
View File
@@ -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
+98 -4
View File
@@ -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<String>,
cover: Option<u32>,
thumb: Option<u32>,
kf8_boundary: Option<u32>,
title: Option<String>,
language: Option<String>,
}
@@ -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<u32>,
boundary: Option<u32>,
) -> Option<Cover> {
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 {
+44 -2
View File
@@ -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<u8> {
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]