16 Commits
Author SHA1 Message Date
funkpower 5f2a471f58 Merge pull request 'Release 0.4.0' (#10) from release-0.4.0 into main 2026-11-13 15:37:47 -05:00
funkpower bd104834ed 0.4.0 — MOBI and AZW books read without calibre, and a cover from Open Library
bookmeta reads MOBI 6, AZW and AZW3 from the MOBI header and its EXTH
records: title, authors, publisher, description, ISBN, subjects, date and
language, and the cover image the book carries (#1). EXTH 113 is filed by
its shape, an ASIN under asin and a calibre UUID under uuid. The EPUB
reader now keeps dc:description as well.

bibfetch fetches a book's cover from Open Library by ISBN, falling back to
the ISBN-10 form and then to the edition's own cover id, which a Record now
carries; `bibfetch cover` does the same from a terminal (#2). idnorm gains
ISBN-10 and ISBN-13 conversion.

Breaking:
- bookmeta::Metadata has two new public fields, description and cover, so
  a struct literal without `..` no longer compiles.
- bibfetch::Record has a new public field, cover_id.
- bibfetch::Error has a new variant, Invalid, so an exhaustive match
  without it no longer compiles.
- A bibfetch input file named `cover` is now read as the subcommand; pass
  it as ./cover.
2026-11-13 15:27:46 -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 a105b1c9aa bookmeta: with no cover named, take only the record at first_image
When a MOBI or AZW3 file names no cover (no EXTH 201 or 202), the cover is
the record at the first image index if it is a JPEG, PNG or GIF, and
nothing otherwise, as calibre does (metadata/mobi.py:524-532). The walk to
the next image is gone: a later record may be an inline illustration, and
kindlegen puts a font or RESC record first.

EXTH 121 is no longer read, which also stops a stale KF8 boundary below
first_image from hiding the cover.
2026-11-13 15:22:04 -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 130324bbe0 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).
2026-11-13 14:51:43 -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
funkpower b7d98e9d1a bookmeta: read MOBI, AZW and AZW3 from the MOBI header and its EXTH records
A Kindle book's title, authors, publisher, description, ISBN, ASIN,
subjects, date and language come from the EXTH block, with the full name,
the Palm database name and the header's locale as fallbacks. The cover
image (EXTH 201, or the thumbnail) is returned as Metadata::cover with its
media type; it stays out of the JSON record. Every offset is bounds-checked:
a damaged header gives an empty record with a note, a broken EXTH block
keeps what came before the break, and an encrypted book is still read and
noted as drm.

Metadata gains description, which the EPUB reader now fills from
dc:description as well.

Fixtures are a story and cover written for the tests (CC0), converted with
calibre to MOBI 6 and AZW3, plus no-EXTH, DRM-flagged and truncated copies.
2026-11-13 14:17:44 -05:00
funkpower 6f32f52293 0.3.1, properly — the version, and the test that names the first register
The commit that reordered the chain did not carry the version bump. The
bump was chained after check.sh with &&, the commit after a semicolon, so
a failing check skipped the bump and let the commit through. The tag then
pointed at a tree still calling itself 0.3.0, which the release workflow
asserts against and would have refused.

check.sh was failing because a test names which register is asked first
about an ISBN, which is exactly what changed. It now names the index, for
the reason the reorder happened.
2026-09-22 10:03:50 -04:00
funkpower e480f07c32 0.3.1 — ask the index first, which answers in one request
The chain asked OpenLibrary's edition record first and its search index
second. The edition record is the slow one: measured against this
library it takes five to twenty-eight seconds, and it names its authors
by key, so a second request was needed anyway. The index, asked for its
fields by name, returns the title, the authors, the publisher, the
language, a page count and the subjects together, in about a quarter of
a second.

Reordering them turns three requests per book into one, and a pass over
five thousand books from fifty-three hours into well under one. The
edition record is still there for a subtitle or a publisher the index
did not give.
2026-09-22 09:36:46 -04:00
funkpower d551adc14c 0.3.0 — ask every register and keep all of the answer
The chain asked three registers and returned whichever answered first
with a title and an author, discarding the rest. The registers are
complementary rather than redundant: OpenLibrary's edition record
carries the publisher, the pagination and the language and names its
authors by key, while its search index carries the authors and not the
publisher. One book, two half-answers, and the whole of it only if they
are put together.

They are merged now, first answer winning each field, so the order of
the chain is the order of trust. The record says which registers spoke.

It also carries what was being thrown away: subjects, language and a
page count, from whichever register has them. The index will not return
those unless asked for by name, which is how a language it held went
unread. Subjects are capped at twelve — the index lists every heading
every edition of a work has ever been given, which on a well-known book
is hundreds.

An edition record keeps its subtitle in a separate field, so a title
came back without it.
2026-09-22 08:39:27 -04:00
funkpower 90fb977a10 colophon: six more things the front matter of a book is not
Read out of the review queue a real run produced, where each of these was
sitting as a proposed title waiting for a person to say no:

  1. General Remarks            a numbered heading
  2   James W. McAllister       a numbered heading
  5222 What Is This             a folio that ran into the line
  827 North Fourth Street       an address
  Page xxxvi / Chapter I        a marker
  title                         a form label, printed literally
  Sidebottom, Harry. Ancient    a bibliography entry
  To Mackenzie,                 a dedication

The numbers are the delicate part. A leading number that could be a year
is left alone, because 1984 Revisited is a book and 5222 What Is This is
a folio, and nothing else distinguishes them. A dedication has to end in
a comma, so To Kill a Mockingbird survives.

The copyright notices in the same pile are already fixed: those
proposals were written before 0.2.2 and are stale rather than wrong.
2026-09-21 15:47:24 -04:00
funkpower 05b712d084 0.2.2 — a subtitle begins with a capital
title_case counted small words from the start of the whole string, so
every subtitle that opened with one was set in lower case: 'Aesthetic in
Kant: a Critique', 'Born Fighting: the Scots-Irish'. A colon starts a
title of its own and the first word of one is capitalised however small
it is.

Found by book-pipeline, which had been proposing a title page's capitals
verbatim into a catalogue and needed this to stop.
2026-09-21 11:30:26 -04:00
funkpower 60943420b4 0.2.1 — the holder goes between the symbol and the year
'(c) Alfredo Ferrarin 2004' was read as a title. The notice pattern
wanted the year to follow the symbol directly, and a copyright line puts
the holder's name in between, which is the ordinary way to set one.
2026-09-21 11:19:54 -04:00
26 changed files with 2994 additions and 83 deletions

No files matched your search

Generated
+26 -26
View File
@@ -101,7 +101,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "avplan"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"clap",
@@ -126,7 +126,7 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bibclean"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"clap",
@@ -143,7 +143,7 @@ dependencies = [
[[package]]
name = "bibfetch"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"clap",
@@ -196,7 +196,7 @@ dependencies = [
[[package]]
name = "bookmeta"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"clap",
@@ -210,7 +210,7 @@ dependencies = [
[[package]]
name = "booksniff"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"clap",
@@ -250,7 +250,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "catena"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"bibclean",
"bibfetch",
@@ -277,7 +277,7 @@ dependencies = [
[[package]]
name = "catena-cli"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"clap",
"clap_mangen",
@@ -312,7 +312,7 @@ checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "chunkmd"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"clap",
@@ -334,7 +334,7 @@ dependencies = [
[[package]]
name = "citefind"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"chunkmd",
@@ -399,7 +399,7 @@ dependencies = [
[[package]]
name = "colophon"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"bibclean",
"catena-cli",
@@ -448,7 +448,7 @@ dependencies = [
[[package]]
name = "corpus-core"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"schemars",
"serde",
@@ -492,7 +492,7 @@ dependencies = [
[[package]]
name = "cuekit"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"corpus-core",
"regex",
@@ -504,7 +504,7 @@ dependencies = [
[[package]]
name = "cuenorm"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"clap",
@@ -514,7 +514,7 @@ dependencies = [
[[package]]
name = "cuepack"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"clap",
@@ -525,7 +525,7 @@ dependencies = [
[[package]]
name = "cueseg"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"clap",
@@ -612,7 +612,7 @@ dependencies = [
[[package]]
name = "emb"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"clap",
@@ -876,7 +876,7 @@ dependencies = [
[[package]]
name = "idnorm"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"bibclean",
"catena-cli",
@@ -942,7 +942,7 @@ dependencies = [
[[package]]
name = "langid"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"clap",
@@ -1022,7 +1022,7 @@ dependencies = [
[[package]]
name = "mdscrub"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"clap",
@@ -1034,7 +1034,7 @@ dependencies = [
[[package]]
name = "mdsplit"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"clap",
@@ -1113,7 +1113,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pdfgutter"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"clap",
@@ -1126,7 +1126,7 @@ dependencies = [
[[package]]
name = "pdftriage"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"clap",
@@ -1370,7 +1370,7 @@ checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
[[package]]
name = "rrf"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"clap",
@@ -1517,7 +1517,7 @@ dependencies = [
[[package]]
name = "seriesof"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"clap",
@@ -1634,7 +1634,7 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "textkit"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"regex",
"tiktoken-rs",
@@ -1724,7 +1724,7 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tocfind"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"catena-cli",
"clap",
+27 -27
View File
@@ -3,39 +3,39 @@ resolver = "3"
members = ["crates/*"]
[workspace.package]
version = "0.2.0"
version = "0.4.0"
edition = "2024"
rust-version = "1.85"
license = "MPL-2.0"
repository = "https://git.pumpin.online/funkpower/catena"
[workspace.dependencies]
catena-cli = { path = "crates/catena-cli", version = "0.2.0" }
corpus-core = { path = "crates/corpus-core", version = "0.2.0" }
cuekit = { path = "crates/cuekit", version = "0.2.0" }
textkit = { path = "crates/textkit", version = "0.2.0" }
chunkmd = { path = "crates/chunkmd", version = "0.2.0" }
mdscrub = { path = "crates/mdscrub", version = "0.2.0" }
mdsplit = { path = "crates/mdsplit", version = "0.2.0" }
pdftriage = { path = "crates/pdftriage", version = "0.2.0" }
cuenorm = { path = "crates/cuenorm", version = "0.2.0" }
cueseg = { path = "crates/cueseg", version = "0.2.0" }
rrf = { path = "crates/rrf", version = "0.2.0" }
cuepack = { path = "crates/cuepack", version = "0.2.0" }
bibclean = { path = "crates/bibclean", version = "0.2.0" }
avplan = { path = "crates/avplan", version = "0.2.0" }
pdfgutter = { path = "crates/pdfgutter", version = "0.2.0" }
catena = { path = "crates/bindings", version = "0.2.0" }
seriesof = { path = "crates/seriesof", version = "0.2.0" }
idnorm = { path = "crates/idnorm", version = "0.2.0" }
bibfetch = { path = "crates/bibfetch", version = "0.2.0" }
bookmeta = { path = "crates/bookmeta", version = "0.2.0" }
langid = { path = "crates/langid", version = "0.2.0" }
tocfind = { path = "crates/tocfind", version = "0.2.0" }
booksniff = { path = "crates/booksniff", version = "0.2.0" }
citefind = { path = "crates/citefind", version = "0.2.0" }
colophon = { path = "crates/colophon", version = "0.2.0" }
emb = { path = "crates/emb", version = "0.2.0" }
catena-cli = { path = "crates/catena-cli", version = "0.4.0" }
corpus-core = { path = "crates/corpus-core", version = "0.4.0" }
cuekit = { path = "crates/cuekit", version = "0.4.0" }
textkit = { path = "crates/textkit", version = "0.4.0" }
chunkmd = { path = "crates/chunkmd", version = "0.4.0" }
mdscrub = { path = "crates/mdscrub", version = "0.4.0" }
mdsplit = { path = "crates/mdsplit", version = "0.4.0" }
pdftriage = { path = "crates/pdftriage", version = "0.4.0" }
cuenorm = { path = "crates/cuenorm", version = "0.4.0" }
cueseg = { path = "crates/cueseg", version = "0.4.0" }
rrf = { path = "crates/rrf", version = "0.4.0" }
cuepack = { path = "crates/cuepack", version = "0.4.0" }
bibclean = { path = "crates/bibclean", version = "0.4.0" }
avplan = { path = "crates/avplan", version = "0.4.0" }
pdfgutter = { path = "crates/pdfgutter", version = "0.4.0" }
catena = { path = "crates/bindings", version = "0.4.0" }
seriesof = { path = "crates/seriesof", version = "0.4.0" }
idnorm = { path = "crates/idnorm", version = "0.4.0" }
bibfetch = { path = "crates/bibfetch", version = "0.4.0" }
bookmeta = { path = "crates/bookmeta", version = "0.4.0" }
langid = { path = "crates/langid", version = "0.4.0" }
tocfind = { path = "crates/tocfind", version = "0.4.0" }
booksniff = { path = "crates/booksniff", version = "0.4.0" }
citefind = { path = "crates/citefind", version = "0.4.0" }
colophon = { path = "crates/colophon", version = "0.4.0" }
emb = { path = "crates/emb", version = "0.4.0" }
clap = { version = "4", features = ["derive"] }
clap_mangen = "0.3"
+45 -1
View File
@@ -67,7 +67,19 @@ pub fn title_case(title: &str) -> String {
.to_lowercase()
.trim_end_matches(['.', ',', ':', ';', '!', '?', ')', ']', '"', '\''])
.to_string();
if SMALL_WORDS.binary_search(&core.as_str()).is_ok() && at > 0 && at < last {
// A small word stays small in the middle of a title, but not
// where a title actually starts. A colon starts a subtitle, and
// "Aesthetic in Kant: a Critique" is the giveaway that the rule
// was only ever counting from the beginning of the whole string.
let opens_a_subtitle = at > 0
&& words[at - 1]
.trim_end_matches(['"', '\'', ')', ']'])
.ends_with([':', ';', '?', '!', '.']);
if SMALL_WORDS.binary_search(&core.as_str()).is_ok()
&& at > 0
&& at < last
&& !opens_a_subtitle
{
return word.to_lowercase();
}
let lowered = word.to_lowercase();
@@ -218,3 +230,35 @@ mod tests {
);
}
}
#[cfg(test)]
mod subtitle_case_tests {
use super::title_case;
#[test]
fn a_subtitle_starts_with_a_capital() {
// The rule counted small words from the start of the string, so every
// subtitle beginning "A", "The" or "Of" was set lower case.
assert_eq!(
title_case("AESTHETIC IN KANT: A CRITIQUE"),
"Aesthetic in Kant: A Critique"
);
assert_eq!(
title_case("BORN FIGHTING: THE SCOTS-IRISH"),
"Born Fighting: The Scots-Irish"
);
assert_eq!(
title_case("A HISTORY: AN INTRODUCTION TO IT"),
"A History: An Introduction to It"
);
}
#[test]
fn a_small_word_in_the_middle_is_still_small() {
assert_eq!(
title_case("THE RISE AND FALL OF THE THIRD REICH"),
"The Rise and Fall of the Third Reich"
);
assert_eq!(title_case("WAR OF THE WORLDS"), "War of the Worlds");
}
}
+6 -1
View File
@@ -260,9 +260,14 @@ mod tests {
#[test]
fn an_underscored_title_is_cleaned_before_it_is_cased() {
let cleaned = clean(&book("THE FALL_ AND THE RISE_txt", "Arendt, Hannah"));
// "And" is capitalised because the colon starts a subtitle, and the
// first word of one is capitalised however small it is. This asserted
// "and" until the rule was found to be counting small words from the
// start of the whole string, which set every subtitle opening "A",
// "The" or "An" in lower case.
assert_eq!(
cleaned.book.title.as_deref(),
Some("The Fall: and the Rise")
Some("The Fall: And the Rise")
);
assert_eq!(cleaned.changes.len(), 2);
}
+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
+11 -6
View File
@@ -63,13 +63,18 @@ pub fn lookup(
for source in chain {
match ask(agent, *source, id, kind, attempts) {
Ok(record) => {
let complete = record.found && record.title.is_some() && !record.authors.is_empty();
let better = best.as_ref().is_none_or(|kept| record.found && !kept.found);
if complete {
return Ok(Some(record));
match &mut best {
// Merged rather than replaced. The registers answer about
// different parts of one book: OpenLibrary's edition record
// carries the publisher, the pagination and the language
// and names its authors by key, while its search index
// carries the authors and not the publisher. Keeping
// whichever arrived first throws the other half away.
Some(kept) => kept.fill_from(record),
None => best = Some(record),
}
if better {
best = Some(record);
if best.as_ref().is_some_and(Record::complete) {
break;
}
}
Err(why) => last_error = Some(why),
File diff suppressed because it is too large. Load diff
+166
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.
@@ -59,6 +61,28 @@ pub struct Record {
/// The abstract, where the register carries one.
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
/// The subject headings the register files it under, as it words them.
///
/// A register states these for far more books than print a cataloguing
/// block, which makes them the second source a shelf otherwise lacks.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub subjects: Vec<String>,
/// The language it is written in, as the register gives it: a two- or
/// three-letter code, or a name. Not normalised here — what a register
/// said is the record, and deciding that "eng" and "English" are one
/// thing belongs to whoever is filing them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
/// 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 {
@@ -80,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 {
@@ -90,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}"),
}
}
}
@@ -112,6 +139,74 @@ pub fn missing(id: &str, kind: &str, source: Source) -> Record {
year: None,
container: None,
summary: None,
subjects: Vec::new(),
language: None,
pages: None,
cover_id: None,
}
}
impl Record {
/// Whether this record says everything a catalogue wants.
///
/// Used to decide when to stop asking. Title and authors are the minimum
/// that makes a record usable; the rest is worth another register only
/// while one is left to ask.
pub fn complete(&self) -> bool {
self.found
&& self.title.is_some()
&& !self.authors.is_empty()
&& self.container.is_some()
&& self.language.is_some()
}
/// Take from another record whatever this one does not have.
///
/// The registers are complementary rather than redundant, and asking them
/// in turn and keeping one answer throws away the difference. OpenLibrary's
/// edition record gives the publisher, the pagination and the language but
/// names its authors by key; its search index gives the authors but not the
/// publisher. One book, two half-answers, and the whole of it only if they
/// are put together.
///
/// Nothing already answered is overwritten, so the order of the chain is
/// the order of trust.
pub fn fill_from(&mut self, other: Record) {
if !other.found {
return;
}
self.found = true;
if self.title.is_none() {
self.title = other.title;
}
if self.authors.is_empty() {
self.authors = other.authors;
}
if self.year.is_none() {
self.year = other.year;
}
if self.container.is_none() {
self.container = other.container;
}
if self.summary.is_none() {
self.summary = other.summary;
}
if self.subjects.is_empty() {
self.subjects = other.subjects;
}
if self.language.is_none() {
self.language = other.language;
}
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);
}
}
}
@@ -135,3 +230,74 @@ mod tests {
assert!(json.contains("\"found\":false"), "{json}");
}
}
#[cfg(test)]
mod merge_tests {
use super::*;
fn rec(source: &str) -> Record {
let mut r = missing("9780199205592", "isbn", Source::OpenLibrary);
r.source = source.to_string();
r.found = true;
r
}
#[test]
fn two_half_answers_make_one_record() {
// Exactly what the two OpenLibrary endpoints give for one book: the
// edition record has the publisher and the language and names its
// authors by key; the index has the authors and not the publisher.
let mut edition = rec("openlibrary");
edition.title = Some("The First World War: A Very Short Introduction".into());
edition.container = Some("Oxford University Press".into());
edition.language = Some("eng".into());
let mut index = rec("openlibrary");
index.title = Some("The First World War".into());
index.authors = vec!["Michael Howard".into()];
index.year = Some(2007);
edition.fill_from(index);
assert_eq!(edition.authors, ["Michael Howard"]);
assert_eq!(edition.year, Some(2007));
// The fuller title arrived first and is not displaced by the shorter.
assert_eq!(
edition.title.as_deref(),
Some("The First World War: A Very Short Introduction")
);
}
#[test]
fn a_register_that_has_nothing_contributes_nothing() {
let mut kept = rec("openlibrary");
kept.title = Some("A Book".into());
let absent = missing("9780199205592", "isbn", Source::GoogleBooks);
kept.fill_from(absent);
assert_eq!(kept.title.as_deref(), Some("A Book"));
assert_eq!(kept.source, "openlibrary");
}
#[test]
fn the_record_names_every_register_that_answered() {
let mut a = rec("openlibrary");
a.title = Some("A Book".into());
let mut b = rec("googlebooks");
b.found = true;
b.pages = Some(134);
a.fill_from(b);
assert_eq!(a.source, "openlibrary+googlebooks");
assert_eq!(a.pages, Some(134));
}
#[test]
fn complete_means_a_catalogue_has_what_it_needs() {
let mut r = rec("openlibrary");
assert!(!r.complete());
r.title = Some("A Book".into());
r.authors = vec!["Someone".into()];
assert!(!r.complete(), "still missing the publisher and language");
r.container = Some("A Press".into());
r.language = Some("eng".into());
assert!(r.complete());
}
}
+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 you@example.org"
idnorm --kind doi refs.txt | bibfetch --polite you@example.org
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 =
+135 -4
View File
@@ -55,9 +55,17 @@ impl Source {
match kind {
"doi" => &[Source::Crossref],
"arxiv" => &[Source::Arxiv],
// The index first. Asked for the fields by name it answers with
// the title, the authors, the publisher, the language, a page
// count and the subjects in one request, which is the whole of
// what a catalogue wants; the edition record is then needed only
// for a subtitle or a publisher the index did not give. Asking it
// second cost three requests per book where one usually does, and
// at three books in flight that is the difference between two
// days and two hours.
"isbn" => &[
Source::OpenLibrary,
Source::OpenLibrarySearch,
Source::OpenLibrary,
Source::GoogleBooks,
],
_ => &[],
@@ -75,9 +83,15 @@ impl Source {
Source::OpenLibrary => {
format!("https://openlibrary.org/isbn/{}.json", encode(id))
}
// `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&limit=1",
&fields=title,subtitle,author_name,first_publish_year,publisher,language,subject,\
number_of_pages_median&limit=1",
encode(id)
),
Source::GoogleBooks => format!(
@@ -222,9 +236,27 @@ struct OpenLibraryBook {
#[serde(default)]
title: Option<String>,
#[serde(default)]
subtitle: Option<String>,
#[serde(default)]
publish_date: Option<String>,
#[serde(default)]
publishers: Vec<String>,
#[serde(default)]
subjects: Vec<String>,
/// `[{"key": "/languages/eng"}]`, which is how OpenLibrary names one.
#[serde(default)]
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)]
struct OpenLibraryKey {
#[serde(default)]
key: String,
}
fn open_library(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
@@ -232,10 +264,28 @@ fn open_library(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
serde_json::from_str(body).map_err(|e| Error::Shape(e.to_string()))?;
let mut record = blank(id, kind, Source::OpenLibrary);
record.title = book.title;
// An edition record keeps the subtitle in its own field, and a catalogue
// wants the whole title. Google Books is already joined the same way.
record.title = match (book.title, book.subtitle) {
(Some(title), Some(subtitle)) => Some(format!("{title}: {subtitle}")),
(title, _) => title,
};
record.container = book.publishers.into_iter().next();
// The date is free text — "1999", "March 1999", "1999-03-01".
record.year = book.publish_date.as_deref().and_then(year_in);
record.subjects = book.subjects;
// "/languages/eng" names the language by the last segment of a key.
record.language = book
.languages
.into_iter()
.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)
}
@@ -257,8 +307,21 @@ struct SearchDoc {
first_publish_year: Option<i64>,
#[serde(default)]
publisher: Vec<String>,
#[serde(default)]
language: Vec<String>,
#[serde(default)]
subject: Vec<String>,
#[serde(rename = "number_of_pages_median", default)]
pages: Option<u32>,
}
/// How many of a work's subject headings are worth carrying.
///
/// The index lists every heading every edition has ever been given, which on a
/// well-known book is hundreds and on Hamlet is thousands. A shelf is filed
/// from a handful, and the first are the broadest.
const SUBJECT_LIMIT: usize = 12;
/// A search that matched nothing answers with no documents rather than a
/// status, so an empty reply is the absence and not a failure.
fn open_library_search(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
@@ -275,6 +338,12 @@ fn open_library_search(id: &str, kind: &str, body: &str) -> Result<Record, Error
record.authors = doc.author_name;
record.year = doc.first_publish_year;
record.container = doc.publisher.into_iter().next();
record.language = doc.language.into_iter().next();
// The index lists every subject any edition of the work carries, which
// runs to hundreds on a well-known book. A shelf needs a handful.
record.subjects = doc.subject;
record.subjects.truncate(SUBJECT_LIMIT);
record.pages = doc.pages;
Ok(record)
}
@@ -304,6 +373,12 @@ struct GoogleVolume {
publisher: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
categories: Vec<String>,
#[serde(rename = "language", default)]
language: Option<String>,
#[serde(rename = "pageCount", default)]
page_count: Option<u32>,
}
/// A search that matched nothing answers with no items rather than a status,
@@ -324,6 +399,9 @@ fn google_books(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
record.year = volume.published_date.as_deref().and_then(year_in);
record.container = volume.publisher;
record.summary = volume.description.map(|text| collapse(&text));
record.subjects = volume.categories;
record.language = volume.language;
record.pages = volume.page_count;
Ok(record)
}
@@ -379,7 +457,10 @@ mod tests {
fn each_kind_goes_to_the_body_that_assigns_it() {
assert_eq!(Source::for_kind("doi"), Some(Source::Crossref));
assert_eq!(Source::for_kind("arxiv"), Some(Source::Arxiv));
assert_eq!(Source::for_kind("isbn"), Some(Source::OpenLibrary));
// The index, not the edition record: it answers with the whole of
// what a catalogue wants in one request, where the edition record
// takes up to twenty-eight seconds and names its authors by key.
assert_eq!(Source::for_kind("isbn"), Some(Source::OpenLibrarySearch));
// No register answers about an ISSN without a key.
assert_eq!(Source::for_kind("issn"), None);
}
@@ -450,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!(
+24 -1
View File
@@ -84,6 +84,7 @@ bookmeta library/*.epub \
|---|---|
| EPUB | The package document named by `META-INF/container.xml` — Dublin Core, plus the `<meta>` entries Calibre adds for a series |
| PDF | The trailer's information dictionary |
| MOBI, AZW, AZW3 | The MOBI header in the first record, and the EXTH records after it |
An EPUB whose container is malformed falls back to the first `.opf` in the
archive, because a converter that wrote a bad container often still wrote a
@@ -94,6 +95,28 @@ both become `isbn`, a UUID becomes `uuid`, anything else `other`. PDF dates
are reduced to the day where they parse and kept verbatim where they do not,
because a date nobody can read is still evidence.
A Kindle book's title is EXTH 503 where it is there, then the MOBI header's
full name, then the Palm database name (cut to 31 bytes, with a note). Authors
(100) and subjects (105) are every record of their kind, in order; publisher
(101), description (103), date (106) and language (524) the first. EXTH 104 is
an `isbn` when it is shaped like one and `other` when not, and 113 is an
`asin`. A contributor (108) is not an author, the same as an EPUB's
`dc:contributor`, and is not reported. With no EXTH language, the language
comes from the header's locale. Strings are decoded as the header says,
UTF-8 or Windows-1252. A book whose text is encrypted still has its header
read, and carries the note `drm`.
Every offset in a Kindle book is checked before it is followed: headers that
are cut short or point outside the file give an empty record with a note, and
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.
## Asking a model
Some files say nothing useful. `--prompt` writes the question and **sends
@@ -147,7 +170,7 @@ if let Some(prompt) = bookmeta::prompt::compose(&meta, Some(opening)) {
}
```
`bookmeta::epub` and `bookmeta::pdf` read one format each, and
`bookmeta::epub`, `bookmeta::pdf` and `bookmeta::mobi` read one format each, and
`Metadata::missing` says which fields a record still wants.
## See also
+15 -3
View File
@@ -85,6 +85,7 @@ fn fill(meta: &mut Metadata, package: &str) {
meta.title = first(package, "title");
meta.authors = creators(package);
meta.publisher = first(package, "publisher");
meta.description = first(package, "description");
meta.date = first(package, "date");
meta.language = first(package, "language");
meta.subjects = all(package, "subject");
@@ -166,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())
@@ -179,7 +180,7 @@ fn identifiers(package: &str) -> Vec<(String, String)> {
}
/// Whether a string is ten or thirteen digits once its hyphens come off.
fn is_isbn(value: &str) -> bool {
pub(crate) fn is_isbn(value: &str) -> bool {
let digits = digits_of(value);
matches!(digits.len(), 10 | 13)
&& value
@@ -187,7 +188,13 @@ fn is_isbn(value: &str) -> bool {
.all(|c| c.is_ascii_digit() || "- xX".contains(c))
}
fn digits_of(value: &str) -> String {
/// 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
.chars()
.filter(|c| c.is_ascii_digit() || matches!(c, 'x' | 'X'))
@@ -253,6 +260,7 @@ mod tests {
<dc:creator opf:role="aut">Kallistos Ware</dc:creator>
<dc:contributor>A Typesetter</dc:contributor>
<dc:publisher>Faber &amp; Faber</dc:publisher>
<dc:description>&lt;p&gt;Writings of the Fathers.&lt;/p&gt;</dc:description>
<dc:date>1984-09-17</dc:date>
<dc:language>en</dc:language>
<dc:subject>Orthodox Christianity</dc:subject>
@@ -275,6 +283,10 @@ mod tests {
let meta = read_package();
assert_eq!(meta.title.as_deref(), Some("The Philokalia, Volume 3"));
assert_eq!(meta.publisher.as_deref(), Some("Faber & Faber"));
assert_eq!(
meta.description.as_deref(),
Some("<p>Writings of the Fathers.</p>")
);
assert_eq!(meta.date.as_deref(), Some("1984-09-17"));
assert_eq!(meta.language.as_deref(), Some("en"));
}
+28
View File
@@ -14,6 +14,7 @@
//! [`bibfetch`]: https://git.pumpin.online/funkpower/catena
pub mod epub;
pub mod mobi;
pub mod pdf;
pub mod prompt;
@@ -30,6 +31,8 @@ pub enum Source {
Opf,
/// A PDF's information dictionary.
PdfInfo,
/// A Kindle book's MOBI header and the EXTH records after it.
Mobi,
/// Nothing was found to read.
None,
}
@@ -40,6 +43,7 @@ impl Source {
match self {
Source::Opf => "opf",
Source::PdfInfo => "pdf_info",
Source::Mobi => "mobi",
Source::None => "none",
}
}
@@ -64,6 +68,9 @@ pub struct Metadata {
/// Who published it.
#[serde(skip_serializing_if = "Option::is_none")]
pub publisher: Option<String>,
/// The publisher's description of the book, as written: often HTML.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// The publication date, in whatever form the file wrote it.
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<String>,
@@ -86,6 +93,23 @@ pub struct Metadata {
/// What was worth saying about the reading.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
/// The cover image the file carries.
///
/// Not part of the record: a record is a line of text and this is an
/// image. A library caller takes it from here.
#[serde(skip)]
#[schemars(skip)]
pub cover: Option<Cover>,
}
/// A cover image, as the file stores it.
#[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`.
pub media_type: String,
/// The image itself.
pub bytes: Vec<u8>,
}
fn version() -> u32 {
@@ -143,6 +167,7 @@ impl Metadata {
self.title.is_none()
&& self.authors.is_empty()
&& self.publisher.is_none()
&& self.description.is_none()
&& self.date.is_none()
&& self.language.is_none()
&& self.identifiers.is_empty()
@@ -171,6 +196,9 @@ pub fn from_bytes(path: &str, bytes: &[u8]) -> Metadata {
if bytes.starts_with(b"%PDF") {
return pdf::read(path, bytes);
}
if mobi::is_mobi(bytes) {
return mobi::read(path, bytes);
}
let mut meta = Metadata::empty(path, Source::None);
meta.note("not a format this reads metadata out of");
meta
+873
View File
@@ -0,0 +1,873 @@
//! A Kindle book's MOBI header and the EXTH records after it.
//!
//! MOBI, AZW and AZW3 files are Palm databases. The first record opens with a
//! PalmDOC header and a MOBI header, which gives the book's full name and says
//! how its strings are encoded. When a flag in that header is set, an EXTH
//! block follows with everything else: authors, publisher, ISBN, subjects, and
//! which image record is the cover. AZW3 (KF8) keeps the same layout in its
//! first record, so one reader covers all three.
//!
//! Every offset in the file is checked before it is followed. A file whose
//! headers are cut short or written wrongly gives an empty record with a note;
//! 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, is_uuid};
use crate::{Cover, Metadata, Source};
/// Where a Palm database says what it holds.
const TYPE_AT: usize = 60;
/// What a Kindle book says there.
const BOOK_TYPE: &[u8] = b"BOOKMOBI";
/// The Palm database's own name: 32 bytes, NUL-padded.
const NAME_LEN: usize = 32;
/// Where the number of records is written.
const RECORD_COUNT_AT: usize = 76;
/// Where the record list starts: eight bytes per record, offset first.
const RECORD_LIST_AT: usize = 78;
/// Where the PalmDOC header says whether the text is encrypted.
const ENCRYPTION_AT: usize = 12;
/// Where the MOBI header begins inside the first record.
const MOBI_AT: usize = 16;
/// The MOBI header's own magic.
const MOBI_MAGIC: &[u8] = b"MOBI";
// Fields of the MOBI header, as offsets from its start. Each is only there
// when the header's declared length reaches past it.
const HEADER_LENGTH: usize = 4;
const TEXT_ENCODING: usize = 12;
const FULL_NAME_OFFSET: usize = 68;
const FULL_NAME_LENGTH: usize = 72;
const LOCALE: usize = 76;
const FIRST_IMAGE: usize = 92;
const EXTH_FLAGS: usize = 112;
/// The flag that says an EXTH block follows the MOBI header.
const HAS_EXTH: u32 = 0x40;
/// The EXTH block's magic.
const EXTH_MAGIC: &[u8] = b"EXTH";
/// The text encodings a MOBI header names.
const UTF_8: u32 = 65001;
const WINDOWS_1252: u32 = 1252;
/// What an index or offset says when it points at nothing.
const NONE: u32 = 0xffff_ffff;
// EXTH record types.
const AUTHOR: u32 = 100;
const PUBLISHER: u32 = 101;
const DESCRIPTION: u32 = 103;
const ISBN: u32 = 104;
const SUBJECT: u32 = 105;
const PUBLISHED: u32 = 106;
const ASIN: u32 = 113;
const COVER_OFFSET: u32 = 201;
const THUMB_OFFSET: u32 = 202;
const UPDATED_TITLE: u32 = 503;
const LANGUAGE: u32 = 524;
/// Whether these bytes are a Kindle book this module can read.
pub fn is_mobi(bytes: &[u8]) -> bool {
bytes.get(TYPE_AT..TYPE_AT + BOOK_TYPE.len()) == Some(BOOK_TYPE)
}
/// Read what a MOBI, AZW or AZW3 file says about itself.
pub fn read(path: &str, bytes: &[u8]) -> Metadata {
let mut meta = Metadata::empty(path, Source::Mobi);
if let Err(why) = fill(&mut meta, bytes) {
let mut empty = Metadata::empty(path, Source::None);
empty.note(why);
return empty;
}
if meta.is_empty() {
meta.note("a MOBI header with nothing filled in");
}
meta
}
/// The strings an EXTH block carries, before they are placed in a record.
#[derive(Default)]
struct Exth {
authors: Vec<String>,
publisher: Option<String>,
description: Option<String>,
isbn: Option<String>,
subjects: Vec<String>,
published: Option<String>,
asin: Option<String>,
cover: Option<u32>,
thumb: Option<u32>,
title: Option<String>,
language: Option<String>,
}
fn fill(meta: &mut Metadata, bytes: &[u8]) -> Result<(), String> {
if !is_mobi(bytes) {
return Err("not a Kindle book: the Palm database type is not BOOKMOBI".into());
}
let records = records(bytes)?;
let first = record(bytes, &records, 0).ok_or("the first record lies outside the file")?;
if first.get(MOBI_AT..MOBI_AT + MOBI_MAGIC.len()) != Some(MOBI_MAGIC) {
return Err("no MOBI header in the first record".into());
}
let header_length =
u32_at(first, MOBI_AT + HEADER_LENGTH).ok_or("the MOBI header is cut short")? as usize;
let header = Header {
record: first,
length: header_length,
};
let encoding = header.field(TEXT_ENCODING).unwrap_or(WINDOWS_1252);
if encoding != UTF_8 && encoding != WINDOWS_1252 {
meta.note(format!(
"text encoding {encoding}, which is neither UTF-8 nor Windows-1252; read as Windows-1252"
));
}
let decode = |raw: &[u8]| text(raw, encoding);
if u16_at(first, ENCRYPTION_AT).is_some_and(|kind| kind != 0) {
meta.note("drm");
}
let exth = if header
.field(EXTH_FLAGS)
.is_some_and(|flags| flags & HAS_EXTH != 0)
{
let at = MOBI_AT
.checked_add(header_length)
.ok_or("the MOBI header length is out of range")?;
exth(first, at, &decode, meta)
} else {
Exth::default()
};
let full_name = header
.field(FULL_NAME_OFFSET)
.zip(header.field(FULL_NAME_LENGTH))
.and_then(|(at, len)| slice(first, at as usize, len as usize))
.and_then(decode);
meta.title = match (exth.title, full_name) {
(Some(title), _) | (None, Some(title)) => Some(title),
(None, None) => {
let name = palm_name(bytes);
if name.is_some() {
meta.note("title from the Palm database name, which is cut to 31 bytes");
}
name
}
};
meta.authors = exth.authors;
meta.publisher = exth.publisher;
meta.description = exth.description;
meta.date = exth.published;
meta.subjects = exth.subjects;
meta.language = exth.language.or_else(|| {
header
.field(LOCALE)
.and_then(|locale| language_of(locale).map(str::to_string))
});
if let Some(isbn) = exth.isbn {
if is_isbn(&isbn) {
meta.identifiers.insert("isbn".into(), digits_of(&isbn));
} else {
meta.identifiers.insert("other".into(), isbn);
}
}
// 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, first_image, offset, meta);
} else {
meta.cover = first_image_record(bytes, &records, first_image);
}
Ok(())
}
/// The MOBI header, for reading fields that may or may not be in it.
struct Header<'a> {
record: &'a [u8],
length: usize,
}
impl Header<'_> {
/// A field at `at` bytes into the header, when the header reaches it.
fn field(&self, at: usize) -> Option<u32> {
if at.checked_add(4)? > self.length {
return None;
}
u32_at(self.record, MOBI_AT.checked_add(at)?)
}
}
/// Where each record starts, as the record list says.
fn records(bytes: &[u8]) -> Result<Vec<usize>, String> {
let count = u16_at(bytes, RECORD_COUNT_AT).ok_or("the Palm database header is cut short")?;
if count == 0 {
return Err("a Palm database with no records".into());
}
let mut out = Vec::with_capacity(count as usize);
for at in 0..count as usize {
let entry = RECORD_LIST_AT + at * 8;
let Some(offset) = u32_at(bytes, entry) else {
return Err("the record list runs past the end of the file".into());
};
out.push(offset as usize);
}
Ok(out)
}
/// One record's bytes: from its offset to the next record's, or to the end.
fn record<'a>(bytes: &'a [u8], records: &[usize], index: usize) -> Option<&'a [u8]> {
let start = *records.get(index)?;
let end = records.get(index + 1).copied().unwrap_or(bytes.len());
bytes.get(start..end)
}
/// Read the EXTH block at `at` inside the first record.
///
/// A block that ends early keeps what was read before the break, with a note:
/// the records already read are as good as they would have been.
fn exth(
first: &[u8],
at: usize,
decode: &dyn Fn(&[u8]) -> Option<String>,
meta: &mut Metadata,
) -> Exth {
let mut out = Exth::default();
if at >= first.len() || first.get(at..at + EXTH_MAGIC.len()) != Some(EXTH_MAGIC) {
meta.note("the header says an EXTH block follows, but none does");
return out;
}
let (Some(length), Some(count)) = (u32_at(first, at + 4), u32_at(first, at + 8)) else {
meta.note("the EXTH block ends early");
return out;
};
let end = at.saturating_add(length as usize).min(first.len());
let mut cursor = at + 12;
for _ in 0..count {
let (Some(kind), Some(size)) = (u32_at(first, cursor), u32_at(first, cursor + 4)) else {
meta.note("the EXTH block ends early");
break;
};
let size = size as usize;
let Some(data) = size
.checked_sub(8)
.and_then(|len| slice(first, cursor + 8, len))
.filter(|_| cursor.checked_add(size).is_some_and(|stop| stop <= end))
else {
meta.note("the EXTH block ends early");
break;
};
cursor += size;
match kind {
AUTHOR => out.authors.extend(decode(data)),
SUBJECT => out.subjects.extend(decode(data)),
PUBLISHER => set(&mut out.publisher, decode(data)),
DESCRIPTION => set(&mut out.description, decode(data)),
ISBN => set(&mut out.isbn, decode(data)),
PUBLISHED => set(&mut out.published, decode(data)),
ASIN => set(&mut out.asin, decode(data)),
UPDATED_TITLE => set(&mut out.title, decode(data)),
LANGUAGE => set(&mut out.language, decode(data)),
COVER_OFFSET => set(&mut out.cover, number(data)),
THUMB_OFFSET => set(&mut out.thumb, number(data)),
_ => {}
}
}
out
}
/// Keep the first value a field is given: a later duplicate is usually a
/// converter writing the same thing twice, and the first is what it meant.
fn set<T>(slot: &mut Option<T>, value: Option<T>) {
if slot.is_none() {
*slot = value;
}
}
/// A four-byte number record that points somewhere.
fn number(data: &[u8]) -> Option<u32> {
let value = u32::from_be_bytes(data.try_into().ok()?);
(value != NONE).then_some(value)
}
/// The cover image, from the record the header and the EXTH offset name.
fn cover(
bytes: &[u8],
records: &[usize],
first_image: Option<u32>,
offset: u32,
meta: &mut Metadata,
) -> Option<Cover> {
let Some(first_image) = first_image.filter(|&index| index != NONE) else {
meta.note("a cover is named, but the header has no image records");
return None;
};
let Some(data) = first_image
.checked_add(offset)
.and_then(|index| record(bytes, records, index as usize))
else {
meta.note("the cover points past the last record");
return None;
};
let Some(media_type) = image_type(data) else {
meta.note("the cover record is not an image");
return None;
};
Some(Cover {
media_type: media_type.to_string(),
bytes: data.to_vec(),
})
}
/// The cover when the file names none: calibre takes the record at the first
/// image index, and only that one.
///
/// When that record is not a JPEG, PNG or GIF there is no cover. The search
/// does not walk on to the next image, because a later record may be an inline
/// illustration, and kindlegen puts a font or a RESC record first.
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)?;
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]) {
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
/// reported as written.
fn palm_name(bytes: &[u8]) -> Option<String> {
let raw = bytes.get(..NAME_LEN)?;
let raw = raw.split(|&byte| byte == 0).next().unwrap_or(raw);
text(raw, WINDOWS_1252)
}
/// A string in the book's declared encoding, trimmed; empty is none.
fn text(raw: &[u8], encoding: u32) -> Option<String> {
let decoded: String = match (encoding, std::str::from_utf8(raw)) {
(UTF_8, Ok(text)) => text.to_string(),
(UTF_8, Err(_)) => String::from_utf8_lossy(raw).into_owned(),
_ => raw.iter().map(|&byte| windows_1252(byte)).collect(),
};
let trimmed = decoded.trim_matches(|c: char| c == '\0' || c.is_whitespace());
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
/// One Windows-1252 byte as a character.
fn windows_1252(byte: u8) -> char {
match byte {
0x80..=0x9f => HIGH_CONTROLS[(byte - 0x80) as usize],
_ => byte as char,
}
}
/// The characters Windows-1252 puts where Latin-1 has C1 controls. The five
/// positions it leaves undefined decode to themselves, as browsers do.
const HIGH_CONTROLS: [char; 32] = [
'\u{20ac}', '\u{81}', '\u{201a}', '\u{192}', '\u{201e}', '\u{2026}', '\u{2020}', '\u{2021}',
'\u{2c6}', '\u{2030}', '\u{160}', '\u{2039}', '\u{152}', '\u{8d}', '\u{17d}', '\u{8f}',
'\u{90}', '\u{2018}', '\u{2019}', '\u{201c}', '\u{201d}', '\u{2022}', '\u{2013}', '\u{2014}',
'\u{2dc}', '\u{2122}', '\u{161}', '\u{203a}', '\u{153}', '\u{9d}', '\u{17e}', '\u{178}',
];
/// The language a MOBI locale names, for books with no EXTH language.
///
/// The low byte is a Windows primary language id; the region above it is
/// dropped, since the file's own language field never carries one either.
fn language_of(locale: u32) -> Option<&'static str> {
Some(match locale & 0xff {
0x01 => "ar",
0x02 => "bg",
0x03 => "ca",
0x04 => "zh",
0x05 => "cs",
0x06 => "da",
0x07 => "de",
0x08 => "el",
0x09 => "en",
0x0a => "es",
0x0b => "fi",
0x0c => "fr",
0x0d => "he",
0x0e => "hu",
0x0f => "is",
0x10 => "it",
0x11 => "ja",
0x12 => "ko",
0x13 => "nl",
0x14 => "no",
0x15 => "pl",
0x16 => "pt",
0x18 => "ro",
0x19 => "ru",
0x1a => "hr",
0x1b => "sk",
0x1d => "sv",
0x1f => "tr",
0x22 => "uk",
0x29 => "fa",
0x2a => "vi",
0x36 => "af",
0x39 => "hi",
_ => return None,
})
}
fn slice(bytes: &[u8], at: usize, len: usize) -> Option<&[u8]> {
bytes.get(at..at.checked_add(len)?)
}
fn u16_at(bytes: &[u8], at: usize) -> Option<u16> {
Some(u16::from_be_bytes(slice(bytes, at, 2)?.try_into().ok()?))
}
fn u32_at(bytes: &[u8], at: usize) -> Option<u32> {
Some(u32::from_be_bytes(slice(bytes, at, 4)?.try_into().ok()?))
}
#[cfg(test)]
mod tests {
use super::*;
const JPEG: &[u8] = &[0xff, 0xd8, 0xff, 0xe0, 0, 16, b'J', b'F', b'I', b'F'];
/// A Kindle book built field by field, so each test can break one thing.
struct Book {
name: &'static [u8],
encoding: u32,
encryption: u16,
locale: u32,
full_name: Vec<u8>,
exth: Option<Vec<(u32, Vec<u8>)>>,
first_image: u32,
images: Vec<Vec<u8>>,
}
impl Default for Book {
fn default() -> Self {
Book {
name: b"A_Test_Book",
encoding: UTF_8,
encryption: 0,
locale: 0x09,
full_name: b"A Test Book".to_vec(),
exth: Some(Vec::new()),
first_image: 1,
images: Vec::new(),
}
}
}
impl Book {
fn bytes(&self) -> Vec<u8> {
const HEADER: usize = 232;
let mut first = vec![0u8; MOBI_AT + HEADER];
first[ENCRYPTION_AT..ENCRYPTION_AT + 2].copy_from_slice(&self.encryption.to_be_bytes());
first[MOBI_AT..MOBI_AT + 4].copy_from_slice(MOBI_MAGIC);
let put = |first: &mut Vec<u8>, at: usize, value: u32| {
first[MOBI_AT + at..MOBI_AT + at + 4].copy_from_slice(&value.to_be_bytes());
};
put(&mut first, HEADER_LENGTH, HEADER as u32);
put(&mut first, TEXT_ENCODING, self.encoding);
put(&mut first, LOCALE, self.locale);
put(&mut first, FIRST_IMAGE, self.first_image);
if let Some(records) = &self.exth {
put(&mut first, EXTH_FLAGS, HAS_EXTH);
let mut body = Vec::new();
for (kind, data) in records {
body.extend_from_slice(&kind.to_be_bytes());
body.extend_from_slice(&(data.len() as u32 + 8).to_be_bytes());
body.extend_from_slice(data);
}
first.extend_from_slice(EXTH_MAGIC);
first.extend_from_slice(&(body.len() as u32 + 12).to_be_bytes());
first.extend_from_slice(&(records.len() as u32).to_be_bytes());
first.extend_from_slice(&body);
}
let name_at = first.len();
first.extend_from_slice(&self.full_name);
put(&mut first, FULL_NAME_OFFSET, name_at as u32);
put(&mut first, FULL_NAME_LENGTH, self.full_name.len() as u32);
let mut records = vec![first];
records.extend(self.images.iter().cloned());
let mut out = vec![0u8; RECORD_LIST_AT + records.len() * 8];
out[..self.name.len()].copy_from_slice(self.name);
out[TYPE_AT..TYPE_AT + 8].copy_from_slice(BOOK_TYPE);
out[RECORD_COUNT_AT..RECORD_COUNT_AT + 2]
.copy_from_slice(&(records.len() as u16).to_be_bytes());
for (at, record) in records.iter().enumerate() {
let offset = out.len() as u32;
let entry = RECORD_LIST_AT + at * 8;
out[entry..entry + 4].copy_from_slice(&offset.to_be_bytes());
out.extend_from_slice(record);
}
out
}
}
fn exth(records: &[(u32, &[u8])]) -> Option<Vec<(u32, Vec<u8>)>> {
Some(records.iter().map(|(k, v)| (*k, v.to_vec())).collect())
}
#[test]
fn the_exth_records_fill_the_record() {
let book = Book {
exth: exth(&[
(UPDATED_TITLE, b"The Real Title"),
(AUTHOR, b"First Author"),
(AUTHOR, b"Second Author"),
(PUBLISHER, b"A Press"),
(DESCRIPTION, b"What it is about."),
(ISBN, b"978-0-306-40615-7"),
(SUBJECT, b"History"),
(SUBJECT, b"Ships"),
(PUBLISHED, b"1999-01-02"),
(ASIN, b"B000TEST00"),
(LANGUAGE, b"de"),
]),
..Book::default()
};
let meta = read("x.mobi", &book.bytes());
assert_eq!(meta.source, "mobi");
assert_eq!(meta.title.as_deref(), Some("The Real Title"));
assert_eq!(meta.authors, ["First Author", "Second Author"]);
assert_eq!(meta.publisher.as_deref(), Some("A Press"));
assert_eq!(meta.description.as_deref(), Some("What it is about."));
assert_eq!(meta.date.as_deref(), Some("1999-01-02"));
assert_eq!(meta.subjects, ["History", "Ships"]);
assert_eq!(meta.language.as_deref(), Some("de"));
assert_eq!(meta.identifiers["isbn"], "9780306406157");
assert_eq!(meta.identifiers["asin"], "B000TEST00");
assert!(meta.notes.is_empty(), "{:?}", meta.notes);
}
#[test]
fn the_title_falls_back_to_the_full_name_and_then_the_database_name() {
let meta = read("x.mobi", &Book::default().bytes());
assert_eq!(meta.title.as_deref(), Some("A Test Book"));
let book = Book {
full_name: Vec::new(),
..Book::default()
};
let meta = read("x.mobi", &book.bytes());
assert_eq!(meta.title.as_deref(), Some("A_Test_Book"));
assert!(meta.notes[0].contains("Palm database name"));
}
#[test]
fn the_language_falls_back_to_the_locale() {
let book = Book {
locale: 0x040c,
..Book::default()
};
assert_eq!(
read("x.mobi", &book.bytes()).language.as_deref(),
Some("fr")
);
let book = Book {
locale: 0,
..Book::default()
};
assert_eq!(read("x.mobi", &book.bytes()).language, None);
}
#[test]
fn windows_1252_strings_decode_as_windows_1252() {
let book = Book {
encoding: WINDOWS_1252,
exth: exth(&[
(AUTHOR, b"Ren\xe9 Ch\x96ar"),
(PUBLISHER, b"\x93Quoted\x94"),
]),
..Book::default()
};
let meta = read("x.mobi", &book.bytes());
assert_eq!(meta.authors, ["René Ch–ar"]);
assert_eq!(meta.publisher.as_deref(), Some("“Quoted”"));
}
#[test]
fn the_cover_is_the_image_record_the_offset_names() {
let book = Book {
exth: exth(&[(COVER_OFFSET, &1u32.to_be_bytes())]),
images: vec![b"GIF89a-thumb".to_vec(), JPEG.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);
}
#[test]
fn the_thumbnail_stands_in_when_no_cover_is_named() {
let book = Book {
exth: exth(&[(THUMB_OFFSET, &0u32.to_be_bytes())]),
images: vec![b"\x89PNG\r\n\x1a\nrest".to_vec()],
..Book::default()
};
let cover = read("x.mobi", &book.bytes()).cover.expect("a cover");
assert_eq!(cover.media_type, "image/png");
}
#[test]
fn with_no_cover_named_only_the_record_at_first_image_can_be_the_cover() {
let book = Book {
images: 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);
// The record at first_image is not an image: no cover, even though a
// later record is one.
let book = Book {
images: vec![
b"not an image".to_vec(),
JPEG.to_vec(),
b"GIF89a-later".to_vec(),
],
..Book::default()
};
assert_eq!(read("x.mobi", &book.bytes()).cover, None);
let book = Book {
images: vec![b"not an image".to_vec()],
..Book::default()
};
assert_eq!(read("x.mobi", &book.bytes()).cover, None);
}
#[test]
fn a_font_at_first_image_is_not_skipped_past_to_an_inline_image() {
// No EXTH 201 or 202. The first resource record is a font, as in
// kindlegen output; calibre reads only that record and finds no cover.
let book = Book {
images: vec![b"FONT\0\0\0\x18".to_vec(), JPEG.to_vec()],
..Book::default()
};
assert_eq!(read("x.mobi", &book.bytes()).cover, None);
}
#[test]
fn a_stale_kf8_boundary_below_first_image_does_not_hide_the_cover() {
let book = Book {
exth: exth(&[(121, &1u32.to_be_bytes())]),
first_image: 2,
images: vec![b"text".to_vec(), JPEG.to_vec()],
..Book::default()
};
assert!(read("x.mobi", &book.bytes()).cover.is_some());
}
#[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 {
exth: exth(&[(COVER_OFFSET, &7u32.to_be_bytes())]),
images: vec![JPEG.to_vec()],
..Book::default()
};
let meta = read("x.mobi", &book.bytes());
assert_eq!(meta.cover, None);
assert_eq!(meta.notes, ["the cover points past the last record"]);
let book = Book {
exth: exth(&[(COVER_OFFSET, &u32::MAX.to_be_bytes())]),
first_image: u32::MAX - 1,
images: vec![JPEG.to_vec()],
..Book::default()
};
assert_eq!(read("x.mobi", &book.bytes()).cover, None);
let book = Book {
exth: exth(&[(COVER_OFFSET, &0u32.to_be_bytes())]),
images: vec![b"not an image".to_vec()],
..Book::default()
};
let meta = read("x.mobi", &book.bytes());
assert_eq!(meta.cover, None);
assert_eq!(meta.notes, ["the cover record is not an image"]);
}
#[test]
fn an_encrypted_book_still_has_its_header_read() {
let book = Book {
encryption: 2,
exth: exth(&[(AUTHOR, b"Somebody")]),
..Book::default()
};
let meta = read("x.azw", &book.bytes());
assert_eq!(meta.authors, ["Somebody"]);
assert_eq!(meta.notes, ["drm"]);
}
#[test]
fn an_exth_record_that_overruns_its_block_stops_the_reading() {
let mut bytes = Book {
exth: exth(&[(AUTHOR, b"Kept"), (PUBLISHER, b"Lost")]),
..Book::default()
}
.bytes();
// The second record's length, made to reach past the block.
let at = find(&bytes, b"Lost") - 4;
bytes[at..at + 4].copy_from_slice(&0x7fff_ffffu32.to_be_bytes());
let meta = read("x.mobi", &bytes);
assert_eq!(meta.authors, ["Kept"]);
assert_eq!(meta.publisher, None);
assert!(
meta.notes
.contains(&"the EXTH block ends early".to_string())
);
}
#[test]
fn a_record_length_shorter_than_its_own_header_stops_the_reading() {
let mut bytes = Book {
exth: exth(&[(AUTHOR, b"Kept")]),
..Book::default()
}
.bytes();
let at = find(&bytes, b"Kept") - 4;
bytes[at..at + 4].copy_from_slice(&3u32.to_be_bytes());
let meta = read("x.mobi", &bytes);
assert!(meta.authors.is_empty());
assert!(
meta.notes
.contains(&"the EXTH block ends early".to_string())
);
}
#[test]
fn what_is_not_a_kindle_book_says_so() {
let meta = read("x.mobi", b"not a palm database at all");
assert_eq!(meta.source, "none");
assert!(meta.notes[0].starts_with("not a Kindle book"));
}
#[test]
fn a_first_record_with_no_mobi_header_is_refused() {
let mut bytes = Book::default().bytes();
let at = mobi_header(&bytes);
bytes[at] = b'X';
let meta = read("x.mobi", &bytes);
assert_eq!(meta.source, "none");
assert_eq!(meta.notes, ["no MOBI header in the first record"]);
}
#[test]
fn a_header_too_short_for_a_field_does_not_have_that_field() {
let mut bytes = Book::default().bytes();
let at = mobi_header(&bytes) + HEADER_LENGTH;
// Long enough for the encoding, too short for the full name.
bytes[at..at + 4].copy_from_slice(&24u32.to_be_bytes());
let meta = read("x.mobi", &bytes);
assert_eq!(meta.title.as_deref(), Some("A_Test_Book"));
assert_eq!(meta.language, None);
}
#[test]
fn every_truncation_returns_without_panicking() {
let bytes = Book {
exth: exth(&[
(UPDATED_TITLE, b"Title"),
(AUTHOR, b"Author"),
(COVER_OFFSET, &0u32.to_be_bytes()),
]),
images: vec![JPEG.to_vec()],
..Book::default()
}
.bytes();
for len in 0..=bytes.len() {
let _ = read("x.mobi", &bytes[..len]);
}
}
/// Where the MOBI header starts: after the record list, so the `MOBI`
/// at the end of the database type is not it.
fn mobi_header(bytes: &[u8]) -> usize {
RECORD_LIST_AT + find(&bytes[RECORD_LIST_AT..], MOBI_MAGIC)
}
fn find(haystack: &[u8], needle: &[u8]) -> usize {
haystack
.windows(needle.len())
.position(|window| window == needle)
.expect("present")
}
}
+28
View File
@@ -0,0 +1,28 @@
# Fixtures
## MOBI and AZW3
`lantern.html` is a two-paragraph story written for these tests, and
`lantern-cover.jpg` a 120×180 cover drawn for them with Pillow. Both are
dedicated to the public domain (CC0 1.0), and so is everything made from them.
`lantern.mobi` (MOBI 6) and `lantern.azw3` (KF8) were made from them with
calibre 9.14:
```sh
common=(--title "A Lantern in the Orchard" --authors "Ada Quill & Tomás Reed"
--publisher "Orchard Press" --isbn 9780306406157 --tags "Fiction,Villages"
--language en --pubdate 2001-04-05 --comments "A short story written for testing."
--cover lantern-cover.jpg)
ebook-convert lantern.html lantern.mobi --mobi-file-type old "${common[@]}"
ebook-convert lantern.html lantern.azw3 "${common[@]}"
```
The other three are `lantern.mobi` with one thing broken. `r0` is the first
record's offset, the big-endian 32-bit number at byte 78 (136 here).
| File | Change |
|---|---|
| `lantern-no-exth.mobi` | The EXTH flag (`0x40`) cleared in the MOBI header's flags, at `r0 + 16 + 112` |
| `lantern-drm.mobi` | The PalmDOC encryption type, at `r0 + 12`, set to 2 |
| `lantern-truncated.mobi` | The first 1024 bytes only |
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
<html><head><meta charset="utf-8"><title>x</title></head><body>
<h1>A Lantern in the Orchard</h1>
<p>The lantern hung from the lowest branch of the pear tree, and every evening someone lit it. Nobody in the village could say who.</p>
<p>Café owners swore it was the miller; the miller swore it was the wind.</p>
</body></html>
Binary file not shown.
+144
View File
@@ -0,0 +1,144 @@
//! MOBI and AZW3 files as a converter writes them, and as they break.
use std::path::{Path, PathBuf};
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
fn read(name: &str) -> bookmeta::Metadata {
bookmeta::read(&fixture(name))
}
/// What the two converted fixtures were given, and so must say.
fn says_what_it_was_given(meta: &bookmeta::Metadata) {
assert_eq!(meta.source, "mobi");
assert_eq!(meta.title.as_deref(), Some("A Lantern in the Orchard"));
assert_eq!(meta.authors, ["Ada Quill", "Tomás Reed"]);
assert_eq!(meta.publisher.as_deref(), Some("Orchard Press"));
assert_eq!(
meta.description.as_deref(),
Some("A short story written for testing.")
);
assert_eq!(meta.date.as_deref(), Some("2001-04-05T04:00:00+00:00"));
assert_eq!(meta.language.as_deref(), Some("en"));
assert_eq!(meta.subjects, ["Fiction", "Villages"]);
assert_eq!(meta.identifiers["isbn"], "9780306406157");
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");
let original = std::fs::read(fixture("lantern-cover.jpg")).expect("the cover fixture");
assert_eq!(cover.bytes, original);
}
#[test]
fn a_mobi_6_file_says_what_it_was_given() {
let meta = read("lantern.mobi");
says_what_it_was_given(&meta);
assert!(meta.notes.is_empty(), "{:?}", meta.notes);
}
#[test]
fn an_azw3_file_says_what_it_was_given() {
let meta = read("lantern.azw3");
says_what_it_was_given(&meta);
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");
assert_eq!(meta.source, "mobi");
assert_eq!(meta.title.as_deref(), Some("A Lantern in the Orchard"));
assert_eq!(meta.language.as_deref(), Some("en"));
assert!(meta.authors.is_empty());
assert!(meta.identifiers.is_empty());
// 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]
fn a_truncated_file_is_an_empty_record_with_a_reason() {
let meta = read("lantern-truncated.mobi");
assert_eq!(meta.source, "none");
assert!(meta.is_empty());
assert_eq!(meta.notes, ["the first record lies outside the file"]);
}
#[test]
fn a_drm_flagged_file_has_its_metadata_and_says_drm() {
let meta = read("lantern-drm.mobi");
says_what_it_was_given(&meta);
assert_eq!(meta.notes, ["drm"]);
}
#[test]
fn every_truncation_of_the_mobi_fixture_returns() {
let bytes = std::fs::read(fixture("lantern.mobi")).expect("the fixture");
let first_record_end = u32::from_be_bytes(bytes[86..90].try_into().unwrap()) as usize;
for len in 0..=bytes.len() {
let meta = bookmeta::from_bytes("cut.mobi", &bytes[..len]);
if len < first_record_end {
assert!(meta.is_empty(), "cut at {len}: {meta:?}");
}
}
}
#[test]
fn every_single_byte_corruption_of_the_header_returns() {
let bytes = std::fs::read(fixture("lantern.mobi")).expect("the fixture");
// The Palm header, the record list, and the whole of the first record:
// every offset the reader follows is in there.
let first_record_end = u32::from_be_bytes(bytes[86..90].try_into().unwrap()) as usize;
for at in 0..first_record_end {
for value in [0x00, 0x7f, 0xff] {
let mut broken = bytes.clone();
broken[at] = value;
let _ = bookmeta::from_bytes("broken.mobi", &broken);
}
}
}
+101 -1
View File
@@ -35,8 +35,33 @@ static LIFESPAN: LazyLock<Regex> = LazyLock::new(|| {
static BARE_DATE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^\d{1,4}\s*(bc|ad|bce|ce)?\.?$").expect("a valid pattern"));
/// `Copyright © 2013 by`, `\u{a9} 2013 Richard Swinburne`, `Copyright 1971 by`.
/// `1. General Remarks`, `2 James W. McAllister` — a numbered heading.
static NUMBERED_HEADING: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s*\d+\s*[.)]\s+\S|^\s*\d+\s{2,}\S").expect("a valid pattern"));
/// `5222 What Is This`, `827 North Fourth Street` — a number that is not a
/// year, leading a line.
static LEADING_NUMBER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s*(\d{1,5})\s+\S").expect("a valid pattern"));
/// `Page xxxvi`, `Chapter I`, `Fig. 3` — a marker, not a name.
static MARKER: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)^\s*(page|pages|p\.|pp\.|fig\.|figure|plate|chapter|ch\.|section|part)\s+[\divxlcm]",
)
.expect("a valid pattern")
});
/// `Sidebottom, Harry. Ancient warfare` — a bibliography entry, which front
/// matter prints in its cataloguing block and in its further reading.
static CATALOGUE_ENTRY: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[A-Z][A-Za-z'\-]+,\s+[A-Z][A-Za-z'.\-]*\.\s+\S").expect("a valid pattern")
});
/// `To Mackenzie,` — a dedication, set alone on its own page.
static DEDICATION: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^\s*(to|for|in memory of)\s+\S.*,\s*$").expect("a valid pattern")
});
static COPYRIGHT_NOTICE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)(\u{a9}|\(c\)|copyright)\s*(\u{a9}|\(c\))?\s*(1[0-9]|20)\d{2}")
// The year need not follow the symbol directly: "(c) Alfredo Ferrarin
// 2004" puts the holder between them, and was read as a title.
Regex::new(r"(?i)(\u{a9}|\(c\)|copyright)[^\n]{0,60}?(1[0-9]|20)\d{2}")
.expect("a valid pattern")
});
/// `Edited by`, with whoever it was set on the line beneath.
@@ -77,10 +102,13 @@ copenhagen|stockholm|moscow|beijing|tokyo|delhi|nairobi)\b[\s,·•|and]*",
static STRUCTURAL: &[&str] = &[
"abbreviations",
"about the author",
"abstract",
"acknowledgements",
"acknowledgments",
"afterword",
"appendix",
"author",
"authors",
"bibliography",
"contents",
"dedication",
@@ -93,6 +121,7 @@ static STRUCTURAL: &[&str] = &[
"illustrations",
"index",
"introduction",
"keywords",
"list of contributors",
"list of illustrations",
"maps",
@@ -100,7 +129,9 @@ static STRUCTURAL: &[&str] = &[
"postscript",
"preface",
"prologue",
"subtitle",
"table of contents",
"title",
"title page",
"translator's note",
];
@@ -180,6 +211,22 @@ fn is_furniture(line: &str) -> bool {
}
// Sentences a copyright page sets, which are not notices and so not caught
// by the notice pattern.
if NUMBERED_HEADING.is_match(t) || MARKER.is_match(t) || CATALOGUE_ENTRY.is_match(t) {
return true;
}
// A line opening with a number that is not a year. "5222 What Is This" and
// "827 North Fourth Street" are a stray folio and an address; "1984
// Revisited" is a book, and the difference is whether the number could be
// the year of one.
if let Some(c) = LEADING_NUMBER.captures(t) {
let n: u32 = c[1].parse().unwrap_or(0);
if !(1000..=2099).contains(&n) {
return true;
}
}
if DEDICATION.is_match(t) {
return true;
}
let lower = t.to_lowercase();
for phrase in [
"moral rights of the author",
@@ -413,6 +460,8 @@ mod furniture_tests {
"12 INDEX",
"Translated from the French by Willard R. Trask",
"Copyright © 2013 by Barry Davies, BEM",
"© Alfredo Ferrarin 2004",
"\u{a9} Cambridge University Press 2011",
"[PART ONE:",
"Piper Verlag GmbH, Munich<span style=\"font-size\">",
"Vol. 11. HENRY JAMES, JR.",
@@ -492,3 +541,54 @@ mod conversion_tests {
}
}
}
#[cfg(test)]
mod review_pile_tests {
use super::*;
/// Everything here was proposed as a title by a real run over this
/// library, and sat in the review queue waiting for a person to say no.
#[test]
fn what_the_review_pile_was_full_of() {
for line in [
"1. General Remarks",
"2 James W. McAllister",
"5222 What Is This",
"827 North Fourth Street",
"Page xxxvi",
"Page xxxviii",
"Chapter I",
"title",
"Sidebottom, Harry. Ancient warfare",
"To Mackenzie,",
] {
assert!(
is_structural(line) || is_furniture(line),
"still read as a title: {line:?}"
);
}
}
/// And none of it catches a book. The numbers are the delicate part: a
/// year can open a title, and a folio cannot.
#[test]
fn a_book_is_still_a_book() {
for line in [
"1984 Revisited",
"1914: The Year the World Ended",
"Chapter and Verse",
"Part of the Furniture",
"To Kill a Mockingbird",
"For Whom the Bell Tolls",
"The Title of This Book",
"Pages from a Cold Island",
"Sections of a Broken Whole",
"In Memory of Bread",
] {
assert!(
!(is_structural(line) || is_furniture(line)),
"rejected a real title: {line:?}"
);
}
}
}
+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;