diff --git a/crates/booksniff/README.md b/crates/booksniff/README.md index 4991a99..499b698 100644 --- a/crates/booksniff/README.md +++ b/crates/booksniff/README.md @@ -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 diff --git a/crates/booksniff/src/fb2.rs b/crates/booksniff/src/fb2.rs new file mode 100644 index 0000000..a182e2b --- /dev/null +++ b/crates/booksniff/src/fb2.rs @@ -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"")?; + } else if let Some(after) = rest.strip_prefix(b"")?; + } else if let Some(after) = rest.strip_prefix(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"\n\ + \n\ + The Mill"; + + #[test] + fn a_fictionbook_is_recognised() { + assert!(is_fictionbook(BOOK)); + assert!(is_fictionbook(b"")); + } + + #[test] + fn the_prolog_is_stepped_over() { + let head = b"\xef\xbb\xbf \r\n\ + \n\ + the mill\"> ]>\n\ + \n\ + "; + assert!(is_fictionbook(head)); + } + + #[test] + fn a_prefixed_root_is_still_a_fictionbook() { + assert!(is_fictionbook( + b"" + )); + } + + #[test] + fn markup_need_not_be_utf8() { + // windows-1251 for "Мельница" in the title. + let head = b"\ + \xcc\xe5\xeb\xfc\xed\xe8\xf6\xe0"; + assert!(is_fictionbook(head)); + } + + #[test] + fn other_xml_is_not_a_fictionbook() { + assert!(!is_fictionbook( + b"\n" + )); + assert!(!is_fictionbook(b"")); + assert!(!is_fictionbook(b"")); + assert!(!is_fictionbook(b"text before ")); + assert!(!is_fictionbook(b"")); + } + + #[test] + fn a_root_the_window_cut_short_is_not_taken() { + assert!(!is_fictionbook(b"")); + } + + #[test] + fn a_truncated_or_mangled_prolog_does_not_panic() { + let head = b"\xef\xbb\xbf'> ] \"]\">\ + "; + 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'>']); + } + } +} diff --git a/crates/booksniff/src/lib.rs b/crates/booksniff/src/lib.rs index e5b48f7..9a47220 100644 --- a/crates/booksniff/src/lib.rs +++ b/crates/booksniff/src/lib.rs @@ -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 = 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"\n\ + \ + \xcc\xe5\xeb\xfc\xed\xe8\xf6\xe0"; + 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"\n"; + 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"").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"); + } + } } diff --git a/crates/booksniff/src/zip.rs b/crates/booksniff/src/zip.rs index e4c0322..08163aa 100644 --- a/crates/booksniff/src/zip.rs +++ b/crates/booksniff/src/zip.rs @@ -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 { + pub(crate) fn zip(names: &[&str]) -> Vec { let mut out: Vec = Vec::new(); let mut offsets = Vec::new(); for name in names { diff --git a/crates/booksniff/tests/cli.rs b/crates/booksniff/tests/cli.rs index a39c7ab..418db8e 100644 --- a/crates/booksniff/tests/cli.rs +++ b/crates/booksniff/tests/cli.rs @@ -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"\n"), + ); + 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::from_utf8(out.stdout) + .expect("utf-8") + .lines() + .map(|line| { + serde_json::from_str::(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")); diff --git a/crates/corpus-core/src/doc.rs b/crates/corpus-core/src/doc.rs index e5d8e47..27ae751 100644 --- a/crates/corpus-core/src/doc.rs +++ b/crates/corpus-core/src/doc.rs @@ -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 { diff --git a/docs/records.md b/docs/records.md index e84f722..385a25f 100644 --- a/docs/records.md +++ b/docs/records.md @@ -19,7 +19,7 @@ Part of [catena](../README.md). | Field | Meaning | |---|---| | `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 | | `bytes` | Size on disk | | `pages` | Page count, for paginated formats |