From 7a0c256601c55240ab47cd46c3d2adbc02f24e31 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 19:33:39 -0500 Subject: [PATCH] Move native bundle and EPUB packaging to craft-libs --- Cargo.lock | 50 ++++- Cargo.toml | 3 + crates/epub/Cargo.toml | 4 +- crates/epub/src/chapters.rs | 2 + crates/epub/src/lib.rs | 3 - crates/epub/src/package.rs | 325 +++++++++++---------------- crates/epub/src/tests.rs | 20 -- crates/format/Cargo.toml | 7 +- crates/format/src/lib.rs | 423 +++++++++--------------------------- crates/format/src/tests.rs | 100 +-------- 10 files changed, 290 insertions(+), 647 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9760f61ff..8229e7d80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1035,6 +1035,32 @@ name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +[[package]] +name = "craft-document-bundle" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=dc20b58f0f6059bd408d6f14ab130f343#dc20b58f0f6059bd408d6f14ab130f343" +dependencies = [ + "craft-zip", + "serde_json", + "thiserror 2.0.21", + "zip 9.1.1", +] + +[[package]] +name = "craft-eps" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=dc20b58f0f6059bd408d6f14ab130f343#dc20b58f0f6059bd408d6f14ab130f343" + +[[package]] +name = "craft-epub" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=dc20b58f0f6059bd408d6f14ab130f343#dc20b58f0f6059bd408d6f14ab130f343" +dependencies = [ + "craft-xml", + "craft-zip", + "thiserror 2.0.21", +] + [[package]] name = "craft-fonts-build" version = "0.1.0" @@ -1181,6 +1207,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "craft-xml" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=dc20b58f0f6059bd408d6f14ab130f343#dc20b58f0f6059bd408d6f14ab130f343" +dependencies = [ + "quick-xml 0.41.0", + "thiserror 2.0.21", +] + +[[package]] +name = "craft-zip" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=dc20b58f0f6059bd408d6f14ab130f343#dc20b58f0f6059bd408d6f14ab130f343" +dependencies = [ + "flate2", + "thiserror 2.0.21", + "zip 9.1.1", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -1436,7 +1481,7 @@ dependencies = [ name = "designcraft-epub" version = "0.20.1" dependencies = [ - "chrono", + "craft-epub", "designcraft-color", "designcraft-doc", "zip 3.0.0", @@ -1459,8 +1504,9 @@ dependencies = [ name = "designcraft-format" version = "0.20.1" dependencies = [ + "craft-document-bundle", + "craft-eps", "designcraft-doc", - "designcraft-images", "serde", "serde_json", "thiserror 2.0.21", diff --git a/Cargo.toml b/Cargo.toml index dcf2c7235..702ff232c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,9 @@ repository = "https://github.com/storytold/designcraft" [workspace.dependencies] # Shared reader and writer; publish the immutable revision before this consumer. craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false } +craft-document-bundle = { git = "https://github.com/storytold/craft-libs", rev = "dc20b58f0f6059bd408d6f14ab130f343", version = "=0.1.0" } +craft-eps = { git = "https://github.com/storytold/craft-libs", rev = "dc20b58f0f6059bd408d6f14ab130f343", version = "=0.1.0" } +craft-epub = { git = "https://github.com/storytold/craft-libs", rev = "dc20b58f0f6059bd408d6f14ab130f343", version = "=0.1.0" } # Shared keyboard bindings: chords, overrides, conflicts, egui dispatch, the Keyboard Shortcuts dialog. craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui", "ui"] } # Separations, ink coverage and the ink manager; same revision as craft-pdf and the krilla patch. diff --git a/crates/epub/Cargo.toml b/crates/epub/Cargo.toml index 2836b0a92..96a95faf1 100644 --- a/crates/epub/Cargo.toml +++ b/crates/epub/Cargo.toml @@ -9,7 +9,9 @@ description = "DesignCraft EPUB 3 (reflowable) export: stories in reading order, workspace = true [dependencies] +craft-epub = { workspace = true } designcraft-doc = { workspace = true } designcraft-color = { workspace = true } + +[dev-dependencies] zip = { workspace = true } -chrono = { workspace = true } diff --git a/crates/epub/src/chapters.rs b/crates/epub/src/chapters.rs index ede96f390..93afea958 100644 --- a/crates/epub/src/chapters.rs +++ b/crates/epub/src/chapters.rs @@ -1,5 +1,6 @@ use super::css::{export_mapping, para_classes}; use super::html::Block; +#[cfg(test)] use super::markup::xml_text; use super::*; @@ -118,6 +119,7 @@ pub(crate) fn toc_entries(doc: &Document, order: &[Block]) -> Vec<(String, Strin /// The table of contents as nested `li`/`ol` items (the content of the navigation's outer /// `ol`): a deeper level goes into the previous item, skipped levels count as one. +#[cfg(test)] pub(crate) fn nav_list(entries: &[(String, String, u8)], files: &BTreeMap) -> String { let mut out = String::new(); let mut open: Vec = Vec::new(); diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index c102a54fe..6fa60cd08 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -11,7 +11,6 @@ use std::collections::BTreeMap; use std::fmt::Write as _; -use std::io::Write; use designcraft_doc::geom::{Affine, Rect}; use designcraft_doc::{ @@ -39,8 +38,6 @@ use fonts::{css_weight_style, generic_family}; use html::base64; pub use html::{Block, HtmlOptions, export_html, images_without_alt, reading_order}; pub use markup::{EpubError, slug}; -#[cfg(test)] -use package::iso8601; pub use package::{FixedPage, contents_title, export_epub, export_fixed_epub}; pub use story_writer::story_html; diff --git a/crates/epub/src/package.rs b/crates/epub/src/package.rs index 63e0399f0..9b33d356b 100644 --- a/crates/epub/src/package.rs +++ b/crates/epub/src/package.rs @@ -1,72 +1,16 @@ -use super::chapters::{chapter_file, link_across_chapters, nav_list, split_chapters}; +use std::borrow::Cow; + +use super::chapters::{chapter_file, link_across_chapters, split_chapters}; use super::css::stylesheet_with; use super::fonts::font_file; use super::html::{Body, body_html}; use super::markup::{EpubError, language_tag, story_markup, xml_attr, xml_text}; -use super::*; - -/// An ISO 8601 UTC timestamp (`2026-10-13T09:30:00Z`) for Unix seconds `t`. -/// A time outside chrono's year range still converts: the calendar repeats every 400 years, so the -/// instant is moved into range by whole cycles and only the year is carried (matches the old -/// `civil_from_days`), rather than resetting to the epoch. -pub(crate) fn iso8601(t: i64) -> String { - let (y, dt) = designcraft_doc::time::utc_parts(t, 0); - // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. - format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%SZ")) -} - -/// 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) -} - -/// Characters of the title the package keeps: it is copied into every content file, so a huge one -/// would multiply by the number of chapters or pages. -const MAX_TITLE_CHARS: usize = 1024; - -fn capped_title(title: String) -> String { - match title.char_indices().nth(MAX_TITLE_CHARS) { - Some((at, _)) => title[..at].to_string(), - None => title, - } -} - -/// 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") } -} +use super::{Document, EpubOptions}; /// The table of contents' title in `language` (a BCP 47 tag or a language name's code), English /// for languages without one here. pub fn contents_title(language: &str) -> &'static str { - let code = language.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase(); - match code.as_str() { - "nl" => "Inhoud", - "de" => "Inhalt", - "fr" => "Table des matières", - "es" | "pt" => "Índice", - "it" => "Indice", - "ja" => "目次", - "zh" => "目录", - _ => "Contents", - } + craft_epub::contents_title(language) } /// A page of a fixed-layout EPUB: its image (PNG), size in points and text (kept for search, @@ -79,105 +23,85 @@ pub struct FixedPage { pub text: String, } +fn capped_title(title: String) -> String { + match title.char_indices().nth(1024) { + Some((at, _)) => title.get(..at).unwrap_or(&title).to_owned(), + None => title, + } +} + +fn identifier(doc: &Document, opts: &EpubOptions, title: &str) -> String { + opts.identifier.clone().filter(|id| !id.trim().is_empty()).unwrap_or_else(|| craft_epub::urn_uuid(&format!("{title}\n{}", doc.created))) +} + +fn modified(doc: &Document, opts: &EpubOptions) -> String { + let timestamp = opts.modified.or((doc.modified > 0).then_some(doc.modified)).or((doc.created > 0).then_some(doc.created)); + timestamp.map_or_else( + || "2026-01-01T00:00:00Z".to_owned(), + |time| { + let (year, date) = designcraft_doc::time::utc_parts(time, 0); + format!("{year:04}-{}", date.format("%m-%dT%H:%M:%SZ")) + }, + ) +} + +fn metadata(doc: &Document, opts: &EpubOptions, title: &str, language: &str) -> craft_epub::Metadata { + craft_epub::Metadata { + identifier: identifier(doc, opts, title), + title: title.to_owned(), + language: language.to_owned(), + creators: opts.author.as_deref().or(doc.metadata_author()).into_iter().map(str::to_owned).collect(), + modified: modified(doc, opts), + generator: Some("DesignCraft".into()), + ..Default::default() + } +} + /// A pre-paginated (fixed-layout) EPUB: one XHTML page per document page. pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions) -> Result, EpubError> { let title = capped_title(opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string())); let lang = language_tag(&opts.language); - 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(); - let mut nav_items = String::new(); - for (i, p) in pages.iter().enumerate() { - let n = i + 1; - let (w, h) = (p.width.round().max(1.0) as u32, p.height.round().max(1.0) as u32); + let mut resources = Vec::with_capacity(pages.len().saturating_mul(2)); + let mut spine = Vec::with_capacity(pages.len()); + let mut navigation = Vec::with_capacity(pages.len()); + for (index, page) in pages.iter().enumerate() { + let number = index + 1; + let width = page.width.round().max(1.0) as u32; + let height = page.height.round().max(1.0) as u32; let xhtml = format!( - "\n\n\n{} — {n}\n\n\"\"/
{}
\n\n", + "\n\n\n{} — {number}\n\n\"\"/
{}
\n\n", xml_text(&title), - story_markup(&p.text).replace('\n', "
") + story_markup(&page.text).replace('\n', "
") ); - let _ = writeln!( - manifest, - "\n", - if n == 1 { " properties=\"cover-image\"" } else { "" } - ); - let _ = writeln!(spine, ""); - let _ = write!(nav_items, "
  • {n}
  • "); - files.push((format!("OEBPS/page{n}.xhtml"), xhtml.into_bytes())); - files.push((format!("OEBPS/pages/p{n}.png"), p.png.clone())); + resources.push(craft_epub::Resource::new( + format!("page{number}"), + format!("page{number}.xhtml"), + "application/xhtml+xml", + xhtml.into_bytes(), + )); + let image = + craft_epub::Resource::new(format!("pimg{number}"), format!("pages/p{number}.png"), "image/png", Cow::Borrowed(page.png.as_slice())); + resources.push(if number == 1 { image.with_properties("cover-image").stored() } else { image.stored() }); + spine.push(format!("page{number}")); + navigation.push(craft_epub::NavItem { href: format!("page{number}.xhtml"), label: number.to_string(), level: 0 }); } - let (vw, vh) = pages.first().map_or((612, 792), |p| (p.width.round() as u32, p.height.round() as u32)); - let nav = format!( - "\n\n\n{}\n\n\n", - xml_text(&title) - ); - let rendition = format!( - "\npre-paginated\nauto\n" - ); - let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref().or(doc.metadata_author()), modified: &modified }; - let opf = package_opf(&head, " prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\"", &rendition, &manifest, &spine); - let entries = files.iter().map(|(name, data)| (name.clone(), data.as_slice(), name.ends_with(".png"))); - write_package(&opf, &nav, entries) -} - -/// The package metadata both EPUB flavours share. -struct PackageHead<'a> { - lang: &'a str, - id: &'a str, - title: &'a str, - author: Option<&'a str>, - modified: &'a str, -} - -/// The OPF package document. `prefix` goes on `` (with its leading space), `meta` after -/// `dcterms:modified` (with its leading newline); `manifest` and `spine` are the item lines. -fn package_opf(h: &PackageHead, prefix: &str, meta: &str, manifest: &str, spine: &str) -> String { - let PackageHead { lang, id, title, author, modified } = h; - let author = author.map(|a| format!("{}", xml_text(a))).unwrap_or_default(); - format!( - "\n\n\n{}\n{}\n{lang}{author}\n{modified}{meta}\n\n\n\n{manifest}\n\n{spine}\n\n", - xml_text(id), - xml_text(title) - ) -} - -/// Zips an EPUB: the stored `mimetype`, `container.xml`, the OPF and nav, then `entries` in order -/// as (path, bytes, stored uncompressed). -fn write_package<'a>(opf: &str, nav: &str, entries: impl IntoIterator) -> Result, EpubError> { - let io = |e: std::io::Error| EpubError(e.to_string()); - let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); - let container = "\n\n"; - let mut buf = std::io::Cursor::new(Vec::new()); - { - let mut z = zip::ZipWriter::new(&mut buf); - let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); - let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); - z.start_file("mimetype", stored).map_err(zerr)?; - z.write_all(b"application/epub+zip").map_err(io)?; - for (name, data) in - [("META-INF/container.xml", container.as_bytes()), ("OEBPS/content.opf", opf.as_bytes()), ("OEBPS/nav.xhtml", nav.as_bytes())] - { - z.start_file(name, deflate).map_err(zerr)?; - z.write_all(data).map_err(io)?; - } - for (name, data, store) in entries { - z.start_file(name, if store { stored } else { deflate }).map_err(zerr)?; - z.write_all(data).map_err(io)?; - } - z.finish().map_err(zerr)?; - } - Ok(buf.into_inner()) + let (width, height) = pages.first().map_or((612, 792), |page| (page.width.round() as u32, page.height.round() as u32)); + let book = craft_epub::Book { + metadata: metadata(doc, opts, &title, lang), + layout: craft_epub::Layout::PrePaginated { width, height }, + resources, + spine, + nav: craft_epub::Nav { kind: craft_epub::NavKind::PageList, heading: "Pages".into(), items: navigation }, + ncx: false, + }; + craft_epub::write(&book).map_err(|error| EpubError(error.to_string())) } /// Export the document as a reflowable EPUB 3. pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubError> { let title = capped_title(opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string())); - let id = identifier(doc, opts, &title); - let modified = modified(doc, opts); - let Body { html: body, images, toc, .. } = body_html(doc, true, opts.split.as_ref(), &|name, _, _| name.to_string()); let lang = language_tag(&opts.language); - // One file per chapter (see `starts_chapter`); one file in all when Split Document is off. + let Body { html: body, images, toc, .. } = body_html(doc, true, opts.split.as_ref(), &|name, _, _| name.to_string()); let chapters: Vec = split_chapters(&body) .into_iter() .map(|part| { @@ -187,69 +111,66 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubEr ) }) .collect(); - // The file each navigation target (an element id) ended up in. - let mut files: BTreeMap = BTreeMap::new(); - for (k, c) in chapters.iter().enumerate() { - for rest in c.split(" id=\"").skip(1) { + let mut files = std::collections::BTreeMap::::new(); + for (index, chapter) in chapters.iter().enumerate() { + for rest in chapter.split(" id=\"").skip(1) { if let Some(id) = rest.split('"').next() { - files.entry(id.to_string()).or_insert_with(|| chapter_file(k)); + files.entry(id.to_owned()).or_insert_with(|| chapter_file(index)); } } } - // Note links whose target ended up in another file (endnotes end the book) point at it. - let chapters: Vec = chapters.iter().enumerate().map(|(k, c)| link_across_chapters(c, &chapter_file(k), &files)).collect(); - let mut nav_items = nav_list(&toc, &files); - if nav_items.is_empty() { - nav_items = format!("
  • {}
  • ", xml_text(&title)); - } - let nav = format!( - "\n\n\n{}\n\n\n", - xml_text(&title), - xml_text(opts.toc_title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(contents_title(&opts.language))) - ); - let mut manifest = String::from( - "\n\n", - ); - let mut spine = String::new(); - for k in 0..chapters.len() { - let item = if k == 0 { "content".to_string() } else { format!("content{}", k + 1) }; - let _ = writeln!(manifest, "", chapter_file(k)); - let _ = writeln!(spine, ""); - } - for (k, (name, (mime, _))) in images.iter().enumerate() { - let _ = writeln!(manifest, "", xml_attr(name), xml_attr(mime)); - } - for (k, f) in opts.fonts.iter().enumerate() { - let (name, mime) = font_file(k, f); - let _ = writeln!(manifest, "", xml_attr(&name)); - } - // Cover: an image page first in reading order. - 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 head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref().or(doc.metadata_author()), modified: &modified }; - let opf = package_opf(&head, "", "", &manifest, &format!("{cover_spine}{spine}")); + let chapters: Vec = + chapters.iter().enumerate().map(|(index, chapter)| link_across_chapters(chapter, &chapter_file(index), &files)).collect(); + let nav_items = if toc.is_empty() { + vec![craft_epub::NavItem { href: "content.xhtml".into(), label: title.clone(), level: 0 }] + } else { + toc.iter() + .map(|(target, label, level)| { + let file = files.get(target).map_or("content.xhtml", String::as_str); + craft_epub::NavItem { href: format!("{file}#{target}"), label: label.clone(), level: *level } + }) + .collect() + }; + let heading = opts.toc_title.as_deref().filter(|value| !value.trim().is_empty()).unwrap_or(contents_title(&opts.language)); + let mut resources = Vec::new(); let css = stylesheet_with(doc, &opts.generic_families, &opts.fonts); - let mut entries: Vec<(String, &[u8], bool)> = vec![("OEBPS/style.css".into(), css.as_bytes(), false)]; - entries.extend(chapters.iter().enumerate().map(|(k, c)| (format!("OEBPS/{}", chapter_file(k)), c.as_bytes(), false))); - entries.extend(images.iter().map(|(name, (_, data))| (format!("OEBPS/{name}"), *data, true))); - entries.extend(opts.fonts.iter().enumerate().map(|(k, f)| (format!("OEBPS/{}", font_file(k, f).0), f.data.as_slice(), false))); - let cover_xhtml = opts.cover.as_ref().map(|c| { - format!( + resources.push(craft_epub::Resource::new("css", "style.css", "text/css", css.into_bytes())); + let mut spine = Vec::new(); + for (index, chapter) in chapters.into_iter().enumerate() { + let id = if index == 0 { "content".to_owned() } else { format!("content{}", index + 1) }; + resources.push(craft_epub::Resource::new(id.clone(), chapter_file(index), "application/xhtml+xml", chapter.into_bytes())); + spine.push(id); + } + for (index, (path, (mime, data))) in images.iter().enumerate() { + resources.push(craft_epub::Resource::new(format!("img{index}"), path.clone(), *mime, Cow::Borrowed(*data)).stored()); + } + for (index, font) in opts.fonts.iter().enumerate() { + let (path, mime) = font_file(index, font); + resources.push(craft_epub::Resource::new(format!("font{index}"), path, mime, Cow::Borrowed(font.data.as_slice()))); + } + if let Some(cover) = &opts.cover { + let (path, mime) = cover_file(cover); + resources.push(craft_epub::Resource::new("cover-img", path, mime, Cow::Borrowed(cover.as_slice())).with_properties("cover-image").stored()); + let xhtml = format!( "\n\n\n{}\n\"{}\"/\n\n", xml_text(&title), - cover_file(c).0, + path, xml_attr(&title) - ) - }); - if let (Some(c), Some(xhtml)) = (&opts.cover, &cover_xhtml) { - entries.push((format!("OEBPS/{}", cover_file(c).0), c.as_slice(), true)); - entries.push(("OEBPS/cover.xhtml".into(), xhtml.as_bytes(), false)); + ); + resources.push(craft_epub::Resource::new("cover", "cover.xhtml", "application/xhtml+xml", xhtml.into_bytes())); + spine.insert(0, "cover".into()); } - write_package(&opf, &nav, entries) + let book = craft_epub::Book { + metadata: metadata(doc, opts, &title, lang), + layout: craft_epub::Layout::Reflowable, + resources, + spine, + nav: craft_epub::Nav { kind: craft_epub::NavKind::Toc, heading: heading.to_owned(), items: nav_items }, + ncx: false, + }; + craft_epub::write(&book).map_err(|error| EpubError(error.to_string())) +} + +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") } } diff --git a/crates/epub/src/tests.rs b/crates/epub/src/tests.rs index 62683f91a..b08a97ff0 100644 --- a/crates/epub/src/tests.rs +++ b/crates/epub/src/tests.rs @@ -122,26 +122,6 @@ fn anchored_objects_are_exported_in_place() { assert!(!story_html(&d, sid).contains(" = craft_document_bundle::Spec { + mimetype_entry: "mimetype", + mimetype: MIME, + metadata_entry: "meta.json", + document_entry: "document.json", + preview_entry: PREVIEW, + assets_directory: "assets", + format_name: "designcraft", + legacy_assets_field: Some("assetData"), +}; #[derive(Debug, thiserror::Error)] pub enum FormatError { @@ -36,260 +41,85 @@ pub enum FormatError { TooNew(u64), #[error("{0}")] Io(String), - /// The document holds something the file can't represent; nothing is written. - #[error("{0}")] + #[error("document cannot be saved: {0}")] Unsaveable(String), } -fn ext_for(mime: &str) -> &'static str { - match mime { - "image/png" => "png", - "image/jpeg" => "jpg", - "image/gif" => "gif", - "image/webp" => "webp", - "image/tiff" => "tif", - "application/pdf" => "pdf", - "application/postscript" => "eps", - "image/svg+xml" => "svg", - _ => "bin", +impl From for FormatError { + fn from(error: craft_document_bundle::Error) -> Self { + match error { + craft_document_bundle::Error::TooNew(version) => FormatError::TooNew(version), + craft_document_bundle::Error::NotBundle(message) => FormatError::NotOurs(message), + craft_document_bundle::Error::Read(message) | craft_document_bundle::Error::Write(message) => FormatError::Io(message), + } } } -/// Serialize a document to `.designcraft` bytes. The file holds each distinct placed file once -/// and leaves out assets nothing references; `doc` itself keeps them (undo can bring them back). +/// Serialize a document to `.designcraft` bytes. pub fn save(doc: &Document) -> Result, FormatError> { save_with_preview(doc, None) } -/// [`save`], plus `preview` (PNG bytes) as `preview.png`. +/// [`save`], plus a PNG preview of the first page. pub fn save_with_preview(doc: &Document, preview: Option<&[u8]>) -> Result, FormatError> { - save_with_large_entries_from(doc, preview, LARGE_ENTRY) -} - -/// Asset entries this size or larger are written with zip64 sizes. Zip rejects an entry over -/// 4 GiB written without them; the margin covers deflate output that is larger than its input. -const LARGE_ENTRY: usize = (u32::MAX as usize).saturating_sub(64 << 20); - -/// The oldest reader capability that keeps everything in `doc`. Documents that need nothing newer -/// keep their older requirement so existing readers still open them. -fn required_version(doc: &Document) -> u32 { - // Version 4 covers both of these: older readers ignore the unknown fields, so they would - // silently drop retained graphic layer choices, or read an explicit all-on PDF layer choice - // (empty hidden list) as "use the file's defaults". A nonempty hidden list stays readable. - if doc.has_graphic_layer_options() || doc.has_explicit_all_on_pdf_layers() { - return 4; - } - // Older readers would silently omit EPS artwork. Preview-only legacy assets and documents - // without EPS stay at 2 so their existing readers keep working; writing 1 would make a - // reader re-run the version 2 paragraph upgrade over current defaults. - if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { 3 } else { 2 } -} - -fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_entry: usize) -> Result, FormatError> { - // JSON writes infinity and NaN as null, which no load accepts: the file would not open. if let Some(id) = doc.non_finite_item() { return Err(FormatError::Unsaveable(format!("object {} has a position or size that is not a finite number", id.0))); } let mut compact = doc.clone(); compact.compact_assets(); - let doc = &compact; - let io = |e: zip::result::ZipError| FormatError::Io(e.to_string()); - let mut buf = Cursor::new(Vec::new()); - { - let mut z = zip::ZipWriter::new(&mut buf); - let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); - let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); - z.start_file("mimetype", stored).map_err(io)?; - z.write_all(MIME.as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; - z.start_file("meta.json", deflate).map_err(io)?; - let version = required_version(doc); - let meta = json!({"format": "designcraft", "version": version, "generator": concat!("DesignCraft ", env!("CARGO_PKG_VERSION"))}); - z.write_all(meta.to_string().as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; - if let Some(png) = preview { - z.start_file(PREVIEW, stored).map_err(io)?; - z.write_all(png).map_err(|e| FormatError::Io(e.to_string()))?; - } - z.start_file("document.json", deflate).map_err(io)?; - let json = serde_json::to_vec_pretty(doc).map_err(|e| FormatError::Io(e.to_string()))?; - z.write_all(&json).map_err(|e| FormatError::Io(e.to_string()))?; - for (id, a) in &doc.assets { - if a.data.is_empty() { - continue; - } - // Already-compressed images are stored as-is. - let opts = if matches!(a.mime.as_str(), "image/png" | "image/jpeg" | "image/gif" | "image/webp") { stored } else { deflate }; - let opts = opts.large_file(a.data.len() >= large_entry); - z.start_file(format!("assets/{}.{}", id.0, ext_for(&a.mime)), opts).map_err(io)?; - z.write_all(&a.data).map_err(|e| FormatError::Io(e.to_string()))?; - } - z.finish().map_err(io)?; - } - Ok(buf.into_inner()) + let version = required_version(&compact); + let json = serde_json::to_vec_pretty(&compact).map_err(|e| FormatError::Io(e.to_string()))?; + let owned_assets = compact.assets.values().map(|asset| (asset.id.0.to_string(), asset.mime.clone(), asset.data.clone())).collect::>(); + let assets = owned_assets.iter().map(|(id, mime, data)| craft_document_bundle::Asset { id, mime, data }).collect::>(); + craft_document_bundle::write(&json, version, concat!("DesignCraft ", env!("CARGO_PKG_VERSION")), preview, &assets, BUNDLE).map_err(Into::into) } -/// The file name extension of a DesignCraft document. -pub const EXTENSION: &str = "designcraft"; +fn required_version(doc: &Document) -> u32 { + if doc.has_graphic_layer_options() || doc.has_explicit_all_on_pdf_layers() { + return 4; + } + if doc.assets.values().any(|asset| craft_eps::is_eps(&asset.data)) { 3 } else { 2 } +} -/// Whether `bytes` are a DesignCraft document package: a zip holding `document.json`. Files -/// without an extension are recognised by this (the legacy JSON form is too generic to sniff). +/// Whether `bytes` are a native DesignCraft ZIP package. pub fn is_designcraft(bytes: &[u8]) -> bool { - bytes.starts_with(b"PK") && zip::ZipArchive::new(Cursor::new(bytes)).is_ok_and(|z| z.index_for_name("document.json").is_some()) + craft_document_bundle::has_document(bytes, BUNDLE) } -/// Whether `prefix`, the start of a file, opens a DesignCraft package: a zip whose first, stored -/// entry is the `mimetype` naming this format. Packages saved before they had one are recognised -/// only whole ([`is_designcraft`]). +/// Whether `prefix` begins with this format's stored mimetype entry. pub fn starts_designcraft(prefix: &[u8]) -> bool { - prefix.starts_with(b"PK\x03\x04") && prefix.get(26..28) == Some(&8u16.to_le_bytes()[..]) && prefix.get(30..38) == Some(b"mimetype") && { - let extra = prefix.get(28..30).map_or(0, |e| usize::from(u16::from_le_bytes([e[0], e[1]]))); - prefix.get(38 + extra..).is_some_and(|rest| rest.starts_with(MIME.as_bytes())) - } + craft_document_bundle::starts_with_mimetype(prefix, BUNDLE) } -/// The zip entry holding the first page's picture. -pub const PREVIEW: &str = "preview.png"; - -/// The PNG bytes of a `.designcraft` file's preview, reading only the zip's directory and that -/// entry. None for files without one (saved before previews, or legacy JSON) or that aren't ours. -pub fn read_preview(file: R) -> Option> { - let mut z = zip::ZipArchive::new(file).ok()?; - let f = z.by_name(PREVIEW).ok()?; - // A preview is small; a huge entry is not one we wrote. - if f.size() > MAX_PREVIEW_BYTES { - return None; - } - // The declared size can lie: bound what the entry inflates to as well. - let mut v = Vec::new(); - f.take(MAX_PREVIEW_BYTES + 1).read_to_end(&mut v).ok()?; - (u64::try_from(v.len()).ok()? <= MAX_PREVIEW_BYTES).then_some(v) +/// Read the first-page PNG preview from a seekable package file, with a fixed 4 MiB cap. +pub fn read_preview(reader: R) -> Option> { + craft_document_bundle::read_preview_from(reader, PREVIEW) } -/// The largest preview read back (a first-page PNG is far smaller). -const MAX_PREVIEW_BYTES: u64 = 4 << 20; - -/// Read `.designcraft` bytes (zip, or the legacy single JSON file). +/// Read `.designcraft` bytes (ZIP, or the legacy single JSON file). pub fn load(bytes: &[u8]) -> Result { - if bytes.starts_with(b"PK") { - return load_zip(bytes); - } - load_legacy_json(bytes) -} - -/// Most entries a `.designcraft` archive may hold. -const MAX_ENTRIES: usize = 100_000; - -/// How much a `.designcraft` archive may expand to when read. The sizes an archive claims are -/// not trusted, so deflated entries are capped; a *stored* entry can't expand (its size is its -/// bytes in the archive, which is already in memory, and all stored entries read together are -/// held to the archive's own size), so a placed image — stored as is by [`save`] — opens whatever -/// its size and doesn't count against the budget. Saving has no cap, and this keeps every saved -/// document openable (#2166). -#[derive(Clone, Copy, Debug)] -struct Limits { - /// Most bytes `meta.json` or `document.json` may expand to. - json: u64, - /// Most bytes one deflated asset may expand to. - asset: u64, - /// Most bytes all the deflated entries read from one archive may expand to together. - total: u64, -} - -impl Limits { - const DEFAULT: Limits = Limits { json: 256 << 20, asset: 1 << 30, total: 4 << 30 }; -} - -/// Read entry `name` of `z`, or `None` when it is missing or unreadable. Refuses a deflated entry -/// that expands past `limit` bytes or past what is `left` of the archive's budget, without -/// decompressing more than that: the sizes the archive claims are not trusted. A stored entry -/// is read at its size in the archive and charged to `stored_left`, the archive's own size; only a -/// stored asset is free of `limit`. -fn read_entry( - z: &mut zip::ZipArchive>, - name: &str, - limit: u64, - left: &mut u64, - stored_left: &mut u64, -) -> Result>, FormatError> { - let Ok(f) = z.by_name(name) else { return Ok(None) }; - let stored = f.compression() == zip::CompressionMethod::Stored; - let limit = if !stored { - limit.min(*left) - } else if name.starts_with("assets/") { - f.compressed_size().min(*stored_left) - } else { - f.compressed_size().min(*stored_left).min(limit) - }; - let too_big = || FormatError::Io(format!("{name} is larger than the {limit}-byte limit (or the archive expands past its {}-byte budget)", *left)); - if f.size() > limit { - return Err(too_big()); - } - let mut v = Vec::new(); - if f.take(limit.saturating_add(1)).read_to_end(&mut v).is_err() { - return Ok(None); - } - let n = v.len() as u64; - if n > limit { - return Err(too_big()); - } - if stored { - *stored_left = stored_left.saturating_sub(n); - } else { - *left = left.saturating_sub(n); - } - Ok(Some(v)) -} - -fn load_zip(bytes: &[u8]) -> Result { - load_zip_within(bytes, Limits::DEFAULT) -} - -fn load_zip_within(bytes: &[u8], limits: Limits) -> Result { - let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?; - if z.len() > MAX_ENTRIES { - return Err(FormatError::NotOurs(format!("{} entries (at most {MAX_ENTRIES})", z.len()))); - } - let mut left = limits.total; - // Stored entries can't expand, but a crafted zip can point several at the same bytes: together - // they get the archive's own size. - let mut stored_left = bytes.len() as u64; - let version = read_entry(&mut z, "meta.json", limits.json, &mut left, &mut stored_left)? - .and_then(|meta| serde_json::from_slice::(&meta).ok()) - .and_then(|m| m.get("version").and_then(Value::as_u64)) - .unwrap_or(1); - if version > u64::from(VERSION) { - return Err(FormatError::TooNew(version)); - } - let json = read_entry(&mut z, "document.json", limits.json, &mut left, &mut stored_left)? - .ok_or_else(|| FormatError::NotOurs("missing document.json".into()))?; - let mut v: Value = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?; - upgrade_json(&mut v); - let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::NotOurs(e.to_string()))?; - let names: Vec = z.file_names().map(str::to_string).collect(); - // Files saved with one copy per placement: identical copies share one buffer as they are - // read, so memory holds each distinct file once. + let package = craft_document_bundle::read(bytes, VERSION, BUNDLE)?; + let mut value = package.document; + upgrade_json(&mut value); + let mut doc: Document = serde_json::from_value(value).map_err(|e| FormatError::NotOurs(e.to_string()))?; let fingerprint = std::collections::hash_map::RandomState::new(); let mut loaded: HashMap>>> = HashMap::new(); - for name in names { - let Some(stem) = name.strip_prefix("assets/") else { continue }; - let Some(id) = stem.split('.').next().and_then(|s| s.parse::().ok()) else { continue }; - // Entries for assets the document doesn't have are never decompressed. - if !doc.assets.contains_key(&AssetId(id)) { - continue; - } - if let (Some(data), Some(a)) = (read_entry(&mut z, &name, limits.asset, &mut left, &mut stored_left)?, doc.assets.get_mut(&AssetId(id))) { - let same = loaded.entry(fingerprint.hash_one(data.as_slice())).or_default(); - let data = match same.iter().find(|d| ***d == data) { - Some(d) => d.clone(), - None => { - let d = Arc::new(data); - same.push(d.clone()); - d - } - }; - Arc::make_mut(a).data = data; - } + for (raw_id, bytes) in package.assets { + let Ok(id) = raw_id.parse::() else { continue }; + let asset_id = AssetId(id); + let Some(asset) = doc.assets.get_mut(&asset_id) else { continue }; + let same = loaded.entry(fingerprint.hash_one(bytes.as_slice())).or_default(); + let data = match same.iter().find(|data| ***data == bytes) { + Some(data) => data.clone(), + None => { + let data = Arc::new(bytes); + same.push(data.clone()); + data + } + }; + Arc::make_mut(asset).data = data; } - upgrade(&mut doc, u32::try_from(version).unwrap_or(u32::MAX)); + upgrade(&mut doc, u32::try_from(package.version).unwrap_or(u32::MAX)); doc.merge_duplicate_assets(); doc.mark_differing_margins_local(); doc.renumber_duplicate_items(); @@ -300,16 +130,12 @@ fn load_zip_within(bytes: &[u8], limits: Limits) -> Result(doc: &mut Document, field: fn(&mut ParaAttrs) -> &mut Option, old: T) { let unset = |a: &ParaAttrs| field(&mut a.clone()).is_none(); // Every style is looked up by name: through one index, not a search of all styles each time. @@ -324,8 +150,8 @@ fn pin_para_default(doc: &mut Document, field: fn(&mut ParaAttrs) -> & }) .collect(); if !roots.is_empty() { - for s in doc.styles_mut().paragraph.iter_mut().filter(|s| roots.contains(&s.name)) { - *field(&mut s.para) = Some(old.clone()); + for style in doc.styles_mut().paragraph.iter_mut().filter(|style| roots.contains(&style.name)) { + *field(&mut style.para) = Some(old.clone()); } } // Borrowed names: one entry per style already in memory, so no copy and no extra bound needed. @@ -338,105 +164,58 @@ fn pin_para_default(doc: &mut Document, field: fn(&mut ParaAttrs) -> & } } }; - for st in doc.stories.values_mut() { - let st = Arc::make_mut(st); - st.for_each_text_mut(&mut pin); - for n in &mut st.endnotes { - Arc::make_mut(n).text.for_each_text_mut(&mut pin); + for story in doc.stories.values_mut() { + let story = Arc::make_mut(story); + story.for_each_text_mut(&mut pin); + for note in &mut story.endnotes { + Arc::make_mut(note).text.for_each_text_mut(&mut pin); } } } -fn load_legacy_json(bytes: &[u8]) -> Result { - let mut v: Value = serde_json::from_slice(bytes).map_err(|e| FormatError::NotOurs(e.to_string()))?; - upgrade_json(&mut v); - let mut doc: Document = serde_json::from_value(v.clone()).map_err(|e| FormatError::NotOurs(e.to_string()))?; - if let Some(data) = v.get("assetData").and_then(Value::as_object) { - for (k, s) in data { - if let (Ok(id), Some(s)) = (k.parse::(), s.as_str()) - && let Some(a) = doc.assets.get_mut(&AssetId(id)) - { - Arc::make_mut(a).data = Arc::new(base64_decode(s)); - } - } - } - upgrade(&mut doc, 1); - doc.merge_duplicate_assets(); - doc.mark_differing_margins_local(); - doc.renumber_duplicate_items(); - doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?; - Ok(doc) -} - -/// Bring older documents' JSON up to the current model. Vertical Type was a text frame option -/// (`content.options.vertical`); it is the story's direction, which a story takes from its first -/// frame. -fn upgrade_json(doc: &mut Value) { +/// Upgrade the old vertical-text representation before deserializing the current model. +fn upgrade_json(document: &mut Value) { let mut vertical = HashSet::new(); - vertical_frames(doc, &mut vertical, 0); + vertical_frames(document, &mut vertical, 0); if vertical.is_empty() { return; } - let Some(stories) = doc.get_mut("stories").and_then(Value::as_object_mut) else { return }; - for st in stories.values_mut() { - let first = st.get("frames").and_then(|f| f.get(0)).and_then(Value::as_u64); - if let (Some(f), Some(st)) = (first, st.as_object_mut()) - && vertical.contains(&f) - && !st.contains_key("vertical") + let Some(stories) = document.get_mut("stories").and_then(Value::as_object_mut) else { return }; + for story in stories.values_mut() { + let first = story.get("frames").and_then(|frames| frames.get(0)).and_then(Value::as_u64); + if let (Some(frame), Some(story)) = (first, story.as_object_mut()) + && vertical.contains(&frame) + && !story.contains_key("vertical") { - st.insert("vertical".into(), Value::Bool(true)); + story.insert("vertical".into(), Value::Bool(true)); } } } -/// Ids of the text frames in `v` (any depth: groups, anchored objects) whose options say vertical. -fn vertical_frames(v: &Value, out: &mut HashSet, depth: usize) { - // serde_json already refuses JSON nested deeper than 128 levels. +fn vertical_frames(value: &Value, out: &mut HashSet, depth: usize) { if depth > 256 { return; } - match v { - Value::Object(m) => { - if let (Some(id), Some(c)) = (m.get("id").and_then(Value::as_u64), m.get("content")) - && c.get("type").and_then(Value::as_str) == Some("text") - && c.get("options").and_then(|o| o.get("vertical")).and_then(Value::as_bool) == Some(true) + match value { + Value::Object(map) => { + if let (Some(id), Some(content)) = (map.get("id").and_then(Value::as_u64), map.get("content")) + && content.get("type").and_then(Value::as_str) == Some("text") + && content.get("options").and_then(|options| options.get("vertical")).and_then(Value::as_bool) == Some(true) { out.insert(id); } - for c in m.values() { - vertical_frames(c, out, depth + 1); + for child in map.values() { + vertical_frames(child, out, depth + 1); } } - Value::Array(a) => { - for c in a { - vertical_frames(c, out, depth + 1); + Value::Array(children) => { + for child in children { + vertical_frames(child, out, depth + 1); } } _ => {} } } -fn base64_decode(s: &str) -> Vec { - let mut out = Vec::with_capacity(s.len() * 3 / 4); - let (mut buf, mut bits) = (0u32, 0); - for b in s.bytes() { - let v = match b { - b'A'..=b'Z' => b - b'A', - b'a'..=b'z' => b - b'a' + 26, - b'0'..=b'9' => b - b'0' + 52, - b'+' | b'-' => 62, - b'/' | b'_' => 63, - _ => continue, - } as u32; - buf = buf << 6 | v; - bits += 6; - if bits >= 8 { - bits -= 8; - out.push((buf >> bits) as u8); - } - } - out -} - #[cfg(test)] mod tests; diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs index ca57c7770..0a62e0939 100644 --- a/crates/format/src/tests.rs +++ b/crates/format/src/tests.rs @@ -1,7 +1,10 @@ use super::*; +use std::io::{Cursor, Read, Write}; + use designcraft_doc::build::NewDocument; use designcraft_doc::geom::Rect; use designcraft_doc::{Asset, ParaFormat, SpreadRef}; +use serde_json::json; fn png_asset(d: &mut Document, data: &[u8]) -> AssetId { let aid = AssetId(d.alloc()); @@ -54,85 +57,6 @@ fn save_uncompacted(d: &Document) -> Vec { buf.into_inner() } -/// A `.designcraft` with the given asset entries, each stored as is or deflated. -fn archive_with(d: &Document, entries: &[(AssetId, &[u8], bool)]) -> Vec { - let mut buf = Cursor::new(Vec::new()); - let mut z = zip::ZipWriter::new(&mut buf); - let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); - let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); - z.start_file("mimetype", stored).unwrap(); - z.write_all(MIME.as_bytes()).unwrap(); - z.start_file("meta.json", deflate).unwrap(); - z.write_all(json!({"format": "designcraft", "version": VERSION}).to_string().as_bytes()).unwrap(); - z.start_file("document.json", deflate).unwrap(); - z.write_all(&serde_json::to_vec(d).unwrap()).unwrap(); - for (id, data, as_is) in entries { - z.start_file(format!("assets/{}.png", id.0), if *as_is { stored } else { deflate }).unwrap(); - z.write_all(data).unwrap(); - } - z.finish().unwrap(); - buf.into_inner() -} - -/// Bytes that don't compress (a simple generator, so the test needs no crate). -fn noise(n: usize) -> Vec { - let mut x: u32 = 0x9e37_79b9; - (0..n) - .map(|_| { - x ^= x << 13; - x ^= x >> 17; - x ^= x << 5; - (x >> 24) as u8 - }) - .collect() -} - -/// #2166: saving has no size cap, so opening must keep every saved document openable. A stored -/// entry (how placed images are saved) can't expand and opens at any size; the total budget is -/// never below the archive's own size. Deflated entries stay capped, so a zip bomb is refused. -#[test] -fn stored_assets_open_at_any_size_while_deflated_ones_stay_capped() { - let mut d = Document::new(&NewDocument::default()); - let big = noise(50_000); - let a = png_asset(&mut d, &big); - let b = png_asset(&mut d, &big); - d.assets.values_mut().for_each(|a| Arc::make_mut(a).data = Arc::new(Vec::new())); - place(&mut d, a, 0.0); - place(&mut d, b, 0.0); - // A budget that fits the document's JSON but not the two 50 000-byte assets together. - let json_len = serde_json::to_vec(&d).unwrap().len() as u64; - let tight = Limits { json: 256 << 20, asset: 1_000, total: json_len + 10_000 }; - assert!(tight.total < 100_000, "the JSON is {json_len} bytes; the budget must stay under the two assets"); - - // Two stored assets, each over the asset cap and together over the total: both open. - let bytes = archive_with(&d, &[(a, &big, true), (b, &big, true)]); - let doc = load_zip_within(&bytes, tight).expect("stored entries open at their own size"); - assert_eq!(doc.assets[&a].data.len(), 50_000); - // (Identical bytes come back as one asset: `load` merges duplicates.) - assert_eq!(doc.assets.len(), 1); - assert!(doc.all_items().iter().filter_map(|i| doc.item(*i)?.graphic()).all(|g| g.asset == a), "both placements show it"); - - // A deflated entry past the cap is refused, whether it really is that big ... - let bytes = archive_with(&d, &[(a, &big, false), (b, &big, true)]); - let e = load_zip_within(&bytes, tight).expect_err("a deflated entry over the cap").to_string(); - assert!(e.contains("larger than the 1000-byte limit"), "{e}"); - // ... or claims to be (a few KB that expand to megabytes). - let bomb = vec![0u8; 3 << 20]; - let bytes = archive_with(&d, &[(a, &bomb, false), (b, &big, true)]); - assert!(bytes.len() < 64 << 10, "the bomb is small on disk: {}", bytes.len()); - assert!(load_zip_within(&bytes, tight).is_err(), "a zip bomb is refused"); - - // The default limits read the same stored archive, and what `save` writes round-trips. - let bytes = archive_with(&d, &[(a, &big, true), (b, &big, true)]); - assert_eq!(load_zip(&bytes).unwrap().assets[&a].data.len(), 50_000); - Arc::make_mut(d.assets.get_mut(&a).unwrap()).data = Arc::new(big.clone()); - Arc::make_mut(d.assets.get_mut(&b).unwrap()).data = Arc::new(big.clone()); - let saved = save(&d).unwrap(); - let doc = load_zip_within(&saved, tight).expect("what save writes opens under a cap smaller than its images"); - // (`save` merges the two identical assets into one.) - assert!(!doc.assets.is_empty() && doc.assets.values().all(|x| x.data.len() == 50_000), "every saved image is back"); -} - #[test] fn explicit_all_on_pdf_layers_require_a_capable_reader_only_when_needed() { let mut d = Document::new(&NewDocument::default()); @@ -421,20 +345,6 @@ fn format_1_drop_caps_stay_unaligned() { assert!(now.styles.resolve_para(&now.story(sid).unwrap().paras[0]).0.drop_cap_align_left); } -/// Assets over the zip size limit are written with zip64 sizes (scaled down here: the real -/// threshold is near 4 GiB); smaller ones are written exactly as before. -#[test] -fn large_assets_are_written_with_zip64_sizes_and_open_again() { - let mut d = Document::new(&NewDocument::default()); - let aid = png_asset(&mut d, &[5; 64]); - place(&mut d, aid, 0.0); - let normal = save(&d).unwrap(); - assert_eq!(normal, save_with_large_entries_from(&d, None, 65).unwrap(), "an asset under the threshold is unchanged"); - let large = save_with_large_entries_from(&d, None, 64).unwrap(); - assert!(large.len() > normal.len(), "the asset entry carries zip64 sizes"); - assert_eq!(*load(&large).unwrap().assets[&aid].data, vec![5; 64]); -} - #[test] fn saves_hold_each_used_file_once() { let mut d = Document::new(&NewDocument::default()); @@ -825,7 +735,7 @@ fn oversized_document_json_is_refused() { assert!(bytes.len() < 4 << 20, "{}", bytes.len()); claim_size(&mut bytes, "document.json", 1); let Err(err) = load(&bytes) else { panic!("an oversized document.json loaded") }; - assert!(err.to_string().contains("limit"), "{err}"); + assert!(err.to_string().contains("larger"), "{err}"); } /// An embedded file that expands past the 1 GiB limit (and says so) is refused too. @@ -840,7 +750,7 @@ fn oversized_asset_is_refused() { z.start_file(format!("assets/{}.png", aid.0), zip::write::SimpleFileOptions::default()).unwrap(); write_fill(&mut z, (1 << 30) + 1, 0); let Err(err) = load(&z.finish().unwrap().into_inner()) else { panic!("an oversized asset loaded") }; - assert!(err.to_string().contains("limit"), "{err}"); + assert!(err.to_string().contains("larger"), "{err}"); } /// An archive with more entries than any document needs is refused before any is read.