Files
catena/crates/booksniff/src/lib.rs
T
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

701 lines
24 KiB
Rust

//! Says what a file actually is, whatever its name claims.
//!
//! A library accumulates files that lie: a DjVu named `.pdf`, a Word document
//! named `.epub`, an EPUB saved as `.zip` and then never opened again, a
//! single PDF somebody zipped and renamed back to `.pdf`. Every one of those
//! fails somewhere downstream, usually as an unhelpful error from a converter
//! that was handed the wrong kind of file.
//!
//! The containers are unambiguous, so they are read rather than guessed at.
pub mod bytes;
pub mod fb2;
pub mod mobi;
pub mod zip;
use corpus_core::{Doc, Kind};
use crate::bytes::Bytes;
/// DjVu's container magic: an IFF stream tagged for the format's originator.
const DJVU: &[u8] = b"AT&TFORM";
/// The OLE2 compound-file header every binary Word document starts with.
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_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.
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 RTF, FictionBook, markup and text
/// apart.
const TEXT_WINDOW: usize = 4096;
/// Identify a file.
///
/// `name` is used only for the extension it claims and for the words in it,
/// both of which are reported as observations rather than believed.
pub fn sniff(source: &impl Bytes, name: &str) -> Doc {
let mut doc = Doc::new(name, Kind::Unknown, source.len() as i64);
doc.declared_ext = extension(name);
doc.kind = if source.magic_at(0, DJVU) {
Kind::Djvu
} else if source.magic_at(0, PDF) {
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 {
match source.slice(0, TEXT_WINDOW) {
Ok(head) => front(&head, &mut doc),
Err(_) => Kind::Unknown,
}
};
if doc.kind == Kind::Unknown {
doc.note("no container or magic bytes this recognises");
}
if let Some(declared) = doc.declared_ext.as_deref()
&& !names_the_same_thing(declared, doc.kind)
{
doc.note(format!(
"named .{declared}, but the bytes say {}",
spelling(doc.kind)
));
}
if source.len() < SMALLEST_BOOK {
doc.note(format!(
"{} bytes, which is too small to be a book",
source.len()
));
}
if let Some(marker) = sample_marker(name) {
doc.note(format!("the name says {marker:?}, so it may be a sample"));
}
doc
}
/// 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");
return Kind::Unknown;
};
let lower: Vec<String> = names.iter().map(|name| name.to_lowercase()).collect();
if lower.iter().any(|name| name == "mimetype")
&& lower
.iter()
.any(|name| name.starts_with("meta-inf/container.xml"))
{
return Kind::Epub;
}
if let Some(pdf) = sole_pdf(&names) {
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 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
.iter()
.any(|name| name.ends_with(".htm") || name.ends_with(".html") || name.ends_with(".xhtml"))
{
doc.note("a zip of HTML documents");
return Kind::Unknown;
}
doc.note(format!(
"a zip of {} members, none of them a book",
names.len()
));
Kind::Unknown
}
/// The one PDF an archive exists to carry, if that is what it is.
///
/// Only an unambiguous wrapper qualifies. An archive holding several PDFs is
/// 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> {
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()
.filter(|name| !name.ends_with('/'))
.filter(|name| {
let lower = name.to_lowercase();
!ARCHIVE_NOISE.iter().any(|noise| lower.contains(noise))
})
.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 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.
fn text(head: &[u8], doc: &mut Doc) -> Kind {
if head.is_empty() {
doc.note("the file is empty");
return Kind::Unknown;
}
// A file with a null byte in its first few kilobytes is not text, and
// nothing else here is going to identify it either.
if head.contains(&0) {
return Kind::Unknown;
}
// 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) {
Ok(text) => text,
Err(e) if e.error_len().is_none() => {
std::str::from_utf8(&head[..e.valid_up_to()]).expect("a valid prefix")
}
Err(_) => return Kind::Unknown,
};
let start = head
.trim_start_matches(|c: char| textkit::is_space(c) || c == '\u{feff}')
.to_lowercase();
if start.starts_with("<!doctype html")
|| start.starts_with("<html")
|| start.starts_with("<?xml")
|| start.starts_with("<head")
{
return Kind::Html;
}
Kind::Txt
}
/// Whether an extension and a verdict are saying the same thing.
fn names_the_same_thing(extension: &str, kind: Kind) -> bool {
match kind {
Kind::Pdf => extension == "pdf",
Kind::Epub => extension == "epub",
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,
}
}
fn spelling(kind: Kind) -> &'static str {
match kind {
Kind::Pdf => "PDF",
Kind::Epub => "EPUB",
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",
}
}
fn extension(name: &str) -> Option<String> {
std::path::Path::new(name)
.extension()
.map(|ext| ext.to_string_lossy().to_lowercase())
}
fn sample_marker(name: &str) -> Option<&'static str> {
let lower = std::path::Path::new(name)
.file_name()
.map(|name| name.to_string_lossy().to_lowercase())
.unwrap_or_default();
SAMPLE_MARKERS
.iter()
.find(|marker| lower.contains(**marker))
.copied()
}
#[cfg(test)]
mod tests {
use super::*;
fn padded(head: &[u8]) -> Vec<u8> {
let mut out = head.to_vec();
out.resize(SMALLEST_BOOK as usize + 1, b'x');
out
}
fn kind_of(head: &[u8], name: &str) -> Kind {
sniff(&padded(head).as_slice(), name).kind
}
#[test]
fn the_magic_decides_not_the_name() {
assert_eq!(kind_of(b"%PDF-1.7\n", "book.epub"), Kind::Pdf);
assert_eq!(kind_of(DJVU, "scan.pdf"), Kind::Djvu);
assert_eq!(kind_of(OLE2, "book.epub"), Kind::Ole2);
}
#[test]
fn a_name_that_disagrees_with_the_bytes_is_noted() {
let doc = sniff(&padded(DJVU).as_slice(), "scan.pdf");
assert_eq!(doc.kind, Kind::Djvu);
assert!(
doc.notes.iter().any(|note| note.contains("named .pdf")),
"{:?}",
doc.notes
);
}
#[test]
fn a_name_that_agrees_is_not_remarked_on() {
let doc = sniff(&padded(b"%PDF-1.7").as_slice(), "book.pdf");
assert!(doc.notes.is_empty(), "{:?}", doc.notes);
}
#[test]
fn text_and_markup_are_told_apart() {
assert_eq!(
kind_of(b"The mill stood at the northern end.", "a.txt"),
Kind::Txt
);
assert_eq!(kind_of(b"<!DOCTYPE html>\n<html>", "a.html"), Kind::Html);
assert_eq!(kind_of(b"\n\n <html lang=\"en\">", "a.txt"), Kind::Html);
assert_eq!(kind_of(b"<?xml version=\"1.0\"?>", "a.xml"), Kind::Html);
}
#[test]
fn binary_rubbish_is_not_called_text() {
assert_eq!(kind_of(&[0x00, 0x01, 0x02, 0x03], "a.txt"), Kind::Unknown);
assert_eq!(kind_of(&[0xff, 0xfe, 0xfd], "a.txt"), Kind::Unknown);
}
#[test]
fn a_small_file_is_remarked_on() {
let doc = sniff(&b"%PDF-1.7".as_slice(), "tiny.pdf");
assert!(
doc.notes.iter().any(|note| note.contains("too small")),
"{:?}",
doc.notes
);
}
#[test]
fn a_name_that_says_sample_is_remarked_on() {
let doc = sniff(&padded(b"%PDF-1.7").as_slice(), "Book - Sample Chapter.pdf");
assert!(
doc.notes.iter().any(|note| note.contains("sample")),
"{:?}",
doc.notes
);
}
#[test]
fn the_one_pdf_in_an_archive_is_the_book_and_several_are_not() {
let one = ["__MACOSX/._x".into(), "dir/".into(), "book.pdf".into()];
assert_eq!(sole_pdf(&one), Some("book.pdf"));
let several = ["one.pdf".to_string(), "two.pdf".to_string()];
assert_eq!(sole_pdf(&several), None);
let mixed = ["book.pdf".to_string(), "cover.jpg".to_string()];
assert_eq!(sole_pdf(&mixed), None);
}
#[test]
fn an_empty_file_is_unknown_and_says_so() {
let doc = sniff(&b"".as_slice(), "nothing.pdf");
assert_eq!(doc.kind, Kind::Unknown);
assert!(doc.notes.iter().any(|note| note.contains("empty")));
assert_eq!(doc.bytes, 0);
}
#[test]
fn the_declared_extension_is_reported_lowercased() {
assert_eq!(
sniff(&padded(b"%PDF-").as_slice(), "BOOK.PDF").declared_ext,
Some("pdf".to_string())
);
assert_eq!(
sniff(&padded(b"%PDF-").as_slice(), "no-extension").declared_ext,
None
);
}
#[test]
fn a_character_cut_in_half_by_the_window_does_not_make_it_binary() {
let mut head = "é".repeat(TEXT_WINDOW).into_bytes();
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!(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");
}
}
}