From 7be88b4484f18d9a9510337adb87e75a3c65abba Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 16:33:34 +0200 Subject: [PATCH] EPUB: real modified time, a stable unique identifier, and a cover image file (#771, #773) - dcterms:modified was always 2026-01-01T00:00:00Z. It is now the export time (from the command; on the web, and in the crate without one, the document's last save or creation time). - The default dc:identifier was urn:designcraft:, shared by books with the same title. It is now a urn:uuid derived from the title and the document's creation time: stable from export to export, different between documents. file.exportEpub takes identifier to set it (an ISBN, say). - file.exportEpub takes coverImage, a PNG or JPEG file used as the cover (cover-image); a JPEG is packaged as cover.jpg/image/jpeg. Fixes #771 Fixes #773 --- crates/engine/src/cmd/export.rs | 44 +++++++++++- crates/epub/src/lib.rs | 120 +++++++++++++++++++++++++++++--- 2 files changed, 151 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 8e0f04d69..0b4a91d32 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> { "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})", has_doc, export_pdf), cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None, - "{path?, title?, author?, language?: \"en\", cover?: bool (the first page as the cover image), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes} (no path: {base64, bytes})", + "{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), cover?: bool (the first page as the cover image), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes} (no path: {base64, bytes})", has_doc, export_epub), cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None, "{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})", @@ -130,13 +130,27 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> { let mut rr = designcraft_render::Renderer::new(); rr.threads = designcraft_render::default_threads(); let ropts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; - let cover = (want_cover && !fixed).then(|| rr.render_page(&st.doc, &s.cache, 0, 2.0, false, &ropts).map(|img| img.to_png())).flatten(); + // A cover image file (PNG or JPEG), else the first page rendered when `cover` asks for it. + let cover = match p.get("coverImage").and_then(Value::as_str).filter(|_| !fixed) { + Some(path) => Some(std::fs::read(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?), + None => (want_cover && !fixed).then(|| rr.render_page(&st.doc, &s.cache, 0, 2.0, false, &ropts).map(|img| img.to_png())).flatten(), + }; + if let Some(c) = &cover + && !(c.starts_with(b"\x89PNG") || c.starts_with(&[0xFF, 0xD8, 0xFF])) + { + return Err(crate::EngineError::Other("the cover image must be a PNG or JPEG file".into())); + } let opts = designcraft_epub::EpubOptions { title: p.get("title").and_then(Value::as_str).map(str::to_string), author: p.get("author").and_then(Value::as_str).map(str::to_string), language: p.get("language").and_then(Value::as_str).unwrap_or("en").to_string(), - identifier: None, + identifier: p.get("identifier").and_then(Value::as_str).map(str::to_string), cover, + // The export time; on the web the document's own save time stands in. + #[cfg(not(target_arch = "wasm32"))] + modified: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().and_then(|d| i64::try_from(d.as_secs()).ok()), + #[cfg(target_arch = "wasm32")] + modified: None, }; if fixed { let d = &st.doc; @@ -672,6 +686,30 @@ mod text_tests { assert!(p1.contains("width=612, height=792") && p1.contains("Page one words"), "{p1}"); } + /// #771, #773: a cover image file and an identifier through the command; the package's + /// modified time is the export's. + #[test] + fn epub_cover_image_file_and_identifier() { + let dir = std::env::temp_dir().join(format!("dc-epub-cover-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let jpg = dir.join("cover.jpg"); + std::fs::write(&jpg, [0xFF, 0xD8, 0xFF, 0xE0, 0, 16]).unwrap(); + let bad = dir.join("cover.txt"); + std::fs::write(&bad, b"not an image").unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap(); + let r = s.execute("file.exportEpub", &json!({"coverImage": jpg.to_str().unwrap(), "identifier": "urn:isbn:9780000000002"})).unwrap(); + let mut z = unzip(r["base64"].as_str().unwrap()); + let opf = read(&mut z, "OEBPS/content.opf"); + assert!(opf.contains("<dc:identifier id=\"bookid\">urn:isbn:9780000000002</dc:identifier>"), "{opf}"); + assert!(opf.contains("href=\"images/cover.jpg\" media-type=\"image/jpeg\""), "{opf}"); + assert!(!opf.contains("2026-01-01T00:00:00Z"), "the export time, not a fixed date: {opf}"); + assert!(z.by_name("OEBPS/images/cover.jpg").is_ok()); + assert!(s.execute("file.exportEpub", &json!({"coverImage": bad.to_str().unwrap()})).is_err()); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn flattener_rasterises_spreads_with_transparency() { let mut s = Session::new(); diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 77b509d0b..8de906466 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -20,8 +20,54 @@ pub struct EpubOptions { pub language: String, /// Stable identifier (urn:uuid:…); derived from the title when absent. pub identifier: Option<String>, - /// Cover image (PNG), shown first and marked as the book's cover. + /// Cover image (PNG or JPEG), shown first and marked as the book's cover. pub cover: Option<Vec<u8>>, + /// `dcterms:modified` (Unix seconds, UTC); the document's last save, else its creation, when + /// absent. + pub modified: Option<i64>, +} + +/// An ISO 8601 UTC timestamp (`2026-10-13T09:30:00Z`) for Unix seconds `t`. +fn iso8601(t: i64) -> String { + let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400)); + // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", secs / 3600, secs % 3600 / 60, secs % 60) +} + +/// The package's `dcterms:modified`: the given time, else the document's last save or creation. +fn modified(doc: &Document, opts: &EpubOptions) -> String { + let t = opts.modified.or((doc.modified > 0).then_some(doc.modified)).or((doc.created > 0).then_some(doc.created)); + t.map_or_else(|| "2026-01-01T00:00:00Z".to_string(), iso8601) +} + +/// The book's identifier: the given one, else a `urn:uuid` derived from the title and the +/// document's creation time, so a book keeps its identifier from export to export and two books +/// with the same title don't share one. +fn identifier(doc: &Document, opts: &EpubOptions, title: &str) -> String { + if let Some(id) = opts.identifier.clone().filter(|i| !i.trim().is_empty()) { + return id; + } + // Two FNV-1a 64-bit hashes (different offsets) as 128 bits, shaped as a version-5-style UUID. + let seed = format!("{title}\n{}", doc.created); + let fnv = |offset: u64| seed.bytes().fold(offset, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3)); + let (a, b) = (fnv(0xcbf2_9ce4_8422_2325), fnv(0x84222325_cbf29ce4)); + let hi = (a & 0xffff_ffff_ffff_0fff) | 0x5000; + let lo = (b & 0x3fff_ffff_ffff_ffff) | 0x8000_0000_0000_0000; + format!("urn:uuid:{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", hi >> 32, (hi >> 16) & 0xffff, hi & 0xffff, lo >> 48, lo & 0xffff_ffff_ffff) +} + +/// Package name and media type of a cover image: JPEG by its signature, else PNG. +fn cover_file(data: &[u8]) -> (&'static str, &'static str) { + if data.starts_with(&[0xFF, 0xD8, 0xFF]) { ("images/cover.jpg", "image/jpeg") } else { ("images/cover.png", "image/png") } } /// A page of a fixed-layout EPUB: its image (PNG), size in points and text (kept for search, @@ -40,7 +86,8 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); let lang = language_tag(&opts.language); - let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title))); + let id = identifier(doc, opts, &title); + let modified = modified(doc, opts); let mut manifest = String::from("<item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/>\n"); let mut spine = String::new(); let mut files: Vec<(String, Vec<u8>)> = Vec::new(); @@ -70,7 +117,7 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions ); let author = opts.author.as_deref().map(|a| format!("<dc:creator>{}</dc:creator>", xml_text(a))).unwrap_or_default(); let opf = format!( - "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\" prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\">\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">2026-01-01T00:00:00Z</meta>\n<meta property=\"rendition:layout\">pre-paginated</meta>\n<meta property=\"rendition:spread\">auto</meta>\n<meta name=\"original-resolution\" content=\"{vw}x{vh}\"/>\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{spine}</spine>\n</package>\n", + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\" prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\">\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">{modified}</meta>\n<meta property=\"rendition:layout\">pre-paginated</meta>\n<meta property=\"rendition:spread\">auto</meta>\n<meta name=\"original-resolution\" content=\"{vw}x{vh}\"/>\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{spine}</spine>\n</package>\n", xml_text(&id), xml_text(&title) ); @@ -100,7 +147,7 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions impl Default for EpubOptions { fn default() -> Self { - EpubOptions { title: None, author: None, language: "en".into(), identifier: None, cover: None } + EpubOptions { title: None, author: None, language: "en".into(), identifier: None, cover: None, modified: None } } } @@ -685,7 +732,8 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, EpubEr let io = |e: std::io::Error| EpubError(e.to_string()); let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); - let id = opts.identifier.clone().unwrap_or_else(|| format!("urn:designcraft:{}", slug(&title))); + let id = identifier(doc, opts, &title); + let modified = modified(doc, opts); let (body, images, toc) = body_html(doc, &|name, _, _| name.to_string()); let lang = language_tag(&opts.language); let chapter = format!( @@ -710,13 +758,17 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, EpubEr let _ = writeln!(manifest, "<item id=\"img{k}\" href=\"{}\" media-type=\"{}\"/>", xml_attr(name), xml_attr(mime)); } // Cover: an image page first in reading order. - if opts.cover.is_some() { - manifest.push_str("<item id=\"cover-img\" href=\"images/cover.png\" media-type=\"image/png\" properties=\"cover-image\"/>\n<item id=\"cover\" href=\"cover.xhtml\" media-type=\"application/xhtml+xml\"/>\n"); + if let Some(c) = &opts.cover { + let (name, mime) = cover_file(c); + let _ = writeln!( + manifest, + "<item id=\"cover-img\" href=\"{name}\" media-type=\"{mime}\" properties=\"cover-image\"/>\n<item id=\"cover\" href=\"cover.xhtml\" media-type=\"application/xhtml+xml\"/>" + ); } let cover_spine = if opts.cover.is_some() { "<itemref idref=\"cover\"/>\n" } else { "" }; let author = opts.author.as_deref().map(|a| format!("<dc:creator>{}</dc:creator>", xml_text(a))).unwrap_or_default(); let opf = format!( - "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\">\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">2026-01-01T00:00:00Z</meta>\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{cover_spine}<itemref idref=\"content\"/>\n</spine>\n</package>\n", + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"bookid\" xml:lang=\"{lang}\">\n<metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n<dc:identifier id=\"bookid\">{}</dc:identifier>\n<dc:title>{}</dc:title>\n<dc:language>{lang}</dc:language>{author}\n<meta property=\"dcterms:modified\">{modified}</meta>\n<meta name=\"generator\" content=\"DesignCraft\"/>\n</metadata>\n<manifest>\n{manifest}</manifest>\n<spine>\n{cover_spine}<itemref idref=\"content\"/>\n</spine>\n</package>\n", xml_text(&id), xml_text(&title) ); @@ -743,11 +795,12 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, EpubEr z.write_all(data).map_err(io)?; } if let Some(c) = &opts.cover { - z.start_file("OEBPS/images/cover.png", stored).map_err(zerr)?; + z.start_file(format!("OEBPS/{}", cover_file(c).0), stored).map_err(zerr)?; z.write_all(c).map_err(io)?; let xhtml = 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=\"{lang}\">\n<head><meta charset=\"utf-8\"/><title>{}\n\"{}\"/\n\n", + "\n\n\n{}\n\"{}\"/\n\n", xml_text(&title), + cover_file(c).0, xml_attr(&title) ); z.start_file("OEBPS/cover.xhtml", deflate).map_err(zerr)?; @@ -788,6 +841,52 @@ mod tests { assert!(z.by_name("OEBPS/nav.xhtml").is_ok()); } + #[test] + fn iso8601_timestamps() { + for (t, want) in [ + (0, "1970-01-01T00:00:00Z"), + (951_782_400, "2000-02-29T00:00:00Z"), + (1_791_891_000, "2026-10-13T11:30:00Z"), + (-86_400, "1969-12-31T00:00:00Z"), + ] { + assert_eq!(iso8601(t), want); + } + } + + /// #771, #773: dcterms:modified is a real time, the default identifier is a stable urn:uuid + /// that differs between documents with the same title, and a JPEG cover keeps its type. + #[test] + fn package_metadata_and_cover_image() { + let opf = |d: &Document, opts: &EpubOptions| { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap(); + let mut s = String::new(); + z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut s).unwrap(); + let names: Vec = z.file_names().map(str::to_string).collect(); + (s, names) + }; + let id_of = |opf: &str| opf.split("").nth(1).and_then(|r| r.split('<').next()).unwrap_or_default().to_string(); + let mut a = Document::new(&NewDocument::default()); + a.title = "Same".into(); + a.created = 1_700_000_000; + let mut b = a.clone(); + b.created = 1_700_000_001; + let (oa, _) = opf(&a, &EpubOptions::default()); + let (oa2, _) = opf(&a, &EpubOptions::default()); + let (ob, _) = opf(&b, &EpubOptions::default()); + assert!(id_of(&oa).starts_with("urn:uuid:") && id_of(&oa).len() == 45, "{}", id_of(&oa)); + assert_eq!(id_of(&oa), id_of(&oa2), "stable from export to export"); + assert_ne!(id_of(&oa), id_of(&ob), "not shared by two books with the same title"); + // Modified: the given time, else the document's save (or creation) time. + assert!(oa.contains("2023-11-14T22:13:20Z"), "{oa}"); + let at = EpubOptions { modified: Some(1_791_891_000), ..Default::default() }; + assert!(opf(&a, &at).0.contains("2026-10-13T11:30:00Z")); + // A JPEG cover is packaged as such. + let jpeg = EpubOptions { cover: Some(vec![0xFF, 0xD8, 0xFF, 0xE0, 1, 2]), ..Default::default() }; + let (o, names) = opf(&a, &jpeg); + assert!(o.contains("href=\"images/cover.jpg\" media-type=\"image/jpeg\" properties=\"cover-image\""), "{o}"); + assert!(names.contains(&"OEBPS/images/cover.jpg".to_string()), "{names:?}"); + } + #[test] fn styles_whose_names_slug_alike_keep_their_own_classes() { let mut d = Document::new(&NewDocument::default()); @@ -902,6 +1001,7 @@ mod tests { language: "English (US)".into(), identifier: Some("notes&edition".into()), cover: None, + modified: None, }, ) .unwrap();