Merge pull request 'booksniff: RTF, Microsoft Reader, FictionBook and comic archives' (#7) from booksniff-more-formats into main

This commit is contained in:
funkpower committed 2026-11-13 15:59:20 -05:00
commit 8ecc4766ee
7 files changed
+479 -18

No files matched your search

+21 -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.
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
renamed back to `.pdf`.
named `.epub`, an RTF saved as `.doc`, an EPUB saved as `.zip`, a single PDF
that somebody zipped and renamed back to `.pdf`.
## Install
@@ -89,7 +89,12 @@ one bad file does not stop a sweep.
| `ole2` | The compound-file header every binary Word document starts with |
| `epub` | A zip holding `mimetype` and `META-INF/container.xml` |
| `zip-wrapped-pdf` | A zip whose only real member is one PDF |
| `zip-wrapped-fb2` | A zip whose only real member is one FictionBook — the `.fb2.zip` most of them are passed around as |
| `cbz` | A zip whose every real member is a page image: JPEG, PNG, GIF or WebP |
| `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 |
| `fb2` | XML whose root element is `FictionBook`, found by stepping over the declaration, comments and doctype in the first four kilobytes |
| `html` | Markup at the front, after any byte-order mark |
| `txt` | Text that is none of the above |
| `unknown` | Anything else, with a note saying what was seen |
@@ -98,6 +103,20 @@ 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
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. Only names are
read, out of the archive's directory, so no member is ever unpacked.
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
```rust
+162
View File
@@ -0,0 +1,162 @@
//! 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.
use crate::BOM;
/// 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 {
root_element(head).is_some_and(|name| local_name(name) == ROOT)
}
/// 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>"));
}
#[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'>']);
}
}
}
+254 -13
View File
@@ -9,6 +9,7 @@
//! The containers are unambiguous, so they are read rather than guessed at.
pub mod bytes;
pub mod fb2;
pub mod mobi;
pub mod zip;
@@ -22,17 +23,32 @@ const DJVU: &[u8] = b"AT&TFORM";
const OLE2: &[u8] = &[0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
/// A PDF announces its version in its first bytes.
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.
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_NOISE: &[&str] = &["comicinfo.xml"];
/// 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.
const SAMPLE_MARKERS: &[&str] = &["sample", "readme", "read me"];
/// Below this, a file is too small to be a book whatever it claims to be.
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;
/// Identify a file.
@@ -49,12 +65,17 @@ pub fn sniff(source: &impl Bytes, name: &str) -> Doc {
Kind::Pdf
} else if source.magic_at(0, OLE2) {
Kind::Ole2
} else if source.magic_at(0, LIT) {
Kind::Lit
} else if source.magic_at(0, zip::LOCAL_HEADER) {
archive(source, &mut doc)
} else if let Some(kind) = mobi::kind(source) {
kind
} 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 {
@@ -80,7 +101,8 @@ pub fn sniff(source: &impl Bytes, name: &str) -> 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 {
let Some(names) = zip::members(source) else {
doc.note("a zip whose directory could not be read");
@@ -99,6 +121,13 @@ fn archive(source: &impl Bytes, doc: &mut Doc) -> Kind {
doc.note(format!("a zip holding one PDF, {pdf:?}"));
return Kind::ZipWrappedPdf;
}
if let Some(fb2) = sole_member(&names, ".fb2") {
doc.note(format!("a zip holding one FictionBook, {fb2:?}"));
return Kind::ZipWrappedFb2;
}
if is_comic(&names) {
return Kind::Cbz;
}
if lower
.iter()
.any(|name| name.ends_with(".htm") || name.ends_with(".html") || name.ends_with(".xhtml"))
@@ -119,25 +148,67 @@ fn archive(source: &impl Bytes, doc: &mut Doc) -> Kind {
/// 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.
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,
}
}
/// Whether an archive is the pages of a comic 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.
fn is_comic(names: &[String]) -> bool {
let pages: Vec<String> = real_members(names)
.into_iter()
.map(|name| name.to_lowercase())
.filter(|name| {
let file = name.rsplit('/').next().unwrap_or(name);
!COMIC_NOISE.contains(&file)
})
.collect();
!pages.is_empty()
&& pages
.iter()
.all(|name| PAGE_IMAGES.iter().any(|image| name.ends_with(image)))
}
/// The members that carry content: no directories, no desktop clutter.
fn real_members(names: &[String]) -> Vec<&String> {
names
.iter()
.filter(|name| !name.ends_with('/'))
.filter(|name| {
let lower = name.to_lowercase();
!ARCHIVE_NOISE.iter().any(|noise| lower.contains(noise))
})
.collect();
match real.as_slice() {
[only] if only.to_lowercase().ends_with(".pdf") => Some(only.as_str()),
_ => None,
.collect()
}
/// 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 lead.starts_with(RTF) {
return Kind::Rtf;
}
if fb2::is_fictionbook(head) {
return Kind::Fb2;
}
text(head, doc)
}
/// Text, markup, or nothing this knows.
fn text(source: &impl Bytes, doc: &mut Doc) -> Kind {
let Ok(head) = source.slice(0, TEXT_WINDOW) else {
return Kind::Unknown;
};
fn text(head: &[u8], doc: &mut Doc) -> Kind {
if head.is_empty() {
doc.note("the file is empty");
return Kind::Unknown;
@@ -149,7 +220,7 @@ fn text(source: &impl Bytes, doc: &mut Doc) -> Kind {
}
// The window may have cut a character in half, which is not the same
// 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,
Err(e) if e.error_len().is_none() => {
std::str::from_utf8(&head[..e.valid_up_to()]).expect("a valid prefix")
@@ -178,10 +249,15 @@ fn names_the_same_thing(extension: &str, kind: Kind) -> bool {
Kind::Djvu => matches!(extension, "djvu" | "djv"),
Kind::Azw3 => matches!(extension, "azw3" | "azw" | "kfx"),
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::Html => matches!(extension, "html" | "htm" | "xhtml" | "xml"),
Kind::Ole2 => matches!(extension, "doc" | "xls" | "ppt"),
Kind::ZipWrappedPdf => extension == "zip",
Kind::ZipWrappedFb2 => matches!(extension, "zip" | "fbz"),
Kind::Cbz => extension == "cbz",
// Nothing was recognised, so there is nothing to disagree with.
Kind::Unknown => true,
}
@@ -194,10 +270,15 @@ fn spelling(kind: Kind) -> &'static str {
Kind::Djvu => "DjVu",
Kind::Azw3 => "AZW3",
Kind::Mobi => "MOBI",
Kind::Lit => "Microsoft Reader",
Kind::Fb2 => "FictionBook",
Kind::Rtf => "RTF",
Kind::Txt => "plain text",
Kind::Html => "HTML",
Kind::Ole2 => "an OLE2 document",
Kind::ZipWrappedPdf => "a zip holding one PDF",
Kind::ZipWrappedFb2 => "a zip holding one FictionBook",
Kind::Cbz => "a comic book archive",
Kind::Unknown => "nothing recognisable",
}
}
@@ -332,4 +413,164 @@ mod tests {
head.truncate(TEXT_WINDOW + 1);
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!(kind_of(br"{\rtx1", "a.rtf"), Kind::Txt);
assert_eq!(kind_of(br"text {\rtf1", "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_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_comic_saved_as_a_zip_is_remarked_on() {
let doc = archive_of(&["001.jpg", "002.jpg"], "issue-1.zip");
assert_eq!(doc.kind, Kind::Cbz);
assert!(
doc.notes.iter().any(|note| note.contains("comic")),
"{:?}",
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)]
mod tests {
pub(crate) mod tests {
use super::*;
/// 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 offsets = Vec::new();
for name in names {
+24
View File
@@ -64,6 +64,30 @@ fn several_files_come_back_as_several_records() {
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]
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"));
+15
View File
@@ -77,6 +77,12 @@ pub enum Kind {
Azw3,
/// Amazon MOBI.
Mobi,
/// Microsoft Reader.
Lit,
/// FictionBook, the XML ebook format.
Fb2,
/// Rich Text Format.
Rtf,
/// Plain text.
Txt,
/// HTML or XHTML.
@@ -85,6 +91,10 @@ pub enum Kind {
Ole2,
/// A zip archive whose only real content is a single PDF.
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.
Unknown,
}
@@ -118,6 +128,11 @@ mod tests {
(Kind::Ole2, "\"ole2\""),
(Kind::Azw3, "\"azw3\""),
(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\""),
];
for (kind, wire) in cases {