diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 5fca76420..b85e24791 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -12,7 +12,7 @@ pub fn specs() -> Vec { "{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), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts not embedded", + "{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), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts not embedded", 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})", @@ -178,12 +178,21 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { rr.threads = designcraft_render::default_threads(); let ropts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; let mut warnings: Vec = Vec::new(); - 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, generic_families: generic_families(&st.doc), fonts: if p.get("embedFonts").and_then(Value::as_bool).unwrap_or(false) && !fixed { @@ -191,6 +200,11 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { } else { Vec::new() }, + // 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; @@ -841,6 +855,30 @@ mod text_tests { assert!(semi.contains("font-weight: 600;"), "{css}"); } + /// #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("urn:isbn:9780000000002"), "{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 c8985a7aa..8f2467417 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -26,13 +26,16 @@ pub struct EpubOptions { pub language: String, /// Stable identifier (urn:uuid:…); derived from the title when absent. pub identifier: Option, - /// 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>, /// Generic CSS family (`serif`, `sans-serif`, `monospace`, `cursive`, `fantasy`) per font /// family, from the fonts' classification; families not in it are guessed from their name. pub generic_families: BTreeMap, /// Font files packaged with the book and declared with `@font-face`. pub fonts: Vec, + /// `dcterms:modified` (Unix seconds, UTC); the document's last save, else its creation, when + /// absent. + pub modified: Option, } /// A font file packaged with an EPUB: the face of `family` in `style` (`Bold Italic`…). @@ -117,6 +120,49 @@ fn font_file(k: usize, f: &EmbeddedFont) -> (String, &'static str) { (format!("fonts/{k}-{name}.{ext}"), mime) } +/// 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, /// read-aloud and accessibility). #[derive(Clone, Debug)] @@ -133,7 +179,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("\n"); let mut spine = String::new(); let mut files: Vec<(String, Vec)> = Vec::new(); @@ -163,7 +210,7 @@ pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions ); let author = opts.author.as_deref().map(|a| format!("{}", xml_text(a))).unwrap_or_default(); let opf = format!( - "\n\n\n{}\n{}\n{lang}{author}\n2026-01-01T00:00:00Z\npre-paginated\nauto\n\n\n\n\n{manifest}\n\n{spine}\n\n", + "\n\n\n{}\n{}\n{lang}{author}\n{modified}\npre-paginated\nauto\n\n\n\n\n{manifest}\n\n{spine}\n\n", xml_text(&id), xml_text(&title) ); @@ -201,6 +248,7 @@ impl Default for EpubOptions { cover: None, generic_families: BTreeMap::new(), fonts: Vec::new(), + modified: None, } } } @@ -1059,7 +1107,8 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, 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, true, &|name, _, _| name.to_string()); let lang = language_tag(&opts.language); let chapter = format!( @@ -1088,13 +1137,17 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr let _ = writeln!(manifest, "", xml_attr(&name)); } // Cover: an image page first in reading order. - if opts.cover.is_some() { - manifest.push_str("\n\n"); + if let Some(c) = &opts.cover { + let (name, mime) = cover_file(c); + let _ = writeln!( + manifest, + "\n" + ); } let cover_spine = if opts.cover.is_some() { "\n" } else { "" }; let author = opts.author.as_deref().map(|a| format!("{}", xml_text(a))).unwrap_or_default(); let opf = format!( - "\n\n\n{}\n{}\n{lang}{author}\n2026-01-01T00:00:00Z\n\n\n\n{manifest}\n\n{cover_spine}\n\n\n", + "\n\n\n{}\n{}\n{lang}{author}\n{modified}\n\n\n\n{manifest}\n\n{cover_spine}\n\n\n", xml_text(&id), xml_text(&title) ); @@ -1125,11 +1178,12 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr z.write_all(&f.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!( - "\n\n\n{}\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)?; @@ -1170,6 +1224,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());