mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-11-13 23:55:40 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd104834ed |
No files matched your search
Generated
+26
-26
@@ -101,7 +101,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "avplan"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -126,7 +126,7 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bibclean"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -143,7 +143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bibfetch"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -196,7 +196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bookmeta"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -210,7 +210,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "booksniff"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -250,7 +250,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "catena"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"bibclean",
|
||||
"bibfetch",
|
||||
@@ -277,7 +277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "catena-cli"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_mangen",
|
||||
@@ -312,7 +312,7 @@ checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "chunkmd"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -334,7 +334,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "citefind"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"chunkmd",
|
||||
@@ -399,7 +399,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "colophon"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"bibclean",
|
||||
"catena-cli",
|
||||
@@ -448,7 +448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "corpus-core"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"schemars",
|
||||
"serde",
|
||||
@@ -492,7 +492,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cuekit"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"corpus-core",
|
||||
"regex",
|
||||
@@ -504,7 +504,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cuenorm"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -514,7 +514,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cuepack"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -525,7 +525,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cueseg"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -612,7 +612,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "emb"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -876,7 +876,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "idnorm"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"bibclean",
|
||||
"catena-cli",
|
||||
@@ -942,7 +942,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "langid"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -1022,7 +1022,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mdscrub"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -1034,7 +1034,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mdsplit"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -1113,7 +1113,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "pdfgutter"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -1126,7 +1126,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdftriage"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -1370,7 +1370,7 @@ checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
|
||||
|
||||
[[package]]
|
||||
name = "rrf"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -1517,7 +1517,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "seriesof"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
@@ -1634,7 +1634,7 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "textkit"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"tiktoken-rs",
|
||||
@@ -1724,7 +1724,7 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tocfind"
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"catena-cli",
|
||||
"clap",
|
||||
|
||||
+27
-27
@@ -3,39 +3,39 @@ resolver = "3"
|
||||
members = ["crates/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.1"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "MPL-2.0"
|
||||
repository = "https://git.pumpin.online/funkpower/catena"
|
||||
|
||||
[workspace.dependencies]
|
||||
catena-cli = { path = "crates/catena-cli", version = "0.3.1" }
|
||||
corpus-core = { path = "crates/corpus-core", version = "0.3.1" }
|
||||
cuekit = { path = "crates/cuekit", version = "0.3.1" }
|
||||
textkit = { path = "crates/textkit", version = "0.3.1" }
|
||||
chunkmd = { path = "crates/chunkmd", version = "0.3.1" }
|
||||
mdscrub = { path = "crates/mdscrub", version = "0.3.1" }
|
||||
mdsplit = { path = "crates/mdsplit", version = "0.3.1" }
|
||||
pdftriage = { path = "crates/pdftriage", version = "0.3.1" }
|
||||
cuenorm = { path = "crates/cuenorm", version = "0.3.1" }
|
||||
cueseg = { path = "crates/cueseg", version = "0.3.1" }
|
||||
rrf = { path = "crates/rrf", version = "0.3.1" }
|
||||
cuepack = { path = "crates/cuepack", version = "0.3.1" }
|
||||
bibclean = { path = "crates/bibclean", version = "0.3.1" }
|
||||
avplan = { path = "crates/avplan", version = "0.3.1" }
|
||||
pdfgutter = { path = "crates/pdfgutter", version = "0.3.1" }
|
||||
catena = { path = "crates/bindings", version = "0.3.1" }
|
||||
seriesof = { path = "crates/seriesof", version = "0.3.1" }
|
||||
idnorm = { path = "crates/idnorm", version = "0.3.1" }
|
||||
bibfetch = { path = "crates/bibfetch", version = "0.3.1" }
|
||||
bookmeta = { path = "crates/bookmeta", version = "0.3.1" }
|
||||
langid = { path = "crates/langid", version = "0.3.1" }
|
||||
tocfind = { path = "crates/tocfind", version = "0.3.1" }
|
||||
booksniff = { path = "crates/booksniff", version = "0.3.1" }
|
||||
citefind = { path = "crates/citefind", version = "0.3.1" }
|
||||
colophon = { path = "crates/colophon", version = "0.3.1" }
|
||||
emb = { path = "crates/emb", version = "0.3.1" }
|
||||
catena-cli = { path = "crates/catena-cli", version = "0.4.0" }
|
||||
corpus-core = { path = "crates/corpus-core", version = "0.4.0" }
|
||||
cuekit = { path = "crates/cuekit", version = "0.4.0" }
|
||||
textkit = { path = "crates/textkit", version = "0.4.0" }
|
||||
chunkmd = { path = "crates/chunkmd", version = "0.4.0" }
|
||||
mdscrub = { path = "crates/mdscrub", version = "0.4.0" }
|
||||
mdsplit = { path = "crates/mdsplit", version = "0.4.0" }
|
||||
pdftriage = { path = "crates/pdftriage", version = "0.4.0" }
|
||||
cuenorm = { path = "crates/cuenorm", version = "0.4.0" }
|
||||
cueseg = { path = "crates/cueseg", version = "0.4.0" }
|
||||
rrf = { path = "crates/rrf", version = "0.4.0" }
|
||||
cuepack = { path = "crates/cuepack", version = "0.4.0" }
|
||||
bibclean = { path = "crates/bibclean", version = "0.4.0" }
|
||||
avplan = { path = "crates/avplan", version = "0.4.0" }
|
||||
pdfgutter = { path = "crates/pdfgutter", version = "0.4.0" }
|
||||
catena = { path = "crates/bindings", version = "0.4.0" }
|
||||
seriesof = { path = "crates/seriesof", version = "0.4.0" }
|
||||
idnorm = { path = "crates/idnorm", version = "0.4.0" }
|
||||
bibfetch = { path = "crates/bibfetch", version = "0.4.0" }
|
||||
bookmeta = { path = "crates/bookmeta", version = "0.4.0" }
|
||||
langid = { path = "crates/langid", version = "0.4.0" }
|
||||
tocfind = { path = "crates/tocfind", version = "0.4.0" }
|
||||
booksniff = { path = "crates/booksniff", version = "0.4.0" }
|
||||
citefind = { path = "crates/citefind", version = "0.4.0" }
|
||||
colophon = { path = "crates/colophon", version = "0.4.0" }
|
||||
emb = { path = "crates/emb", version = "0.4.0" }
|
||||
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
clap_mangen = "0.3"
|
||||
|
||||
@@ -112,35 +112,10 @@ an EXTH block that breaks off keeps the records before the break.
|
||||
|
||||
## Covers
|
||||
|
||||
`Metadata::cover` is the cover image the file carries, with its media type
|
||||
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.
|
||||
|
||||
A Kindle book's cover is EXTH 201, or the thumbnail, 202, when no cover is
|
||||
named, and with neither the first image record.
|
||||
|
||||
An EPUB's is the first of these that reaches an image:
|
||||
|
||||
1. The manifest item whose `properties` include `cover-image` (EPUB 3).
|
||||
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
|
||||
followed too.
|
||||
3. The guide's `<reference type="cover">`, a page, and the first `<img src>`
|
||||
or SVG `<image href>` on it. A guide that points straight at an image is
|
||||
taken at its word.
|
||||
4. Any image in the manifest whose id or address says `cover`.
|
||||
|
||||
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
|
||||
document they are written in, with their fragment off and their
|
||||
percent-escapes undone, and one that climbs out of the archive or names a
|
||||
scheme (`http:`, `data:`) is not followed.
|
||||
|
||||
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
|
||||
`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
|
||||
claims for it.
|
||||
`Metadata::cover` is the cover image a Kindle book carries (EXTH 201, or the
|
||||
thumbnail, 202, when no cover is named), with its media type read off its
|
||||
first bytes. It is for library callers and is not part of the JSON record,
|
||||
which is a line of text.
|
||||
|
||||
## Asking a model
|
||||
|
||||
|
||||
+1
-724
@@ -4,17 +4,10 @@
|
||||
//! document carries Dublin Core elements: title, creator, publisher, date,
|
||||
//! language, identifier, subject. Calibre adds its own `<meta>` entries for
|
||||
//! the series, which nothing in the standard has a place for.
|
||||
//!
|
||||
//! The cover is whichever image the package names, asked in the order the
|
||||
//! standards came: EPUB 3's `cover-image` property, EPUB 2's
|
||||
//! `<meta name="cover">`, the guide's cover page and the image on it, and
|
||||
//! last any image whose name says cover. Every address is resolved inside the
|
||||
//! archive and nowhere else, and every member is read no further than a fixed
|
||||
//! bound, [`MAX_COVER`] for an image.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use crate::{Cover, MAX_COVER, Metadata, Source, image_type};
|
||||
use crate::{Metadata, Source};
|
||||
|
||||
/// How large a package document is worth reading.
|
||||
///
|
||||
@@ -45,7 +38,6 @@ pub fn read(path: &str, bytes: &[u8]) -> Metadata {
|
||||
};
|
||||
|
||||
fill(&mut meta, &package);
|
||||
meta.cover = cover(&mut archive, &package_path, &package, &mut meta);
|
||||
if meta.is_empty() {
|
||||
meta.note("a package document with nothing filled in");
|
||||
}
|
||||
@@ -256,339 +248,6 @@ fn unescape(text: &str) -> String {
|
||||
.replace("&", "&")
|
||||
}
|
||||
|
||||
/// One entry of the package's manifest.
|
||||
struct Item {
|
||||
id: String,
|
||||
href: String,
|
||||
media_type: String,
|
||||
properties: String,
|
||||
/// Where `href` leads inside the archive, when it leads anywhere.
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
/// The cover image, by the first of the four routes that reaches one.
|
||||
///
|
||||
/// A route that names something unusable leaves a note and the next route is
|
||||
/// asked, so a book whose declared cover is missing can still have the one
|
||||
/// its file name gives away.
|
||||
fn cover<R: std::io::Read + std::io::Seek>(
|
||||
archive: &mut zip::ZipArchive<R>,
|
||||
package_path: &str,
|
||||
package: &str,
|
||||
meta: &mut Metadata,
|
||||
) -> Option<Cover> {
|
||||
let base = directory_of(package_path);
|
||||
let items: Vec<Item> = tags(package)
|
||||
.filter(|(name, _)| *name == "item")
|
||||
.map(|(_, tag)| {
|
||||
let href = attr(tag, "href").unwrap_or_default();
|
||||
Item {
|
||||
id: attr(tag, "id").unwrap_or_default(),
|
||||
path: resolve(base, &href),
|
||||
href,
|
||||
media_type: attr(tag, "media-type").unwrap_or_default(),
|
||||
properties: attr(tag, "properties").unwrap_or_default(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut look = Look {
|
||||
archive,
|
||||
items: &items,
|
||||
tried: Vec::new(),
|
||||
meta,
|
||||
};
|
||||
|
||||
// EPUB 3: the manifest item whose properties say so.
|
||||
for item in items.iter().filter(|item| {
|
||||
item.properties
|
||||
.split_ascii_whitespace()
|
||||
.any(|property| property == "cover-image")
|
||||
}) {
|
||||
if let Some(found) = look.image(base, &item.href) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
|
||||
// EPUB 2: `<meta name="cover" content="…">` names an item by its id, or
|
||||
// in some converters' hands by its address.
|
||||
if let Some(named) = tags(package)
|
||||
.filter(|(name, _)| *name == "meta")
|
||||
.find(|(_, tag)| attr(tag, "name").as_deref() == Some("cover"))
|
||||
.and_then(|(_, tag)| attr(tag, "content"))
|
||||
.map(|content| content.trim().to_string())
|
||||
.filter(|content| !content.is_empty())
|
||||
{
|
||||
let as_path = resolve(base, &named);
|
||||
let href = items
|
||||
.iter()
|
||||
.find(|item| item.id == named)
|
||||
.or_else(|| {
|
||||
items
|
||||
.iter()
|
||||
.find(|item| item.path.is_some() && item.path == as_path)
|
||||
})
|
||||
.map_or(named.as_str(), |item| item.href.as_str());
|
||||
if let Some(found) = look.image(base, href) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
|
||||
// The guide's cover page, and the first image on it.
|
||||
if let Some(page) = tags(package)
|
||||
.filter(|(name, _)| *name == "reference")
|
||||
.find(|(_, tag)| attr(tag, "type").is_some_and(|kind| kind.eq_ignore_ascii_case("cover")))
|
||||
.and_then(|(_, tag)| attr(tag, "href"))
|
||||
{
|
||||
if let Some(found) = look.page(base, &page) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
|
||||
// Last: an image whose id or address says cover.
|
||||
for item in items.iter().filter(|item| {
|
||||
item.media_type.starts_with("image/")
|
||||
&& (item.id.to_lowercase().contains("cover")
|
||||
|| item.href.to_lowercase().contains("cover"))
|
||||
}) {
|
||||
if let Some(found) = look.image(base, &item.href) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The search for a cover, remembering what it has already looked at so
|
||||
/// that two routes to the same member read it, and note it, once.
|
||||
struct Look<'a, R> {
|
||||
archive: &'a mut zip::ZipArchive<R>,
|
||||
items: &'a [Item],
|
||||
tried: Vec<String>,
|
||||
meta: &'a mut Metadata,
|
||||
}
|
||||
|
||||
impl<R: std::io::Read + std::io::Seek> Look<'_, R> {
|
||||
/// Where an address leads, or a note saying it leads nowhere it may.
|
||||
fn locate(&mut self, base: &str, href: &str) -> Option<String> {
|
||||
let Some(path) = resolve(base, href) else {
|
||||
self.meta.note(format!(
|
||||
"the cover's address, {href}, is not a path inside the archive"
|
||||
));
|
||||
return None;
|
||||
};
|
||||
if self.tried.contains(&path) {
|
||||
return None;
|
||||
}
|
||||
self.tried.push(path.clone());
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// An image at an address, if that is what is there.
|
||||
fn image(&mut self, base: &str, href: &str) -> Option<Cover> {
|
||||
let path = self.locate(base, href)?;
|
||||
let bytes = self.fetch(&path, MAX_COVER, "cover")?;
|
||||
self.check(&path, bytes)
|
||||
}
|
||||
|
||||
/// The image a cover page shows, or the page itself where the guide
|
||||
/// points straight at an image.
|
||||
fn page(&mut self, base: &str, href: &str) -> Option<Cover> {
|
||||
let path = self.locate(base, href)?;
|
||||
let bytes = self.fetch(&path, MAX_PACKAGE as usize, "cover page")?;
|
||||
if image_type(&bytes).is_some() {
|
||||
return self.check(&path, bytes);
|
||||
}
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let Some(src) = tags(&text).find_map(|(name, tag)| match name {
|
||||
"img" => attr(tag, "src"),
|
||||
"image" => attr(tag, "xlink:href").or_else(|| attr(tag, "href")),
|
||||
_ => None,
|
||||
}) else {
|
||||
self.meta
|
||||
.note(format!("the cover page, {path}, shows no image"));
|
||||
return None;
|
||||
};
|
||||
self.image(directory_of(&path), &src)
|
||||
}
|
||||
|
||||
/// One member's bytes, read no further than `cap`.
|
||||
///
|
||||
/// The size the archive declares is checked first and the read is
|
||||
/// bounded anyway, because the declaration may be a lie.
|
||||
fn fetch(&mut self, path: &str, cap: usize, what: &str) -> Option<Vec<u8>> {
|
||||
let file = match self.archive.by_name(path) {
|
||||
Ok(file) => file,
|
||||
Err(zip::result::ZipError::FileNotFound) => {
|
||||
self.meta
|
||||
.note(format!("the {what}, {path}, is not in the archive"));
|
||||
return None;
|
||||
}
|
||||
Err(_) => {
|
||||
self.meta
|
||||
.note(format!("the {what}, {path}, could not be read"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let too_large = format!("the {what}, {path}, is larger than {}", crate::size(cap));
|
||||
if file.size() > cap as u64 {
|
||||
self.meta.note(too_large);
|
||||
return None;
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
if file.take(cap as u64 + 1).read_to_end(&mut bytes).is_err() {
|
||||
self.meta
|
||||
.note(format!("the {what}, {path}, could not be read"));
|
||||
return None;
|
||||
}
|
||||
if bytes.len() > cap {
|
||||
self.meta.note(too_large);
|
||||
return None;
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// The bytes as a cover when they are an image, typed by what they are
|
||||
/// rather than by what the manifest says.
|
||||
fn check(&mut self, path: &str, bytes: Vec<u8>) -> Option<Cover> {
|
||||
let Some(media_type) = image_type(&bytes) else {
|
||||
self.meta
|
||||
.note(format!("the cover, {path}, is not an image"));
|
||||
return None;
|
||||
};
|
||||
if let Some(item) = self
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.path.as_deref() == Some(path))
|
||||
{
|
||||
let declared = item.media_type.trim();
|
||||
if !declared.is_empty() && !declared.eq_ignore_ascii_case(media_type) {
|
||||
self.meta.note(format!(
|
||||
"the manifest calls the cover, {path}, {declared}, but it is {media_type}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(Cover {
|
||||
media_type: media_type.to_string(),
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory a member sits in, with no trailing slash.
|
||||
fn directory_of(path: &str) -> &str {
|
||||
path.rsplit_once('/').map_or("", |(dir, _)| dir)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// scheme, or one that climbs above the root of the archive, leads nowhere
|
||||
/// this will follow.
|
||||
fn resolve(base: &str, href: &str) -> Option<String> {
|
||||
let href = href.split('#').next().unwrap_or("").trim();
|
||||
// `http:`, `data:`, `file:`: a scheme before the first slash.
|
||||
if let Some(colon) = href.find(':') {
|
||||
if !href[..colon].contains('/') {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let href = percent_decode(href)?;
|
||||
let mut parts: Vec<&str> = if href.starts_with('/') {
|
||||
Vec::new()
|
||||
} else {
|
||||
base.split('/').filter(|part| !part.is_empty()).collect()
|
||||
};
|
||||
for part in href.split('/') {
|
||||
match part {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
parts.pop()?;
|
||||
}
|
||||
part => parts.push(part),
|
||||
}
|
||||
}
|
||||
// An address of nothing but the directory it is written in names no
|
||||
// member at all.
|
||||
if href.is_empty() || parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(parts.join("/"))
|
||||
}
|
||||
|
||||
/// `%20` back to a space, and so on, where the result is still UTF-8.
|
||||
fn percent_decode(text: &str) -> Option<String> {
|
||||
let raw = text.as_bytes();
|
||||
let mut out = Vec::with_capacity(raw.len());
|
||||
let mut at = 0;
|
||||
while at < raw.len() {
|
||||
if raw[at] == b'%' {
|
||||
let hex = std::str::from_utf8(raw.get(at + 1..at + 3)?).ok()?;
|
||||
out.push(u8::from_str_radix(hex, 16).ok()?);
|
||||
at += 3;
|
||||
} else {
|
||||
out.push(raw[at]);
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).ok()
|
||||
}
|
||||
|
||||
/// Every start tag in a document, in order, as its local name and its text.
|
||||
///
|
||||
/// Comments, declarations, processing instructions and end tags are passed
|
||||
/// over. The local name is what follows any namespace prefix, so
|
||||
/// `<opf:item>` is an `item`.
|
||||
fn tags(xml: &str) -> impl Iterator<Item = (&str, &str)> {
|
||||
let mut rest = xml;
|
||||
std::iter::from_fn(move || {
|
||||
loop {
|
||||
let at = rest.find('<')?;
|
||||
rest = &rest[at..];
|
||||
if let Some(after) = rest.strip_prefix("<!--") {
|
||||
rest = after.find("-->").map_or("", |end| &after[end + 3..]);
|
||||
continue;
|
||||
}
|
||||
let end = rest.find('>')?;
|
||||
let tag = &rest[1..end];
|
||||
rest = &rest[end + 1..];
|
||||
if tag.starts_with(['/', '!', '?']) {
|
||||
continue;
|
||||
}
|
||||
let name = tag
|
||||
.split(|c: char| c.is_ascii_whitespace() || c == '/')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
let local = name.rsplit_once(':').map_or(name, |(_, local)| local);
|
||||
return Some((local, tag));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The value of one attribute in a tag's text, entities undone.
|
||||
///
|
||||
/// The name has to stand on its own — `id` is not the end of `xml:id` or
|
||||
/// `data-id` — and may have space either side of its `=`.
|
||||
fn attr(tag: &str, name: &str) -> Option<String> {
|
||||
let mut from = 0;
|
||||
while let Some(found) = tag[from..].find(name) {
|
||||
let at = from + found;
|
||||
from = at + name.len();
|
||||
if !tag[..at].ends_with(|c: char| c.is_ascii_whitespace()) {
|
||||
continue;
|
||||
}
|
||||
let Some(rest) = tag[from..].trim_start().strip_prefix('=') else {
|
||||
continue;
|
||||
};
|
||||
let rest = rest.trim_start();
|
||||
let Some(quote) = rest.chars().next().filter(|c| matches!(c, '"' | '\'')) else {
|
||||
continue;
|
||||
};
|
||||
let value = &rest[1..];
|
||||
let end = value.find(quote)?;
|
||||
return Some(unescape(&value[..end]));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -697,386 +356,4 @@ mod tests {
|
||||
assert_eq!(meta.source, "none");
|
||||
assert!(!meta.notes.is_empty());
|
||||
}
|
||||
|
||||
// Covers. Each book is built here, a few members zipped in memory: the
|
||||
// container, a package in `OEBPS/`, and whatever the case is about.
|
||||
|
||||
const JPEG: &[u8] = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00jpeg body";
|
||||
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR png body";
|
||||
const GIF: &[u8] = b"GIF89a\x01\x00\x01\x00 gif body";
|
||||
const WEBP: &[u8] = b"RIFF\x20\x00\x00\x00WEBPVP8 webp body";
|
||||
|
||||
const CONTAINER: &str = r#"<?xml version="1.0"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>"#;
|
||||
|
||||
/// A package with this manifest and these extra elements, around a title.
|
||||
fn opf(manifest: &str, extra: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>Covered</dc:title>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="text" href="text/one.xhtml" media-type="application/xhtml+xml"/>
|
||||
{manifest}
|
||||
</manifest>
|
||||
{extra}
|
||||
</package>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// An EPUB of these members, after the container.
|
||||
fn epub(members: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
let container: (&str, &[u8]) = ("META-INF/container.xml", CONTAINER.as_bytes());
|
||||
for (name, data) in std::iter::once(&container).chain(members) {
|
||||
zip.start_file(*name, options).unwrap();
|
||||
zip.write_all(data).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn book(package: &str, members: &[(&str, &[u8])]) -> Metadata {
|
||||
let mut all = vec![("OEBPS/content.opf", package.as_bytes())];
|
||||
all.extend_from_slice(members);
|
||||
read("x.epub", &epub(&all))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epub_3_names_its_cover_with_a_property() {
|
||||
let package = opf(
|
||||
r#"<item id="decoy-cover" href="images/cover-thumb.png" media-type="image/png"/>
|
||||
<item id="c" href="images/front.jpg" media-type="image/jpeg" properties="svg cover-image"/>"#,
|
||||
r#"<metadata><meta name="cover" content="decoy-cover"/></metadata>"#,
|
||||
);
|
||||
let meta = book(
|
||||
&package,
|
||||
&[
|
||||
("OEBPS/images/front.jpg", JPEG),
|
||||
("OEBPS/images/cover-thumb.png", PNG),
|
||||
],
|
||||
);
|
||||
let cover = meta.cover.expect("a cover");
|
||||
assert_eq!(cover.media_type, "image/jpeg");
|
||||
assert_eq!(cover.bytes, JPEG);
|
||||
assert!(meta.notes.is_empty(), "{:?}", meta.notes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epub_2_names_its_cover_with_a_meta_holding_an_id() {
|
||||
let package = opf(
|
||||
r#"<opf:item id="cover-img" href="Images/Front%20Page.png#top" media-type="image/png"/>"#,
|
||||
r#"<metadata><meta content="cover-img" name="cover" /></metadata>"#,
|
||||
);
|
||||
let meta = book(&package, &[("OEBPS/Images/Front Page.png", PNG)]);
|
||||
let cover = meta.cover.expect("a cover");
|
||||
assert_eq!(cover.media_type, "image/png");
|
||||
assert_eq!(cover.bytes, PNG);
|
||||
assert!(meta.notes.is_empty(), "{:?}", meta.notes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cover_meta_holding_an_address_is_followed_as_one() {
|
||||
// Listed in the manifest under another id, and not listed at all.
|
||||
let listed = opf(
|
||||
r#"<item id="x1" href="../art/front.gif" media-type="image/gif"/>"#,
|
||||
r#"<metadata><meta name="cover" content="../art/front.gif"/></metadata>"#,
|
||||
);
|
||||
let meta = book(&listed, &[("art/front.gif", GIF)]);
|
||||
assert_eq!(meta.cover.expect("a cover").media_type, "image/gif");
|
||||
|
||||
let unlisted = opf(
|
||||
"",
|
||||
r#"<metadata><meta name="cover" content="front.webp"/></metadata>"#,
|
||||
);
|
||||
let meta = book(&unlisted, &[("OEBPS/front.webp", WEBP)]);
|
||||
let cover = meta.cover.expect("a cover");
|
||||
assert_eq!(cover.media_type, "image/webp");
|
||||
assert_eq!(cover.bytes, WEBP);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_guide_cover_page_gives_the_first_image_on_it() {
|
||||
let package = opf(
|
||||
r#"<item id="page" href="text/cover.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="art" href="images/art.jpg" media-type="image/jpeg"/>"#,
|
||||
r#"<guide><reference type="Cover" title="Cover" href="text/cover.xhtml#start"/></guide>"#,
|
||||
);
|
||||
let page = br#"<html><body><!-- <img src="nothing.png"/> -->
|
||||
<img alt="cover" src="../images/art.jpg"/><img src="../images/later.png"/></body></html>"#;
|
||||
let meta = book(
|
||||
&package,
|
||||
&[
|
||||
("OEBPS/text/cover.xhtml", page),
|
||||
("OEBPS/images/art.jpg", JPEG),
|
||||
("OEBPS/images/later.png", PNG),
|
||||
],
|
||||
);
|
||||
let cover = meta.cover.expect("a cover");
|
||||
assert_eq!(cover.bytes, JPEG);
|
||||
assert!(meta.notes.is_empty(), "{:?}", meta.notes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_guide_cover_page_may_draw_its_image_in_svg() {
|
||||
let package = opf(
|
||||
"",
|
||||
r#"<guide><reference type="cover" href="titlepage.xhtml"/></guide>"#,
|
||||
);
|
||||
for image in [
|
||||
r#"<svg:image width="600" xlink:href="cover.png"/>"#,
|
||||
r#"<image href="cover.png" />"#,
|
||||
] {
|
||||
let page = format!(r#"<html><body><svg:svg>{image}</svg:svg></body></html>"#);
|
||||
let meta = book(
|
||||
&package,
|
||||
&[
|
||||
("OEBPS/titlepage.xhtml", page.as_bytes()),
|
||||
("OEBPS/cover.png", PNG),
|
||||
],
|
||||
);
|
||||
assert_eq!(meta.cover.expect(image).bytes, PNG);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_an_image_whose_name_says_cover() {
|
||||
let package = opf(
|
||||
r#"<item id="pic1" href="images/plate.jpg" media-type="image/jpeg"/>
|
||||
<item id="CoverImage" href="images/i2.gif" media-type="image/gif"/>"#,
|
||||
"",
|
||||
);
|
||||
let meta = book(
|
||||
&package,
|
||||
&[
|
||||
("OEBPS/images/plate.jpg", JPEG),
|
||||
("OEBPS/images/i2.gif", GIF),
|
||||
],
|
||||
);
|
||||
assert_eq!(meta.cover.expect("a cover").bytes, GIF);
|
||||
|
||||
let by_address = opf(
|
||||
r#"<item id="i9" href="images/Book_COVER.jpeg" media-type="image/jpeg"/>"#,
|
||||
"",
|
||||
);
|
||||
let meta = book(&by_address, &[("OEBPS/images/Book_COVER.jpeg", JPEG)]);
|
||||
assert_eq!(meta.cover.expect("a cover").bytes, JPEG);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_book_that_names_no_cover_has_none_and_says_nothing() {
|
||||
let package = opf(
|
||||
r#"<item id="plate" href="images/plate.jpg" media-type="image/jpeg"/>"#,
|
||||
"",
|
||||
);
|
||||
let meta = book(&package, &[("OEBPS/images/plate.jpg", JPEG)]);
|
||||
assert_eq!(meta.cover, None);
|
||||
assert!(meta.notes.is_empty(), "{:?}", meta.notes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mislabelled_cover_is_what_its_bytes_say() {
|
||||
let package = opf(
|
||||
r#"<item id="c" href="c.png" media-type="image/png" properties="cover-image"/>"#,
|
||||
"",
|
||||
);
|
||||
let meta = book(&package, &[("OEBPS/c.png", JPEG)]);
|
||||
assert_eq!(meta.cover.expect("a cover").media_type, "image/jpeg");
|
||||
assert_eq!(
|
||||
meta.notes,
|
||||
["the manifest calls the cover, OEBPS/c.png, image/png, but it is image/jpeg"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cover_that_is_not_an_image_is_a_note_and_not_a_cover() {
|
||||
let package = opf(
|
||||
r#"<item id="c" href="c.jpg" media-type="image/jpeg" properties="cover-image"/>"#,
|
||||
"",
|
||||
);
|
||||
let meta = book(&package, &[("OEBPS/c.jpg", b"<html>not an image</html>")]);
|
||||
assert_eq!(meta.cover, None);
|
||||
assert_eq!(meta.notes, ["the cover, OEBPS/c.jpg, is not an image"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cover_larger_than_the_cap_is_left_out() {
|
||||
let mut huge = JPEG.to_vec();
|
||||
huge.resize(MAX_COVER + 1, 0);
|
||||
let package = opf(
|
||||
r#"<item id="c" href="c.jpg" media-type="image/jpeg" properties="cover-image"/>"#,
|
||||
"",
|
||||
);
|
||||
let meta = book(&package, &[("OEBPS/c.jpg", &huge)]);
|
||||
assert_eq!(meta.cover, None);
|
||||
assert_eq!(
|
||||
meta.notes,
|
||||
["the cover, OEBPS/c.jpg, is larger than 16 MiB"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Read one member through `Look::fetch` with a cap of `cap`.
|
||||
fn fetch(bytes: Vec<u8>, cap: usize) -> (Option<usize>, Vec<String>) {
|
||||
let mut meta = Metadata::empty("x.epub", Source::Opf);
|
||||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut look = Look {
|
||||
archive: &mut archive,
|
||||
items: &[],
|
||||
tried: Vec::new(),
|
||||
meta: &mut meta,
|
||||
};
|
||||
let got = look.fetch("big.jpg", cap, "cover").map(|bytes| bytes.len());
|
||||
(got, meta.notes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_is_read_no_further_than_its_cap() {
|
||||
let bytes = epub(&[("big.jpg", &[0u8; 4096])]);
|
||||
assert_eq!(fetch(bytes.clone(), 4096).0, Some(4096));
|
||||
assert_eq!(
|
||||
fetch(bytes, 4095),
|
||||
(
|
||||
None,
|
||||
vec!["the cover, big.jpg, is larger than 4095 bytes".to_string()]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_whose_archive_understates_its_size_is_still_bounded() {
|
||||
let mut bytes = epub(&[("big.jpg", &[0u8; 4096])]);
|
||||
// Declare 10 bytes uncompressed, in the local header and in the
|
||||
// central directory, where 4096 are compressed in.
|
||||
// The name comes 30 bytes into a local header and 46 into a central
|
||||
// directory entry, and the uncompressed size 22 and 24.
|
||||
let local = find(&bytes, b"big.jpg") - 30;
|
||||
bytes[local + 22..local + 26].copy_from_slice(&10u32.to_le_bytes());
|
||||
let central = local + 31 + find(&bytes[local + 31..], b"big.jpg") - 46;
|
||||
assert_eq!(&bytes[central..central + 4], b"PK\x01\x02");
|
||||
bytes[central + 24..central + 28].copy_from_slice(&10u32.to_le_bytes());
|
||||
|
||||
// The declaration passes the check, and the read stops at the cap.
|
||||
let (got, notes) = fetch(bytes, 100);
|
||||
assert_eq!(got, None);
|
||||
assert_eq!(notes, ["the cover, big.jpg, is larger than 100 bytes"]);
|
||||
}
|
||||
|
||||
fn find(haystack: &[u8], needle: &[u8]) -> usize {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle)
|
||||
.expect("present")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cover_named_but_missing_is_a_note_and_the_next_route_is_asked() {
|
||||
let package = opf(
|
||||
r#"<item id="c" href="gone.jpg" media-type="image/jpeg" properties="cover-image"/>"#,
|
||||
"",
|
||||
);
|
||||
let meta = book(&package, &[]);
|
||||
assert_eq!(meta.cover, None);
|
||||
assert_eq!(
|
||||
meta.notes,
|
||||
["the cover, OEBPS/gone.jpg, is not in the archive"]
|
||||
);
|
||||
|
||||
// A missing page is said the same way.
|
||||
let page = opf(
|
||||
"",
|
||||
r#"<guide><reference type="cover" href="none.xhtml"/></guide>"#,
|
||||
);
|
||||
assert_eq!(
|
||||
book(&page, &[]).notes,
|
||||
["the cover page, OEBPS/none.xhtml, is not in the archive"]
|
||||
);
|
||||
|
||||
// The same file reached again by another route is not read twice,
|
||||
// and an image the name gives away still stands in.
|
||||
let package = opf(
|
||||
r#"<item id="cover" href="gone.jpg" media-type="image/jpeg" properties="cover-image"/>
|
||||
<item id="cover-small" href="small.png" media-type="image/png"/>"#,
|
||||
r#"<metadata><meta name="cover" content="cover"/></metadata>"#,
|
||||
);
|
||||
let meta = book(&package, &[("OEBPS/small.png", PNG)]);
|
||||
assert_eq!(meta.cover.expect("the stand-in").bytes, PNG);
|
||||
assert_eq!(
|
||||
meta.notes,
|
||||
["the cover, OEBPS/gone.jpg, is not in the archive"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_never_leaves_the_archive() {
|
||||
assert_eq!(resolve("OEBPS", "../c.jpg").as_deref(), Some("c.jpg"));
|
||||
assert_eq!(resolve("OEBPS", "/c.jpg").as_deref(), Some("c.jpg"));
|
||||
assert_eq!(
|
||||
resolve("OEBPS/text", "./a/../b.jpg").as_deref(),
|
||||
Some("OEBPS/text/b.jpg")
|
||||
);
|
||||
assert_eq!(resolve("", "a%2Fb.jpg").as_deref(), Some("a/b.jpg"));
|
||||
for hostile in [
|
||||
"../../c.jpg",
|
||||
"../../../etc/passwd",
|
||||
"http://example.com/c.jpg",
|
||||
"data:image/png;base64,AAAA",
|
||||
"file:///etc/passwd",
|
||||
"%ZZ.jpg",
|
||||
"%ff.jpg",
|
||||
"c%",
|
||||
"#only-a-fragment",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(resolve("OEBPS", hostile), None, "{hostile}");
|
||||
}
|
||||
|
||||
let package = opf(
|
||||
r#"<item id="c" href="../../c.jpg" media-type="image/jpeg" properties="cover-image"/>"#,
|
||||
"",
|
||||
);
|
||||
let meta = book(&package, &[("c.jpg", JPEG)]);
|
||||
assert_eq!(meta.cover, None);
|
||||
assert_eq!(
|
||||
meta.notes,
|
||||
["the cover's address, ../../c.jpg, is not a path inside the archive"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attribute_is_matched_by_its_whole_name() {
|
||||
let tag = r#"item data-id="no" xml:id="no" id = 'yes' href="a&b.jpg""#;
|
||||
assert_eq!(attr(tag, "id").as_deref(), Some("yes"));
|
||||
assert_eq!(attr(tag, "href").as_deref(), Some("a&b.jpg"));
|
||||
assert_eq!(attr(r#"image xlink:href="x.png""#, "href"), None);
|
||||
assert_eq!(attr(r#"item id="unterminated"#, "id"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_truncation_of_a_book_with_a_cover_returns() {
|
||||
let package = opf(
|
||||
r#"<item id="c" href="c.jpg" media-type="image/jpeg" properties="cover-image"/>"#,
|
||||
r#"<guide><reference type="cover" href="p.xhtml"/></guide>"#,
|
||||
);
|
||||
let bytes = epub(&[
|
||||
("OEBPS/content.opf", package.as_bytes()),
|
||||
("OEBPS/c.jpg", JPEG),
|
||||
("OEBPS/p.xhtml", b"<img src=\"c.jpg\"/>"),
|
||||
]);
|
||||
for len in 0..=bytes.len() {
|
||||
let _ = read("cut.epub", &bytes[..len]);
|
||||
}
|
||||
for at in 0..bytes.len() {
|
||||
let mut broken = bytes.clone();
|
||||
broken[at] ^= 0xff;
|
||||
let _ = read("broken.epub", &broken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,47 +106,12 @@ pub struct Metadata {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Cover {
|
||||
/// What kind of image it is, read off its first bytes: `image/jpeg`,
|
||||
/// `image/png`, `image/gif`, `image/webp` or `image/bmp`.
|
||||
/// `image/png`, `image/gif` or `image/bmp`.
|
||||
pub media_type: String,
|
||||
/// The image itself, never more than [`MAX_COVER`] bytes.
|
||||
/// The image itself.
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// How large a cover is worth keeping.
|
||||
///
|
||||
/// A real one is a few hundred kilobytes. An EPUB's cover has to be
|
||||
/// decompressed to be read, and this is as far as that goes: a larger one is
|
||||
/// left out with a note, in every format, so a hostile file cannot make a
|
||||
/// reader allocate whatever it likes.
|
||||
pub const MAX_COVER: usize = 16 * 1024 * 1024;
|
||||
|
||||
/// A size, as a note says it.
|
||||
pub(crate) fn size(bytes: usize) -> String {
|
||||
const MIB: usize = 1024 * 1024;
|
||||
if bytes >= MIB && bytes % MIB == 0 {
|
||||
format!("{} MiB", bytes / MIB)
|
||||
} else {
|
||||
format!("{bytes} bytes")
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of image these bytes are, by their magic.
|
||||
pub(crate) fn image_type(data: &[u8]) -> Option<&'static str> {
|
||||
if data.starts_with(&[0xff, 0xd8, 0xff]) {
|
||||
Some("image/jpeg")
|
||||
} else if data.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
Some("image/png")
|
||||
} else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
|
||||
Some("image/gif")
|
||||
} else if data.starts_with(b"RIFF") && data.get(8..12) == Some(b"WEBP") {
|
||||
Some("image/webp")
|
||||
} else if data.starts_with(b"BM") && data.len() > 14 {
|
||||
Some("image/bmp")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn version() -> u32 {
|
||||
corpus_core::V
|
||||
}
|
||||
|
||||
+16
-11
@@ -13,7 +13,7 @@
|
||||
//! panics or reads past the end.
|
||||
|
||||
use crate::epub::{digits_of, is_isbn, is_uuid};
|
||||
use crate::{Cover, MAX_COVER, Metadata, Source, image_type};
|
||||
use crate::{Cover, Metadata, Source};
|
||||
|
||||
/// Where a Palm database says what it holds.
|
||||
const TYPE_AT: usize = 60;
|
||||
@@ -332,13 +332,6 @@ fn cover(
|
||||
meta.note("the cover record is not an image");
|
||||
return None;
|
||||
};
|
||||
if data.len() > MAX_COVER {
|
||||
meta.note(format!(
|
||||
"the cover record is larger than {}",
|
||||
crate::size(MAX_COVER)
|
||||
));
|
||||
return None;
|
||||
}
|
||||
Some(Cover {
|
||||
media_type: media_type.to_string(),
|
||||
bytes: data.to_vec(),
|
||||
@@ -354,9 +347,6 @@ fn cover(
|
||||
fn first_image_record(bytes: &[u8], records: &[usize], first_image: Option<u32>) -> Option<Cover> {
|
||||
let first = first_image.filter(|&index| index != NONE)?;
|
||||
let data = record(bytes, records, first as usize)?;
|
||||
if data.len() > MAX_COVER {
|
||||
return None;
|
||||
}
|
||||
let media_type = image_type(data).filter(|kind| *kind != "image/bmp")?;
|
||||
Some(Cover {
|
||||
media_type: media_type.to_string(),
|
||||
@@ -369,6 +359,21 @@ fn is_asin(value: &str) -> bool {
|
||||
value.len() == 10 && value.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
/// What kind of image these bytes are, by their magic.
|
||||
fn image_type(data: &[u8]) -> Option<&'static str> {
|
||||
if data.starts_with(&[0xff, 0xd8, 0xff]) {
|
||||
Some("image/jpeg")
|
||||
} else if data.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
Some("image/png")
|
||||
} else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
|
||||
Some("image/gif")
|
||||
} else if data.starts_with(b"BM") && data.len() > 14 {
|
||||
Some("image/bmp")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The Palm database's name, the last place a title can come from.
|
||||
///
|
||||
/// Converters write the title there with spaces made underscores; it is
|
||||
|
||||
@@ -20,7 +20,6 @@ to any of them is a change to a decision rather than to an accident.
|
||||
- [pdfgutter — a page tree that does not add up is refused](#pdfgutter--a-page-tree-that-does-not-add-up-is-refused)
|
||||
- [pdfgutter — a sheet on its side is cut the way it is read](#pdfgutter--a-sheet-on-its-side-is-cut-the-way-it-is-read)
|
||||
- [pdfgutter — an unreadable file is an answer, not a false](#pdfgutter--an-unreadable-file-is-an-answer-not-a-false)
|
||||
- [bookmeta — a cover is what its bytes say it is](#bookmeta--a-cover-is-what-its-bytes-say-it-is)
|
||||
- [Every pattern — two regex engines, two Unicode tables](#every-pattern--two-regex-engines-two-unicode-tables)
|
||||
|
||||
## cuenorm — settings after the end time are the format, not a malformation
|
||||
@@ -244,23 +243,6 @@ answers, and the [contract](contract.md) gives them three different numbers. A
|
||||
caller wanting them collapsed treats 1 and 2 alike; a caller wanting to know
|
||||
which files it failed on can now find out.
|
||||
|
||||
## bookmeta — a cover is what its bytes say it is
|
||||
|
||||
An EPUB's manifest gives every image a media type, and the cover's is not
|
||||
used: the type comes from the image's first bytes, and a disagreement is a
|
||||
note. Elsewhere `bookmeta` reports what the file says rather than what is
|
||||
true, and this is the one place it does not.
|
||||
|
||||
The difference is that a title nobody believes is still a title, while a
|
||||
media type that is wrong is an image that does not open. A caller handed
|
||||
`image/png` for a JPEG writes `cover.png`, serves it as PNG, or hands it to a
|
||||
decoder that refuses it. An image can be replaced by a converter or by hand
|
||||
without its manifest entry changing, and the note keeps what the file said,
|
||||
so nothing is lost by correcting it.
|
||||
|
||||
The same reasoning goes the other way: bytes that are not an image are not a
|
||||
cover, whatever the manifest calls them.
|
||||
|
||||
## Every pattern — two regex engines, two Unicode tables
|
||||
|
||||
Patterns here spell their character classes explicitly rather than leaning on
|
||||
|
||||
Reference in new issue
Block a user