Author SHA1 Message Date
funkpower 65fd4adaad Merge remote-tracking branch 'origin/main' into fixes-040 2026-11-13 16:04:00 -05:00
funkpower 58573399e7 Merge pull request 'bibfetch: the books a register has under a title' (#9) from bibfetch-search into main 2026-11-13 15:59:31 -05:00
funkpower d06e13e2f3 Merge pull request 'bookmeta: read an EPUB's cover, by the four routes a package can name one' (#8) from bookmeta-epub-cover into main 2026-11-13 15:59:25 -05:00
funkpower 8ecc4766ee Merge pull request 'booksniff: RTF, Microsoft Reader, FictionBook and comic archives' (#7) from booksniff-more-formats into main 2026-11-13 15:59:20 -05:00
funkpower 3bd3ea11ae 0.5.0 — covers a caller can trust, more formats known, and books found by title
bookmeta gives an EPUB's cover by the routes its package can name one, and
marks a cover the file left open (a MOBI's first image, an EPUB image
found by its name) as guessed. booksniff knows RTF, Microsoft Reader,
FictionBook and comic book zips. bibfetch searches the registers by title
and author for a book with no identifier; covers by ISBN keep under Open
Library's rate limit, and a refusal for it is Error::Limited, not an
outage. The Python package gains fetch_cover, search_title and
catena.Limited.

Breaking: corpus_core::Kind has five new kinds; bibfetch::Error has
Limited; bookmeta::Cover has guessed; bibfetch::Covers has pace.
2026-11-13 15:58:49 -05:00
funkpower b7feefb704 Merge remote-tracking branch 'origin/main' into fixes-040 2026-11-13 15:58:00 -05:00
funkpower f43b9d4900 bibfetch: a title Open Library will not find as written is asked again
Open Library's title search, given an author as well, finds nothing for
The Ladder of Divine Ascent and four editions of Ladder of Divine Ascent.
When the title as given finds nothing, the title without a leading The, A
or An is asked, then the title and author as one free-text query, each
only when the one before found nothing. Google Books, whose anonymous
quota runs out, is asked only after all three.
2026-11-13 15:56:49 -05:00
funkpower 6844f10e0e Merge remote-tracking branch 'origin/fixes-040-sniff' into fixes-040 2026-11-13 15:51:01 -05:00
funkpower 4aae5b6ac0 Merge remote-tracking branch 'origin/fixes-040-search' into fixes-040 2026-11-13 15:51:01 -05:00
funkpower 3fd48d4999 bibfetch: a search candidate is one edition, and a refused fallback says so
A candidate whose edition gave an ISBN but no date or publisher took the
work's first_publish_year and first publisher, so one record could carry
one edition's ISBN and cover beside another's year and imprint. The id,
year, publisher, language and cover now come from the named edition only,
and are left out when it lacks them; the work gives only the title,
authors and subjects. A work with no edition named is named by its key,
not by an ISBN out of its editions' mixed list. The index is no longer
asked for the work-level fields, and gives no edition a length, so Open
Library candidates carry no pages.

When Open Library had nothing and Google Books failed, search answered
with no candidates and the CLI said no register had the title. The
failure is now the answer: Error::Limited for Google's 403, or 429 on
every attempt, which exits 75, and Error::Unreachable otherwise, exit 69.
2026-11-13 15:50:46 -05:00
funkpower 0e83c6ffc1 bookmeta: an EPUB cover by the word, not the letters
The last route took any image whose id or address contained "cover", so
images/discover-ch1.png listed before images/cover.jpg won, and
recovery-map.png alone became the cover. A name now says cover only with
it as a word of its own: words break at anything not a letter or digit
and where a lower case letter meets a capital, so CoverImage and
Book_COVER still do. An image named cover outright comes first, then
manifest order. It is still a guess.

The guide's cover page took its first image, which can be a logo. Where
the page has several, the one whose file name, alt, id or class says
cover is taken; where none does, the first is, with a note saying how
many there were.

Each member was read no further than MAX_COVER, but nothing bounded how
many: two hundred cover-named images of 16 MiB took seven seconds to
inflate and left two hundred notes. One search now reads at most eight
members and stops with one note.

A percent-escape takes two hex digits and nothing else. from_str_radix
also takes a sign, so a%+fb decoded to a, 0x0f, b.
2026-11-13 15:49:18 -05:00
funkpower 48478fe84e booksniff: a zip of pictures is not a comic until something says so
A zip whose members are all images was called cbz from their names alone,
so a folder of holiday photographs came back as a comic. It is now cbz
only when it carries a ComicInfo.xml or is itself named .cbz or .cbr;
otherwise it is unknown, with a note that it is a zip of N images and
would be a comic book if its name said .cbz. Only the archive's directory
is read, so members are still judged by name, and the README says so,
for a wrapped FictionBook too.

{\rtf has to be the whole control word: the byte after it must be the
version digit, a space or the next control word, so {\rtfoo is not RTF.

A FictionBook saved as UTF-16 was unknown. A byte-order mark in either
order now has the front decoded, at most 2048 code units, before the same
walk to the root element.
2026-11-13 15:49:17 -05:00
funkpower de23a7abad bookmeta, bibfetch, bindings: what a caller needs to trust a cover
bookmeta: Cover::guessed says the file left the choice open — a Kindle
book's first image record, or an EPUB image picked because its name says
cover — so a caller with a register to ask can prefer that. A cover the
file names is never a guess. Cover::extension matches bibfetch's.

bibfetch: Open Library answers 403 to more than a hundred covers by ISBN
in five minutes from one address. That is now Error::Limited, not
Unreachable: the other form of the ISBN is not asked, the cover id still
is, since those are not limited, and the CLI exits 75. Covers asked by
ISBN are kept three seconds apart across the process (Covers::pace), which
stays under the limit however long a run goes. A deadline too long to add
to the clock no longer panics; it is no deadline.

bindings: fetch_cover and search_title for Python, and catena.Limited,
which fetch_identifier raises too when a register refuses for being asked
too often.

corpus-core: exit 75, a service refused for now.
2026-11-13 15:44:50 -05:00
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 7aa1783a3e Merge remote-tracking branch 'origin/bibfetch-search' into fixes-040 2026-11-13 15:32:45 -05:00
funkpower 4d21cc5927 Merge remote-tracking branch 'origin/bookmeta-epub-cover' into fixes-040 2026-11-13 15:32:45 -05:00
funkpower 6510973850 Merge remote-tracking branch 'origin/booksniff-more-formats' into fixes-040 2026-11-13 15:32:45 -05:00
funkpower 03c19c7293 Merge remote-tracking branch 'origin/main' into bibfetch-search 2026-11-13 15:29:05 -05:00
funkpower ce8332d6d8 bibfetch: the books a register has under a title
A book that arrives as a file often has no identifier to ask about, only
a title and perhaps a name. `bibfetch search --title T [--author A]
[--limit N]` asks Open Library's index which books it has under those,
and Google Books only when Open Library has nothing, and prints one
record per candidate in the order the register gave them. From Rust,
`bibfetch::search(&agent, title, author, limit, attempts)`.

Each candidate is one edition. The index answers with works and, asked
for `editions.*`, names the edition of each that best matches; the ISBN,
publisher, language, year and cover come from that edition, the title,
authors, subjects and length from the work. The work's own ISBN list
mixes editions: the first War and Peace work lists four, while the
edition matched is a 1917 printing with none.

`id` is the first ISBN whose check digit agrees, in thirteen digits,
thirteen-digit entries taken first. A candidate with none is named by
the register's key: `olid` (OL…M for the edition, OL…W for the work when
no edition was named) or `googlebooks` (a volume id). Google's `OTHER`
shelfmarks are never read as ISBNs.

`cover_id` keeps the meaning it has in a lookup record: the edition's
own first cover, taken from `editions.cover_i` and only when the record
names that edition. The work's `cover_i` and `cover_edition_key` are not
asked for. A work with no edition named, and every Google Books
candidate, has none.

Open Library having nothing stands when Google Books is down; both down
is exit 69. Exit 0 a candidate, 1 none, 65 an empty title.

The retrying GET that lookups use is now `ask::get`, shared by both, and
no longer sleeps after its last attempt.
2026-11-13 15:28:08 -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 5c4f770d76 booksniff: RTF, Microsoft Reader, FictionBook and comic archives
Four formats a library of ebooks holds and this had no name for, so each
came back as text, as HTML or as unknown.

RTF is its opening group, after any byte-order mark and blank space.
Microsoft Reader is its signature at the front. A FictionBook is XML
whose root element is FictionBook, found by stepping over the
declaration, comments and doctype in the first four kilobytes. That walk
is over bytes rather than text, because a great many FictionBooks are
windows-1251 and would otherwise be called binary; other XML is still
HTML, as before.

A zip whose only real member is one FictionBook is zip-wrapped-fb2, the
same way a zipped PDF is zip-wrapped-pdf: handed to a converter as
FictionBook it is still a zip. A zip whose every real member is a JPEG,
PNG, GIF or WebP is cbz, once directories, ComicInfo.xml and desktop
clutter are passed over. One PDF or one readme among the pages and it is
not a comic. Only the names in the archive's directory are read, as for
every other zip.

The kind vocabulary grows by five: lit, fb2, rtf, cbz and
zip-wrapped-fb2.
2026-11-13 15:25:33 -05:00
27 changed files with 2629 additions and 156 deletions

No files matched your search

Generated
+26 -26
View File
@@ -101,7 +101,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "avplan" name = "avplan"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -126,7 +126,7 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]] [[package]]
name = "bibclean" name = "bibclean"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -143,7 +143,7 @@ dependencies = [
[[package]] [[package]]
name = "bibfetch" name = "bibfetch"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -196,7 +196,7 @@ dependencies = [
[[package]] [[package]]
name = "bookmeta" name = "bookmeta"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -210,7 +210,7 @@ dependencies = [
[[package]] [[package]]
name = "booksniff" name = "booksniff"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -250,7 +250,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "catena" name = "catena"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"bibclean", "bibclean",
"bibfetch", "bibfetch",
@@ -277,7 +277,7 @@ dependencies = [
[[package]] [[package]]
name = "catena-cli" name = "catena-cli"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"clap", "clap",
"clap_mangen", "clap_mangen",
@@ -312,7 +312,7 @@ checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]] [[package]]
name = "chunkmd" name = "chunkmd"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -334,7 +334,7 @@ dependencies = [
[[package]] [[package]]
name = "citefind" name = "citefind"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"chunkmd", "chunkmd",
@@ -399,7 +399,7 @@ dependencies = [
[[package]] [[package]]
name = "colophon" name = "colophon"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"bibclean", "bibclean",
"catena-cli", "catena-cli",
@@ -448,7 +448,7 @@ dependencies = [
[[package]] [[package]]
name = "corpus-core" name = "corpus-core"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"schemars", "schemars",
"serde", "serde",
@@ -492,7 +492,7 @@ dependencies = [
[[package]] [[package]]
name = "cuekit" name = "cuekit"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"corpus-core", "corpus-core",
"regex", "regex",
@@ -504,7 +504,7 @@ dependencies = [
[[package]] [[package]]
name = "cuenorm" name = "cuenorm"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -514,7 +514,7 @@ dependencies = [
[[package]] [[package]]
name = "cuepack" name = "cuepack"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -525,7 +525,7 @@ dependencies = [
[[package]] [[package]]
name = "cueseg" name = "cueseg"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -612,7 +612,7 @@ dependencies = [
[[package]] [[package]]
name = "emb" name = "emb"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -876,7 +876,7 @@ dependencies = [
[[package]] [[package]]
name = "idnorm" name = "idnorm"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"bibclean", "bibclean",
"catena-cli", "catena-cli",
@@ -942,7 +942,7 @@ dependencies = [
[[package]] [[package]]
name = "langid" name = "langid"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -1022,7 +1022,7 @@ dependencies = [
[[package]] [[package]]
name = "mdscrub" name = "mdscrub"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -1034,7 +1034,7 @@ dependencies = [
[[package]] [[package]]
name = "mdsplit" name = "mdsplit"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -1113,7 +1113,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "pdfgutter" name = "pdfgutter"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -1126,7 +1126,7 @@ dependencies = [
[[package]] [[package]]
name = "pdftriage" name = "pdftriage"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -1370,7 +1370,7 @@ checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
[[package]] [[package]]
name = "rrf" name = "rrf"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -1517,7 +1517,7 @@ dependencies = [
[[package]] [[package]]
name = "seriesof" name = "seriesof"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
@@ -1634,7 +1634,7 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]] [[package]]
name = "textkit" name = "textkit"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"regex", "regex",
"tiktoken-rs", "tiktoken-rs",
@@ -1724,7 +1724,7 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]] [[package]]
name = "tocfind" name = "tocfind"
version = "0.3.1" version = "0.5.0"
dependencies = [ dependencies = [
"catena-cli", "catena-cli",
"clap", "clap",
+27 -27
View File
@@ -3,39 +3,39 @@ resolver = "3"
members = ["crates/*"] members = ["crates/*"]
[workspace.package] [workspace.package]
version = "0.3.1" version = "0.5.0"
edition = "2024" edition = "2024"
rust-version = "1.85" rust-version = "1.85"
license = "MPL-2.0" license = "MPL-2.0"
repository = "https://git.pumpin.online/funkpower/catena" repository = "https://git.pumpin.online/funkpower/catena"
[workspace.dependencies] [workspace.dependencies]
catena-cli = { path = "crates/catena-cli", version = "0.3.1" } catena-cli = { path = "crates/catena-cli", version = "0.5.0" }
corpus-core = { path = "crates/corpus-core", version = "0.3.1" } corpus-core = { path = "crates/corpus-core", version = "0.5.0" }
cuekit = { path = "crates/cuekit", version = "0.3.1" } cuekit = { path = "crates/cuekit", version = "0.5.0" }
textkit = { path = "crates/textkit", version = "0.3.1" } textkit = { path = "crates/textkit", version = "0.5.0" }
chunkmd = { path = "crates/chunkmd", version = "0.3.1" } chunkmd = { path = "crates/chunkmd", version = "0.5.0" }
mdscrub = { path = "crates/mdscrub", version = "0.3.1" } mdscrub = { path = "crates/mdscrub", version = "0.5.0" }
mdsplit = { path = "crates/mdsplit", version = "0.3.1" } mdsplit = { path = "crates/mdsplit", version = "0.5.0" }
pdftriage = { path = "crates/pdftriage", version = "0.3.1" } pdftriage = { path = "crates/pdftriage", version = "0.5.0" }
cuenorm = { path = "crates/cuenorm", version = "0.3.1" } cuenorm = { path = "crates/cuenorm", version = "0.5.0" }
cueseg = { path = "crates/cueseg", version = "0.3.1" } cueseg = { path = "crates/cueseg", version = "0.5.0" }
rrf = { path = "crates/rrf", version = "0.3.1" } rrf = { path = "crates/rrf", version = "0.5.0" }
cuepack = { path = "crates/cuepack", version = "0.3.1" } cuepack = { path = "crates/cuepack", version = "0.5.0" }
bibclean = { path = "crates/bibclean", version = "0.3.1" } bibclean = { path = "crates/bibclean", version = "0.5.0" }
avplan = { path = "crates/avplan", version = "0.3.1" } avplan = { path = "crates/avplan", version = "0.5.0" }
pdfgutter = { path = "crates/pdfgutter", version = "0.3.1" } pdfgutter = { path = "crates/pdfgutter", version = "0.5.0" }
catena = { path = "crates/bindings", version = "0.3.1" } catena = { path = "crates/bindings", version = "0.5.0" }
seriesof = { path = "crates/seriesof", version = "0.3.1" } seriesof = { path = "crates/seriesof", version = "0.5.0" }
idnorm = { path = "crates/idnorm", version = "0.3.1" } idnorm = { path = "crates/idnorm", version = "0.5.0" }
bibfetch = { path = "crates/bibfetch", version = "0.3.1" } bibfetch = { path = "crates/bibfetch", version = "0.5.0" }
bookmeta = { path = "crates/bookmeta", version = "0.3.1" } bookmeta = { path = "crates/bookmeta", version = "0.5.0" }
langid = { path = "crates/langid", version = "0.3.1" } langid = { path = "crates/langid", version = "0.5.0" }
tocfind = { path = "crates/tocfind", version = "0.3.1" } tocfind = { path = "crates/tocfind", version = "0.5.0" }
booksniff = { path = "crates/booksniff", version = "0.3.1" } booksniff = { path = "crates/booksniff", version = "0.5.0" }
citefind = { path = "crates/citefind", version = "0.3.1" } citefind = { path = "crates/citefind", version = "0.5.0" }
colophon = { path = "crates/colophon", version = "0.3.1" } colophon = { path = "crates/colophon", version = "0.5.0" }
emb = { path = "crates/emb", version = "0.3.1" } emb = { path = "crates/emb", version = "0.5.0" }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
clap_mangen = "0.3" clap_mangen = "0.3"
+1 -1
View File
@@ -34,7 +34,7 @@ Each tool installs by name, so you can take one without taking the rest.
| [`vocabmerge`](crates/bibclean/README.md#vocabmerge--the-other-command) | The near-duplicate headings in a catalogue's vocabulary | | [`vocabmerge`](crates/bibclean/README.md#vocabmerge--the-other-command) | The near-duplicate headings in a catalogue's vocabulary |
| [`seriesof`](crates/seriesof/README.md) | The series a book belongs to, and which volume this is | | [`seriesof`](crates/seriesof/README.md) | The series a book belongs to, and which volume this is |
| [`idnorm`](crates/idnorm/README.md) | DOIs, ISSNs, ISBNs and arXiv ids, read and checked | | [`idnorm`](crates/idnorm/README.md) | DOIs, ISSNs, ISBNs and arXiv ids, read and checked |
| [`bibfetch`](crates/bibfetch/README.md) | What a register says about an identifier | | [`bibfetch`](crates/bibfetch/README.md) | What a register says about an identifier, and which books it has under a title |
| [`citefind`](crates/citefind/README.md) | The references in a bibliography or a page of notes | | [`citefind`](crates/citefind/README.md) | The references in a bibliography or a page of notes |
| [`mdscrub`](crates/mdscrub/README.md) | Converted markdown in, clean markdown out | | [`mdscrub`](crates/mdscrub/README.md) | Converted markdown in, clean markdown out |
| [`mdsplit`](crates/mdsplit/README.md) | One markdown file into the works inside it | | [`mdsplit`](crates/mdsplit/README.md) | One markdown file into the works inside it |
+106 -4
View File
@@ -20,6 +20,8 @@ So this asks, and that is the only thing it does.
- **Filling in a reference** you have only the identifier for. - **Filling in a reference** you have only the identifier for.
- **Before citing something**, to check the year and the authors are what you - **Before citing something**, to check the year and the authors are what you
think they are. think they are.
- **Cataloguing a book that came with no identifier**, to find the editions a
register has under its title, and an ISBN to file it by.
## Install ## Install
@@ -134,6 +136,14 @@ 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 the fetch's turn, so a fetch queued behind others in the same process can wait
that long again first. that long again first.
Open Library allows a hundred covers by ISBN every five minutes from one
address, and refuses the rest with `403`. Covers asked by ISBN are kept three
seconds apart across the whole process (`Covers::pace`), which stays under that
limit however long a run goes. A refusal all the same is not mistaken for an
outage: the other form of the ISBN is not asked, the cover id is, because
Open Library does not limit those, and with no cover found that way the fetch
fails with `Error::Limited`, which means ask again later.
| Option | Effect | | Option | Effect |
|---|---| |---|---|
| `-o`, `--output <FILE>` | Where to write the image | | `-o`, `--output <FILE>` | Where to write the image |
@@ -142,12 +152,103 @@ that long again first.
Exit `0` the cover was written, `1` Open Library has none, `65` the argument 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 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. reached, `74` the file could not be written, `75` Open Library refused for
being asked too often and will answer later.
From Rust, `bibfetch::cover(&agent, isbn, cover_id, size)` returns 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 `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. bytes, the media type and the address asked when there is.
## Searching by title
```
$ bibfetch search --title 'The First World War' --author Howard --limit 2
{"v":1,"id":"9780192853622","kind":"isbn","found":true,"source":"openlibrary","title":"The First World War","authors":["Michael Eliot Howard"],"year":2002,"container":"Oxford University Press","subjects":["World War, 1914-1918"],"language":"eng","cover_id":1123124}
{"v":1,"id":"9780199205592","kind":"isbn","found":true,"source":"openlibrary","title":"The First World War: A Very Short Introduction","authors":["Michael Howard"],"year":2007,"container":"Oxford University Press","language":"eng","cover_id":14606475}
```
A book that arrives as a file often has no identifier to ask about, only a
title and perhaps a name. This asks which books a register has under those,
and prints one record per candidate: Open Library's index first, and Google
Books only when Open Library has nothing.
Open Library's title search is strict. Given an author as well, it finds
nothing for *The Ladder of Divine Ascent* and four editions of *Ladder of
Divine Ascent*. So when the title as given finds nothing, the title without a
leading "The", "A" or "An" is asked next, and then the title and author as one
free-text query. Each is asked only when the one before found nothing, so a
title that matches costs one request. The looser questions can bring back
books that only share words with the title, which is one more reason the
candidates are for the caller to judge.
Each candidate is one edition. Open Library's index answers with works, and
names the edition of each that best matches the search. The ISBN, publisher,
language, year and cover are that edition's and no other's: a field the
edition does not give is left out rather than filled from the work, whose
publishers, years and ISBNs belong to all its editions at once. Only the
title, the authors and the subjects, which describe the work, are the work's.
Open Library's index gives no length for an edition, so its candidates carry
no `pages`. Google Books answers with volumes, which are editions already.
| Field | When the candidate has an ISBN | When it has none |
|---|---|---|
| `id` | The first ISBN of the edition whose check digit agrees, in thirteen digits; one written in thirteen is taken over one written in ten | The register's own key: an Open Library id such as `OL37863121M` for the edition, or `OL267171W` for the work when no edition was named; a Google Books volume id |
| `kind` | `isbn` | `olid` for Open Library, `googlebooks` for Google Books |
A work the index names no edition of, or names one it gives neither an ISBN
nor a key for, is a candidate named by the work's key, with its title,
authors and subjects and nothing an edition would say.
An ISBN whose check digit is wrong is passed over rather than corrected, and
Google's shelfmarks, typed `OTHER`, are not read as ISBNs even when their
digits would pass for one. A record of kind `isbn` can be handed straight back
to `bibfetch` for everything the registers say about that edition; `olid` and
`googlebooks` are names no lookup here answers about.
`cover_id` is the same thing it is in a lookup record: the first cover in
Open Library's record of the edition the record names, by that edition's ISBN
or its key, so `bibfetch cover --cover-id` fetches that edition's cover and no
other. The cover the index picked for the whole work is never asked for,
because it is often another edition's. Google Books knows nothing of Open
Library's covers and gives none.
The candidates are what the register said, in the order it said them. This
makes no judgement about which one is the book in hand, and keeps one that is
plainly another book, because a title is not an identifier: *The Hamlet* comes
back for *Hamlet*. Deciding whether a candidate is the same work is a
comparison of titles, and [`bibclean`](../bibclean/README.md)'s `same_work`
is one.
```sh
bibfetch search --title 'Anathem' --author Stephenson \
| jq -r 'select(.kind == "isbn") | .id' | head -1
```
Open Library having nothing is an answer only once Google Books has been
asked and has nothing either. When Google Books cannot be reached, the search
fails rather than say no register has the title, and when it refuses for its
quota (`403`, or `429` on every attempt) the failure says so and the command
exits `75`. Open Library being down is a failure only when Google Books has
nothing to offer in its place. Each is asked the same way a lookup asks,
retried with the same waits, except that a `403` from Google Books is not
asked again. Open Library is sent the title and the author as written; Google Books
is sent each word as its own `intitle:` or `inauthor:` term, so nothing in a
title is read as search syntax.
| Option | Effect |
|---|---|
| `--title <TITLE>` | The title, or as much of it as you have |
| `--author <AUTHOR>` | An author's name, or part of one |
| `--limit <COUNT>` | The most candidates to print, `1` to `40`. Default `10` |
Exit `0` at least one candidate was printed, `1` no register has the title,
`65` the title was empty, `69` a register that had to answer could not be
reached, `74` the output could not be written, `75` Google Books refused for
its quota.
From Rust, `bibfetch::search(&agent, title, author, limit, attempts)` returns
the candidates, empty when no register has the title.
## Reaching the network ## Reaching the network
This and [`emb`](../emb/README.md) are the only two tools here that do. Both This and [`emb`](../emb/README.md) are the only two tools here that do. Both
@@ -156,10 +257,11 @@ something that happens on the way to something else — which is the condition
under which [the contract](../../docs/contract.md) allows it at all. under which [the contract](../../docs/contract.md) allows it at all.
Nothing is cached, nothing is stored but a cover you asked to have written, 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 and nothing is sent but the identifier, or the title and author searched for,
`--polite`. and the address you pass to `--polite`.
## See also ## See also
- [`idnorm`](../idnorm/README.md) — reading the identifier in the first place. - [`idnorm`](../idnorm/README.md) — reading the identifier in the first place.
- [`bibclean`](../bibclean/README.md) — the rest of a catalogue record. - [`bibclean`](../bibclean/README.md) — the rest of a catalogue record, and
whether a candidate is the same work.
+30 -25
View File
@@ -89,10 +89,6 @@ pub fn lookup(
} }
/// Ask a register, retrying a failure that might not be one. /// Ask a register, retrying a failure that might not be one.
///
/// Waits a second before the second attempt and two before the third: long
/// enough for a register that is rate-limiting, short enough that one which
/// is simply down does not hold up a pipeline.
fn ask( fn ask(
agent: &ureq::Agent, agent: &ureq::Agent,
source: Source, source: Source,
@@ -100,39 +96,48 @@ fn ask(
kind: &str, kind: &str,
attempts: u32, attempts: u32,
) -> Result<crate::Record, Error> { ) -> Result<crate::Record, Error> {
let mut last = Error::Unreachable("no attempt was made".into()); match get(agent, &source.url(id), attempts)? {
Some(body) => source.read(id, kind, &body),
// A register that does not have it says so with a status, and that is
// an answer rather than a failure.
None => Ok(crate::missing(id, kind, source)),
}
}
for attempt in 0..attempts.max(1) { /// The body at an address, or `None` when the register says it has no such
match fetch(agent, source, id, kind) { /// thing; a failure is asked again.
Ok(record) => return Ok(record), ///
// A shape this cannot read will not read any better next time. /// Waits a second before the second attempt and two before the third: long
Err(e @ Error::Shape(_)) => return Err(e), /// enough for a register that is rate-limiting, short enough that one which
/// is simply down does not hold up a pipeline. Reading what came back is left
/// to the caller, because an answer this cannot read will not read any better
/// next time.
pub(crate) fn get(agent: &ureq::Agent, url: &str, attempts: u32) -> Result<Option<String>, Error> {
let attempts = attempts.max(1);
let mut last = Error::Unreachable("no attempt was made".into());
for attempt in 0..attempts {
match once(agent, url) {
Ok(answer) => return Ok(answer),
Err(e) => last = e, Err(e) => last = e,
} }
std::thread::sleep(Duration::from_secs(1 << attempt)); if attempt + 1 < attempts {
std::thread::sleep(Duration::from_secs(1 << attempt.min(5)));
}
} }
Err(last) Err(last)
} }
fn fetch( fn once(agent: &ureq::Agent, url: &str) -> Result<Option<String>, Error> {
agent: &ureq::Agent, let mut response = match agent.get(url).call() {
source: Source,
id: &str,
kind: &str,
) -> Result<crate::Record, Error> {
let response = agent.get(source.url(id)).call();
let mut response = match response {
Ok(response) => response, Ok(response) => response,
// A register that does not have it says so with a status, and that is Err(ureq::Error::StatusCode(404)) => return Ok(None),
// an answer rather than a failure.
Err(ureq::Error::StatusCode(404)) => return Ok(crate::missing(id, kind, source)),
Err(e) => return Err(Error::Unreachable(e.to_string())), Err(e) => return Err(Error::Unreachable(e.to_string())),
}; };
let body = response response
.body_mut() .body_mut()
.read_to_string() .read_to_string()
.map_err(|e| Error::Unreachable(e.to_string()))?; .map(Some)
source.read(id, kind, &body) .map_err(|e| Error::Unreachable(e.to_string()))
} }
/// Who is asking. /// Who is asking.
+126 -8
View File
@@ -46,6 +46,11 @@ const REDIRECT_HOSTS: [&str; 2] = ["covers.openlibrary.org", "archive.org"];
/// agent's timeout each, and a minute's `Retry-After` between attempts. /// agent's timeout each, and a minute's `Retry-After` between attempts.
pub const DEADLINE: Duration = Duration::from_secs(120); pub const DEADLINE: Duration = Duration::from_secs(120);
/// How long to leave between two covers asked by ISBN. Open Library allows a
/// hundred every five minutes from one address and refuses the rest with
/// `403`; one every three seconds stays under that however long a run goes.
pub const PACE: Duration = Duration::from_secs(3);
/// The longest a `Retry-After` is honoured for. A register asking for more /// The longest a `Retry-After` is honoured for. A register asking for more
/// than this is better treated as down than waited on. /// than this is better treated as down than waited on.
const MAX_WAIT: Duration = Duration::from_secs(60); const MAX_WAIT: Duration = Duration::from_secs(60);
@@ -54,7 +59,10 @@ const MAX_WAIT: Duration = Duration::from_secs(60);
/// this, and limits covers asked by ISBN to a hundred every five minutes /// this, and limits covers asked by ISBN to a hundred every five minutes
/// from one address; threads asking at once would only meet the limit /// from one address; threads asking at once would only meet the limit
/// sooner and all back off together. /// sooner and all back off together.
static ONE_AT_A_TIME: Mutex<()> = Mutex::new(()); ///
/// It holds when the last cover was asked for by ISBN, so that [`PACE`] is
/// kept across fetches and threads.
static ONE_AT_A_TIME: Mutex<Option<Instant>> = Mutex::new(None);
/// Which of Open Library's three sizes to ask for. /// Which of Open Library's three sizes to ask for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -127,6 +135,9 @@ pub struct Covers {
/// request is given whatever is left of it, or the agent's own timeout /// request is given whatever is left of it, or the agent's own timeout
/// if that is shorter, and a wait that would run past it is not waited. /// if that is shorter, and a wait that would run past it is not waited.
pub deadline: Duration, pub deadline: Duration,
/// The least time between two covers asked by ISBN; [`PACE`] by default.
/// Covers asked by id are not limited and are not spaced out.
pub pace: Duration,
} }
impl Default for Covers { impl Default for Covers {
@@ -135,6 +146,7 @@ impl Default for Covers {
base: OPEN_LIBRARY_COVERS.to_string(), base: OPEN_LIBRARY_COVERS.to_string(),
attempts: crate::ATTEMPTS, attempts: crate::ATTEMPTS,
deadline: DEADLINE, deadline: DEADLINE,
pace: PACE,
} }
} }
} }
@@ -162,6 +174,12 @@ impl Covers {
/// of the ISBN does not stop the other being asked, but it does stop the /// of the ISBN does not stop the other being asked, but it does stop the
/// cover id being asked: the ISBN's own cover may exist, and is the one /// cover id being asked: the ISBN's own cover may exist, and is the one
/// wanted. The failure is returned when no ISBN form gave a cover. /// wanted. The failure is returned when no ISBN form gave a cover.
///
/// Refusal for asking too often is the exception. It is
/// [`Error::Limited`], the other form is not asked, since it would only
/// be refused too, and the cover id is still asked, because Open Library
/// does not limit those. A cover found that way is returned; otherwise the
/// refusal is, so that the caller knows to ask again later.
pub fn fetch( pub fn fetch(
&self, &self,
agent: &ureq::Agent, agent: &ureq::Agent,
@@ -186,31 +204,54 @@ impl Covers {
.map(|form| format!("{}/b/isbn/{form}-{letter}.jpg?default=false", self.base)) .map(|form| format!("{}/b/isbn/{form}-{letter}.jpg?default=false", self.base))
.collect(); .collect();
let _turn = ONE_AT_A_TIME let mut last_by_isbn = ONE_AT_A_TIME
.lock() .lock()
.unwrap_or_else(|held| held.into_inner()); .unwrap_or_else(|held| held.into_inner());
// From here, not from the call: waiting for the lock is time another // From here, not from the call: waiting for the lock is time another
// fetch spent, and counting it would let a busy process starve this // fetch spent, and counting it would let a busy process starve this
// one of the time its own requests need. // one of the time its own requests need. A deadline too long to add
let until = Instant::now() + self.deadline; // to the clock is no deadline at all.
let now = Instant::now();
let until = now
.checked_add(self.deadline)
.unwrap_or_else(|| now + Duration::from_secs(365 * 24 * 60 * 60));
let mut failed = None; let mut failed = None;
for address in &addresses { for address in &addresses {
match self.ask(agent, address, until) { if let Some(ready) = last_by_isbn.and_then(|at| at.checked_add(self.pace)) {
let now = Instant::now();
if ready > now {
if ready >= until {
return Err(Error::Unreachable(format!(
"{address}: past the {:?} deadline, waiting to keep {:?} \
between covers asked by ISBN",
self.deadline, self.pace
)));
}
std::thread::sleep(ready - now);
}
}
let answer = self.ask(agent, address, until);
*last_by_isbn = Some(Instant::now());
match answer {
Ok(Answer::Image(cover)) => return Ok(Some(cover)), Ok(Answer::Image(cover)) => return Ok(Some(cover)),
Ok(Answer::Absent) => {} Ok(Answer::Absent) => {}
Err(why @ Error::Limited(_)) => {
failed = Some(why);
break;
}
Err(why) => failed = Some(why), Err(why) => failed = Some(why),
} }
} }
if let Some(why) = failed { if let Some(why) = failed.take_if(|why| !matches!(why, Error::Limited(_))) {
return Err(why); return Err(why);
} }
let Some(id) = cover_id else { let Some(id) = cover_id else {
return Ok(None); return failed.map_or(Ok(None), Err);
}; };
let address = format!("{}/b/id/{id}-{letter}.jpg?default=false", self.base); let address = format!("{}/b/id/{id}-{letter}.jpg?default=false", self.base);
match self.ask(agent, &address, until)? { match self.ask(agent, &address, until)? {
Answer::Image(cover) => Ok(Some(cover)), Answer::Image(cover) => Ok(Some(cover)),
Answer::Absent => Ok(None), Answer::Absent => failed.map_or(Ok(None), Err),
} }
} }
@@ -311,6 +352,15 @@ Open Library's covers or the Internet Archive"
// `default=false` makes a missing cover a 404 rather than a // `default=false` makes a missing cover a 404 rather than a
// stand-in image. // stand-in image.
404 | 410 => return Ok(Answer::Absent), 404 | 410 => return Ok(Answer::Absent),
// Open Library's answer to more than a hundred covers asked by
// ISBN in five minutes from one address. Asking again at once
// would only be refused again.
403 => {
return Err(Fault::Final(Error::Limited(format!(
"{at}: refused (403); Open Library allows a hundred covers \
by ISBN every five minutes from one address"
))));
}
429 | 503 => { 429 | 503 => {
let wait = header(&response, "retry-after") let wait = header(&response, "retry-after")
.and_then(|value| value.trim().parse::<u64>().ok()) .and_then(|value| value.trim().parse::<u64>().ok())
@@ -603,6 +653,7 @@ mod tests {
base: self.base.clone(), base: self.base.clone(),
attempts: 3, attempts: 3,
deadline: DEADLINE, deadline: DEADLINE,
pace: Duration::ZERO,
} }
} }
@@ -1052,6 +1103,73 @@ mod tests {
assert_eq!(stub.asked(), [ISBN13, ISBN10]); assert_eq!(stub.asked(), [ISBN13, ISBN10]);
} }
#[test]
fn a_refusal_for_asking_too_often_still_asks_the_cover_id() {
let stub = Stub::start(|_, path| match path {
ISBN13 => Reply::status(403),
"/b/id/42-L.jpg?default=false" => Reply::image("image/jpeg", jpeg(4096)),
_ => Reply::status(404),
});
let cover = stub
.covers()
.fetch(&agent(), "9780306406157", Some(42), CoverSize::Large)
.expect("an answer")
.expect("a cover");
assert!(cover.url.ends_with("/b/id/42-L.jpg?default=false"));
// The ten-digit form would only have been refused too.
assert_eq!(stub.asked(), [ISBN13, "/b/id/42-L.jpg?default=false"]);
}
#[test]
fn a_refusal_for_asking_too_often_is_not_an_absence() {
let stub = Stub::start(|_, path| match path {
ISBN13 => Reply::status(403),
_ => Reply::status(404),
});
let found = stub
.covers()
.fetch(&agent(), "9780306406157", None, CoverSize::Large)
.map(|cover| cover.map(|cover| cover.url));
assert!(matches!(found, Err(Error::Limited(_))), "{found:?}");
assert_eq!(stub.asked(), [ISBN13]);
// With a cover id that has no cover either, the refusal still stands:
// the ISBN's own cover may be there once the limit passes.
let found = stub
.covers()
.fetch(&agent(), "9780306406157", Some(42), CoverSize::Large)
.map(|cover| cover.map(|cover| cover.url));
assert!(matches!(found, Err(Error::Limited(_))), "{found:?}");
}
#[test]
fn a_deadline_too_long_to_count_is_no_deadline() {
let stub = Stub::start(|_, _| Reply::image("image/jpeg", jpeg(2048)));
let covers = Covers {
deadline: Duration::MAX,
..stub.covers()
};
let cover = covers
.fetch(&agent(), "9780306406157", None, CoverSize::Large)
.expect("an answer");
assert!(cover.is_some());
}
#[test]
fn covers_asked_by_isbn_are_spaced_out() {
let stub = Stub::start(|_, _| Reply::status(404));
let covers = Covers {
pace: Duration::from_millis(300),
..stub.covers()
};
let started = Instant::now();
let found = covers.fetch(&agent(), "9780306406157", None, CoverSize::Large);
assert!(matches!(found, Ok(None)), "{found:?}");
// Two forms of the ISBN, so at least one pause between them.
assert!(started.elapsed() >= Duration::from_millis(300));
assert_eq!(stub.asked(), [ISBN13, ISBN10]);
}
#[test] #[test]
fn a_redirect_to_an_address_by_number_is_refused() { fn a_redirect_to_an_address_by_number_is_refused() {
// A listener standing in for a service on the LAN; it only counts. // A listener standing in for a service on the LAN; it only counts.
+10
View File
@@ -12,9 +12,14 @@
//! them: Crossref for a DOI, the arXiv for a preprint, OpenLibrary for a //! them: Crossref for a DOI, the arXiv for a preprint, OpenLibrary for a
//! book. There is no register for an ISSN that answers without a key, so an //! book. There is no register for an ISSN that answers without a key, so an
//! ISSN is looked up as the journal of a work rather than on its own. //! ISSN is looked up as the journal of a work rather than on its own.
//!
//! A book with no identifier at all can still be looked for by its title,
//! with [`search()`]: Open Library's index, then Google Books, each candidate
//! a record of one edition, in the order the register gave them.
pub mod ask; pub mod ask;
pub mod cover; pub mod cover;
pub mod search;
pub mod source; pub mod source;
use std::time::Duration; use std::time::Duration;
@@ -23,6 +28,7 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub use cover::{Cover, CoverSize, cover}; pub use cover::{Cover, CoverSize, cover};
pub use search::search;
pub use source::Source; pub use source::Source;
/// How long to wait on a register before giving up. /// How long to wait on a register before giving up.
@@ -106,6 +112,9 @@ pub enum Error {
NoRegister(String), NoRegister(String),
/// What was asked about is not an identifier of the kind it should be. /// What was asked about is not an identifier of the kind it should be.
Invalid(String), Invalid(String),
/// The register refused because it has been asked too often from this
/// address. It is not down, and asking again later will work.
Limited(String),
} }
impl std::fmt::Display for Error { impl std::fmt::Display for Error {
@@ -117,6 +126,7 @@ impl std::fmt::Display for Error {
write!(f, "no register here answers about {kind} identifiers") write!(f, "no register here answers about {kind} identifiers")
} }
Error::Invalid(why) => write!(f, "{why}"), Error::Invalid(why) => write!(f, "{why}"),
Error::Limited(why) => write!(f, "{why}"),
} }
} }
} }
+75 -9
View File
@@ -30,7 +30,8 @@ Example:
idnorm refs.txt | bibfetch | jq -r 'select(.found) | .title' 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" bibfetch cover 978-0-306-40615-7 -o cover.jpg
bibfetch search --title 'The First World War' --author Howard"
)] )]
struct Args { struct Args {
#[command(subcommand)] #[command(subcommand)]
@@ -74,6 +75,26 @@ enum Command {
#[arg(long, value_name = "ID")] #[arg(long, value_name = "ID")]
cover_id: Option<u64>, cover_id: Option<u64>,
}, },
/// Find the books a register has under a title, and an author.
///
/// Prints one record per candidate edition, in the order the register
/// gave them: Open Library's index, or Google Books when that has
/// nothing. Which one is the book in hand is left to you.
///
/// Exit codes: 0 at least one candidate, 1 none, 65 no title,
/// 69 no register could be reached, 74 the output could not be written.
Search {
/// The title, or as much of it as you have.
#[arg(long)]
title: String,
/// An author's name, or part of one.
#[arg(long)]
author: Option<String>,
/// The most candidates to print.
#[arg(long, default_value_t = 10, value_name = "COUNT",
value_parser = clap::value_parser!(u16).range(1..=bibfetch::search::MAX_LIMIT as i64))]
limit: u16,
},
} }
#[derive(Clone, Copy, clap::ValueEnum)] #[derive(Clone, Copy, clap::ValueEnum)]
@@ -96,14 +117,27 @@ fn main() -> ExitCode {
} }
.agent(); .agent();
if let Some(Command::Cover { match &args.command {
isbn, Some(Command::Cover {
output, isbn,
size, output,
cover_id, size,
}) = &args.command cover_id,
{ }) => return cover(&agent, args.attempts, isbn, output, *size, *cover_id),
return cover(&agent, args.attempts, isbn, output, *size, *cover_id); Some(Command::Search {
title,
author,
limit,
}) => {
return search(
&agent,
args.attempts,
title,
author.as_deref(),
usize::from(*limit),
);
}
None => {}
} }
let text = match catena_cli::read_text(args.path.as_deref()) { let text = match catena_cli::read_text(args.path.as_deref()) {
@@ -191,12 +225,44 @@ fn cover(
eprintln!("bibfetch: {isbn}: {why}"); eprintln!("bibfetch: {isbn}: {why}");
ExitCode::from(match why { ExitCode::from(match why {
bibfetch::Error::Unreachable(_) => exit::UNAVAILABLE, bibfetch::Error::Unreachable(_) => exit::UNAVAILABLE,
bibfetch::Error::Limited(_) => exit::TEMPFAIL,
_ => exit::DATA, _ => exit::DATA,
}) })
} }
} }
} }
/// Search by title and print the candidates.
fn search(
agent: &ureq::Agent,
attempts: u32,
title: &str,
author: Option<&str>,
limit: usize,
) -> ExitCode {
match bibfetch::search(agent, title, author, limit, attempts) {
Ok(found) => {
let wrote = catena_cli::stream("bibfetch", &found);
if format!("{wrote:?}") != format!("{:?}", ExitCode::from(exit::OK)) {
return wrote;
}
if found.is_empty() {
eprintln!("bibfetch: no register has {title:?}");
return ExitCode::from(exit::NO);
}
ExitCode::from(exit::OK)
}
Err(why) => {
eprintln!("bibfetch: {why}");
ExitCode::from(match why {
bibfetch::Error::Invalid(_) => exit::DATA,
bibfetch::Error::Limited(_) => exit::TEMPFAIL,
_ => exit::UNAVAILABLE,
})
}
}
}
/// The identifier and its kind out of one record. /// The identifier and its kind out of one record.
fn read_identifier(line: &str) -> Result<(String, String), String> { fn read_identifier(line: &str) -> Result<(String, String), String> {
let value: serde_json::Value = let value: serde_json::Value =
File diff suppressed because it is too large. Load diff
+29 -11
View File
@@ -117,7 +117,7 @@ number_of_pages_median&limit=1",
/// ///
/// A DOI suffix may hold a slash, a question mark or a hash, and every one of /// A DOI suffix may hold a slash, a question mark or a hash, and every one of
/// those means something else in a URL. /// those means something else in a URL.
fn encode(text: &str) -> String { pub(crate) fn encode(text: &str) -> String {
let mut out = String::with_capacity(text.len()); let mut out = String::with_capacity(text.len());
for byte in text.bytes() { for byte in text.bytes() {
match byte { match byte {
@@ -131,7 +131,7 @@ fn encode(text: &str) -> String {
} }
/// A record with the fields nothing found fills in. /// A record with the fields nothing found fills in.
fn blank(id: &str, kind: &str, source: Source) -> Record { pub(crate) fn blank(id: &str, kind: &str, source: Source) -> Record {
let mut record = crate::missing(id, kind, source); let mut record = crate::missing(id, kind, source);
record.found = true; record.found = true;
record record
@@ -320,7 +320,7 @@ struct SearchDoc {
/// The index lists every heading every edition has ever been given, which on a /// 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 /// well-known book is hundreds and on Hamlet is thousands. A shelf is filed
/// from a handful, and the first are the broadest. /// from a handful, and the first are the broadest.
const SUBJECT_LIMIT: usize = 12; pub(crate) const SUBJECT_LIMIT: usize = 12;
/// A search that matched nothing answers with no documents rather than a /// A search that matched nothing answers with no documents rather than a
/// status, so an empty reply is the absence and not a failure. /// status, so an empty reply is the absence and not a failure.
@@ -348,19 +348,22 @@ fn open_library_search(id: &str, kind: &str, body: &str) -> Result<Record, Error
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct GoogleReply { pub(crate) struct GoogleReply {
#[serde(default)] #[serde(default)]
items: Vec<GoogleItem>, pub(crate) items: Vec<GoogleItem>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct GoogleItem { pub(crate) struct GoogleItem {
/// Google's own key for the volume.
#[serde(default)]
pub(crate) id: Option<String>,
#[serde(rename = "volumeInfo", default)] #[serde(rename = "volumeInfo", default)]
volume_info: GoogleVolume, pub(crate) volume_info: GoogleVolume,
} }
#[derive(Deserialize, Default)] #[derive(Deserialize, Default)]
struct GoogleVolume { pub(crate) struct GoogleVolume {
#[serde(default)] #[serde(default)]
title: Option<String>, title: Option<String>,
#[serde(default)] #[serde(default)]
@@ -379,6 +382,18 @@ struct GoogleVolume {
language: Option<String>, language: Option<String>,
#[serde(rename = "pageCount", default)] #[serde(rename = "pageCount", default)]
page_count: Option<u32>, page_count: Option<u32>,
/// `[{"type": "ISBN_13", "identifier": "…"}]`, alongside library
/// shelfmarks typed `OTHER`.
#[serde(rename = "industryIdentifiers", default)]
pub(crate) identifiers: Vec<GoogleIdentifier>,
}
#[derive(Deserialize)]
pub(crate) struct GoogleIdentifier {
#[serde(rename = "type", default)]
pub(crate) kind: String,
#[serde(default)]
pub(crate) identifier: String,
} }
/// A search that matched nothing answers with no items rather than a status, /// A search that matched nothing answers with no items rather than a status,
@@ -388,8 +403,11 @@ fn google_books(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
let Some(item) = reply.items.into_iter().next() else { let Some(item) = reply.items.into_iter().next() else {
return Ok(crate::missing(id, kind, Source::GoogleBooks)); return Ok(crate::missing(id, kind, Source::GoogleBooks));
}; };
let volume = item.volume_info; Ok(google_volume(id, kind, item.volume_info))
}
/// One Google Books volume as a record.
pub(crate) fn google_volume(id: &str, kind: &str, volume: GoogleVolume) -> Record {
let mut record = blank(id, kind, Source::GoogleBooks); let mut record = blank(id, kind, Source::GoogleBooks);
record.title = match (volume.title, volume.subtitle) { record.title = match (volume.title, volume.subtitle) {
(Some(title), Some(subtitle)) => Some(format!("{title}: {subtitle}")), (Some(title), Some(subtitle)) => Some(format!("{title}: {subtitle}")),
@@ -402,11 +420,11 @@ fn google_books(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
record.subjects = volume.categories; record.subjects = volume.categories;
record.language = volume.language; record.language = volume.language;
record.pages = volume.page_count; record.pages = volume.page_count;
Ok(record) record
} }
/// The first four-digit year in a string. /// The first four-digit year in a string.
fn year_in(text: &str) -> Option<i64> { pub(crate) fn year_in(text: &str) -> Option<i64> {
let bytes: Vec<char> = text.chars().collect(); let bytes: Vec<char> = text.chars().collect();
for window in bytes.windows(4) { for window in bytes.windows(4) {
if window.iter().all(char::is_ascii_digit) { if window.iter().all(char::is_ascii_digit) {
@@ -21,6 +21,8 @@ from ._catena import (
compose_subjects, compose_subjects,
count_tokens, count_tokens,
cue_windows, cue_windows,
Limited,
fetch_cover,
fetch_identifier, fetch_identifier,
find_citations, find_citations,
fuse_hits, fuse_hits,
@@ -41,6 +43,7 @@ from ._catena import (
record_severity, record_severity,
same_work, same_work,
scrub, scrub,
search_title,
segment_cues, segment_cues,
series_of, series_of,
shelve, shelve,
@@ -53,6 +56,7 @@ from ._catena import (
__all__ = [ __all__ = [
"__version__", "__version__",
"Limited",
"canonical_subject", "canonical_subject",
"chunk_markdown", "chunk_markdown",
"citation_prompt", "citation_prompt",
@@ -62,6 +66,7 @@ __all__ = [
"compose_subjects", "compose_subjects",
"count_tokens", "count_tokens",
"cue_windows", "cue_windows",
"fetch_cover",
"fetch_identifier", "fetch_identifier",
"find_citations", "find_citations",
"fuse_hits", "fuse_hits",
@@ -82,6 +87,7 @@ __all__ = [
"record_severity", "record_severity",
"same_work", "same_work",
"scrub", "scrub",
"search_title",
"segment_cues", "segment_cues",
"series_of", "series_of",
"shelve", "shelve",
@@ -103,6 +103,40 @@ def fetch_identifier(
""" """
... ...
class Limited(Exception):
"""A register refused because it has been asked too often; ask again later."""
def fetch_cover(
isbn: str,
cover_id: int | None = ...,
size: str = ...,
polite: str | None = ...,
timeout: int = ...,
deadline: int = ...,
) -> dict[str, Any] | None:
"""The cover Open Library has for a book, by ISBN, then by cover id.
A dict with `bytes`, `media_type`, `extension`, `source` and `url`, or
`None` when there is none. Raises `Limited` when Open Library refused for
being asked too often. Blocks on the network.
"""
...
def search_title(
title: str,
author: str | None = ...,
limit: int = ...,
polite: str | None = ...,
timeout: int = ...,
attempts: int = ...,
) -> list[dict[str, Any]]:
"""The editions the registers list for a title and author, in their order.
Records shaped like `fetch_identifier`'s. Use `same_work` to decide which
one is the book. Blocks on the network.
"""
...
def colophon_prompt( def colophon_prompt(
record: dict[str, Any], text: str, pages: int = ... record: dict[str, Any], text: str, pages: int = ...
) -> str | None: ) -> str | None:
+104 -2
View File
@@ -10,7 +10,7 @@
use pyo3::exceptions::PyValueError; use pyo3::exceptions::PyValueError;
use pyo3::prelude::*; use pyo3::prelude::*;
use pyo3::types::{PyDict, PyList}; use pyo3::types::{PyBytes, PyDict, PyList};
use crate::{ChunkBudgets, CueBudgets}; use crate::{ChunkBudgets, CueBudgets};
@@ -434,6 +434,9 @@ fn _catena(module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add_function(wrap_pyfunction!(read_colophon, module)?)?; module.add_function(wrap_pyfunction!(read_colophon, module)?)?;
module.add_function(wrap_pyfunction!(shelve, module)?)?; module.add_function(wrap_pyfunction!(shelve, module)?)?;
module.add_function(wrap_pyfunction!(fetch_identifier, module)?)?; module.add_function(wrap_pyfunction!(fetch_identifier, module)?)?;
module.add_function(wrap_pyfunction!(fetch_cover, module)?)?;
module.add_function(wrap_pyfunction!(search_title, module)?)?;
module.add("Limited", module.py().get_type::<Limited>())?;
module.add_function(wrap_pyfunction!(colophon_prompt, module)?)?; module.add_function(wrap_pyfunction!(colophon_prompt, module)?)?;
module.add_function(wrap_pyfunction!(merge_colophon, module)?)?; module.add_function(wrap_pyfunction!(merge_colophon, module)?)?;
Ok(()) Ok(())
@@ -512,13 +515,112 @@ fn fetch_identifier<'py>(
// would stop every other thread in the interpreter for the whole request. // would stop every other thread in the interpreter for the whole request.
let found = py let found = py
.detach(|| bibfetch::ask::lookup(&agent, id, kind, ask.attempts)) .detach(|| bibfetch::ask::lookup(&agent, id, kind, ask.attempts))
.map_err(|e| PyValueError::new_err(e.to_string()))?; .map_err(register_error)?;
match found { match found {
Some(record) => Ok(Some(to_py(py, &record)?)), Some(record) => Ok(Some(to_py(py, &record)?)),
None => Ok(None), None => Ok(None),
} }
} }
pyo3::create_exception!(
_catena,
Limited,
pyo3::exceptions::PyException,
"A register refused because it has been asked too often; ask again later."
);
/// A register's error as the Python exception that says what to do about it.
fn register_error(error: bibfetch::Error) -> PyErr {
match error {
bibfetch::Error::Limited(why) => Limited::new_err(why),
other => PyValueError::new_err(other.to_string()),
}
}
/// The cover Open Library has for a book, by its ISBN, then by the cover id
/// a record named.
///
/// Returns a dict with `bytes`, `media_type` (`image/jpeg` or `image/png`),
/// `extension`, `source` and `url`, or `None` when there is no cover. Covers
/// asked by ISBN are kept three seconds apart across the process, and the
/// whole fetch stops after `deadline` seconds. Raises `catena.Limited` when
/// Open Library refused for being asked too often, and `ValueError` when the
/// ISBN is not one or the store could not be reached. Blocks on the network.
#[pyfunction]
#[pyo3(signature = (isbn, cover_id = None, size = "large", polite = None, timeout = 20, deadline = 120))]
fn fetch_cover<'py>(
py: Python<'py>,
isbn: &str,
cover_id: Option<u64>,
size: &str,
polite: Option<String>,
timeout: u64,
deadline: u64,
) -> PyResult<Option<Bound<'py, PyDict>>> {
let size = match size {
"small" => bibfetch::CoverSize::Small,
"medium" => bibfetch::CoverSize::Medium,
"large" => bibfetch::CoverSize::Large,
other => {
return Err(PyValueError::new_err(format!(
"size is small, medium or large, not {other:?}"
)));
}
};
let agent = bibfetch::ask::Ask {
polite,
timeout: std::time::Duration::from_secs(timeout.max(1)),
..Default::default()
}
.agent();
let covers = bibfetch::cover::Covers {
deadline: std::time::Duration::from_secs(deadline.max(1)),
..Default::default()
};
let found = py
.detach(|| covers.fetch(&agent, isbn, cover_id, size))
.map_err(register_error)?;
let Some(cover) = found else {
return Ok(None);
};
let out = PyDict::new(py);
out.set_item("bytes", PyBytes::new(py, &cover.bytes))?;
out.set_item("media_type", cover.media_type)?;
out.set_item("extension", cover.extension())?;
out.set_item("source", cover.source)?;
out.set_item("url", cover.url)?;
Ok(Some(out))
}
/// The editions the registers list for a title, and an author if one is known.
///
/// Open Library is asked first and Google Books only when it has nothing.
/// The candidates come back in the register's own order, as records shaped
/// like `fetch_identifier`'s; nothing here decides which is the book, which is
/// what `same_work` is for. Blocks on the network.
#[pyfunction]
#[pyo3(signature = (title, author = None, limit = 10, polite = None, timeout = 20, attempts = 3))]
fn search_title<'py>(
py: Python<'py>,
title: &str,
author: Option<&str>,
limit: usize,
polite: Option<String>,
timeout: u64,
attempts: u32,
) -> PyResult<Bound<'py, PyList>> {
let ask = bibfetch::ask::Ask {
polite,
timeout: std::time::Duration::from_secs(timeout.max(1)),
attempts: attempts.max(1),
};
let agent = ask.agent();
let found = py
.detach(|| bibfetch::search(&agent, title, author, limit, ask.attempts))
.map_err(register_error)?;
list_to_py(py, &found)
}
/// The question to put to a model about the fields a book's pages did not /// The question to put to a model about the fields a book's pages did not
/// settle, or `None` when they settled everything. /// settle, or `None` when they settled everything.
/// ///
+30 -8
View File
@@ -99,8 +99,10 @@ 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 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 (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 (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 an `isbn` when it is shaped like one and `other` when not. 113 is filed by
`asin`. A contributor (108) is not an author, the same as an EPUB's its shape: a `uuid` when it is one (calibre writes its book id there), an
`asin` when it is ten letters and digits, and `other` when it is neither. 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 `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, 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 UTF-8 or Windows-1252. A book whose text is encrypted still has its header
@@ -117,7 +119,10 @@ 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. 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 A Kindle book's cover is EXTH 201, or the thumbnail, 202, when no cover is
named, and with neither the first image record. named. With neither, it is the record at the header's first image index, as
calibre does, and only that record: when it is not a JPEG, PNG or GIF there is
no cover, because the records after it can be illustrations, and kindlegen
sometimes puts a font there first.
An EPUB's is the first of these that reaches an image: An EPUB's is the first of these that reaches an image:
@@ -125,10 +130,18 @@ An EPUB's is the first of these that reaches an image:
2. The item `<meta name="cover" content="…">` names (EPUB 2). Some 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 converters write the image's address there rather than its id, and that is
followed too. followed too.
3. The guide's `<reference type="cover">`, a page, and the first `<img src>` 3. The guide's `<reference type="cover">`, a page, and the `<img src>` or
or SVG `<image href>` on it. A guide that points straight at an image is SVG `<image href>` on it. Where the page has several, the one whose file
taken at its word. name, `alt`, `id` or `class` says `cover` is taken; where none does, the
4. Any image in the manifest whose id or address says `cover`. first is, with a note that there were others. A guide that points straight
at an image is taken at its word.
4. Any image in the manifest whose id or file name says `cover`, one named
`cover` outright first and the rest in manifest order.
A name says `cover` when it has it as a word of its own, in any case. Words
break at anything not a letter or digit and where a lower case letter meets a
capital, so `CoverImage`, `Book_COVER` and `cover-thumb` say it, and
`discover-ch1` and `recovery-map` do not.
A route that names something unusable — not in the archive, not an image — 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 leaves a note, and the next route is asked. Addresses are resolved against the
@@ -140,7 +153,16 @@ 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 `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 `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 an EPUB's is never decompressed further than that, whatever size the archive
claims for it. claims for it. Nor are more than eight members read looking for one,
however many candidates the manifest lists: the search stops there, with a
note.
`Cover::guessed` says the file left the choice open: a Kindle book's first
image record, or an EPUB image picked because its name says `cover`. Either
can be an illustration from inside the book, so a caller that can get a cover
another way, from a register by ISBN, should prefer that one. A cover the file
names is never a guess. `Cover::extension` gives the usual file extension for
its media type.
## Asking a model ## Asking a model
+284 -16
View File
@@ -22,6 +22,13 @@ use crate::{Cover, MAX_COVER, Metadata, Source, image_type};
/// document or is trying to be a decompression bomb. /// document or is trying to be a decompression bomb.
const MAX_PACKAGE: u64 = 4 * 1024 * 1024; const MAX_PACKAGE: u64 = 4 * 1024 * 1024;
/// How many members one search for a cover will decompress.
///
/// Each is bounded by [`MAX_COVER`], but a manifest can list as many
/// cover-named images as it likes, and reading every one of them is time a
/// single book is not owed. A real cover is found in one or two reads.
const MAX_READS: usize = 8;
/// Read what an EPUB says about itself. /// Read what an EPUB says about itself.
pub fn read(path: &str, bytes: &[u8]) -> Metadata { pub fn read(path: &str, bytes: &[u8]) -> Metadata {
let mut meta = Metadata::empty(path, Source::Opf); let mut meta = Metadata::empty(path, Source::Opf);
@@ -295,6 +302,8 @@ fn cover<R: std::io::Read + std::io::Seek>(
archive, archive,
items: &items, items: &items,
tried: Vec::new(), tried: Vec::new(),
reads: 0,
spent: false,
meta, meta,
}; };
@@ -344,13 +353,23 @@ fn cover<R: std::io::Read + std::io::Seek>(
} }
} }
// Last: an image whose id or address says cover. // Last: an image whose id or file name has the word cover in it, one
for item in items.iter().filter(|item| { // named `cover` outright first, then in manifest order.
item.media_type.starts_with("image/") let file_stem = |item: &Item| stem(item.path.as_deref().unwrap_or(&item.href)).to_string();
&& (item.id.to_lowercase().contains("cover") let mut named: Vec<&Item> = items
|| item.href.to_lowercase().contains("cover")) .iter()
}) { .filter(|item| {
if let Some(found) = look.image(base, &item.href) { item.media_type.starts_with("image/")
&& (says_cover(&item.id) || says_cover(&file_stem(item)))
})
.collect();
named.sort_by_key(|item| !file_stem(item).eq_ignore_ascii_case("cover"));
for item in named {
if look.spent {
break;
}
if let Some(mut found) = look.image(base, &item.href) {
found.guessed = true;
return Some(found); return Some(found);
} }
} }
@@ -363,12 +382,19 @@ struct Look<'a, R> {
archive: &'a mut zip::ZipArchive<R>, archive: &'a mut zip::ZipArchive<R>,
items: &'a [Item], items: &'a [Item],
tried: Vec<String>, tried: Vec<String>,
/// How many members have been decompressed, against [`MAX_READS`].
reads: usize,
/// Whether the reads have run out, and said so.
spent: bool,
meta: &'a mut Metadata, meta: &'a mut Metadata,
} }
impl<R: std::io::Read + std::io::Seek> Look<'_, R> { impl<R: std::io::Read + std::io::Seek> Look<'_, R> {
/// Where an address leads, or a note saying it leads nowhere it may. /// Where an address leads, or a note saying it leads nowhere it may.
fn locate(&mut self, base: &str, href: &str) -> Option<String> { fn locate(&mut self, base: &str, href: &str) -> Option<String> {
if self.spent {
return None;
}
let Some(path) = resolve(base, href) else { let Some(path) = resolve(base, href) else {
self.meta.note(format!( self.meta.note(format!(
"the cover's address, {href}, is not a path inside the archive" "the cover's address, {href}, is not a path inside the archive"
@@ -391,6 +417,11 @@ impl<R: std::io::Read + std::io::Seek> Look<'_, R> {
/// The image a cover page shows, or the page itself where the guide /// The image a cover page shows, or the page itself where the guide
/// points straight at an image. /// points straight at an image.
///
/// A page with several images may lead with a logo or an ornament, so
/// the one whose file name, `alt`, `id` or `class` says cover is taken
/// over the first; with none of them saying so, the first is taken, and
/// a note says the page had others.
fn page(&mut self, base: &str, href: &str) -> Option<Cover> { fn page(&mut self, base: &str, href: &str) -> Option<Cover> {
let path = self.locate(base, href)?; let path = self.locate(base, href)?;
let bytes = self.fetch(&path, MAX_PACKAGE as usize, "cover page")?; let bytes = self.fetch(&path, MAX_PACKAGE as usize, "cover page")?;
@@ -398,23 +429,55 @@ impl<R: std::io::Read + std::io::Seek> Look<'_, R> {
return self.check(&path, bytes); return self.check(&path, bytes);
} }
let text = String::from_utf8_lossy(&bytes); let text = String::from_utf8_lossy(&bytes);
let Some(src) = tags(&text).find_map(|(name, tag)| match name { let images: Vec<(String, bool)> = tags(&text)
"img" => attr(tag, "src"), .filter_map(|(name, tag)| {
"image" => attr(tag, "xlink:href").or_else(|| attr(tag, "href")), let src = match name {
_ => None, "img" => attr(tag, "src"),
}) else { "image" => attr(tag, "xlink:href").or_else(|| attr(tag, "href")),
_ => None,
}?;
let named = says_cover(stem(src.split('#').next().unwrap_or("")))
|| ["alt", "id", "class"]
.iter()
.any(|name| attr(tag, name).is_some_and(|value| says_cover(&value)));
Some((src, named))
})
.collect();
let src = if let Some((src, _)) = images.iter().find(|(_, named)| *named) {
src
} else if let Some((first, _)) = images.first() {
if images.len() > 1 {
self.meta.note(format!(
"the cover page, {path}, shows {} images, and the first was taken",
images.len()
));
}
first
} else {
self.meta self.meta
.note(format!("the cover page, {path}, shows no image")); .note(format!("the cover page, {path}, shows no image"));
return None; return None;
}; };
self.image(directory_of(&path), &src) self.image(directory_of(&path), src)
} }
/// One member's bytes, read no further than `cap`. /// One member's bytes, read no further than `cap`.
/// ///
/// The size the archive declares is checked first and the read is /// The size the archive declares is checked first and the read is
/// bounded anyway, because the declaration may be a lie. /// bounded anyway, because the declaration may be a lie. No more than
/// [`MAX_READS`] members are read in all; the first asked for after
/// that ends the search, with a note.
fn fetch(&mut self, path: &str, cap: usize, what: &str) -> Option<Vec<u8>> { fn fetch(&mut self, path: &str, cap: usize, what: &str) -> Option<Vec<u8>> {
if self.spent {
return None;
}
if self.reads == MAX_READS {
self.spent = true;
self.meta.note(format!(
"the search for a cover stopped after reading {MAX_READS} members"
));
return None;
}
let file = match self.archive.by_name(path) { let file = match self.archive.by_name(path) {
Ok(file) => file, Ok(file) => file,
Err(zip::result::ZipError::FileNotFound) => { Err(zip::result::ZipError::FileNotFound) => {
@@ -433,6 +496,7 @@ impl<R: std::io::Read + std::io::Seek> Look<'_, R> {
self.meta.note(too_large); self.meta.note(too_large);
return None; return None;
} }
self.reads += 1;
let mut bytes = Vec::new(); let mut bytes = Vec::new();
if file.take(cap as u64 + 1).read_to_end(&mut bytes).is_err() { if file.take(cap as u64 + 1).read_to_end(&mut bytes).is_err() {
self.meta self.meta
@@ -469,6 +533,7 @@ impl<R: std::io::Read + std::io::Seek> Look<'_, R> {
Some(Cover { Some(Cover {
media_type: media_type.to_string(), media_type: media_type.to_string(),
bytes, bytes,
guessed: false,
}) })
} }
} }
@@ -478,6 +543,50 @@ fn directory_of(path: &str) -> &str {
path.rsplit_once('/').map_or("", |(dir, _)| dir) path.rsplit_once('/').map_or("", |(dir, _)| dir)
} }
/// A path's file name without its extension.
fn stem(path: &str) -> &str {
let name = path.rsplit_once('/').map_or(path, |(_, name)| name);
match name.rsplit_once('.') {
Some((stem, _)) if !stem.is_empty() => stem,
_ => name,
}
}
/// Whether a name has `cover` in it as a word of its own, in any case.
///
/// Words are split at anything not a letter or a digit and where a lower
/// case letter meets a capital, so `CoverImage`, `Book_COVER` and
/// `cover-thumb` say cover, and `discover` and `recovery` do not.
fn says_cover(name: &str) -> bool {
words(name)
.iter()
.any(|word| word.eq_ignore_ascii_case("cover"))
}
/// The words of a name, as [`says_cover`] splits them.
fn words(name: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start: Option<usize> = None;
let mut prev = ' ';
for (at, c) in name.char_indices() {
let begins =
c.is_alphanumeric() && (start.is_none() || (prev.is_lowercase() && c.is_uppercase()));
if !c.is_alphanumeric() || begins {
if let Some(from) = start.take() {
out.push(&name[from..at]);
}
}
if begins {
start = Some(at);
}
prev = c;
}
if let Some(from) = start {
out.push(&name[from..]);
}
out
}
/// Where an address written in a member in `base` leads inside the archive. /// 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 /// The fragment comes off and percent-escapes are undone. An address with a
@@ -521,7 +630,13 @@ fn percent_decode(text: &str) -> Option<String> {
let mut at = 0; let mut at = 0;
while at < raw.len() { while at < raw.len() {
if raw[at] == b'%' { if raw[at] == b'%' {
let hex = std::str::from_utf8(raw.get(at + 1..at + 3)?).ok()?; // Two hex digits and nothing else: `from_str_radix` would also
// take a sign, and read `%+f` as 15.
let hex = raw.get(at + 1..at + 3)?;
if !hex.iter().all(u8::is_ascii_hexdigit) {
return None;
}
let hex = std::str::from_utf8(hex).ok()?;
out.push(u8::from_str_radix(hex, 16).ok()?); out.push(u8::from_str_radix(hex, 16).ok()?);
at += 3; at += 3;
} else { } else {
@@ -767,6 +882,8 @@ mod tests {
let cover = meta.cover.expect("a cover"); let cover = meta.cover.expect("a cover");
assert_eq!(cover.media_type, "image/jpeg"); assert_eq!(cover.media_type, "image/jpeg");
assert_eq!(cover.bytes, JPEG); assert_eq!(cover.bytes, JPEG);
assert!(!cover.guessed, "a declared cover is not a guess");
assert_eq!(cover.extension(), "jpg");
assert!(meta.notes.is_empty(), "{:?}", meta.notes); assert!(meta.notes.is_empty(), "{:?}", meta.notes);
} }
@@ -861,7 +978,10 @@ mod tests {
("OEBPS/images/i2.gif", GIF), ("OEBPS/images/i2.gif", GIF),
], ],
); );
assert_eq!(meta.cover.expect("a cover").bytes, GIF); let cover = meta.cover.expect("a cover");
assert_eq!(cover.bytes, GIF);
assert!(cover.guessed, "a cover found by its name is a guess");
assert_eq!(cover.extension(), "gif");
let by_address = opf( let by_address = opf(
r#"<item id="i9" href="images/Book_COVER.jpeg" media-type="image/jpeg"/>"#, r#"<item id="i9" href="images/Book_COVER.jpeg" media-type="image/jpeg"/>"#,
@@ -871,6 +991,147 @@ mod tests {
assert_eq!(meta.cover.expect("a cover").bytes, JPEG); assert_eq!(meta.cover.expect("a cover").bytes, JPEG);
} }
#[test]
fn a_name_says_cover_only_as_a_word_of_its_own() {
for yes in [
"cover",
"CoverImage",
"Book_COVER",
"cover-thumb",
"frontCover",
"x.cover",
] {
assert!(says_cover(yes), "{yes}");
}
for no in [
"discover-ch1",
"recovery-map",
"covers",
"Coverage",
"uncover",
"",
] {
assert!(!says_cover(no), "{no}");
}
assert_eq!(words("CoverImage_v2"), ["Cover", "Image", "v2"]);
}
#[test]
fn a_name_that_only_contains_cover_is_not_a_cover() {
let package = opf(
r#"<item id="ch1" href="images/discover-ch1.png" media-type="image/png"/>
<item id="i2" href="images/cover.jpg" media-type="image/jpeg"/>"#,
"",
);
let meta = book(
&package,
&[
("OEBPS/images/discover-ch1.png", PNG),
("OEBPS/images/cover.jpg", JPEG),
],
);
let cover = meta.cover.expect("a cover");
assert_eq!(cover.bytes, JPEG);
assert!(cover.guessed);
let alone = opf(
r#"<item id="map" href="images/recovery-map.png" media-type="image/png"/>"#,
"",
);
let meta = book(&alone, &[("OEBPS/images/recovery-map.png", PNG)]);
assert_eq!(meta.cover, None);
assert!(meta.notes.is_empty(), "{:?}", meta.notes);
}
#[test]
fn an_image_named_cover_outright_beats_one_listed_before_it() {
let package = opf(
r#"<item id="cover-thumb" href="images/thumb.png" media-type="image/png"/>
<item id="i2" href="images/Cover.jpg" media-type="image/jpeg"/>"#,
"",
);
let meta = book(
&package,
&[
("OEBPS/images/thumb.png", PNG),
("OEBPS/images/Cover.jpg", JPEG),
],
);
let cover = meta.cover.expect("a cover");
assert_eq!(cover.bytes, JPEG);
assert!(cover.guessed);
}
#[test]
fn the_guide_cover_page_prefers_the_image_that_says_cover() {
let package = opf(
"",
r#"<guide><reference type="cover" href="text/cover.xhtml"/></guide>"#,
);
for art in [
r#"<img src="../images/front-cover.jpg"/>"#,
r#"<img alt="Cover" src="../images/front-cover.jpg"/>"#,
r#"<img id="coverImage" src="../images/front-cover.jpg"/>"#,
r#"<img class="full cover" src="../images/front-cover.jpg"/>"#,
] {
let page = format!(r#"<html><body><img src="../images/logo.png"/>{art}</body></html>"#);
let meta = book(
&package,
&[
("OEBPS/text/cover.xhtml", page.as_bytes()),
("OEBPS/images/logo.png", PNG),
("OEBPS/images/front-cover.jpg", JPEG),
],
);
assert_eq!(meta.cover.expect(art).bytes, JPEG, "{art}");
assert!(meta.notes.is_empty(), "{art}: {:?}", meta.notes);
}
}
#[test]
fn a_guide_cover_page_with_several_unnamed_images_takes_the_first_and_says_so() {
let package = opf(
"",
r#"<guide><reference type="cover" href="text/cover.xhtml"/></guide>"#,
);
let page = br#"<html><body><img src="../images/logo.png"/><img src="../images/art.jpg"/></body></html>"#;
let meta = book(
&package,
&[
("OEBPS/text/cover.xhtml", page),
("OEBPS/images/logo.png", PNG),
("OEBPS/images/art.jpg", JPEG),
],
);
assert_eq!(meta.cover.expect("a cover").bytes, PNG);
assert_eq!(
meta.notes,
["the cover page, OEBPS/text/cover.xhtml, shows 2 images, and the first was taken"]
);
}
#[test]
fn a_search_for_a_cover_reads_no_more_than_its_budget() {
let count = MAX_READS + 4;
let manifest: String = (0..count)
.map(|n| format!(r#"<item id="cover-{n}" href="c{n}.jpg" media-type="image/jpeg"/>"#))
.collect();
let names: Vec<String> = (0..count).map(|n| format!("OEBPS/c{n}.jpg")).collect();
let members: Vec<(&str, &[u8])> = names
.iter()
.map(|name| (name.as_str(), &b"<html>not an image</html>"[..]))
.collect();
let meta = book(&opf(&manifest, ""), &members);
assert_eq!(meta.cover, None);
let mut expected: Vec<String> = (0..MAX_READS)
.map(|n| format!("the cover, OEBPS/c{n}.jpg, is not an image"))
.collect();
expected.push(format!(
"the search for a cover stopped after reading {MAX_READS} members"
));
assert_eq!(meta.notes, expected);
}
#[test] #[test]
fn a_book_that_names_no_cover_has_none_and_says_nothing() { fn a_book_that_names_no_cover_has_none_and_says_nothing() {
let package = opf( let package = opf(
@@ -931,6 +1192,8 @@ mod tests {
archive: &mut archive, archive: &mut archive,
items: &[], items: &[],
tried: Vec::new(), tried: Vec::new(),
reads: 0,
spent: false,
meta: &mut meta, meta: &mut meta,
}; };
let got = look.fetch("big.jpg", cap, "cover").map(|bytes| bytes.len()); let got = look.fetch("big.jpg", cap, "cover").map(|bytes| bytes.len());
@@ -1023,6 +1286,8 @@ mod tests {
Some("OEBPS/text/b.jpg") Some("OEBPS/text/b.jpg")
); );
assert_eq!(resolve("", "a%2Fb.jpg").as_deref(), Some("a/b.jpg")); assert_eq!(resolve("", "a%2Fb.jpg").as_deref(), Some("a/b.jpg"));
assert_eq!(percent_decode("a%2fb%2F").as_deref(), Some("a/b/"));
assert_eq!(percent_decode("a%+fb"), None);
for hostile in [ for hostile in [
"../../c.jpg", "../../c.jpg",
"../../../etc/passwd", "../../../etc/passwd",
@@ -1031,6 +1296,9 @@ mod tests {
"file:///etc/passwd", "file:///etc/passwd",
"%ZZ.jpg", "%ZZ.jpg",
"%ff.jpg", "%ff.jpg",
"a%+fb",
"a%-1b",
"a% fb",
"c%", "c%",
"#only-a-fragment", "#only-a-fragment",
"", "",
+18
View File
@@ -110,6 +110,24 @@ pub struct Cover {
pub media_type: String, pub media_type: String,
/// The image itself, never more than [`MAX_COVER`] bytes. /// The image itself, never more than [`MAX_COVER`] bytes.
pub bytes: Vec<u8>, pub bytes: Vec<u8>,
/// Whether the file left the choice open. A MOBI that names no cover, or
/// an EPUB whose only cover is an image with "cover" in its name, has one
/// picked by position or by name, and that pick can be an illustration
/// from inside the book. A caller with a better source should prefer it.
pub guessed: bool,
}
impl Cover {
/// The usual file extension for the image, without the dot.
pub fn extension(&self) -> &'static str {
match self.media_type.as_str() {
"image/png" => "png",
"image/gif" => "gif",
"image/webp" => "webp",
"image/bmp" => "bmp",
_ => "jpg",
}
}
} }
/// How large a cover is worth keeping. /// How large a cover is worth keeping.
+2
View File
@@ -342,6 +342,7 @@ fn cover(
Some(Cover { Some(Cover {
media_type: media_type.to_string(), media_type: media_type.to_string(),
bytes: data.to_vec(), bytes: data.to_vec(),
guessed: false,
}) })
} }
@@ -361,6 +362,7 @@ fn first_image_record(bytes: &[u8], records: &[usize], first_image: Option<u32>)
Some(Cover { Some(Cover {
media_type: media_type.to_string(), media_type: media_type.to_string(),
bytes: data.to_vec(), bytes: data.to_vec(),
guessed: true,
}) })
} }
+2
View File
@@ -31,6 +31,7 @@ fn says_what_it_was_given(meta: &bookmeta::Metadata) {
let cover = meta.cover.as_ref().expect("a cover"); let cover = meta.cover.as_ref().expect("a cover");
assert_eq!(cover.media_type, "image/jpeg"); assert_eq!(cover.media_type, "image/jpeg");
assert!(!cover.guessed, "EXTH 201 names the cover");
let original = std::fs::read(fixture("lantern-cover.jpg")).expect("the cover fixture"); let original = std::fs::read(fixture("lantern-cover.jpg")).expect("the cover fixture");
assert_eq!(cover.bytes, original); assert_eq!(cover.bytes, original);
} }
@@ -84,6 +85,7 @@ fn a_file_naming_no_cover_gets_its_first_image_record() {
let meta = bookmeta::from_bytes("x.mobi", &lantern_naming_no_cover()); let meta = bookmeta::from_bytes("x.mobi", &lantern_naming_no_cover());
let cover = meta.cover.expect("the first image record"); let cover = meta.cover.expect("the first image record");
assert_eq!(cover.media_type, "image/jpeg"); assert_eq!(cover.media_type, "image/jpeg");
assert!(cover.guessed, "a cover taken by position is a guess");
let original = std::fs::read(fixture("lantern-cover.jpg")).expect("the cover fixture"); let original = std::fs::read(fixture("lantern-cover.jpg")).expect("the cover fixture");
assert_eq!(cover.bytes, original); assert_eq!(cover.bytes, original);
} }
+30 -2
View File
@@ -26,8 +26,8 @@ file too small to be a book, a name that says "sample".
cover images that ended up in a folder of PDFs. cover images that ended up in a folder of PDFs.
A library accumulates files that lie: a DjVu named `.pdf`, a Word document A library accumulates files that lie: a DjVu named `.pdf`, a Word document
named `.epub`, an EPUB saved as `.zip`, a single PDF that somebody zipped and named `.epub`, an RTF saved as `.doc`, an EPUB saved as `.zip`, a single PDF
renamed back to `.pdf`. that somebody zipped and renamed back to `.pdf`.
## Install ## Install
@@ -89,7 +89,12 @@ one bad file does not stop a sweep.
| `ole2` | The compound-file header every binary Word document starts with | | `ole2` | The compound-file header every binary Word document starts with |
| `epub` | A zip holding `mimetype` and `META-INF/container.xml` | | `epub` | A zip holding `mimetype` and `META-INF/container.xml` |
| `zip-wrapped-pdf` | A zip whose only real member is one PDF | | `zip-wrapped-pdf` | A zip whose only real member is one PDF |
| `zip-wrapped-fb2` | A zip whose only real member is named `.fb2` — the `.fb2.zip` most FictionBooks are passed around as |
| `cbz` | A zip whose every real member is a page image (JPEG, PNG, GIF or WebP), and which carries a `ComicInfo.xml` or is itself named `.cbz` or `.cbr` |
| `azw3`, `mobi` | A Palm database tagged `BOOKMOBI`, told apart by the format version inside the first record | | `azw3`, `mobi` | A Palm database tagged `BOOKMOBI`, told apart by the format version inside the first record |
| `lit` | `ITOLITLS`, Microsoft Reader's signature |
| `rtf` | `{\rtf` at the front, after any byte-order mark and blank space, followed by the version digit, a space or the next control word |
| `fb2` | XML whose root element is `FictionBook`, found by stepping over the declaration, comments and doctype in the first four kilobytes; UTF-16 with a byte-order mark is decoded first |
| `html` | Markup at the front, after any byte-order mark | | `html` | Markup at the front, after any byte-order mark |
| `txt` | Text that is none of the above | | `txt` | Text that is none of the above |
| `unknown` | Anything else, with a note saying what was seen | | `unknown` | Anything else, with a note saying what was seen |
@@ -98,6 +103,29 @@ An archive holding several PDFs is a collection, not a wrapped book: choosing
among them would be a guess, and naming one chapter as the whole work is worse among them would be a guess, and naming one chapter as the whole work is worse
than refusing. than refusing.
A zipped FictionBook is reported as wrapped rather than as `fb2`, the same way
a zipped PDF is: a converter handed it as FictionBook is handed a zip, and the
kind is there to stop exactly that.
A comic is the pages and nothing else. Directories, the `ComicInfo.xml` a
comic reader writes into the archive, and the desktop clutter every archive
picks up are passed over; anything more — one PDF among the pages, a readme —
and the zip is not a comic, and says what it holds instead.
A zip of pictures is as often a holiday as a comic, so images alone are not
enough. It is a comic when it carries a `ComicInfo.xml`, or when its own name
ends `.cbz` or `.cbr`. Otherwise it is `unknown`, with a note saying it is a
zip of so many images and would be a comic book if its name said `.cbz`.
Members are judged by name. Only the archive's directory is read, so no
member is ever unpacked and a page called `001.jpg` is taken to be an image
without its bytes being looked at. The same goes for a wrapped FictionBook:
the one member is taken to be a FictionBook because its name ends `.fb2`.
FictionBook is read as bytes rather than as text. A great many are written in
windows-1251, which spells its markup in ASCII but is not UTF-8, and would
otherwise be called binary.
## As a library ## As a library
```rust ```rust
+261
View File
@@ -0,0 +1,261 @@
//! Telling a FictionBook from any other XML.
//!
//! A FictionBook is an XML document whose root element is `FictionBook`.
//! The root comes after the prolog — a declaration, comments, perhaps a
//! doctype — so it is found by stepping over those rather than by matching
//! at a fixed offset.
//!
//! The walk is over bytes, not text. A great many FictionBooks are written in
//! windows-1251, which is not UTF-8 but spells its markup in ASCII all the
//! same, and a reader that insisted on UTF-8 would call every one of them
//! binary.
//!
//! A FictionBook saved as UTF-16 is the one case where the markup is not
//! ASCII bytes. Its byte-order mark says so, and the front is decoded before
//! the same walk is made over it.
use crate::BOM;
/// The UTF-16 byte-order mark, little-endian.
const UTF16_LE: &[u8] = b"\xff\xfe";
/// The UTF-16 byte-order mark, big-endian.
const UTF16_BE: &[u8] = b"\xfe\xff";
/// Most UTF-16 code units decoded from the front: the same window, in
/// characters, as the bytes read for every other encoding.
const UTF16_UNITS: usize = 2048;
/// What the root element of a FictionBook is called.
const ROOT: &[u8] = b"FictionBook";
/// Whether the front of a file is the start of a FictionBook.
///
/// `head` is however much of the front the caller read. A root element that
/// does not begin inside it is not found, so the answer is no rather than a
/// guess.
pub fn is_fictionbook(head: &[u8]) -> bool {
match utf16(head) {
Some(decoded) => is_fictionbook_root(decoded.as_bytes()),
None => is_fictionbook_root(head),
}
}
fn is_fictionbook_root(head: &[u8]) -> bool {
root_element(head).is_some_and(|name| local_name(name) == ROOT)
}
/// The front of a UTF-16 file as UTF-8, if a byte-order mark says it is one.
///
/// Only a bounded prefix is decoded. A half character at the end, or a
/// surrogate without its pair, becomes a replacement character, which the
/// walk takes as part of a name and so never as the end of one.
fn utf16(head: &[u8]) -> Option<String> {
let (body, big_endian) = match head.strip_prefix(UTF16_LE) {
Some(body) => (body, false),
None => (head.strip_prefix(UTF16_BE)?, true),
};
let units = body.chunks_exact(2).take(UTF16_UNITS).map(|pair| {
let pair = [pair[0], pair[1]];
if big_endian {
u16::from_be_bytes(pair)
} else {
u16::from_le_bytes(pair)
}
});
Some(
char::decode_utf16(units)
.map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER))
.collect(),
)
}
/// The name of the first element, after the prolog.
fn root_element(head: &[u8]) -> Option<&[u8]> {
let mut rest = head.strip_prefix(BOM).unwrap_or(head);
loop {
rest = rest.trim_ascii_start();
if let Some(after) = rest.strip_prefix(b"<?") {
rest = after_marker(after, b"?>")?;
} else if let Some(after) = rest.strip_prefix(b"<!--") {
rest = after_marker(after, b"-->")?;
} else if let Some(after) = rest.strip_prefix(b"<!") {
rest = after_declaration(after)?;
} else {
// Anything but an element here is text before the root, which
// XML does not allow.
let after = rest.strip_prefix(b"<")?;
// A name that runs to the end of what was read may be the front
// of a longer one, so it is not taken.
let end = after.iter().position(|byte| !is_name_byte(*byte))?;
let ends_properly =
matches!(after[end], b'>' | b'/') || after[end].is_ascii_whitespace();
return (end > 0 && ends_properly).then_some(&after[..end]);
}
}
}
/// What follows the first `marker`, if it is there at all.
fn after_marker<'a>(rest: &'a [u8], marker: &[u8]) -> Option<&'a [u8]> {
rest.windows(marker.len())
.position(|window| window == marker)
.map(|at| &rest[at + marker.len()..])
}
/// What follows a doctype, whose internal subset may hold `>` of its own.
fn after_declaration(rest: &[u8]) -> Option<&[u8]> {
let mut depth = 0usize;
let mut quote = None;
for (at, &byte) in rest.iter().enumerate() {
match quote {
Some(open) if byte == open => quote = None,
Some(_) => {}
None => match byte {
b'"' | b'\'' => quote = Some(byte),
b'[' => depth += 1,
b']' => depth = depth.saturating_sub(1),
b'>' if depth == 0 => return Some(&rest[at + 1..]),
_ => {}
},
}
}
None
}
/// The name without its namespace prefix.
fn local_name(name: &[u8]) -> &[u8] {
match name.iter().rposition(|byte| *byte == b':') {
Some(at) => &name[at + 1..],
None => name,
}
}
fn is_name_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':') || byte >= 0x80
}
#[cfg(test)]
mod tests {
use super::*;
const BOOK: &[u8] = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<FictionBook xmlns=\"http://www.gribuser.ru/xml/fictionbook/2.0\">\n\
<description><title-info><book-title>The Mill</book-title></title-info></description>";
#[test]
fn a_fictionbook_is_recognised() {
assert!(is_fictionbook(BOOK));
assert!(is_fictionbook(b"<FictionBook>"));
}
#[test]
fn the_prolog_is_stepped_over() {
let head = b"\xef\xbb\xbf <?xml version=\"1.0\"?>\r\n\
<!-- converted by hand -->\n\
<!DOCTYPE FictionBook [ <!ENTITY mill \"<b>the mill</b>\"> ]>\n\
<?xml-stylesheet href=\"fb2.css\"?>\n\
<FictionBook\n xmlns=\"http://www.gribuser.ru/xml/fictionbook/2.0\">";
assert!(is_fictionbook(head));
}
#[test]
fn a_prefixed_root_is_still_a_fictionbook() {
assert!(is_fictionbook(
b"<fb:FictionBook xmlns:fb=\"http://www.gribuser.ru/xml/fictionbook/2.0\">"
));
}
#[test]
fn markup_need_not_be_utf8() {
// windows-1251 for "Мельница" in the title.
let head = b"<?xml version=\"1.0\" encoding=\"windows-1251\"?>\
<FictionBook><description><book-title>\xcc\xe5\xeb\xfc\xed\xe8\xf6\xe0</book-title>";
assert!(is_fictionbook(head));
}
#[test]
fn other_xml_is_not_a_fictionbook() {
assert!(!is_fictionbook(
b"<?xml version=\"1.0\"?>\n<rss version=\"2.0\">"
));
assert!(!is_fictionbook(b"<?xml version=\"1.0\"?><FictionBookish>"));
assert!(!is_fictionbook(b"<html><FictionBook>"));
assert!(!is_fictionbook(b"text before <FictionBook>"));
assert!(!is_fictionbook(b""));
}
#[test]
fn a_root_the_window_cut_short_is_not_taken() {
assert!(!is_fictionbook(b"<?xml version=\"1.0\"?><FictionBook"));
assert!(!is_fictionbook(b"<?xml version=\"1.0\"?><Fiction"));
assert!(!is_fictionbook(b"<?xml version=\"1.0\""));
assert!(!is_fictionbook(b"<!-- never closed <FictionBook>"));
}
fn utf16(text: &str, big_endian: bool) -> Vec<u8> {
let mut out = if big_endian {
UTF16_BE.to_vec()
} else {
UTF16_LE.to_vec()
};
for unit in text.encode_utf16() {
out.extend_from_slice(&if big_endian {
unit.to_be_bytes()
} else {
unit.to_le_bytes()
});
}
out
}
const UTF16_BOOK: &str = "<?xml version=\"1.0\" encoding=\"UTF-16\"?>\r\n\
<FictionBook xmlns=\"http://www.gribuser.ru/xml/fictionbook/2.0\">\
<description><book-title>Мельница</book-title>";
#[test]
fn a_little_endian_utf16_fictionbook_is_recognised() {
assert!(is_fictionbook(&utf16(UTF16_BOOK, false)));
assert!(!is_fictionbook(&utf16(
"<?xml version=\"1.0\"?><rss>",
false
)));
}
#[test]
fn a_big_endian_utf16_fictionbook_is_recognised() {
assert!(is_fictionbook(&utf16(UTF16_BOOK, true)));
assert!(!is_fictionbook(&utf16(
"<?xml version=\"1.0\"?><rss>",
true
)));
}
#[test]
fn utf16_read_in_the_wrong_order_is_not_a_fictionbook() {
let mut swapped = utf16(UTF16_BOOK, false);
swapped[..2].copy_from_slice(UTF16_BE);
assert!(!is_fictionbook(&swapped));
}
#[test]
fn a_utf16_root_the_window_cut_short_is_not_taken() {
let head = utf16("<FictionBook>", false);
for cut in 0..head.len() {
assert!(!is_fictionbook(&head[..cut]), "cut at {cut}");
}
assert!(is_fictionbook(&head));
}
#[test]
fn a_truncated_or_mangled_prolog_does_not_panic() {
let head = b"\xef\xbb\xbf<?xml?><!--c--><!DOCTYPE x [ <!ENTITY a '>'> ] \"]\">\
<FictionBook>";
assert!(is_fictionbook(head));
for cut in 0..head.len() {
let _ = is_fictionbook(&head[..cut]);
}
for byte in 0..=255u8 {
let _ = is_fictionbook(&[b'<', byte]);
let _ = is_fictionbook(&[b'<', b'!', byte, b'>']);
}
}
}
+378 -13
View File
@@ -9,6 +9,7 @@
//! The containers are unambiguous, so they are read rather than guessed at. //! The containers are unambiguous, so they are read rather than guessed at.
pub mod bytes; pub mod bytes;
pub mod fb2;
pub mod mobi; pub mod mobi;
pub mod zip; pub mod zip;
@@ -22,17 +23,35 @@ const DJVU: &[u8] = b"AT&TFORM";
const OLE2: &[u8] = &[0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]; const OLE2: &[u8] = &[0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
/// A PDF announces its version in its first bytes. /// A PDF announces its version in its first bytes.
const PDF: &[u8] = b"%PDF-"; const PDF: &[u8] = b"%PDF-";
/// Microsoft Reader's signature, ahead of everything else in the file.
const LIT: &[u8] = b"ITOLITLS";
/// The group every RTF document is, opened with the control word that names
/// the format.
const RTF: &[u8] = br"{\rtf";
/// The UTF-8 byte-order mark.
const BOM: &[u8] = b"\xef\xbb\xbf";
/// Archive members that carry no content: resource forks and desktop clutter. /// Archive members that carry no content: resource forks and desktop clutter.
const ARCHIVE_NOISE: &[&str] = &["__macosx/", ".ds_store", "thumbs.db"]; const ARCHIVE_NOISE: &[&str] = &["__macosx/", ".ds_store", "thumbs.db"];
/// What a comic book archive carries besides its pages: the catalogue entry
/// comic readers write into it.
const COMIC_CATALOGUE: &str = "comicinfo.xml";
/// The extensions that say an archive is a comic book.
const COMIC_EXTENSIONS: &[&str] = &["cbz", "cbr"];
/// The extensions of the images a comic book archive is made of.
const PAGE_IMAGES: &[&str] = &[".jpg", ".jpeg", ".png", ".gif", ".webp"];
/// Substrings in a name that mark a file as a sample rather than a book. /// Substrings in a name that mark a file as a sample rather than a book.
const SAMPLE_MARKERS: &[&str] = &["sample", "readme", "read me"]; const SAMPLE_MARKERS: &[&str] = &["sample", "readme", "read me"];
/// Below this, a file is too small to be a book whatever it claims to be. /// Below this, a file is too small to be a book whatever it claims to be.
const SMALLEST_BOOK: u64 = 4096; const SMALLEST_BOOK: u64 = 4096;
/// How much of the front is read to tell text from markup. /// How much of the front is read to tell RTF, FictionBook, markup and text
/// apart.
const TEXT_WINDOW: usize = 4096; const TEXT_WINDOW: usize = 4096;
/// Identify a file. /// Identify a file.
@@ -49,12 +68,17 @@ pub fn sniff(source: &impl Bytes, name: &str) -> Doc {
Kind::Pdf Kind::Pdf
} else if source.magic_at(0, OLE2) { } else if source.magic_at(0, OLE2) {
Kind::Ole2 Kind::Ole2
} else if source.magic_at(0, LIT) {
Kind::Lit
} else if source.magic_at(0, zip::LOCAL_HEADER) { } else if source.magic_at(0, zip::LOCAL_HEADER) {
archive(source, &mut doc) archive(source, &mut doc)
} else if let Some(kind) = mobi::kind(source) { } else if let Some(kind) = mobi::kind(source) {
kind kind
} else { } else {
text(source, &mut doc) match source.slice(0, TEXT_WINDOW) {
Ok(head) => front(&head, &mut doc),
Err(_) => Kind::Unknown,
}
}; };
if doc.kind == Kind::Unknown { if doc.kind == Kind::Unknown {
@@ -80,7 +104,8 @@ pub fn sniff(source: &impl Bytes, name: &str) -> Doc {
doc doc
} }
/// What is inside a zip: an EPUB, a wrapped PDF, or a collection of pages. /// What is inside a zip: an EPUB, a wrapped book, a comic, or a collection of
/// pages.
fn archive(source: &impl Bytes, doc: &mut Doc) -> Kind { fn archive(source: &impl Bytes, doc: &mut Doc) -> Kind {
let Some(names) = zip::members(source) else { let Some(names) = zip::members(source) else {
doc.note("a zip whose directory could not be read"); doc.note("a zip whose directory could not be read");
@@ -99,6 +124,28 @@ fn archive(source: &impl Bytes, doc: &mut Doc) -> Kind {
doc.note(format!("a zip holding one PDF, {pdf:?}")); doc.note(format!("a zip holding one PDF, {pdf:?}"));
return Kind::ZipWrappedPdf; return Kind::ZipWrappedPdf;
} }
if let Some(fb2) = sole_member(&names, ".fb2") {
doc.note(format!("a zip holding one FictionBook, {fb2:?}"));
return Kind::ZipWrappedFb2;
}
if let Some(pages) = comic_pages(&names) {
// A zip of pictures is as often a holiday as a comic, and only the
// names have been read. It is a comic when something says so: the
// catalogue entry a comic reader writes, or a name that claims it.
let named_comic = doc
.declared_ext
.as_deref()
.is_some_and(|ext| COMIC_EXTENSIONS.contains(&ext));
if pages.catalogued || named_comic {
return Kind::Cbz;
}
doc.note(format!(
"a zip of {} image{}, which would be a comic book if its name said .cbz",
pages.count,
if pages.count == 1 { "" } else { "s" }
));
return Kind::Unknown;
}
if lower if lower
.iter() .iter()
.any(|name| name.ends_with(".htm") || name.ends_with(".html") || name.ends_with(".xhtml")) .any(|name| name.ends_with(".htm") || name.ends_with(".html") || name.ends_with(".xhtml"))
@@ -119,25 +166,94 @@ fn archive(source: &impl Bytes, doc: &mut Doc) -> Kind {
/// a collection, and choosing among them would be a guess — better to say it /// a collection, and choosing among them would be a guess — better to say it
/// is not a book than to name one chapter as the whole. /// is not a book than to name one chapter as the whole.
pub fn sole_pdf(names: &[String]) -> Option<&str> { pub fn sole_pdf(names: &[String]) -> Option<&str> {
let real: Vec<&String> = names sole_member(names, ".pdf")
}
/// The one member an archive exists to carry, if it is the only real member
/// and its name ends in `extension`.
fn sole_member<'a>(names: &'a [String], extension: &str) -> Option<&'a str> {
match real_members(names).as_slice() {
[only] if only.to_lowercase().ends_with(extension) => Some(only.as_str()),
_ => None,
}
}
/// The pages of an archive that is images and nothing else.
struct Pages {
/// How many images there are.
count: usize,
/// Whether a comic reader's catalogue entry is there beside them.
catalogued: bool,
}
/// The pages, if an archive is images and nothing else.
///
/// Every real member has to be an image. One PDF or one text file among the
/// pages makes it something else, and saying what would be a guess. Images
/// are known by their names alone: only the archive's directory is read.
fn comic_pages(names: &[String]) -> Option<Pages> {
let mut catalogued = false;
let pages: Vec<String> = real_members(names)
.into_iter()
.map(|name| name.to_lowercase())
.filter(|name| {
let file = name.rsplit('/').next().unwrap_or(name);
let catalogue = file == COMIC_CATALOGUE;
catalogued |= catalogue;
!catalogue
})
.collect();
let all_images = !pages.is_empty()
&& pages
.iter()
.all(|name| PAGE_IMAGES.iter().any(|image| name.ends_with(image)));
all_images.then_some(Pages {
count: pages.len(),
catalogued,
})
}
/// The members that carry content: no directories, no desktop clutter.
fn real_members(names: &[String]) -> Vec<&String> {
names
.iter() .iter()
.filter(|name| !name.ends_with('/')) .filter(|name| !name.ends_with('/'))
.filter(|name| { .filter(|name| {
let lower = name.to_lowercase(); let lower = name.to_lowercase();
!ARCHIVE_NOISE.iter().any(|noise| lower.contains(noise)) !ARCHIVE_NOISE.iter().any(|noise| lower.contains(noise))
}) })
.collect(); .collect()
match real.as_slice() { }
[only] if only.to_lowercase().ends_with(".pdf") => Some(only.as_str()),
_ => None, /// What the front of a file says when it is not a container: RTF, a
/// FictionBook, markup, text, or nothing this knows.
fn front(head: &[u8], doc: &mut Doc) -> Kind {
// Both are recognised on bytes rather than text. RTF is seven-bit by
// design but not always in practice, and a FictionBook is as often
// windows-1251 as UTF-8.
let lead = head.strip_prefix(BOM).unwrap_or(head).trim_ascii_start();
if is_rtf(lead) {
return Kind::Rtf;
} }
if fb2::is_fictionbook(head) {
return Kind::Fb2;
}
text(head, doc)
}
/// Whether `lead` opens an RTF document.
///
/// `{\rtf` has to be the whole control word: what follows it is the version,
/// normally `1`, or the space or next control word that ends it. `{\rtfoo` is
/// some other word that happens to start the same way.
fn is_rtf(lead: &[u8]) -> bool {
lead.strip_prefix(RTF)
.and_then(|rest| rest.first())
.is_some_and(|next| next.is_ascii_digit() || next.is_ascii_whitespace() || *next == b'\\')
} }
/// Text, markup, or nothing this knows. /// Text, markup, or nothing this knows.
fn text(source: &impl Bytes, doc: &mut Doc) -> Kind { fn text(head: &[u8], doc: &mut Doc) -> Kind {
let Ok(head) = source.slice(0, TEXT_WINDOW) else {
return Kind::Unknown;
};
if head.is_empty() { if head.is_empty() {
doc.note("the file is empty"); doc.note("the file is empty");
return Kind::Unknown; return Kind::Unknown;
@@ -149,7 +265,7 @@ fn text(source: &impl Bytes, doc: &mut Doc) -> Kind {
} }
// The window may have cut a character in half, which is not the same // The window may have cut a character in half, which is not the same
// thing as the file not being text. // thing as the file not being text.
let head = match std::str::from_utf8(&head) { let head = match std::str::from_utf8(head) {
Ok(text) => text, Ok(text) => text,
Err(e) if e.error_len().is_none() => { Err(e) if e.error_len().is_none() => {
std::str::from_utf8(&head[..e.valid_up_to()]).expect("a valid prefix") std::str::from_utf8(&head[..e.valid_up_to()]).expect("a valid prefix")
@@ -178,10 +294,15 @@ fn names_the_same_thing(extension: &str, kind: Kind) -> bool {
Kind::Djvu => matches!(extension, "djvu" | "djv"), Kind::Djvu => matches!(extension, "djvu" | "djv"),
Kind::Azw3 => matches!(extension, "azw3" | "azw" | "kfx"), Kind::Azw3 => matches!(extension, "azw3" | "azw" | "kfx"),
Kind::Mobi => matches!(extension, "mobi" | "prc" | "azw"), Kind::Mobi => matches!(extension, "mobi" | "prc" | "azw"),
Kind::Lit => extension == "lit",
Kind::Fb2 => extension == "fb2",
Kind::Rtf => extension == "rtf",
Kind::Txt => matches!(extension, "txt" | "text" | "md" | "markdown"), Kind::Txt => matches!(extension, "txt" | "text" | "md" | "markdown"),
Kind::Html => matches!(extension, "html" | "htm" | "xhtml" | "xml"), Kind::Html => matches!(extension, "html" | "htm" | "xhtml" | "xml"),
Kind::Ole2 => matches!(extension, "doc" | "xls" | "ppt"), Kind::Ole2 => matches!(extension, "doc" | "xls" | "ppt"),
Kind::ZipWrappedPdf => extension == "zip", Kind::ZipWrappedPdf => extension == "zip",
Kind::ZipWrappedFb2 => matches!(extension, "zip" | "fbz"),
Kind::Cbz => extension == "cbz",
// Nothing was recognised, so there is nothing to disagree with. // Nothing was recognised, so there is nothing to disagree with.
Kind::Unknown => true, Kind::Unknown => true,
} }
@@ -194,10 +315,15 @@ fn spelling(kind: Kind) -> &'static str {
Kind::Djvu => "DjVu", Kind::Djvu => "DjVu",
Kind::Azw3 => "AZW3", Kind::Azw3 => "AZW3",
Kind::Mobi => "MOBI", Kind::Mobi => "MOBI",
Kind::Lit => "Microsoft Reader",
Kind::Fb2 => "FictionBook",
Kind::Rtf => "RTF",
Kind::Txt => "plain text", Kind::Txt => "plain text",
Kind::Html => "HTML", Kind::Html => "HTML",
Kind::Ole2 => "an OLE2 document", Kind::Ole2 => "an OLE2 document",
Kind::ZipWrappedPdf => "a zip holding one PDF", Kind::ZipWrappedPdf => "a zip holding one PDF",
Kind::ZipWrappedFb2 => "a zip holding one FictionBook",
Kind::Cbz => "a comic book archive",
Kind::Unknown => "nothing recognisable", Kind::Unknown => "nothing recognisable",
} }
} }
@@ -332,4 +458,243 @@ mod tests {
head.truncate(TEXT_WINDOW + 1); head.truncate(TEXT_WINDOW + 1);
assert_eq!(sniff(&head.as_slice(), "a.txt").kind, Kind::Txt); assert_eq!(sniff(&head.as_slice(), "a.txt").kind, Kind::Txt);
} }
fn archive_of(names: &[&str], name: &str) -> Doc {
sniff(&zip::tests::zip(names).as_slice(), name)
}
#[test]
fn rtf_is_recognised_by_its_opening_group() {
assert_eq!(kind_of(br"{\rtf1\ansi\deff0 {\fonttbl", "a.rtf"), Kind::Rtf);
assert_eq!(
kind_of(b"\xef\xbb\xbf\r\n {\\rtf1\\ansi", "a.rtf"),
Kind::Rtf
);
// Eight-bit text in the body does not stop it being RTF.
assert_eq!(kind_of(b"{\\rtf1 caf\xe9 \x00", "a.rtf"), Kind::Rtf);
}
#[test]
fn rtf_cut_short_of_its_control_word_is_not_rtf() {
assert_eq!(sniff(&br"{\rt".as_slice(), "a.rtf").kind, Kind::Txt);
assert_eq!(sniff(&br"{\rtf".as_slice(), "a.rtf").kind, Kind::Txt);
assert_eq!(kind_of(br"{\rtx1", "a.rtf"), Kind::Txt);
assert_eq!(kind_of(br"text {\rtf1", "a.rtf"), Kind::Txt);
}
#[test]
fn rtf_is_the_whole_control_word() {
assert_eq!(kind_of(br"{\rtf\ansi\deff0", "a.rtf"), Kind::Rtf);
assert_eq!(kind_of(b"{\\rtf \\ansi", "a.rtf"), Kind::Rtf);
assert_eq!(kind_of(b"{\\rtf\r\n\\ansi", "a.rtf"), Kind::Rtf);
assert_eq!(kind_of(br"{\rtf2", "a.rtf"), Kind::Rtf);
assert_eq!(kind_of(br"{\rtfoo bar}", "a.rtf"), Kind::Txt);
assert_eq!(kind_of(br"{\rtf}", "a.rtf"), Kind::Txt);
assert_eq!(kind_of(br"{\rtf-1", "a.rtf"), Kind::Txt);
}
#[test]
fn rtf_named_as_a_word_document_is_remarked_on() {
let doc = sniff(&padded(br"{\rtf1\ansi").as_slice(), "letter.doc");
assert_eq!(doc.kind, Kind::Rtf);
assert!(
doc.notes
.iter()
.any(|note| note == "named .doc, but the bytes say RTF"),
"{:?}",
doc.notes
);
}
#[test]
fn microsoft_reader_is_recognised_by_its_signature() {
let doc = sniff(&padded(b"ITOLITLS\x01\x00\x00\x00").as_slice(), "book.lit");
assert_eq!(doc.kind, Kind::Lit);
assert!(doc.notes.is_empty(), "{:?}", doc.notes);
assert_eq!(kind_of(b"ITOLITL", "book.lit"), Kind::Txt);
assert_eq!(kind_of(b" ITOLITLS", "book.lit"), Kind::Txt);
}
#[test]
fn a_fictionbook_is_recognised_and_other_xml_is_not() {
let book = b"<?xml version=\"1.0\" encoding=\"windows-1251\"?>\n\
<FictionBook xmlns=\"http://www.gribuser.ru/xml/fictionbook/2.0\">\
<book-title>\xcc\xe5\xeb\xfc\xed\xe8\xf6\xe0</book-title>";
let doc = sniff(&padded(book).as_slice(), "mill.fb2");
assert_eq!(doc.kind, Kind::Fb2);
assert!(doc.notes.is_empty(), "{:?}", doc.notes);
let feed = b"<?xml version=\"1.0\"?>\n<rss version=\"2.0\"><channel>";
assert_eq!(kind_of(feed, "feed.xml"), Kind::Html);
}
#[test]
fn a_utf16_fictionbook_is_recognised_in_either_byte_order() {
let book = "<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n<FictionBook>";
for big_endian in [false, true] {
let mut bytes = if big_endian {
vec![0xfe, 0xff]
} else {
vec![0xff, 0xfe]
};
for unit in book.encode_utf16() {
bytes.extend_from_slice(&if big_endian {
unit.to_be_bytes()
} else {
unit.to_le_bytes()
});
}
let doc = sniff(&padded(&bytes).as_slice(), "mill.fb2");
assert_eq!(doc.kind, Kind::Fb2, "big-endian: {big_endian}");
assert!(doc.notes.is_empty(), "{:?}", doc.notes);
}
}
#[test]
fn a_fictionbook_named_as_something_else_is_remarked_on() {
let doc = sniff(&padded(b"<FictionBook>").as_slice(), "mill.txt");
assert_eq!(doc.kind, Kind::Fb2);
assert!(
doc.notes.iter().any(|note| note.contains("FictionBook")),
"{:?}",
doc.notes
);
}
#[test]
fn a_zip_holding_one_fictionbook_is_a_wrapped_one() {
let doc = archive_of(&["__MACOSX/._mill.fb2", "mill.fb2"], "mill.fb2.zip");
assert_eq!(doc.kind, Kind::ZipWrappedFb2);
assert_eq!(doc.declared_ext.as_deref(), Some("zip"));
assert!(
doc.notes
.iter()
.any(|note| note.contains("one FictionBook")),
"{:?}",
doc.notes
);
assert!(!doc.notes.iter().any(|note| note.contains("named .")));
let two = archive_of(&["one.fb2", "two.fb2"], "pair.zip");
assert_eq!(two.kind, Kind::Unknown);
}
#[test]
fn a_zip_of_page_images_is_a_comic() {
let doc = archive_of(
&[
"Issue 1/",
"Issue 1/001.jpg",
"Issue 1/002.JPEG",
"Issue 1/003.png",
"Issue 1/004.gif",
"Issue 1/005.webp",
"ComicInfo.xml",
"__MACOSX/Issue 1/._001.jpg",
"Issue 1/Thumbs.db",
".DS_Store",
],
"issue-1.cbz",
);
assert_eq!(doc.kind, Kind::Cbz);
assert!(
!doc.notes.iter().any(|note| note.contains("named .")),
"{:?}",
doc.notes
);
}
#[test]
fn a_zip_of_photographs_is_not_a_comic() {
let doc = archive_of(&["IMG_0001.jpg", "IMG_0002.jpg"], "holiday.zip");
assert_eq!(doc.kind, Kind::Unknown);
assert!(
doc.notes.iter().any(|note| note
== "a zip of 2 images, which would be a comic book if its name said .cbz"),
"{:?}",
doc.notes
);
let one = archive_of(&["IMG_0001.jpg"], "holiday.zip");
assert!(
one.notes
.iter()
.any(|note| note.starts_with("a zip of 1 image,")),
"{:?}",
one.notes
);
}
#[test]
fn the_same_images_named_as_a_comic_are_one() {
let doc = archive_of(&["IMG_0001.jpg", "IMG_0002.jpg"], "holiday.cbz");
assert_eq!(doc.kind, Kind::Cbz);
assert!(
!doc.notes.iter().any(|note| note.contains("named .")),
"{:?}",
doc.notes
);
// A comic zipped and named as if it were RAR is still a comic, and
// the name is remarked on.
let rar = archive_of(&["001.jpg", "002.jpg"], "issue-1.cbr");
assert_eq!(rar.kind, Kind::Cbz);
assert!(
rar.notes.iter().any(|note| note.contains("named .cbr")),
"{:?}",
rar.notes
);
}
#[test]
fn a_comic_saved_as_a_zip_is_known_by_its_catalogue_and_remarked_on() {
let doc = archive_of(&["001.jpg", "002.jpg", "ComicInfo.xml"], "issue-1.zip");
assert_eq!(doc.kind, Kind::Cbz);
assert!(
doc.notes
.iter()
.any(|note| note == "named .zip, but the bytes say a comic book archive"),
"{:?}",
doc.notes
);
}
#[test]
fn a_zip_with_anything_but_images_is_not_a_comic() {
let one_pdf = archive_of(&["001.jpg", "002.jpg", "extras.pdf"], "issue-1.cbz");
assert_eq!(one_pdf.kind, Kind::Unknown);
assert!(
one_pdf
.notes
.iter()
.any(|note| note.contains("none of them a book")),
"{:?}",
one_pdf.notes
);
let readme = archive_of(&["001.jpg", "readme.txt"], "issue-1.cbz");
assert_eq!(readme.kind, Kind::Unknown);
// A name that ends like an image without being one.
let lookalike = archive_of(&["001.jpg", "notes.jpg.txt"], "issue-1.cbz");
assert_eq!(lookalike.kind, Kind::Unknown);
}
#[test]
fn a_zip_with_no_pages_is_not_a_comic() {
let catalogue_only = archive_of(&["ComicInfo.xml", "pages/"], "issue-1.cbz");
assert_eq!(catalogue_only.kind, Kind::Unknown);
let empty = archive_of(&[], "issue-1.cbz");
assert_eq!(empty.kind, Kind::Unknown);
}
#[test]
fn a_truncated_comic_does_not_panic() {
let whole = zip::tests::zip(&["001.jpg", "002.png", "ComicInfo.xml"]);
for cut in 0..whole.len() {
let _ = sniff(&&whole[..cut], "issue-1.cbz");
}
}
} }
+2 -2
View File
@@ -78,11 +78,11 @@ fn directory_start(source: &impl Bytes) -> Option<(usize, u64)> {
} }
#[cfg(test)] #[cfg(test)]
mod tests { pub(crate) mod tests {
use super::*; use super::*;
/// Build a zip holding `names`, each with empty stored content. /// Build a zip holding `names`, each with empty stored content.
fn zip(names: &[&str]) -> Vec<u8> { pub(crate) fn zip(names: &[&str]) -> Vec<u8> {
let mut out: Vec<u8> = Vec::new(); let mut out: Vec<u8> = Vec::new();
let mut offsets = Vec::new(); let mut offsets = Vec::new();
for name in names { for name in names {
+24
View File
@@ -64,6 +64,30 @@ fn several_files_come_back_as_several_records() {
assert_eq!(kinds, ["\"pdf\"", "\"djvu\""]); assert_eq!(kinds, ["\"pdf\"", "\"djvu\""]);
} }
#[test]
fn the_formats_read_from_the_front_come_back_by_their_wire_names() {
let rtf = written("letter.rtf", &padded(br"{\rtf1\ansi"));
let lit = written("book.lit", &padded(b"ITOLITLS"));
let fb2 = written(
"mill.fb2",
&padded(b"<?xml version=\"1.0\"?>\n<FictionBook xmlns=\"http://www.gribuser.ru/xml/fictionbook/2.0\">"),
);
let out = run(&[
rtf.to_str().expect("utf-8 path"),
lit.to_str().expect("utf-8 path"),
fb2.to_str().expect("utf-8 path"),
]);
assert_eq!(out.status.code(), Some(0));
let kinds: Vec<String> = String::from_utf8(out.stdout)
.expect("utf-8")
.lines()
.map(|line| {
serde_json::from_str::<serde_json::Value>(line).expect("valid JSON")["kind"].to_string()
})
.collect();
assert_eq!(kinds, ["\"rtf\"", "\"lit\"", "\"fb2\""]);
}
#[test] #[test]
fn a_file_that_is_not_there_is_an_io_error_and_the_rest_still_run() { fn a_file_that_is_not_there_is_an_io_error_and_the_rest_still_run() {
let path = written("kept.pdf", &padded(b"%PDF-1.7")); let path = written("kept.pdf", &padded(b"%PDF-1.7"));
+15
View File
@@ -77,6 +77,12 @@ pub enum Kind {
Azw3, Azw3,
/// Amazon MOBI. /// Amazon MOBI.
Mobi, Mobi,
/// Microsoft Reader.
Lit,
/// FictionBook, the XML ebook format.
Fb2,
/// Rich Text Format.
Rtf,
/// Plain text. /// Plain text.
Txt, Txt,
/// HTML or XHTML. /// HTML or XHTML.
@@ -85,6 +91,10 @@ pub enum Kind {
Ole2, Ole2,
/// A zip archive whose only real content is a single PDF. /// A zip archive whose only real content is a single PDF.
ZipWrappedPdf, ZipWrappedPdf,
/// A zip archive whose only real content is a single FictionBook.
ZipWrappedFb2,
/// A comic book archive: a zip of page images.
Cbz,
/// Recognised as none of the above. /// Recognised as none of the above.
Unknown, Unknown,
} }
@@ -118,6 +128,11 @@ mod tests {
(Kind::Ole2, "\"ole2\""), (Kind::Ole2, "\"ole2\""),
(Kind::Azw3, "\"azw3\""), (Kind::Azw3, "\"azw3\""),
(Kind::ZipWrappedPdf, "\"zip-wrapped-pdf\""), (Kind::ZipWrappedPdf, "\"zip-wrapped-pdf\""),
(Kind::ZipWrappedFb2, "\"zip-wrapped-fb2\""),
(Kind::Fb2, "\"fb2\""),
(Kind::Lit, "\"lit\""),
(Kind::Rtf, "\"rtf\""),
(Kind::Cbz, "\"cbz\""),
(Kind::Unknown, "\"unknown\""), (Kind::Unknown, "\"unknown\""),
]; ];
for (kind, wire) in cases { for (kind, wire) in cases {
+2
View File
@@ -30,6 +30,8 @@ pub const UNAVAILABLE: u8 = 69;
pub const SOFTWARE: u8 = 70; pub const SOFTWARE: u8 = 70;
/// Reading or writing failed. /// Reading or writing failed.
pub const IO: u8 = 74; pub const IO: u8 = 74;
/// A service refused for now, and asking again later will work.
pub const TEMPFAIL: u8 = 75;
/// The answer a predicate tool gives. /// The answer a predicate tool gives.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
+2 -1
View File
@@ -47,6 +47,7 @@ that was concatenated or written on Windows still reads.
| `69` | A program it needs is not installed | | `69` | A program it needs is not installed |
| `70` | Its own fault | | `70` | Its own fault |
| `74` | Reading or writing failed | | `74` | Reading or writing failed |
| `75` | A service refused for now; try again later |
Failures start where `sysexits.h` puts them, so "no" and "broke" are never the Failures start where `sysexits.h` puts them, so "no" and "broke" are never the
same number. That is what makes this work: same number. That is what makes this work:
@@ -64,7 +65,7 @@ No network, no database, no configuration file, no state kept between runs.
Five exceptions, each declared in `--help`: Five exceptions, each declared in `--help`:
- [`emb`](../crates/emb/README.md) makes an HTTP call, which is the whole of what it does. - [`emb`](../crates/emb/README.md) makes an HTTP call, which is the whole of what it does.
- [`bibfetch`](../crates/bibfetch/README.md) asks a register about an identifier, likewise. - [`bibfetch`](../crates/bibfetch/README.md) asks a register about an identifier or a title, likewise.
- [`pdftriage`](../crates/pdftriage/README.md) runs `pdftotext` and `pdfinfo`. - [`pdftriage`](../crates/pdftriage/README.md) runs `pdftotext` and `pdfinfo`.
- [`pdfgutter`](../crates/pdfgutter/README.md) runs `pdftoppm` to look at a page. - [`pdfgutter`](../crates/pdfgutter/README.md) runs `pdftoppm` to look at a page.
- [`avplan`](../crates/avplan/README.md) runs `ffprobe` to read a file's streams, and - [`avplan`](../crates/avplan/README.md) runs `ffprobe` to read a file's streams, and
+1 -1
View File
@@ -19,7 +19,7 @@ Part of [catena](../README.md).
| Field | Meaning | | Field | Meaning |
|---|---| |---|---|
| `path` | Where the file was read from, as given | | `path` | Where the file was read from, as given |
| `kind` | `pdf`, `epub`, `djvu`, `azw3`, `mobi`, `txt`, `html`, `ole2`, `zip-wrapped-pdf`, `unknown` | | `kind` | `pdf`, `epub`, `djvu`, `azw3`, `mobi`, `lit`, `fb2`, `rtf`, `txt`, `html`, `ole2`, `cbz`, `zip-wrapped-pdf`, `zip-wrapped-fb2`, `unknown` |
| `declared_ext` | The extension the name claimed, lowercased, without the dot | | `declared_ext` | The extension the name claimed, lowercased, without the dot |
| `bytes` | Size on disk | | `bytes` | Size on disk |
| `pages` | Page count, for paginated formats | | `pages` | Page count, for paginated formats |