mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 14:22:39 +00:00
Move native bundle and EPUB packaging to craft-libs
This commit is contained in:
10 files changed
+290
-647
No files matched your search
Generated
+48
-2
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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") }
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user