Move native bundle and EPUB packaging to craft-libs

This commit is contained in:
Dylan M. Taylor committed 2026-11-13 00:16:41 -05:00
1 parent 33a4678722
commit 7a0c256601
10 files changed
+290 -647

No files matched your search

Generated
+48 -2
View File
@@ -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",
+3
View File
@@ -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.
+3 -1
View File
@@ -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 }
+2
View File
@@ -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, String>) -> String {
let mut out = String::new();
let mut open: Vec<u8> = Vec::new();
-3
View File
@@ -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;
+123 -202
View File
@@ -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<Vec<u8>, 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("<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();
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!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xml:lang=\"{lang}\" lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><meta name=\"viewport\" content=\"width={w}, height={h}\"/><title>{} — {n}</title>\n<style>body{{margin:0;width:{w}px;height:{h}px;position:relative}} img{{position:absolute;left:0;top:0;width:{w}px;height:{h}px}} .t{{position:absolute;left:0;top:0;width:{w}px;color:transparent;font-size:1px;overflow:hidden}}</style></head>\n<body><img src=\"pages/p{n}.png\" alt=\"\"/><div class=\"t\">{}</div></body>\n</html>\n",
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE html>\n<html xmlns=\"http://www.w3.org/1999/xhtml\" xml:lang=\"{lang}\" lang=\"{lang}\">\n<head><meta charset=\"utf-8\"/><meta name=\"viewport\" content=\"width={width}, height={height}\"/><title>{} — {number}</title>\n<style>body{{margin:0;width:{width}px;height:{height}px;position:relative}} img{{position:absolute;left:0;top:0;width:{width}px;height:{height}px}} .t{{position:absolute;left:0;top:0;width:{width}px;color:transparent;font-size:1px;overflow:hidden}}</style></head>\n<body><img src=\"pages/p{number}.png\" alt=\"\"/><div class=\"t\">{}</div></body>\n</html>\n",
xml_text(&title),
story_markup(&p.text).replace('\n', "<br/>")
story_markup(&page.text).replace('\n', "<br/>")
);
let _ = writeln!(
manifest,
"<item id=\"page{n}\" href=\"page{n}.xhtml\" media-type=\"application/xhtml+xml\"/>\n<item id=\"pimg{n}\" href=\"pages/p{n}.png\" media-type=\"image/png\"{}/>",
if n == 1 { " properties=\"cover-image\"" } else { "" }
);
let _ = writeln!(spine, "<itemref idref=\"page{n}\"/>");
let _ = write!(nav_items, "<li><a href=\"page{n}.xhtml\">{n}</a></li>");
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!(
"<?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>{}</title></head>\n<body><nav epub:type=\"page-list\" id=\"toc\"><h1>Pages</h1><ol>{nav_items}</ol></nav></body>\n</html>\n",
xml_text(&title)
);
let rendition = format!(
"\n<meta property=\"rendition:layout\">pre-paginated</meta>\n<meta property=\"rendition:spread\">auto</meta>\n<meta name=\"original-resolution\" content=\"{vw}x{vh}\"/>"
);
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 `<package>` (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!("<dc:creator>{}</dc:creator>", xml_text(a))).unwrap_or_default();
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}>\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>{meta}\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)
)
}
/// 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<Item = (String, &'a [u8], bool)>) -> Result<Vec<u8>, EpubError> {
let io = |e: std::io::Error| EpubError(e.to_string());
let zerr = |e: zip::result::ZipError| EpubError(e.to_string());
let container = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\"><rootfiles><rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/></rootfiles></container>\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<Vec<u8>, 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<String> = split_chapters(&body)
.into_iter()
.map(|part| {
@@ -187,69 +111,66 @@ pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result<Vec<u8>, EpubEr
)
})
.collect();
// The file each navigation target (an element id) ended up in.
let mut files: BTreeMap<String, String> = BTreeMap::new();
for (k, c) in chapters.iter().enumerate() {
for rest in c.split(" id=\"").skip(1) {
let mut files = std::collections::BTreeMap::<String, String>::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<String> = 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!("<li><a href=\"content.xhtml\">{}</a></li>", xml_text(&title));
}
let nav = 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>{}</title></head>\n<body><nav epub:type=\"toc\" id=\"toc\"><h1>{}</h1><ol>{nav_items}</ol></nav></body>\n</html>\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(
"<item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/>\n<item id=\"css\" href=\"style.css\" media-type=\"text/css\"/>\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, "<item id=\"{item}\" href=\"{}\" media-type=\"application/xhtml+xml\"/>", chapter_file(k));
let _ = writeln!(spine, "<itemref idref=\"{item}\"/>");
}
for (k, (name, (mime, _))) in images.iter().enumerate() {
let _ = writeln!(manifest, "<item id=\"img{k}\" href=\"{}\" media-type=\"{}\"/>", xml_attr(name), xml_attr(mime));
}
for (k, f) in opts.fonts.iter().enumerate() {
let (name, mime) = font_file(k, f);
let _ = writeln!(manifest, "<item id=\"font{k}\" href=\"{}\" media-type=\"{mime}\"/>", 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,
"<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 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<String> =
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!(
"<?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>{}</title><style>body{{margin:0;text-align:center}} img{{max-width:100%;max-height:100vh}}</style></head>\n<body epub:type=\"cover\"><img src=\"{}\" alt=\"{}\"/></body>\n</html>\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") }
}
-20
View File
@@ -122,26 +122,6 @@ fn anchored_objects_are_exported_in_place() {
assert!(!story_html(&d, sid).contains("<img"));
}
#[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"),
// Years past 9999 keep the plain zero-padded year (no `+`).
(253_402_300_800, "10000-01-01T00:00:00Z"),
// Beyond chrono's range the old `civil_from_days` still converted; pin that output
// (never the epoch) in both directions and at the `i64` extremes.
(10_000_000_000_000, "318857-05-20T17:46:40Z"),
(-10_000_000_000_000, "-314918-08-13T06:13:20Z"),
(i64::MAX, "292277026596-12-04T15:30:07Z"),
(i64::MIN, "-292277022657-01-27T08:29:52Z"),
] {
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]
+5 -2
View File
@@ -9,9 +9,12 @@ description = "DesignCraft native file format: zip with document.json and embedd
workspace = true
[dependencies]
craft-document-bundle = { workspace = true }
craft-eps = { workspace = true }
designcraft-doc = { workspace = true }
designcraft-images = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
zip = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
zip = { workspace = true }
+101 -322
View File
@@ -1,32 +1,37 @@
//! The native `.designcraft` format: a zip archive holding
//! The native `.designcraft` document format: the document model plus its shared ZIP bundle
//! envelope (`mimetype`, metadata, JSON, preview and embedded assets).
//!
//! - `mimetype` (stored first, uncompressed): `application/vnd.designcraft+zip`
//! - `document.json`: the [`Document`] (pretty JSON, documented by the serde model in `designcraft-doc`)
//! - `assets/<id>.<ext>`: embedded placed files, byte-for-byte: one per distinct file, and only
//! the ones the document references (see [`Document::compact_assets`])
//! - `meta.json`: format version and generator
//! - `preview.png` (optional): a small picture of the first page, for the start screen's recent files
//!
//! Older single-file JSON documents (assets as base64 in `assetData`) still open. Documents
//! from older format versions are upgraded on load so they lay out as they did.
//! The model stays in DesignCraft. `craft-document-bundle` owns package IO, bounded ZIP reads,
//! compression, preview extraction and legacy inline-asset decoding.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
use std::collections::{HashMap, HashSet};
use std::hash::BuildHasher;
use std::io::{Cursor, Read, Write};
use std::io::{Read, Seek};
use std::sync::Arc;
use designcraft_doc::{AssetId, Document, ParaAttrs, Story};
use serde_json::{Value, json};
use serde_json::Value;
pub const MIME: &str = "application/vnd.designcraft+zip";
/// Latest reader capability. Version 2 pinned the Keep Lines Together and drop cap defaults;
/// version 3 adds rendering of retained EPS source assets; version 4 is one capability covering both
/// retained placed-graphic layer options and explicit all-on placed-PDF layer choices, so a version 4
/// reader understands both. Any later capability needs version 5. [`required_version`] is the only
/// place that picks a version.
/// Latest reader capability. Version 2 pinned paragraph defaults, version 3 adds retained EPS
/// sources, and version 4 adds placed-graphic and PDF layer options.
pub const VERSION: u32 = 4;
pub const EXTENSION: &str = "designcraft";
pub const PREVIEW: &str = "preview.png";
pub const MAX_PREVIEW_BYTES: u64 = 4 << 20;
const BUNDLE: craft_document_bundle::Spec<'static> = 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<craft_document_bundle::Error> 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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::<Vec<_>>();
let assets = owned_assets.iter().map(|(id, mime, data)| craft_document_bundle::Asset { id, mime, data }).collect::<Vec<_>>();
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<R: Read + std::io::Seek>(file: R) -> Option<Vec<u8>> {
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<R: Read + Seek>(reader: R) -> Option<Vec<u8>> {
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<Document, FormatError> {
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<Cursor<&[u8]>>,
name: &str,
limit: u64,
left: &mut u64,
stored_left: &mut u64,
) -> Result<Option<Vec<u8>>, 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<Document, FormatError> {
load_zip_within(bytes, Limits::DEFAULT)
}
fn load_zip_within(bytes: &[u8], limits: Limits) -> Result<Document, FormatError> {
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::<Value>(&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<String> = 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<u64, Vec<Arc<Vec<u8>>>> = 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::<u64>().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::<u64>() 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<Document, FormatError
/// Bring a document saved in format `from` up to [`VERSION`].
fn upgrade(doc: &mut Document, from: u32) {
if from < 2 {
// Format 1 laid out an unset Keep Lines Together mode as all lines in paragraph, and drop
// caps without Align Left Edge.
pin_para_default(doc, |a| &mut a.keep_all_lines, true);
pin_para_default(doc, |a| &mut a.drop_cap_align_left, false);
pin_para_default(doc, |attrs| &mut attrs.keep_all_lines, true);
pin_para_default(doc, |attrs| &mut attrs.drop_cap_align_left, false);
}
}
/// Pin a paragraph attribute to the value an older format resolved it to where nothing set it:
/// at the start of each paragraph style chain that leaves it unset, and on paragraphs whose style
/// is missing.
/// Pin an unset paragraph attribute to its old resolved value where an old format requires it.
fn pin_para_default<T: Clone>(doc: &mut Document, field: fn(&mut ParaAttrs) -> &mut Option<T>, 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<T: Clone>(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<T: Clone>(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<Document, FormatError> {
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::<u64>(), 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<u64>, depth: usize) {
// serde_json already refuses JSON nested deeper than 128 levels.
fn vertical_frames(value: &Value, out: &mut HashSet<u64>, 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<u8> {
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;
+5 -95
View File
@@ -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<u8> {
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<u8> {
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<u8> {
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.