mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-11-14 08:05:40 +00:00
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:
3 files changed
+148
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user