EPUB
E-books made from chapters of HTML or Markdown, with a title, author, cover and table of contents, and their details read back.
Version 1.0.0 · 2 functions · 3 crates
Functions
What use epub.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use epub_write, epub_info from epub (Quillo 0.6).
epub_write(path: str, book: any, chapters: list) → intor an errorWrites an EPUB at path (folders are made) and gives the number of chapters. book is { title, author, lang, description, cover, css }: lang like "en" (the default), cover the path of a JPEG or PNG, css a style sheet (a plain one otherwise). Each chapter is { title, html }: html is the chapter's body (from markdown(...) too); when it has no <h1>, the title is put above it.
epub_info(path: str) → anyor an errorWhat an EPUB file says about itself: { title, author, lang, items, files }: items counts the pages in its reading order (chapters, and a contents page if it has one).
Get it
$ quillo install epub # epub.qwer into this folder$ quillo install epub --example # and its example, epub.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/epub.qwer$ curl -O https://quillolang.org/plugins/files/epub.qwe$ curl -O https://quillolang.org/plugins/files/epub_sample.jpgThen, in your program, all of it or only what you call:
use epub.qweruse epub_write, epub_info from epub // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
epub-builder = { version = "0.8", default-features = false, features = ["zip-library"] }zip = { version = "2", default-features = false, features = ["deflate"] }roxmltree = "0.20"Example
// A short e-book made from chapters written in Markdown, ready to download.//// quillo run epub.qwe then open http://localhost:8080/book.epubuse "epub.qwer"let chapters = [ { title: "Beans", text: "Good coffee starts with **fresh beans**, roasted no more than a month ago." }, { title: "Water", text: "Use water just off the boil: about 94 °C.\n\n- Soft water tastes sweeter\n- Hard water dulls the cup" }, { title: "Time", text: "Four minutes for a French press. Then pour it all, or it goes bitter." },]fn make_book() = epub_write("data/coffee.epub", { title: "Coffee at Home", author: "Quillo Café", lang: "en", description: "Three short lessons.", cover: "epub_sample.jpg" }, chapters | map((c) => { title: c.title, html: str(markdown(c.text)) }))route GET /book.epub { make_book()? file("data/coffee.epub")}route GET /info { make_book()? epub_info("data/coffee.epub")}$ quillo run epub.qwe$ curl 'localhost:8080/info'$ curl 'localhost:8080/book.epub'Source
//! E-books (EPUB 3) made from chapters of HTML, with a title, author, cover and table of contents, and an e-book's details read back.//! version: 1.0.0//!//! [dependencies]//! epub-builder = { version = "0.8", default-features = false, features = ["zip-library"] }//! zip = { version = "2", default-features = false, features = ["deflate"] }//! roxmltree = "0.20"use epub_builder::{EpubBuilder, EpubContent, EpubVersion, ReferenceType, ZipLibrary};use serde_json::{json, Value};use std::io::Read;const MAX_CHAPTERS: usize = 2000;const DEFAULT_CSS: &str = "body { font-family: serif; line-height: 1.5; margin: 0 5%; } h1, h2, h3 { font-family: sans-serif; line-height: 1.2; } img { max-width: 100%; } pre { white-space: pre-wrap; }";fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)}// HTML as e-readers' XHTML wants it: void elements closed, and the named entities XML does not know as numbers.fn xhtml(html: &str) -> String { let mut out = String::with_capacity(html.len() + 64); let mut rest = html; while let Some(i) = rest.find('<') { out.push_str(&rest[..i]); rest = &rest[i..]; let Some(end) = rest.find('>') else { break }; let tag = &rest[..=end]; let name: String = tag[1..].chars().take_while(|c| c.is_ascii_alphanumeric()).collect::<String>().to_lowercase(); if matches!(name.as_str(), "br" | "hr" | "img" | "meta" | "link" | "input" | "wbr" | "col" | "source") && !tag.ends_with("/>") { out.push_str(&tag[..tag.len() - 1]); out.push_str("/>"); } else { out.push_str(tag); } rest = &rest[end + 1..]; } out.push_str(rest); for (named, num) in [(" ", " "), ("©", "©"), ("—", "—"), ("–", "–"), ("…", "…"), ("’", "’"), ("‘", "‘"), ("“", "“"), ("”", "”"), ("é", "é")] { out = out.replace(named, num); } out}fn page(title: &str, lang: &str, body: &str) -> String { format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{l}\" lang=\"{l}\">\n<head><meta charset=\"utf-8\"/><title>{t}</title><link rel=\"stylesheet\" type=\"text/css\" href=\"stylesheet.css\"/></head>\n<body>\n{b}\n</body>\n</html>\n", l = esc(lang), t = esc(title), b = body)}fn text(v: &Value, key: &str) -> String { v.get(key).and_then(|x| x.as_str()).unwrap_or("").to_string()}/// Writes an EPUB at `path` (folders are made) and gives the number of chapters./// `book` is `{ title, author, lang, description, cover, css }`: `lang` like "en" (the default), `cover` the path of a JPEG or PNG, `css` a style sheet (a plain one otherwise)./// Each chapter is `{ title, html }`: `html` is the chapter's body (from `markdown(...)` too); when it has no `<h1>`, the title is put above it.pub fn epub_write(path: &str, book: serde_json::Value, chapters: Vec<serde_json::Value>) -> Result<i64, String> { if chapters.is_empty() || chapters.len() > MAX_CHAPTERS { return Err(format!("give 1 to {} chapters", MAX_CHAPTERS)); } let title = text(&book, "title"); if title.is_empty() { return Err("the book needs a title".into()); } let lang = Some(text(&book, "lang")).filter(|l| !l.is_empty()).unwrap_or_else(|| "en".into()); let e = |e: epub_builder::Error| e.to_string(); let mut b = EpubBuilder::new(ZipLibrary::new().map_err(e)?).map_err(e)?; b.epub_version(EpubVersion::V30); b.set_title(title.clone()); b.set_lang(lang.clone()); b.set_generator("Quillo"); let author = text(&book, "author"); if !author.is_empty() { b.add_author(author); } let desc = text(&book, "description"); if !desc.is_empty() { b.add_description(desc); } let css = Some(text(&book, "css")).filter(|c| !c.is_empty()).unwrap_or_else(|| DEFAULT_CSS.into()); b.stylesheet(css.as_bytes()).map_err(e)?; let cover = text(&book, "cover"); if !cover.is_empty() { let data = std::fs::read(&cover).map_err(|err| format!("{}: {}", cover, err))?; let (ext, mime) = if data.starts_with(&[0x89, b'P', b'N', b'G']) { ("png", "image/png") } else if data.starts_with(&[0xFF, 0xD8]) { ("jpg", "image/jpeg") } else { return Err(format!("{}: the cover is a JPEG or a PNG", cover)) }; b.add_cover_image(format!("cover.{}", ext), data.as_slice(), mime).map_err(e)?; } for (i, ch) in chapters.iter().enumerate() { let ct = Some(text(ch, "title")).filter(|t| !t.is_empty()).unwrap_or_else(|| format!("Chapter {}", i + 1)); let mut html = xhtml(&text(ch, "html")); if !html.to_lowercase().contains("<h1") { html = format!("<h1>{}</h1>\n{}", esc(&ct), html); } let content = page(&ct, &lang, &html); b.add_content(EpubContent::new(format!("chapter_{}.xhtml", i + 1), content.as_bytes()).title(ct).reftype(ReferenceType::Text)).map_err(e)?; } b.inline_toc(); if let Some(dir) = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) { std::fs::create_dir_all(dir).map_err(|err| format!("{}: {}", dir.display(), err))?; } let f = std::fs::File::create(path).map_err(|err| format!("{}: {}", path, err))?; b.generate(f).map_err(e)?; Ok(chapters.len() as i64)}/// What an EPUB file says about itself: `{ title, author, lang, items, files }`: `items` counts the pages in its reading order (chapters, and a contents page if it has one).pub fn epub_info(path: &str) -> Result<Value, String> { let f = std::fs::File::open(path).map_err(|e| format!("{}: {}", path, e))?; let mut z = zip::ZipArchive::new(f).map_err(|_| format!("{}: not an EPUB (it is not a zip)", path))?; let mut read = |name: &str| -> Option<String> { let e = z.by_name(name).ok()?; let mut s = String::new(); e.take(16 * 1024 * 1024).read_to_string(&mut s).ok()?; Some(s) }; let container = read("META-INF/container.xml").ok_or_else(|| format!("{}: not an EPUB (no META-INF/container.xml)", path))?; let cdoc = roxmltree::Document::parse(&container).map_err(|e| e.to_string())?; let opf_path = cdoc.descendants().find(|n| n.has_tag_name("rootfile")).and_then(|n| n.attribute("full-path")).ok_or("the EPUB names no package file")?.to_string(); let opf = read(&opf_path).ok_or_else(|| format!("the EPUB has no {}", opf_path))?; let doc = roxmltree::Document::parse(&opf).map_err(|e| e.to_string())?; let dc = |name: &str| doc.descendants().find(|n| n.is_element() && n.tag_name().name() == name).and_then(|n| n.text()).map(|t| t.trim().to_string()); Ok(json!({ "title": dc("title"), "author": dc("creator"), "lang": dc("language"), "items": doc.descendants().filter(|n| n.is_element() && n.tag_name().name() == "itemref").count(), "files": z.len(), }))}Related
pdf_tools.qwer · 1.0.0Merge PDFs, pick and split their pages, count them, and read or set the title and author.8 views · 6 downloadsText encodings text_encoding.qwer · 1.0.0Text files in old encodings (Windows-1252, Latin-1, Shift_JIS, GBK, Big5...) detected and read as UTF-8, and written back in them.8 views · 8 downloadsWord documents docx.qwer · 1.0.0Word documents written from blocks: headings, paragraphs with bold and italic, lists, tables and page breaks.6 views · 7 downloadsFixed width fixed_width.qwer · 1.0.0Fixed-width records read and written from a column spec: bank payment files, payroll exports and mainframe formats.8 views · 8 downloadsParquet parquet.qwer · 1.0.0Parquet files for data lakes, DuckDB, Spark and pandas: rows written with their column types found, and read back as rows.6 views · 9 downloads3D models models_3d.qwer · 1.0.0STL and OBJ models read: triangles, size, volume, surface area and whether the mesh is closed, a 3D-print quote by weight, and OBJ turned into binary STL.6 views · 7 downloadsThread 0 posts
On EPUB: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.