Author SHA1 Message Date
funkpower 897b2210b6 Merge remote-tracking branch 'origin/main' into bookmeta-epub-cover
# Conflicts:
#	crates/bookmeta/src/mobi.rs
2026-11-13 15:29:53 -05:00
funkpower 57ce9137e6 bookmeta: read an EPUB's cover, by the four routes a package can name one
The cover is the first image reached by EPUB 3's cover-image property,
EPUB 2's <meta name="cover"> (by id, or by address where a converter wrote
one), the guide's cover page and the first <img> or SVG <image> on it, and
last any manifest image whose id or address says cover. A route that names
something unusable leaves a note and the next is asked.

Addresses are resolved against the document they are written in, with the
fragment off and percent-escapes undone; one that climbs out of the archive
or carries a scheme is not followed. Every member is read through a bound,
checked against the size the archive declares and enforced on the read
itself, so an understated entry stops at the cap too.

The media type is read off the bytes, now WebP as well, and a manifest that
disagrees is a note; bytes that are not an image are no cover. MAX_COVER
(16 MiB) is the cap for both formats, so a Kindle cover record larger than
that is left out as well.
2026-11-13 15:26:49 -05:00
funkpower b4000b9b67 Merge pull request 'bibfetch: Open Library cover fetch by ISBN' (#2) from bibfetch-openlibrary-cover into main 2026-11-13 15:26:01 -05:00
funkpower 23969269e8 Merge remote-tracking branch 'origin/main' into bibfetch-openlibrary-cover 2026-11-13 15:24:43 -05:00
funkpower 452102639a Merge pull request 'bookmeta: MOBI/AZW/AZW3 metadata and cover from the MOBI header and EXTH records' (#1) from bookmeta-mobi-exth into main 2026-11-13 15:24:34 -05:00
funkpower 1abed4632d bibfetch: a cover id is the edition's own, and a fetch has a deadline
Review id 3 on catena#2.

P0: Record.cover_id came from the search index's cover_i, the cover
chosen for the whole work, and the index is asked first, so cover()
fell back to another edition's image (Penguin War and Peace,
9780140447934, got Standard Ebooks' Maude cover 12621906). cover_i is
no longer asked for or read; cover_id is the first positive id in the
edition record's covers, or None. An ISBN request that fails, as
opposed to a 404, now ends the fetch with that failure instead of
going on to the cover id.

Redirects go only over HTTPS on the standard port to
covers.openlibrary.org, archive.org or a host under archive.org, and
never to an address by number; the covers store the fetch began at is
still allowed.

The 1 KB size floor is gone. With default=false a missing cover is a
404, and the stand-in without it is a 43-byte GIF the media type check
refuses, so the floor could only drop real Small covers.

Covers.deadline (DEADLINE, two minutes) bounds a whole fetch: every
address, attempt, redirect, body and wait. It starts once the fetch
holds the process lock. The timeout test no longer times a span that
includes the lock wait.
2026-11-13 15:04:34 -05:00
funkpower 19e5f3ddc3 bibfetch: a book's cover from Open Library, by its ISBN
`bibfetch::cover(&agent, isbn, cover_id, size)` asks Open Library's covers
store by the ISBN's thirteen-digit form, then its ten-digit form, then by a
cover id a lookup record named, and returns the image or `Ok(None)`. The
answer is checked before it is trusted: JPEG or PNG by content type and by
magic bytes, between 1 KB and 10 MB, at most three redirects, none of them
off HTTPS. One request at a time per process; a 429 or 503 is waited out
for its Retry-After, up to a minute.

`idnorm::isbn` converts between the two forms, and only for an ISBN whose
check digit agrees. Lookup records carry `cover_id` from the edition record
and the search index. The CLI gains `bibfetch cover <isbn> -o <file>`.
2026-11-13 14:36:32 -05:00
12 changed files with 2256 additions and 36 deletions

No files matched your search

+44 -2
View File
@@ -107,6 +107,47 @@ register that is rate-limiting, short enough that one which is simply down
does not hold up a pipeline. An answer this cannot read is not retried,
because it will not read any better next time.
## Covers
```sh
bibfetch cover 978-0-306-40615-7 -o cover.jpg
```
Fetches a book's cover from Open Library's covers store by its ISBN and
writes the image. The thirteen-digit form is asked first and the ten-digit
form second, because a book is often filed under only one of them; an ISBN
whose check digit is wrong is asked about as written, since converting it
would give another book's number. `--cover-id` names an Open Library cover id
to ask by last, which is what the `cover_id` field of a lookup record holds:
the first cover in Open Library's record of that edition, never the cover its
index picked for the whole work, which is often another edition's. The cover
id is asked only when every form of the ISBN asked was answered with no cover; if
asking by ISBN failed, that failure is the result.
What comes back is checked before it is written: a JPEG or PNG by its bytes
as well as by what the server says, no larger than ten megabytes, reached in at
most three redirects, each over HTTPS to `covers.openlibrary.org`,
`archive.org` or a host under `archive.org`, and never to an address by number.
One request is made at a time, a `429` or `503` is waited out for as long as
its `Retry-After` says, up to a minute, and the whole fetch, every attempt and
redirect included, stops after two minutes. The two minutes start once it is
the fetch's turn, so a fetch queued behind others in the same process can wait
that long again first.
| Option | Effect |
|---|---|
| `-o`, `--output <FILE>` | Where to write the image |
| `--size <SIZE>` | `small`, `medium` or `large`. Default `large` |
| `--cover-id <ID>` | An Open Library cover id to ask by when the ISBN has none |
Exit `0` the cover was written, `1` Open Library has none, `65` the argument
was not an ISBN or the answer was not an image, `69` Open Library could not be
reached, `74` the file could not be written.
From Rust, `bibfetch::cover(&agent, isbn, cover_id, size)` returns
`Ok(None)` when there is no cover, and a [`Cover`](src/cover.rs) with the
bytes, the media type and the address asked when there is.
## Reaching the network
This and [`emb`](../emb/README.md) are the only two tools here that do. Both
@@ -114,8 +155,9 @@ say so in `--help`, and for both it is the whole of what they do rather than
something that happens on the way to something else — which is the condition
under which [the contract](../../docs/contract.md) allows it at all.
Nothing is cached, nothing is stored, and nothing is sent but the identifier
and the address you pass to `--polite`.
Nothing is cached, nothing is stored but a cover you asked to have written,
and nothing is sent but the identifier and the address you pass to
`--polite`.
## See also
File diff suppressed because it is too large. Load diff
+16
View File
@@ -14,6 +14,7 @@
//! ISSN is looked up as the journal of a work rather than on its own.
pub mod ask;
pub mod cover;
pub mod source;
use std::time::Duration;
@@ -21,6 +22,7 @@ use std::time::Duration;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub use cover::{Cover, CoverSize, cover};
pub use source::Source;
/// How long to wait on a register before giving up.
@@ -74,6 +76,13 @@ pub struct Record {
/// How many pages the register says it has.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pages: Option<u32>,
/// The first cover id in Open Library's record of this edition, for
/// [`cover()`] to ask by when the ISBN has none. Taken from the edition
/// record only, never from the index's cover for the work, which is
/// often another edition's; `None` when the edition names no cover or
/// the lookup stopped before asking for the edition record.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cover_id: Option<u64>,
}
fn version() -> u32 {
@@ -95,6 +104,8 @@ pub enum Error {
Shape(String),
/// Nothing here knows where to ask about this kind.
NoRegister(String),
/// What was asked about is not an identifier of the kind it should be.
Invalid(String),
}
impl std::fmt::Display for Error {
@@ -105,6 +116,7 @@ impl std::fmt::Display for Error {
Error::NoRegister(kind) => {
write!(f, "no register here answers about {kind} identifiers")
}
Error::Invalid(why) => write!(f, "{why}"),
}
}
}
@@ -130,6 +142,7 @@ pub fn missing(id: &str, kind: &str, source: Source) -> Record {
subjects: Vec::new(),
language: None,
pages: None,
cover_id: None,
}
}
@@ -187,6 +200,9 @@ impl Record {
if self.pages.is_none() {
self.pages = other.pages;
}
if self.cover_id.is_none() {
self.cover_id = other.cover_id;
}
// Which register answered becomes which registers did.
if !self.source.split('+').any(|s| s == other.source) {
self.source = format!("{}+{}", self.source, other.source);
+94 -11
View File
@@ -29,33 +29,83 @@ Exit codes: 0 every identifier was found, 1 at least one was not,
Example:
idnorm refs.txt | bibfetch | jq -r 'select(.found) | .title'
idnorm --kind doi refs.txt | bibfetch --polite [email protected]"
idnorm --kind doi refs.txt | bibfetch --polite [email protected]
bibfetch cover 978-0-306-40615-7 -o cover.jpg"
)]
struct Args {
#[command(subcommand)]
command: Option<Command>,
/// Identifier records to look up, as `idnorm` writes them. Reads stdin
/// when absent.
path: Option<PathBuf>,
/// An address to put in the request, which the registers ask for and
/// answer faster when they get.
#[arg(long, value_name = "EMAIL")]
#[arg(long, global = true, value_name = "EMAIL")]
polite: Option<String>,
/// How long to wait on a register, in seconds.
#[arg(long, default_value_t = bibfetch::TIMEOUT.as_secs(), value_name = "SECONDS")]
#[arg(long, global = true, default_value_t = bibfetch::TIMEOUT.as_secs(), value_name = "SECONDS")]
timeout: u64,
/// How many times to ask before believing a failure.
#[arg(long, default_value_t = bibfetch::ATTEMPTS, value_name = "COUNT")]
#[arg(long, global = true, default_value_t = bibfetch::ATTEMPTS, value_name = "COUNT")]
attempts: u32,
}
#[derive(clap::Subcommand)]
enum Command {
/// Fetch a book's cover from Open Library by its ISBN.
///
/// Exit codes: 0 the cover was written, 1 Open Library has none,
/// 65 not an ISBN or not an image, 69 Open Library could not be reached,
/// 74 the file could not be written.
Cover {
/// The ISBN, ten or thirteen digits, hyphens or not.
isbn: String,
/// Where to write the image.
#[arg(short, long, value_name = "FILE")]
output: PathBuf,
/// Which size to ask for.
#[arg(long, value_enum, default_value_t = Size::Large)]
size: Size,
/// An Open Library cover id to ask by when the ISBN has no cover.
#[arg(long, value_name = "ID")]
cover_id: Option<u64>,
},
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum Size {
Small,
Medium,
Large,
}
fn main() -> ExitCode {
let args: Args = match catena_cli::parse() {
Ok(args) => args,
Err(code) => return code,
};
let agent = bibfetch::ask::Ask {
polite: args.polite.clone(),
timeout: Duration::from_secs(args.timeout),
attempts: args.attempts,
}
.agent();
if let Some(Command::Cover {
isbn,
output,
size,
cover_id,
}) = &args.command
{
return cover(&agent, args.attempts, isbn, output, *size, *cover_id);
}
let text = match catena_cli::read_text(args.path.as_deref()) {
Ok(text) => text,
Err((code, why)) => {
@@ -64,13 +114,6 @@ fn main() -> ExitCode {
}
};
let agent = bibfetch::ask::Ask {
polite: args.polite.clone(),
timeout: Duration::from_secs(args.timeout),
attempts: args.attempts,
}
.agent();
let mut records = Vec::new();
let mut missing = 0usize;
@@ -114,6 +157,46 @@ fn main() -> ExitCode {
})
}
/// Fetch one cover and write it where asked.
fn cover(
agent: &ureq::Agent,
attempts: u32,
isbn: &str,
output: &std::path::Path,
size: Size,
cover_id: Option<u64>,
) -> ExitCode {
let covers = bibfetch::cover::Covers {
attempts,
..Default::default()
};
let size = match size {
Size::Small => bibfetch::CoverSize::Small,
Size::Medium => bibfetch::CoverSize::Medium,
Size::Large => bibfetch::CoverSize::Large,
};
match covers.fetch(agent, isbn, cover_id, size) {
Ok(Some(found)) => match std::fs::write(output, &found.bytes) {
Ok(()) => ExitCode::from(exit::OK),
Err(why) => {
eprintln!("bibfetch: {}: {why}", output.display());
ExitCode::from(exit::IO)
}
},
Ok(None) => {
eprintln!("bibfetch: {isbn}: Open Library has no cover");
ExitCode::from(exit::NO)
}
Err(why) => {
eprintln!("bibfetch: {isbn}: {why}");
ExitCode::from(match why {
bibfetch::Error::Unreachable(_) => exit::UNAVAILABLE,
_ => exit::DATA,
})
}
}
}
/// The identifier and its kind out of one record.
fn read_identifier(line: &str) -> Result<(String, String), String> {
let value: serde_json::Value =
+61
View File
@@ -85,6 +85,9 @@ impl Source {
}
// `fields` or the index answers with a handful of defaults and
// keeps the rest, which is how a language it holds went unread.
// Not `cover_i`: that is the cover chosen for the whole work, and
// on a work with hundreds of editions it is usually another
// edition's, in another translation or language.
Source::OpenLibrarySearch => format!(
"https://openlibrary.org/search.json?q=isbn:{}\
&fields=title,subtitle,author_name,first_publish_year,publisher,language,subject,\
@@ -245,6 +248,9 @@ struct OpenLibraryBook {
languages: Vec<OpenLibraryKey>,
#[serde(default)]
number_of_pages: Option<u32>,
/// Cover ids, best first. A deleted cover is left in the list as `-1`.
#[serde(default)]
covers: Vec<i64>,
}
#[derive(serde::Deserialize)]
@@ -275,6 +281,11 @@ fn open_library(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
.find_map(|l| l.key.rsplit('/').next().map(str::to_string))
.filter(|l| !l.is_empty());
record.pages = book.number_of_pages;
// The edition's own cover, and the only place a cover id is taken from.
record.cover_id = book
.covers
.into_iter()
.find_map(|id| u64::try_from(id).ok().filter(|id| *id > 0));
Ok(record)
}
@@ -520,6 +531,56 @@ mod tests {
}
}
#[test]
fn the_cover_id_a_record_names_is_kept_for_the_cover_lookup() {
// A deleted cover stays in an edition's list as -1, ahead of the
// ones still there.
let record = open_library("1", "isbn", r#"{"title":"A","covers":[-1,1162784,99]}"#)
.expect("a record");
assert_eq!(record.cover_id, Some(1162784));
let record = open_library("1", "isbn", r#"{"title":"A"}"#).expect("a record");
assert_eq!(record.cover_id, None);
}
#[test]
fn the_cover_id_is_the_editions_and_not_the_works() {
// ISBN 9780140447934 today: the index (asked first) names the work's
// cover; the edition record has none. Merged as `ask::lookup` merges.
let mut kept = open_library_search(
"9780140447934",
"isbn",
r#"{"docs":[{"title":"War and Peace","cover_i":12621906}]}"#,
)
.expect("a record");
let edition = open_library("9780140447934", "isbn", r#"{"title":"War and Peace"}"#)
.expect("a record");
kept.fill_from(edition);
// Cover 12621906 is OL34941467M, Standard Ebooks' Maude translation.
assert_eq!(kept.cover_id, None);
}
#[test]
fn an_edition_with_no_covers_gives_no_cover_id() {
let index = r#"{"docs":[{"title":"War and Peace","cover_i":12621906}]}"#;
// `lookup` often stops at the index, before the edition is asked.
let alone = open_library_search("9780140447934", "isbn", index).expect("a record");
assert_eq!(alone.cover_id, None);
// An edition whose only cover was deleted, and one with an empty list.
for edition in [
r#"{"title":"War and Peace","covers":[-1]}"#,
r#"{"title":"War and Peace","covers":[]}"#,
] {
let mut kept = open_library_search("9780140447934", "isbn", index).expect("a record");
kept.fill_from(open_library("9780140447934", "isbn", edition).expect("a record"));
assert_eq!(kept.cover_id, None, "{edition}");
}
}
#[test]
fn the_index_is_not_asked_for_the_works_cover() {
assert!(!Source::OpenLibrarySearch.url("1").contains("cover_i"));
}
#[test]
fn an_answer_that_is_not_what_it_should_be_says_so() {
assert!(matches!(
+29 -4
View File
@@ -112,10 +112,35 @@ 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.
`Metadata::cover` is the cover image the file carries, with its media type
read off its first bytes: JPEG, PNG, GIF, WebP or BMP. It is for library
callers and is not part of the JSON record, which is a line of text.
A Kindle book's cover is EXTH 201, or the thumbnail, 202, when no cover is
named, and with neither the first image record.
An EPUB's is the first of these that reaches an image:
1. The manifest item whose `properties` include `cover-image` (EPUB 3).
2. The item `<meta name="cover" content="…">` names (EPUB 2). Some
converters write the image's address there rather than its id, and that is
followed too.
3. The guide's `<reference type="cover">`, a page, and the first `<img src>`
or SVG `<image href>` on it. A guide that points straight at an image is
taken at its word.
4. Any image in the manifest whose id or address says `cover`.
A route that names something unusable — not in the archive, not an image —
leaves a note, and the next route is asked. Addresses are resolved against the
document they are written in, with their fragment off and their
percent-escapes undone, and one that climbs out of the archive or names a
scheme (`http:`, `data:`) is not followed.
What the bytes say beats what the manifest says: a JPEG listed as
`image/png` comes back as `image/jpeg`, with a note. A cover larger than
`bookmeta::MAX_COVER` (16 MiB) is left out with a note, in either format, and
an EPUB's is never decompressed further than that, whatever size the archive
claims for it.
## Asking a model
+724 -1
View File
@@ -4,10 +4,17 @@
//! document carries Dublin Core elements: title, creator, publisher, date,
//! language, identifier, subject. Calibre adds its own `<meta>` entries for
//! the series, which nothing in the standard has a place for.
//!
//! The cover is whichever image the package names, asked in the order the
//! standards came: EPUB 3's `cover-image` property, EPUB 2's
//! `<meta name="cover">`, the guide's cover page and the image on it, and
//! last any image whose name says cover. Every address is resolved inside the
//! archive and nowhere else, and every member is read no further than a fixed
//! bound, [`MAX_COVER`] for an image.
use std::io::Read;
use crate::{Metadata, Source};
use crate::{Cover, MAX_COVER, Metadata, Source, image_type};
/// How large a package document is worth reading.
///
@@ -38,6 +45,7 @@ pub fn read(path: &str, bytes: &[u8]) -> Metadata {
};
fill(&mut meta, &package);
meta.cover = cover(&mut archive, &package_path, &package, &mut meta);
if meta.is_empty() {
meta.note("a package document with nothing filled in");
}
@@ -248,6 +256,339 @@ fn unescape(text: &str) -> String {
.replace("&amp;", "&")
}
/// One entry of the package's manifest.
struct Item {
id: String,
href: String,
media_type: String,
properties: String,
/// Where `href` leads inside the archive, when it leads anywhere.
path: Option<String>,
}
/// The cover image, by the first of the four routes that reaches one.
///
/// A route that names something unusable leaves a note and the next route is
/// asked, so a book whose declared cover is missing can still have the one
/// its file name gives away.
fn cover<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
package_path: &str,
package: &str,
meta: &mut Metadata,
) -> Option<Cover> {
let base = directory_of(package_path);
let items: Vec<Item> = tags(package)
.filter(|(name, _)| *name == "item")
.map(|(_, tag)| {
let href = attr(tag, "href").unwrap_or_default();
Item {
id: attr(tag, "id").unwrap_or_default(),
path: resolve(base, &href),
href,
media_type: attr(tag, "media-type").unwrap_or_default(),
properties: attr(tag, "properties").unwrap_or_default(),
}
})
.collect();
let mut look = Look {
archive,
items: &items,
tried: Vec::new(),
meta,
};
// EPUB 3: the manifest item whose properties say so.
for item in items.iter().filter(|item| {
item.properties
.split_ascii_whitespace()
.any(|property| property == "cover-image")
}) {
if let Some(found) = look.image(base, &item.href) {
return Some(found);
}
}
// EPUB 2: `<meta name="cover" content="…">` names an item by its id, or
// in some converters' hands by its address.
if let Some(named) = tags(package)
.filter(|(name, _)| *name == "meta")
.find(|(_, tag)| attr(tag, "name").as_deref() == Some("cover"))
.and_then(|(_, tag)| attr(tag, "content"))
.map(|content| content.trim().to_string())
.filter(|content| !content.is_empty())
{
let as_path = resolve(base, &named);
let href = items
.iter()
.find(|item| item.id == named)
.or_else(|| {
items
.iter()
.find(|item| item.path.is_some() && item.path == as_path)
})
.map_or(named.as_str(), |item| item.href.as_str());
if let Some(found) = look.image(base, href) {
return Some(found);
}
}
// The guide's cover page, and the first image on it.
if let Some(page) = tags(package)
.filter(|(name, _)| *name == "reference")
.find(|(_, tag)| attr(tag, "type").is_some_and(|kind| kind.eq_ignore_ascii_case("cover")))
.and_then(|(_, tag)| attr(tag, "href"))
{
if let Some(found) = look.page(base, &page) {
return Some(found);
}
}
// Last: an image whose id or address says cover.
for item in items.iter().filter(|item| {
item.media_type.starts_with("image/")
&& (item.id.to_lowercase().contains("cover")
|| item.href.to_lowercase().contains("cover"))
}) {
if let Some(found) = look.image(base, &item.href) {
return Some(found);
}
}
None
}
/// The search for a cover, remembering what it has already looked at so
/// that two routes to the same member read it, and note it, once.
struct Look<'a, R> {
archive: &'a mut zip::ZipArchive<R>,
items: &'a [Item],
tried: Vec<String>,
meta: &'a mut Metadata,
}
impl<R: std::io::Read + std::io::Seek> Look<'_, R> {
/// Where an address leads, or a note saying it leads nowhere it may.
fn locate(&mut self, base: &str, href: &str) -> Option<String> {
let Some(path) = resolve(base, href) else {
self.meta.note(format!(
"the cover's address, {href}, is not a path inside the archive"
));
return None;
};
if self.tried.contains(&path) {
return None;
}
self.tried.push(path.clone());
Some(path)
}
/// An image at an address, if that is what is there.
fn image(&mut self, base: &str, href: &str) -> Option<Cover> {
let path = self.locate(base, href)?;
let bytes = self.fetch(&path, MAX_COVER, "cover")?;
self.check(&path, bytes)
}
/// The image a cover page shows, or the page itself where the guide
/// points straight at an image.
fn page(&mut self, base: &str, href: &str) -> Option<Cover> {
let path = self.locate(base, href)?;
let bytes = self.fetch(&path, MAX_PACKAGE as usize, "cover page")?;
if image_type(&bytes).is_some() {
return self.check(&path, bytes);
}
let text = String::from_utf8_lossy(&bytes);
let Some(src) = tags(&text).find_map(|(name, tag)| match name {
"img" => attr(tag, "src"),
"image" => attr(tag, "xlink:href").or_else(|| attr(tag, "href")),
_ => None,
}) else {
self.meta
.note(format!("the cover page, {path}, shows no image"));
return None;
};
self.image(directory_of(&path), &src)
}
/// One member's bytes, read no further than `cap`.
///
/// The size the archive declares is checked first and the read is
/// bounded anyway, because the declaration may be a lie.
fn fetch(&mut self, path: &str, cap: usize, what: &str) -> Option<Vec<u8>> {
let file = match self.archive.by_name(path) {
Ok(file) => file,
Err(zip::result::ZipError::FileNotFound) => {
self.meta
.note(format!("the {what}, {path}, is not in the archive"));
return None;
}
Err(_) => {
self.meta
.note(format!("the {what}, {path}, could not be read"));
return None;
}
};
let too_large = format!("the {what}, {path}, is larger than {}", crate::size(cap));
if file.size() > cap as u64 {
self.meta.note(too_large);
return None;
}
let mut bytes = Vec::new();
if file.take(cap as u64 + 1).read_to_end(&mut bytes).is_err() {
self.meta
.note(format!("the {what}, {path}, could not be read"));
return None;
}
if bytes.len() > cap {
self.meta.note(too_large);
return None;
}
Some(bytes)
}
/// The bytes as a cover when they are an image, typed by what they are
/// rather than by what the manifest says.
fn check(&mut self, path: &str, bytes: Vec<u8>) -> Option<Cover> {
let Some(media_type) = image_type(&bytes) else {
self.meta
.note(format!("the cover, {path}, is not an image"));
return None;
};
if let Some(item) = self
.items
.iter()
.find(|item| item.path.as_deref() == Some(path))
{
let declared = item.media_type.trim();
if !declared.is_empty() && !declared.eq_ignore_ascii_case(media_type) {
self.meta.note(format!(
"the manifest calls the cover, {path}, {declared}, but it is {media_type}"
));
}
}
Some(Cover {
media_type: media_type.to_string(),
bytes,
})
}
}
/// The directory a member sits in, with no trailing slash.
fn directory_of(path: &str) -> &str {
path.rsplit_once('/').map_or("", |(dir, _)| dir)
}
/// Where an address written in a member in `base` leads inside the archive.
///
/// The fragment comes off and percent-escapes are undone. An address with a
/// scheme, or one that climbs above the root of the archive, leads nowhere
/// this will follow.
fn resolve(base: &str, href: &str) -> Option<String> {
let href = href.split('#').next().unwrap_or("").trim();
// `http:`, `data:`, `file:`: a scheme before the first slash.
if let Some(colon) = href.find(':') {
if !href[..colon].contains('/') {
return None;
}
}
let href = percent_decode(href)?;
let mut parts: Vec<&str> = if href.starts_with('/') {
Vec::new()
} else {
base.split('/').filter(|part| !part.is_empty()).collect()
};
for part in href.split('/') {
match part {
"" | "." => {}
".." => {
parts.pop()?;
}
part => parts.push(part),
}
}
// An address of nothing but the directory it is written in names no
// member at all.
if href.is_empty() || parts.is_empty() {
return None;
}
Some(parts.join("/"))
}
/// `%20` back to a space, and so on, where the result is still UTF-8.
fn percent_decode(text: &str) -> Option<String> {
let raw = text.as_bytes();
let mut out = Vec::with_capacity(raw.len());
let mut at = 0;
while at < raw.len() {
if raw[at] == b'%' {
let hex = std::str::from_utf8(raw.get(at + 1..at + 3)?).ok()?;
out.push(u8::from_str_radix(hex, 16).ok()?);
at += 3;
} else {
out.push(raw[at]);
at += 1;
}
}
String::from_utf8(out).ok()
}
/// Every start tag in a document, in order, as its local name and its text.
///
/// Comments, declarations, processing instructions and end tags are passed
/// over. The local name is what follows any namespace prefix, so
/// `<opf:item>` is an `item`.
fn tags(xml: &str) -> impl Iterator<Item = (&str, &str)> {
let mut rest = xml;
std::iter::from_fn(move || {
loop {
let at = rest.find('<')?;
rest = &rest[at..];
if let Some(after) = rest.strip_prefix("<!--") {
rest = after.find("-->").map_or("", |end| &after[end + 3..]);
continue;
}
let end = rest.find('>')?;
let tag = &rest[1..end];
rest = &rest[end + 1..];
if tag.starts_with(['/', '!', '?']) {
continue;
}
let name = tag
.split(|c: char| c.is_ascii_whitespace() || c == '/')
.next()
.unwrap_or("");
let local = name.rsplit_once(':').map_or(name, |(_, local)| local);
return Some((local, tag));
}
})
}
/// The value of one attribute in a tag's text, entities undone.
///
/// The name has to stand on its own — `id` is not the end of `xml:id` or
/// `data-id` — and may have space either side of its `=`.
fn attr(tag: &str, name: &str) -> Option<String> {
let mut from = 0;
while let Some(found) = tag[from..].find(name) {
let at = from + found;
from = at + name.len();
if !tag[..at].ends_with(|c: char| c.is_ascii_whitespace()) {
continue;
}
let Some(rest) = tag[from..].trim_start().strip_prefix('=') else {
continue;
};
let rest = rest.trim_start();
let Some(quote) = rest.chars().next().filter(|c| matches!(c, '"' | '\'')) else {
continue;
};
let value = &rest[1..];
let end = value.find(quote)?;
return Some(unescape(&value[..end]));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -356,4 +697,386 @@ mod tests {
assert_eq!(meta.source, "none");
assert!(!meta.notes.is_empty());
}
// Covers. Each book is built here, a few members zipped in memory: the
// container, a package in `OEBPS/`, and whatever the case is about.
const JPEG: &[u8] = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00jpeg body";
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR png body";
const GIF: &[u8] = b"GIF89a\x01\x00\x01\x00 gif body";
const WEBP: &[u8] = b"RIFF\x20\x00\x00\x00WEBPVP8 webp body";
const CONTAINER: &str = r#"<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>"#;
/// A package with this manifest and these extra elements, around a title.
fn opf(manifest: &str, extra: &str) -> String {
format!(
r#"<?xml version="1.0"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Covered</dc:title>
</metadata>
<manifest>
<item id="text" href="text/one.xhtml" media-type="application/xhtml+xml"/>
{manifest}
</manifest>
{extra}
</package>"#
)
}
/// An EPUB of these members, after the container.
fn epub(members: &[(&str, &[u8])]) -> Vec<u8> {
use std::io::Write;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
let container: (&str, &[u8]) = ("META-INF/container.xml", CONTAINER.as_bytes());
for (name, data) in std::iter::once(&container).chain(members) {
zip.start_file(*name, options).unwrap();
zip.write_all(data).unwrap();
}
zip.finish().unwrap().into_inner()
}
fn book(package: &str, members: &[(&str, &[u8])]) -> Metadata {
let mut all = vec![("OEBPS/content.opf", package.as_bytes())];
all.extend_from_slice(members);
read("x.epub", &epub(&all))
}
#[test]
fn epub_3_names_its_cover_with_a_property() {
let package = opf(
r#"<item id="decoy-cover" href="images/cover-thumb.png" media-type="image/png"/>
<item id="c" href="images/front.jpg" media-type="image/jpeg" properties="svg cover-image"/>"#,
r#"<metadata><meta name="cover" content="decoy-cover"/></metadata>"#,
);
let meta = book(
&package,
&[
("OEBPS/images/front.jpg", JPEG),
("OEBPS/images/cover-thumb.png", PNG),
],
);
let cover = meta.cover.expect("a cover");
assert_eq!(cover.media_type, "image/jpeg");
assert_eq!(cover.bytes, JPEG);
assert!(meta.notes.is_empty(), "{:?}", meta.notes);
}
#[test]
fn epub_2_names_its_cover_with_a_meta_holding_an_id() {
let package = opf(
r#"<opf:item id="cover-img" href="Images/Front%20Page.png#top" media-type="image/png"/>"#,
r#"<metadata><meta content="cover-img" name="cover" /></metadata>"#,
);
let meta = book(&package, &[("OEBPS/Images/Front Page.png", PNG)]);
let cover = meta.cover.expect("a cover");
assert_eq!(cover.media_type, "image/png");
assert_eq!(cover.bytes, PNG);
assert!(meta.notes.is_empty(), "{:?}", meta.notes);
}
#[test]
fn a_cover_meta_holding_an_address_is_followed_as_one() {
// Listed in the manifest under another id, and not listed at all.
let listed = opf(
r#"<item id="x1" href="../art/front.gif" media-type="image/gif"/>"#,
r#"<metadata><meta name="cover" content="../art/front.gif"/></metadata>"#,
);
let meta = book(&listed, &[("art/front.gif", GIF)]);
assert_eq!(meta.cover.expect("a cover").media_type, "image/gif");
let unlisted = opf(
"",
r#"<metadata><meta name="cover" content="front.webp"/></metadata>"#,
);
let meta = book(&unlisted, &[("OEBPS/front.webp", WEBP)]);
let cover = meta.cover.expect("a cover");
assert_eq!(cover.media_type, "image/webp");
assert_eq!(cover.bytes, WEBP);
}
#[test]
fn the_guide_cover_page_gives_the_first_image_on_it() {
let package = opf(
r#"<item id="page" href="text/cover.xhtml" media-type="application/xhtml+xml"/>
<item id="art" href="images/art.jpg" media-type="image/jpeg"/>"#,
r#"<guide><reference type="Cover" title="Cover" href="text/cover.xhtml#start"/></guide>"#,
);
let page = br#"<html><body><!-- <img src="nothing.png"/> -->
<img alt="cover" src="../images/art.jpg"/><img src="../images/later.png"/></body></html>"#;
let meta = book(
&package,
&[
("OEBPS/text/cover.xhtml", page),
("OEBPS/images/art.jpg", JPEG),
("OEBPS/images/later.png", PNG),
],
);
let cover = meta.cover.expect("a cover");
assert_eq!(cover.bytes, JPEG);
assert!(meta.notes.is_empty(), "{:?}", meta.notes);
}
#[test]
fn the_guide_cover_page_may_draw_its_image_in_svg() {
let package = opf(
"",
r#"<guide><reference type="cover" href="titlepage.xhtml"/></guide>"#,
);
for image in [
r#"<svg:image width="600" xlink:href="cover.png"/>"#,
r#"<image href="cover.png" />"#,
] {
let page = format!(r#"<html><body><svg:svg>{image}</svg:svg></body></html>"#);
let meta = book(
&package,
&[
("OEBPS/titlepage.xhtml", page.as_bytes()),
("OEBPS/cover.png", PNG),
],
);
assert_eq!(meta.cover.expect(image).bytes, PNG);
}
}
#[test]
fn last_an_image_whose_name_says_cover() {
let package = opf(
r#"<item id="pic1" href="images/plate.jpg" media-type="image/jpeg"/>
<item id="CoverImage" href="images/i2.gif" media-type="image/gif"/>"#,
"",
);
let meta = book(
&package,
&[
("OEBPS/images/plate.jpg", JPEG),
("OEBPS/images/i2.gif", GIF),
],
);
assert_eq!(meta.cover.expect("a cover").bytes, GIF);
let by_address = opf(
r#"<item id="i9" href="images/Book_COVER.jpeg" media-type="image/jpeg"/>"#,
"",
);
let meta = book(&by_address, &[("OEBPS/images/Book_COVER.jpeg", JPEG)]);
assert_eq!(meta.cover.expect("a cover").bytes, JPEG);
}
#[test]
fn a_book_that_names_no_cover_has_none_and_says_nothing() {
let package = opf(
r#"<item id="plate" href="images/plate.jpg" media-type="image/jpeg"/>"#,
"",
);
let meta = book(&package, &[("OEBPS/images/plate.jpg", JPEG)]);
assert_eq!(meta.cover, None);
assert!(meta.notes.is_empty(), "{:?}", meta.notes);
}
#[test]
fn a_mislabelled_cover_is_what_its_bytes_say() {
let package = opf(
r#"<item id="c" href="c.png" media-type="image/png" properties="cover-image"/>"#,
"",
);
let meta = book(&package, &[("OEBPS/c.png", JPEG)]);
assert_eq!(meta.cover.expect("a cover").media_type, "image/jpeg");
assert_eq!(
meta.notes,
["the manifest calls the cover, OEBPS/c.png, image/png, but it is image/jpeg"]
);
}
#[test]
fn a_cover_that_is_not_an_image_is_a_note_and_not_a_cover() {
let package = opf(
r#"<item id="c" href="c.jpg" media-type="image/jpeg" properties="cover-image"/>"#,
"",
);
let meta = book(&package, &[("OEBPS/c.jpg", b"<html>not an image</html>")]);
assert_eq!(meta.cover, None);
assert_eq!(meta.notes, ["the cover, OEBPS/c.jpg, is not an image"]);
}
#[test]
fn a_cover_larger_than_the_cap_is_left_out() {
let mut huge = JPEG.to_vec();
huge.resize(MAX_COVER + 1, 0);
let package = opf(
r#"<item id="c" href="c.jpg" media-type="image/jpeg" properties="cover-image"/>"#,
"",
);
let meta = book(&package, &[("OEBPS/c.jpg", &huge)]);
assert_eq!(meta.cover, None);
assert_eq!(
meta.notes,
["the cover, OEBPS/c.jpg, is larger than 16 MiB"]
);
}
/// Read one member through `Look::fetch` with a cap of `cap`.
fn fetch(bytes: Vec<u8>, cap: usize) -> (Option<usize>, Vec<String>) {
let mut meta = Metadata::empty("x.epub", Source::Opf);
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut look = Look {
archive: &mut archive,
items: &[],
tried: Vec::new(),
meta: &mut meta,
};
let got = look.fetch("big.jpg", cap, "cover").map(|bytes| bytes.len());
(got, meta.notes)
}
#[test]
fn a_member_is_read_no_further_than_its_cap() {
let bytes = epub(&[("big.jpg", &[0u8; 4096])]);
assert_eq!(fetch(bytes.clone(), 4096).0, Some(4096));
assert_eq!(
fetch(bytes, 4095),
(
None,
vec!["the cover, big.jpg, is larger than 4095 bytes".to_string()]
)
);
}
#[test]
fn a_member_whose_archive_understates_its_size_is_still_bounded() {
let mut bytes = epub(&[("big.jpg", &[0u8; 4096])]);
// Declare 10 bytes uncompressed, in the local header and in the
// central directory, where 4096 are compressed in.
// The name comes 30 bytes into a local header and 46 into a central
// directory entry, and the uncompressed size 22 and 24.
let local = find(&bytes, b"big.jpg") - 30;
bytes[local + 22..local + 26].copy_from_slice(&10u32.to_le_bytes());
let central = local + 31 + find(&bytes[local + 31..], b"big.jpg") - 46;
assert_eq!(&bytes[central..central + 4], b"PK\x01\x02");
bytes[central + 24..central + 28].copy_from_slice(&10u32.to_le_bytes());
// The declaration passes the check, and the read stops at the cap.
let (got, notes) = fetch(bytes, 100);
assert_eq!(got, None);
assert_eq!(notes, ["the cover, big.jpg, is larger than 100 bytes"]);
}
fn find(haystack: &[u8], needle: &[u8]) -> usize {
haystack
.windows(needle.len())
.position(|window| window == needle)
.expect("present")
}
#[test]
fn a_cover_named_but_missing_is_a_note_and_the_next_route_is_asked() {
let package = opf(
r#"<item id="c" href="gone.jpg" media-type="image/jpeg" properties="cover-image"/>"#,
"",
);
let meta = book(&package, &[]);
assert_eq!(meta.cover, None);
assert_eq!(
meta.notes,
["the cover, OEBPS/gone.jpg, is not in the archive"]
);
// A missing page is said the same way.
let page = opf(
"",
r#"<guide><reference type="cover" href="none.xhtml"/></guide>"#,
);
assert_eq!(
book(&page, &[]).notes,
["the cover page, OEBPS/none.xhtml, is not in the archive"]
);
// The same file reached again by another route is not read twice,
// and an image the name gives away still stands in.
let package = opf(
r#"<item id="cover" href="gone.jpg" media-type="image/jpeg" properties="cover-image"/>
<item id="cover-small" href="small.png" media-type="image/png"/>"#,
r#"<metadata><meta name="cover" content="cover"/></metadata>"#,
);
let meta = book(&package, &[("OEBPS/small.png", PNG)]);
assert_eq!(meta.cover.expect("the stand-in").bytes, PNG);
assert_eq!(
meta.notes,
["the cover, OEBPS/gone.jpg, is not in the archive"]
);
}
#[test]
fn an_address_never_leaves_the_archive() {
assert_eq!(resolve("OEBPS", "../c.jpg").as_deref(), Some("c.jpg"));
assert_eq!(resolve("OEBPS", "/c.jpg").as_deref(), Some("c.jpg"));
assert_eq!(
resolve("OEBPS/text", "./a/../b.jpg").as_deref(),
Some("OEBPS/text/b.jpg")
);
assert_eq!(resolve("", "a%2Fb.jpg").as_deref(), Some("a/b.jpg"));
for hostile in [
"../../c.jpg",
"../../../etc/passwd",
"http://example.com/c.jpg",
"data:image/png;base64,AAAA",
"file:///etc/passwd",
"%ZZ.jpg",
"%ff.jpg",
"c%",
"#only-a-fragment",
"",
] {
assert_eq!(resolve("OEBPS", hostile), None, "{hostile}");
}
let package = opf(
r#"<item id="c" href="../../c.jpg" media-type="image/jpeg" properties="cover-image"/>"#,
"",
);
let meta = book(&package, &[("c.jpg", JPEG)]);
assert_eq!(meta.cover, None);
assert_eq!(
meta.notes,
["the cover's address, ../../c.jpg, is not a path inside the archive"]
);
}
#[test]
fn an_attribute_is_matched_by_its_whole_name() {
let tag = r#"item data-id="no" xml:id="no" id = 'yes' href="a&amp;b.jpg""#;
assert_eq!(attr(tag, "id").as_deref(), Some("yes"));
assert_eq!(attr(tag, "href").as_deref(), Some("a&b.jpg"));
assert_eq!(attr(r#"image xlink:href="x.png""#, "href"), None);
assert_eq!(attr(r#"item id="unterminated"#, "id"), None);
}
#[test]
fn every_truncation_of_a_book_with_a_cover_returns() {
let package = opf(
r#"<item id="c" href="c.jpg" media-type="image/jpeg" properties="cover-image"/>"#,
r#"<guide><reference type="cover" href="p.xhtml"/></guide>"#,
);
let bytes = epub(&[
("OEBPS/content.opf", package.as_bytes()),
("OEBPS/c.jpg", JPEG),
("OEBPS/p.xhtml", b"<img src=\"c.jpg\"/>"),
]);
for len in 0..=bytes.len() {
let _ = read("cut.epub", &bytes[..len]);
}
for at in 0..bytes.len() {
let mut broken = bytes.clone();
broken[at] ^= 0xff;
let _ = read("broken.epub", &broken);
}
}
}
+37 -2
View File
@@ -106,12 +106,47 @@ pub struct Metadata {
#[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`.
/// `image/png`, `image/gif`, `image/webp` or `image/bmp`.
pub media_type: String,
/// The image itself.
/// The image itself, never more than [`MAX_COVER`] bytes.
pub bytes: Vec<u8>,
}
/// How large a cover is worth keeping.
///
/// A real one is a few hundred kilobytes. An EPUB's cover has to be
/// decompressed to be read, and this is as far as that goes: a larger one is
/// left out with a note, in every format, so a hostile file cannot make a
/// reader allocate whatever it likes.
pub const MAX_COVER: usize = 16 * 1024 * 1024;
/// A size, as a note says it.
pub(crate) fn size(bytes: usize) -> String {
const MIB: usize = 1024 * 1024;
if bytes >= MIB && bytes % MIB == 0 {
format!("{} MiB", bytes / MIB)
} else {
format!("{bytes} bytes")
}
}
/// What kind of image these bytes are, by their magic.
pub(crate) 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"RIFF") && data.get(8..12) == Some(b"WEBP") {
Some("image/webp")
} else if data.starts_with(b"BM") && data.len() > 14 {
Some("image/bmp")
} else {
None
}
}
fn version() -> u32 {
corpus_core::V
}
+11 -16
View File
@@ -13,7 +13,7 @@
//! panics or reads past the end.
use crate::epub::{digits_of, is_isbn, is_uuid};
use crate::{Cover, Metadata, Source};
use crate::{Cover, MAX_COVER, Metadata, Source, image_type};
/// Where a Palm database says what it holds.
const TYPE_AT: usize = 60;
@@ -332,6 +332,13 @@ fn cover(
meta.note("the cover record is not an image");
return None;
};
if data.len() > MAX_COVER {
meta.note(format!(
"the cover record is larger than {}",
crate::size(MAX_COVER)
));
return None;
}
Some(Cover {
media_type: media_type.to_string(),
bytes: data.to_vec(),
@@ -347,6 +354,9 @@ fn cover(
fn first_image_record(bytes: &[u8], records: &[usize], first_image: Option<u32>) -> Option<Cover> {
let first = first_image.filter(|&index| index != NONE)?;
let data = record(bytes, records, first as usize)?;
if data.len() > MAX_COVER {
return None;
}
let media_type = image_type(data).filter(|kind| *kind != "image/bmp")?;
Some(Cover {
media_type: media_type.to_string(),
@@ -359,21 +369,6 @@ 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]) {
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
+99
View File
@@ -0,0 +1,99 @@
//! An ISBN's two forms, ten digits and thirteen.
//!
//! A book printed before 2007 carries a ten-digit ISBN, one printed since
//! carries thirteen, and every ten-digit one has a thirteen-digit twin: `978`
//! in front and the check digit worked out again. A catalogue may file a
//! book under either, so asking about one form and not the other misses
//! books that are there.
//!
//! Only a well-formed ISBN is converted. Working out a fresh check digit for
//! one whose own check digit is wrong would not correct the typing error, it
//! would turn the number into a different book's.
/// Just the digits of an ISBN, with a check character of ten as `X`.
pub fn digits(text: &str) -> String {
bibclean::isbn::digits(text).to_ascii_uppercase()
}
/// The thirteen-digit form of an ISBN, from either form.
///
/// `None` when the text is not an ISBN whose check digit agrees.
pub fn to_13(text: &str) -> Option<String> {
let digits = digits(text);
if !bibclean::isbn::valid(&digits) {
return None;
}
match digits.len() {
13 => Some(digits),
10 => {
let body = format!("978{}", &digits[..9]);
let sum: u32 = body
.bytes()
.enumerate()
.map(|(at, b)| u32::from(b - b'0') * if at % 2 == 0 { 1 } else { 3 })
.sum();
Some(format!("{body}{}", (10 - sum % 10) % 10))
}
_ => None,
}
}
/// The ten-digit form of an ISBN, from either form.
///
/// `None` when the text is not an ISBN whose check digit agrees, or when it
/// is a thirteen-digit one starting `979`, which was never issued in ten.
pub fn to_10(text: &str) -> Option<String> {
let digits = digits(text);
if !bibclean::isbn::valid(&digits) {
return None;
}
match digits.len() {
10 => Some(digits),
13 if digits.starts_with("978") => {
let body = &digits[3..12];
let sum: u32 = body
.bytes()
.enumerate()
.map(|(at, b)| u32::from(b - b'0') * (10 - at as u32))
.sum();
let check = match (11 - sum % 11) % 11 {
10 => 'X',
n => char::from(b'0' + n as u8),
};
Some(format!("{body}{check}"))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_form_becomes_the_other() {
assert_eq!(to_13("0-306-40615-2").as_deref(), Some("9780306406157"));
assert_eq!(to_10("978-0-306-40615-7").as_deref(), Some("0306406152"));
assert_eq!(to_13("9780306406157").as_deref(), Some("9780306406157"));
assert_eq!(to_10("0306406152").as_deref(), Some("0306406152"));
}
#[test]
fn a_check_digit_of_ten_is_written_x() {
assert_eq!(to_10("9780804429573").as_deref(), Some("080442957X"));
assert_eq!(to_13("080442957x").as_deref(), Some("9780804429573"));
}
#[test]
fn a_979_isbn_has_no_ten_digit_form() {
assert_eq!(to_10("979-10-90636-07-1"), None);
assert_eq!(to_13("979-10-90636-07-1").as_deref(), Some("9791090636071"));
}
#[test]
fn a_mistyped_isbn_is_not_converted_into_a_different_book() {
assert_eq!(to_13("0-306-40615-3"), None);
assert_eq!(to_10("978-0-306-40615-8"), None);
assert_eq!(to_13("12345"), None);
}
}
+1
View File
@@ -13,6 +13,7 @@
pub mod arxiv;
pub mod doi;
pub mod isbn;
pub mod issn;
use schemars::JsonSchema;
+18
View File
@@ -20,6 +20,7 @@ to any of them is a change to a decision rather than to an accident.
- [pdfgutter — a page tree that does not add up is refused](#pdfgutter--a-page-tree-that-does-not-add-up-is-refused)
- [pdfgutter — a sheet on its side is cut the way it is read](#pdfgutter--a-sheet-on-its-side-is-cut-the-way-it-is-read)
- [pdfgutter — an unreadable file is an answer, not a false](#pdfgutter--an-unreadable-file-is-an-answer-not-a-false)
- [bookmeta — a cover is what its bytes say it is](#bookmeta--a-cover-is-what-its-bytes-say-it-is)
- [Every pattern — two regex engines, two Unicode tables](#every-pattern--two-regex-engines-two-unicode-tables)
## cuenorm — settings after the end time are the format, not a malformation
@@ -243,6 +244,23 @@ answers, and the [contract](contract.md) gives them three different numbers. A
caller wanting them collapsed treats 1 and 2 alike; a caller wanting to know
which files it failed on can now find out.
## bookmeta — a cover is what its bytes say it is
An EPUB's manifest gives every image a media type, and the cover's is not
used: the type comes from the image's first bytes, and a disagreement is a
note. Elsewhere `bookmeta` reports what the file says rather than what is
true, and this is the one place it does not.
The difference is that a title nobody believes is still a title, while a
media type that is wrong is an image that does not open. A caller handed
`image/png` for a JPEG writes `cover.png`, serves it as PNG, or hands it to a
decoder that refuses it. An image can be replaced by a converter or by hand
without its manifest entry changing, and the note keeps what the file said,
so nothing is lost by correcting it.
The same reasoning goes the other way: bytes that are not an image are not a
cover, whatever the manifest calls them.
## Every pattern — two regex engines, two Unicode tables
Patterns here spell their character classes explicitly rather than leaning on