From ef01d69f7d2f05bd185431f300998ddd9b3b8557 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 19:11:55 -0500 Subject: [PATCH 1/8] Shared document bundle package IO --- Cargo.lock | 10 + Cargo.toml | 1 + crates/document-bundle/Cargo.toml | 17 ++ crates/document-bundle/src/lib.rs | 292 ++++++++++++++++++++++++++++++ crates/zip/src/archive.rs | 7 + 5 files changed, 327 insertions(+) create mode 100644 crates/document-bundle/Cargo.toml create mode 100644 crates/document-bundle/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index cf0e0bd47..174984c1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1336,6 +1336,16 @@ dependencies = [ name = "craft-docpath" version = "0.1.0" +[[package]] +name = "craft-document-bundle" +version = "0.1.0" +dependencies = [ + "craft-zip", + "serde_json", + "thiserror 2.0.21", + "zip", +] + [[package]] name = "craft-docx" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 629bab64c..4735dba39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ craft-dav = { path = "crates/dav" } craft-dialogs = { path = "crates/dialogs" } craft-doc-binary = { path = "crates/doc-binary" } craft-docx = { path = "crates/docx" } +craft-document-bundle = { path = "crates/document-bundle" } craft-dxf = { path = "crates/dxf" } craft-drawingml = { path = "crates/drawingml" } craft-eps = { path = "crates/eps" } diff --git a/crates/document-bundle/Cargo.toml b/crates/document-bundle/Cargo.toml new file mode 100644 index 000000000..705ac7db5 --- /dev/null +++ b/crates/document-bundle/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "craft-document-bundle" +description = "A reusable JSON document bundle envelope over ZIP: metadata, previews, asset entries and legacy inline asset extraction." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +craft-zip = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +zip = { version = "9", default-features = false, features = ["deflate-flate2"] } diff --git a/crates/document-bundle/src/lib.rs b/crates/document-bundle/src/lib.rs new file mode 100644 index 000000000..97acba322 --- /dev/null +++ b/crates/document-bundle/src/lib.rs @@ -0,0 +1,292 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! A JSON document bundle envelope over ZIP. +//! +//! Applications keep their document models. This crate owns the reusable container mechanics: +//! ordered mimetype, metadata, JSON model, previews, embedded asset paths, compression policy, +//! bounded reads and extraction of legacy base64 asset maps. Entry names and MIME type are +//! supplied by the format using the bundle. + +use std::io::{Read, Seek}; + +use craft_zip::{Archive, Compression, Limits, Writer}; +use serde_json::Value; + +const MAX_ENTRIES: u64 = 100_000; +const JSON_LIMIT: u64 = 256 << 20; +const ASSET_LIMIT: u64 = 1 << 30; +const EXPANSION_LIMIT: u64 = 4 << 30; + +/// Per-entry and total-expansion limits for an untrusted document bundle. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ReadLimits { + /// Maximum expanded size of metadata and document JSON. + pub json_bytes: u64, + /// Maximum expanded size of one compressed asset. Stored assets are bounded by archive size. + pub compressed_asset_bytes: u64, + /// Maximum expansion beyond the bytes in the source archive. + pub expansion_bytes: u64, +} + +impl Default for ReadLimits { + fn default() -> Self { + Self { json_bytes: JSON_LIMIT, compressed_asset_bytes: ASSET_LIMIT, expansion_bytes: EXPANSION_LIMIT } + } +} + +/// Names and identity used by one JSON document bundle format. +#[derive(Clone, Copy, Debug)] +pub struct Spec<'a> { + /// Stored first entry and its payload. + pub mimetype_entry: &'a str, + pub mimetype: &'a str, + /// Metadata and document JSON member names. + pub metadata_entry: &'a str, + pub document_entry: &'a str, + /// Optional preview entry name. + pub preview_entry: &'a str, + /// Directory prefix for embedded assets, without a trailing slash. + pub assets_directory: &'a str, + /// Metadata value written in the `format` field. + pub format_name: &'a str, + /// Old single-file JSON field containing base64 assets keyed by decimal id. + pub legacy_assets_field: Option<&'a str>, +} + +/// An embedded asset passed to [`write`]. +#[derive(Clone, Copy, Debug)] +pub struct Asset<'a> { + /// Decimal asset id used in the archive entry path. + pub id: &'a str, + /// MIME type used to select the extension and compression policy. + pub mime: &'a str, + /// File bytes retained byte-for-byte. + pub data: &'a [u8], +} + +/// A document and embedded payloads returned by [`read`]. +#[derive(Clone, Debug, PartialEq)] +pub struct Package { + /// Parsed model JSON, after extracting legacy inline assets. + pub document: Value, + /// Format version declared by metadata, or 1 when absent. + pub version: u64, + /// Embedded asset bytes keyed by decimal asset id. + pub assets: Vec<(String, Vec)>, +} + +/// Why a document bundle could not be read or written. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// The bytes are not a bundle or legacy JSON document. + #[error("not a document bundle: {0}")] + NotBundle(String), + /// The bundle requires a newer reader. + #[error("document format {0} is newer than this reader supports")] + TooNew(u64), + /// A member could not be written. + #[error("could not write document bundle: {0}")] + Write(String), + /// A member exceeded a read limit or could not be read. + #[error("could not read document bundle: {0}")] + Read(String), +} + +fn extension(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", + } +} + +/// Whether `bytes` are a ZIP bundle that contains the configured document member. +pub fn has_document(bytes: &[u8], spec: Spec<'_>) -> bool { + let Ok(archive) = Archive::open(bytes, Limits { max_entries: MAX_ENTRIES, max_total_bytes: 0 }) else { return false }; + archive.contains(spec.document_entry) +} + +/// Whether `prefix` starts with the configured stored mimetype entry. +pub fn starts_with_mimetype(prefix: &[u8], spec: Spec<'_>) -> bool { + craft_zip::stored_mimetype(prefix).is_some_and(|mime| mime == spec.mimetype.as_bytes()) +} + +/// Write a ZIP document bundle containing the serialized document, metadata, preview and assets. +pub fn write(document: &[u8], version: u32, generator: &str, preview: Option<&[u8]>, assets: &[Asset<'_>], spec: Spec<'_>) -> Result, Error> { + let mut writer = Writer::new(); + writer.add(spec.mimetype_entry, spec.mimetype.as_bytes(), Compression::Stored).map_err(|e| Error::Write(e.to_string()))?; + let metadata = serde_json::json!({"format":spec.format_name, "version":version, "generator":generator}); + let metadata = serde_json::to_vec(&metadata).map_err(|e| Error::Write(e.to_string()))?; + writer.add(spec.metadata_entry, &metadata, Compression::Deflated).map_err(|e| Error::Write(e.to_string()))?; + if let Some(preview) = preview { + writer.add(spec.preview_entry, preview, Compression::Stored).map_err(|e| Error::Write(e.to_string()))?; + } + writer.add(spec.document_entry, document, Compression::Deflated).map_err(|e| Error::Write(e.to_string()))?; + for asset in assets.iter().filter(|asset| !asset.data.is_empty()) { + let path = format!("{}/{}.{}", spec.assets_directory, asset.id, extension(asset.mime)); + let compression = + if matches!(asset.mime, "image/png" | "image/jpeg" | "image/gif" | "image/webp") { Compression::Stored } else { Compression::Deflated }; + writer.add(&path, asset.data, compression).map_err(|e| Error::Write(e.to_string()))?; + } + writer.finish().map_err(|e| Error::Write(e.to_string())) +} + +/// Read a ZIP bundle or legacy JSON document within bounded member sizes. +/// +/// Members under the configured assets directory whose id does not occur in the document's +/// `assets` object are never decompressed. Unreferenced data cannot consume the caller's memory. +pub fn read(bytes: &[u8], supported_version: u32, spec: Spec<'_>) -> Result { + read_with_limits(bytes, supported_version, spec, ReadLimits::default()) +} + +/// Like [`read`], with caller-selected bounded-read limits. +pub fn read_with_limits(bytes: &[u8], supported_version: u32, spec: Spec<'_>, limits: ReadLimits) -> Result { + if !bytes.starts_with(b"PK") { + return read_legacy(bytes, spec); + } + let source_size = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + let mut archive = Archive::open(bytes, Limits { max_entries: MAX_ENTRIES, max_total_bytes: source_size.saturating_add(limits.expansion_bytes) }) + .map_err(|e| Error::NotBundle(e.to_string()))?; + let version = archive + .read(spec.metadata_entry, limits.json_bytes) + .map_err(|e| Error::Read(e.to_string()))? + .and_then(|metadata| serde_json::from_slice::(&metadata).ok()) + .and_then(|metadata| metadata.get("version").and_then(Value::as_u64)) + .unwrap_or(1); + if version > u64::from(supported_version) { + return Err(Error::TooNew(version)); + } + let document_bytes = archive + .read(spec.document_entry, limits.json_bytes) + .map_err(|e| Error::Read(e.to_string()))? + .ok_or_else(|| Error::NotBundle(format!("missing {}", spec.document_entry)))?; + let document: Value = serde_json::from_slice(&document_bytes).map_err(|e| Error::NotBundle(e.to_string()))?; + let wanted: std::collections::HashSet = + document.get("assets").and_then(Value::as_object).into_iter().flat_map(|assets| assets.keys().cloned()).collect(); + let prefix = format!("{}/", spec.assets_directory); + let mut embedded = Vec::new(); + for path in archive.names().map(|name| name.into_owned()).collect::>() { + let Some(member) = path.strip_prefix(&prefix) else { continue }; + let Some(id) = member.split('.').next() else { continue }; + if !wanted.contains(id) || id.parse::().is_err() { + continue; + } + let stored = archive.is_stored(&path).unwrap_or(false); + let limit = if stored { source_size } else { limits.compressed_asset_bytes }; + let data = archive.read(&path, limit).map_err(|e| Error::Read(e.to_string()))?; + if let Some(data) = data { + embedded.push((id.to_owned(), data)); + } + } + Ok(Package { document, version, assets: embedded }) +} + +/// Read a preview with a fixed maximum size without decompressing the full bundle. +pub fn read_preview(bytes: &[u8], preview_entry: &str) -> Option> { + const PREVIEW_LIMIT: u64 = 4 << 20; + let mut archive = Archive::open(bytes, Limits { max_entries: MAX_ENTRIES, max_total_bytes: PREVIEW_LIMIT }).ok()?; + archive.read(preview_entry, PREVIEW_LIMIT).ok().flatten() +} + +/// Read a bounded preview from a seekable file without loading the full bundle into memory. +pub fn read_preview_from(reader: R, preview_entry: &str) -> Option> { + const PREVIEW_LIMIT: u64 = 4 << 20; + let mut zip = zip::ZipArchive::new(reader).ok()?; + let entry = zip.by_name(preview_entry).ok()?; + if entry.size() > PREVIEW_LIMIT { + return None; + } + let mut preview = Vec::new(); + entry.take(PREVIEW_LIMIT.saturating_add(1)).read_to_end(&mut preview).ok()?; + (u64::try_from(preview.len()).ok()? <= PREVIEW_LIMIT).then_some(preview) +} + +fn read_legacy(bytes: &[u8], spec: Spec<'_>) -> Result { + let mut document: Value = serde_json::from_slice(bytes).map_err(|e| Error::NotBundle(e.to_string()))?; + let mut assets = Vec::new(); + if let Some(field) = spec.legacy_assets_field { + if let Some(inline) = document.get(field).and_then(Value::as_object) { + for (id, value) in inline { + if id.parse::().is_ok() + && let Some(encoded) = value.as_str() + { + assets.push((id.clone(), decode_base64(encoded))); + } + } + } + if let Some(object) = document.as_object_mut() { + object.remove(field); + } + } + Ok(Package { document, version: 1, assets }) +} + +fn decode_base64(input: &str) -> Vec { + let mut out = Vec::with_capacity(input.len().saturating_mul(3) / 4); + let (mut buffer, mut bits) = (0u32, 0u32); + for byte in input.bytes() { + let value = match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a' + 26, + b'0'..=b'9' => byte - b'0' + 52, + b'+' | b'-' => 62, + b'/' | b'_' => 63, + _ => continue, + }; + buffer = (buffer << 6) | u32::from(value); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buffer >> bits) as u8); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const SPEC: Spec<'static> = Spec { + mimetype_entry: "mimetype", + mimetype: "application/vnd.test+zip", + metadata_entry: "meta.json", + document_entry: "document.json", + preview_entry: "preview.png", + assets_directory: "assets", + format_name: "test", + legacy_assets_field: Some("assetData"), + }; + + #[test] + fn writes_and_reads_bundle_members() { + let payload = [1u8, 2, 3]; + let bytes = + write(br#"{"assets":{"7":{}}}"#, 4, "Test 0.1", Some(b"png"), &[Asset { id: "7", mime: "image/png", data: &payload }], SPEC).unwrap(); + assert!(has_document(&bytes, SPEC)); + assert!(starts_with_mimetype(&bytes, SPEC)); + let loaded = read(&bytes, 4, SPEC).unwrap(); + assert_eq!(loaded.document, serde_json::json!({"assets":{"7":{}}})); + assert_eq!(loaded.version, 4); + assert_eq!(loaded.assets, [("7".to_owned(), payload.to_vec())]); + assert_eq!(read_preview(&bytes, SPEC.preview_entry).as_deref(), Some(&b"png"[..])); + } + + #[test] + fn extracts_legacy_inline_assets() { + let loaded = read(br#"{"assetData":{"7":"AQID"},"name":"x"}"#, 4, SPEC).unwrap(); + assert_eq!(loaded.document, serde_json::json!({"name":"x"})); + assert_eq!(loaded.assets, [("7".to_owned(), vec![1, 2, 3])]); + } + + #[test] + fn refuses_newer_versions() { + let bytes = write(b"{}", 9, "future", None, &[], SPEC).unwrap(); + assert_eq!(read(&bytes, 4, SPEC), Err(Error::TooNew(9))); + } +} diff --git a/crates/zip/src/archive.rs b/crates/zip/src/archive.rs index bdcf64cd7..448e2220b 100644 --- a/crates/zip/src/archive.rs +++ b/crates/zip/src/archive.rs @@ -97,6 +97,13 @@ impl<'a> Archive<'a> { self.zip.by_index_raw(index).ok().map(|f| f.compressed_size()) } + /// Is `name` stored without compression? The answer lets a format with in-memory source bytes + /// apply an output-size cap to compressed entries while bounding stored reads by source size. + pub fn is_stored(&mut self, name: &str) -> Option { + let index = self.zip.index_for_name(name)?; + self.zip.by_index_raw(index).ok().map(|f| f.compression() == zip::CompressionMethod::Stored) + } + /// The first `n` bytes of entry `name` (all of it when it is shorter), or `None` when there /// is no such entry: for telling a file by its signature without inflating it. The bytes are /// charged to the total; a total with less left than the bytes wanted refuses before reading. From dc20b58f0157df05f6059bd408d6f14ab130f343 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 19:19:53 -0500 Subject: [PATCH 2/8] Bound document bundle asset reads --- crates/document-bundle/src/lib.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/document-bundle/src/lib.rs b/crates/document-bundle/src/lib.rs index 97acba322..18e40a3ff 100644 --- a/crates/document-bundle/src/lib.rs +++ b/crates/document-bundle/src/lib.rs @@ -152,6 +152,9 @@ pub fn read_with_limits(bytes: &[u8], supported_version: u32, spec: Spec<'_>, li let source_size = u64::try_from(bytes.len()).unwrap_or(u64::MAX); let mut archive = Archive::open(bytes, Limits { max_entries: MAX_ENTRIES, max_total_bytes: source_size.saturating_add(limits.expansion_bytes) }) .map_err(|e| Error::NotBundle(e.to_string()))?; + if archive.has_duplicate_names() { + return Err(Error::NotBundle("the archive has duplicate member names".into())); + } let version = archive .read(spec.metadata_entry, limits.json_bytes) .map_err(|e| Error::Read(e.to_string()))? @@ -209,12 +212,21 @@ pub fn read_preview_from(reader: R, preview_entry: &str) -> Opti fn read_legacy(bytes: &[u8], spec: Spec<'_>) -> Result { let mut document: Value = serde_json::from_slice(bytes).map_err(|e| Error::NotBundle(e.to_string()))?; let mut assets = Vec::new(); + let mut total = 0u64; if let Some(field) = spec.legacy_assets_field { if let Some(inline) = document.get(field).and_then(Value::as_object) { for (id, value) in inline { if id.parse::().is_ok() && let Some(encoded) = value.as_str() { + let decoded_size = u64::try_from(encoded.len()).unwrap_or(u64::MAX).saturating_mul(3) / 4; + if decoded_size > ASSET_LIMIT { + return Err(Error::Read(format!("legacy asset {id} exceeds the {ASSET_LIMIT}-byte limit"))); + } + total = total.saturating_add(decoded_size); + if total > EXPANSION_LIMIT { + return Err(Error::Read(format!("legacy assets exceed the {EXPANSION_LIMIT}-byte total limit"))); + } assets.push((id.clone(), decode_base64(encoded))); } } @@ -289,4 +301,17 @@ mod tests { let bytes = write(b"{}", 9, "future", None, &[], SPEC).unwrap(); assert_eq!(read(&bytes, 4, SPEC), Err(Error::TooNew(9))); } + + #[test] + fn stored_assets_use_source_size_and_deflated_assets_use_their_limit() { + let bytes = vec![7u8; 128]; + let document = br#"{"assets":{"7":{}}}"#; + let limits = ReadLimits { json_bytes: 1 << 20, compressed_asset_bytes: 8, expansion_bytes: 1 << 20 }; + let stored = write(document, 4, "test", None, &[Asset { id: "7", mime: "image/png", data: &bytes }], SPEC).unwrap(); + let package = read_with_limits(&stored, 4, SPEC, limits).unwrap(); + assert_eq!(package.assets, [("7".to_owned(), bytes.clone())]); + + let compressed = write(document, 4, "test", None, &[Asset { id: "7", mime: "application/octet-stream", data: &bytes }], SPEC).unwrap(); + assert!(read_with_limits(&compressed, 4, SPEC, limits).is_err()); + } } From 3afade51630fcdb02662c761e0805bc6147bb0ac Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 00:13:31 -0500 Subject: [PATCH 3/8] Expose renderer-neutral SVG preflight --- crates/svg/src/lib.rs | 1 + crates/svg/src/preflight.rs | 91 +++++++++++++++++++++++++++++++++++++ crates/svg/src/tree.rs | 34 +++++++------- 3 files changed, 109 insertions(+), 17 deletions(-) create mode 100644 crates/svg/src/preflight.rs diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index fd6ca685e..982e4f82b 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -20,6 +20,7 @@ pub mod color; pub mod guard; pub mod paths; +pub mod preflight; pub mod sniff; pub mod svgz; #[cfg(feature = "tree")] diff --git a/crates/svg/src/preflight.rs b/crates/svg/src/preflight.rs new file mode 100644 index 000000000..3ab381ff3 --- /dev/null +++ b/crates/svg/src/preflight.rs @@ -0,0 +1,91 @@ +//! Decode and validate SVG/SVGZ text before a caller hands it to a renderer. +//! +//! This boundary has no dependency on an SVG renderer. It decodes the file's text encoding and +//! bounded gzip container, then checks entity expansion, attribute work, XML structure, node +//! count and nesting depth. + +use std::borrow::Cow; + +/// Limits for decoding and validating one SVG document. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Limits { + /// Bytes of source text, also the largest unpacked SVGZ. + pub bytes: usize, + /// Deepest XML element nesting (the root alone is 1). + pub depth: usize, + /// Total bytes entities may expand to. + pub entity_bytes: usize, + /// Refuse a doctype entity containing markup. + pub refuse_entity_markup: bool, + /// Attributes on one element. + pub attributes: usize, + /// Sum of squared attribute counts across the document. + pub attribute_work: usize, + /// Maximum XML nodes after entity expansion. + pub xml_nodes: u32, +} + +impl Default for Limits { + fn default() -> Self { + Limits { + bytes: 64 << 20, + depth: 256, + entity_bytes: crate::guard::MAX_ENTITY_EXPANSION, + refuse_entity_markup: false, + attributes: crate::guard::MAX_ATTRIBUTES, + attribute_work: crate::guard::MAX_ATTRIBUTE_WORK, + xml_nodes: 4_000_000, + } + } +} + +/// Why an SVG's text or XML could not be read within `Limits`. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// The bytes are too large, corrupt SVGZ, nested SVGZ, or invalid text encoding. + #[error(transparent)] + Text(#[from] crate::svgz::Error), + /// Entity references expand past the configured limit. + #[error("the SVG's entities expand to too much text")] + Entities, + /// A doctype entity contains markup and is refused by the caller's policy. + #[error("the SVG's document type declares an entity holding markup, which isn't supported")] + EntityMarkup, + /// XML element nesting exceeds the configured limit. + #[error("the SVG's elements are nested more than {0} levels deep")] + TooDeep(usize), + /// Attribute count or total attribute work exceeds the configured limit. + #[error("an element of the SVG has more than {0} attributes, or its elements have too many in all")] + TooWide(usize), + /// The XML is malformed or exceeds the node limit. + #[error("invalid SVG XML: {0}")] + Xml(String), +} + +/// Decodes SVG or SVGZ text and checks the XML against `limits`. +pub fn text<'a>(bytes: &'a [u8], limits: &Limits) -> Result, Error> { + let text = crate::svgz::text(bytes, limits.bytes)?; + parse_text(&text, limits)?; + Ok(text) +} + +/// Parses already-decoded SVG text and checks it against `limits`. +pub fn parse_text<'a>(svg: &'a str, limits: &Limits) -> Result, Error> { + if svg.len() > limits.bytes { + return Err(Error::Text(crate::svgz::Error::TooLarge(limits.bytes))); + } + if limits.refuse_entity_markup && crate::guard::entity_holds_markup(svg) { + return Err(Error::EntityMarkup); + } + if crate::guard::entities_expand_past(svg, limits.entity_bytes) { + return Err(Error::Entities); + } + if crate::guard::too_wide(svg, limits.attributes, limits.attribute_work) { + return Err(Error::TooWide(limits.attributes)); + } + if crate::guard::deeper_than(svg, limits.depth) { + return Err(Error::TooDeep(limits.depth)); + } + let options = roxmltree::ParsingOptions { allow_dtd: true, nodes_limit: limits.xml_nodes, ..Default::default() }; + roxmltree::Document::parse_with_options(svg, options).map_err(|e| Error::Xml(e.to_string())) +} diff --git a/crates/svg/src/tree.rs b/crates/svg/src/tree.rs index 6c79c33a7..dd14be619 100644 --- a/crates/svg/src/tree.rs +++ b/crates/svg/src/tree.rs @@ -125,23 +125,23 @@ pub fn parse_text(svg: &str, limits: &Limits, options: &usvg::Options<'_>) -> Re /// Every check on SVG text, cheapest first; the XML it parses to on the way. fn checked<'s>(svg: &'s str, limits: &Limits) -> Result, Error> { - if svg.len() > limits.bytes { - return Err(Error::Text(svgz::Error::TooLarge(limits.bytes))); - } - if limits.refuse_entity_markup && guard::entity_holds_markup(svg) { - return Err(Error::EntityMarkup); - } - if guard::entities_expand_past(svg, limits.entity_bytes) { - return Err(Error::Entities); - } - if guard::too_wide(svg, limits.attributes, limits.attribute_work) { - return Err(Error::TooWide(limits.attributes)); - } - if guard::deeper_than(svg, limits.depth) { - return Err(Error::TooDeep(limits.depth)); - } - let options = roxmltree::ParsingOptions { allow_dtd: true, nodes_limit: limits.xml_nodes, ..Default::default() }; - let xml = roxmltree::Document::parse_with_options(svg, options).map_err(|e| Error::Xml(e.to_string()))?; + let preflight_limits = crate::preflight::Limits { + bytes: limits.bytes, + depth: limits.depth, + entity_bytes: limits.entity_bytes, + refuse_entity_markup: limits.refuse_entity_markup, + attributes: limits.attributes, + attribute_work: limits.attribute_work, + xml_nodes: limits.xml_nodes, + }; + let xml = crate::preflight::parse_text(svg, &preflight_limits).map_err(|e| match e { + crate::preflight::Error::Text(e) => Error::Text(e), + crate::preflight::Error::Entities => Error::Entities, + crate::preflight::Error::EntityMarkup => Error::EntityMarkup, + crate::preflight::Error::TooDeep(depth) => Error::TooDeep(depth), + crate::preflight::Error::TooWide(attributes) => Error::TooWide(attributes), + crate::preflight::Error::Xml(msg) => Error::Xml(msg), + })?; if height(&xml, limits.depth)?.is_none() { return Err(Error::TooDeep(limits.depth)); } From ba24fe2435adb3c81ad6057faf36365fbba736cf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 00:39:37 -0500 Subject: [PATCH 4/8] Move bounded EPS rectangle parsing to craft-eps --- crates/eps/src/lib.rs | 2 + crates/eps/src/rectangles.rs | 136 +++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 crates/eps/src/rectangles.rs diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index acffdfcf2..e48532349 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -19,10 +19,12 @@ pub mod ascii85; mod container; mod dsc; mod preview; +mod rectangles; pub use container::{DOS_HEADER, DOS_MAGIC, Sections, sections, wrap}; pub use dsc::{Comments, MAX_HEADER, MAX_TRAILER}; pub use preview::{Bitmap, MAX_EPSI, epsi}; +pub use rectangles::{MAX_SIMPLE_RECTANGLE_SAVE_DEPTH, MAX_SIMPLE_RECTANGLE_TOKENS, MAX_SIMPLE_RECTANGLES, Rectangle, simple_rectangles}; use std::fmt; diff --git a/crates/eps/src/rectangles.rs b/crates/eps/src/rectangles.rs new file mode 100644 index 000000000..d1c871762 --- /dev/null +++ b/crates/eps/src/rectangles.rs @@ -0,0 +1,136 @@ +//! A bounded subset of PostScript used for simple EPS previews. + +use std::collections::HashMap; + +/// A solid rectangle drawn by a simple EPS program. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rectangle { + /// Left edge in PostScript points. + pub x: f64, + /// Bottom edge in PostScript points. + pub y: f64, + /// Width in PostScript points. + pub width: f64, + /// Height in PostScript points. + pub height: f64, + /// Straight sRGB RGBA. + pub color: [u8; 4], +} + +pub const MAX_SIMPLE_RECTANGLES: usize = 200_000; +pub const MAX_SIMPLE_RECTANGLE_TOKENS: usize = 2_000_000; +pub const MAX_SIMPLE_RECTANGLE_SAVE_DEPTH: usize = 64; + +/// Simple PostScript tokens without comments, with braces kept as individual tokens. +fn tokens(text: &str) -> impl Iterator { + text.split(['\n', '\r']).flat_map(|line| { + line.split('%').next().unwrap_or("").split_whitespace().flat_map(|word| word.split_inclusive(['{', '}'])).flat_map(|piece| { + let (body, brace) = match piece.char_indices().last() { + Some((at, '{' | '}')) => (piece.get(..at).unwrap_or(""), piece.get(at..)), + _ => (piece, None), + }; + [Some(body).filter(|body| !body.is_empty()), brace].into_iter().flatten() + }) + }) +} + +/// Read solid rectangle fills and their sRGB colours from the restricted PostScript subset used +/// by simple EPS previews. None means the program uses an unsupported operator or exceeds a +/// fixed work bound. The function never executes PostScript. +pub fn simple_rectangles(ps: &[u8]) -> Option> { + let text = std::str::from_utf8(ps).ok()?; + let mut aliases: HashMap = HashMap::new(); + let mut stack: Vec = Vec::new(); + let mut color = [0u8, 0, 0, 255]; + let mut saved: Vec<[u8; 4]> = Vec::new(); + let mut rectangles = Vec::new(); + let mut input = tokens(text); + let mut count = 0usize; + while let Some(token) = input.next() { + count += 1; + if count > MAX_SIMPLE_RECTANGLE_TOKENS { + return None; + } + if let Ok(value) = token.parse::() { + if !value.is_finite() { + return None; + } + stack.push(value); + continue; + } + if let Some(name) = token.strip_prefix('/') { + let mut body: Vec<&str> = Vec::new(); + while body.len() < 5 { + count += 1; + if count > MAX_SIMPLE_RECTANGLE_TOKENS { + return None; + } + match input.next() { + Some("def") | None => break, + Some(token) => body.push(token), + } + } + let op = match body.as_slice() { + ["{", op, "}"] | ["{", op, "}", _] => (*op).to_string(), + _ => return None, + }; + aliases.insert(name.to_string(), op); + continue; + } + let op = aliases.get(token).map_or(token, String::as_str); + let mut pop = |n: usize| -> Option> { (stack.len() >= n).then(|| stack.split_off(stack.len() - n)) }; + match op { + "setcmykcolor" => { + let values = pop(4)?; + let black = values[3].clamp(0.0, 1.0); + let channel = |value: f64| ((1.0 - value.clamp(0.0, 1.0)) * (1.0 - black) * 255.0).round() as u8; + color = [channel(values[0]), channel(values[1]), channel(values[2]), 255]; + } + "setrgbcolor" => { + let values = pop(3)?; + let channel = |value: f64| (value.clamp(0.0, 1.0) * 255.0).round() as u8; + color = [channel(values[0]), channel(values[1]), channel(values[2]), 255]; + } + "setgray" => { + let values = pop(1)?; + let gray = (values[0].clamp(0.0, 1.0) * 255.0).round() as u8; + color = [gray, gray, gray, 255]; + } + "rectfill" => { + let values = pop(4)?; + if rectangles.len() >= MAX_SIMPLE_RECTANGLES { + return None; + } + let x1 = values[0] + values[2]; + let y1 = values[1] + values[3]; + if !x1.is_finite() || !y1.is_finite() { + return None; + } + rectangles.push(Rectangle { + x: values[0].min(x1), + y: values[1].min(y1), + width: (x1 - values[0]).abs(), + height: (y1 - values[1]).abs(), + color, + }); + } + "gsave" | "save" => { + if saved.len() >= MAX_SIMPLE_RECTANGLE_SAVE_DEPTH { + return None; + } + saved.push(color); + } + "grestore" | "restore" => { + if let Some(previous) = saved.pop() { + color = previous; + } + } + "pop" => { + pop(1)?; + } + "bind" | "newpath" | "showpage" | "end" | "begin" => {} + _ => return None, + } + } + (!rectangles.is_empty()).then_some(rectangles) +} From 96c848798408370cba30b9930a89b6a73d1b9aa8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 01:30:54 -0500 Subject: [PATCH 5/8] Add shared InDesign conversion facade --- Cargo.lock | 12 ++++++++++++ Cargo.toml | 2 ++ crates/indd/Cargo.toml | 14 ++++++++++++++ crates/indd/src/lib.rs | 16 ++++++++++++++++ 4 files changed, 44 insertions(+) create mode 100644 crates/indd/Cargo.toml create mode 100644 crates/indd/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 174984c1d..03a7e15f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1638,6 +1638,13 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-indd" +version = "0.1.0" +dependencies = [ + "indd", +] + [[package]] name = "craft-jmap" version = "0.1.0" @@ -4738,6 +4745,11 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" +[[package]] +name = "indd" +version = "0.1.0" +source = "git+https://github.com/storytold/indd-utils?rev=7f3060e5bce78d21ea70e0d5eacb96edce15dfbe#7f3060e5bce78d21ea70e0d5eacb96edce15dfbe" + [[package]] name = "indexmap" version = "2.14.2" diff --git a/Cargo.toml b/Cargo.toml index 4735dba39..bc06a403b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ craft-fig = { path = "crates/fig" } craft-fonts-build = { path = "crates/fonts-build" } craft-i18n = { path = "crates/i18n" } craft-idml = { path = "crates/idml" } +craft-indd = { path = "crates/indd" } craft-jmap = { path = "crates/jmap" } craft-ical = { path = "crates/ical" } craft-jpeg = { path = "crates/jpeg" } @@ -126,6 +127,7 @@ craft-vectordrawable = { path = "crates/vectordrawable" } craft-xls = { path = "crates/xls" } craft-wordmodel = { path = "crates/wordmodel" } craft-xlsx = { path = "crates/xlsx" } +indd = { git = "https://github.com/storytold/indd-utils", rev = "7f3060e5bce78d21ea70e0d5eacb96edce15dfbe" } craft-xcf = { path = "crates/xcf" } craft-xml = { path = "crates/xml" } craft-zip = { path = "crates/zip" } diff --git a/crates/indd/Cargo.toml b/crates/indd/Cargo.toml new file mode 100644 index 000000000..874ccb0d6 --- /dev/null +++ b/crates/indd/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "craft-indd" +description = "Shared InDesign document to IDML conversion facade for Craft applications" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +indd = { workspace = true } + +[lints] +workspace = true diff --git a/crates/indd/src/lib.rs b/crates/indd/src/lib.rs new file mode 100644 index 000000000..2cce05c0d --- /dev/null +++ b/crates/indd/src/lib.rs @@ -0,0 +1,16 @@ +//! Shared InDesign document conversion boundary. +//! +//! InDesign's binary document model is converted to IDML here. Applications can import the +//! resulting package through [`craft_idml`] while retaining only their own model and UI mapping. + +pub use indd::{Conversion, Error, Preview, Warning}; + +/// Whether `bytes` begins with the fixed signature of an InDesign document. +pub fn is_indd(bytes: &[u8]) -> bool { + bytes.starts_with(&indd::header::SIGNATURE) +} + +/// Convert an InDesign document to IDML. `name` is the file name stored in the package. +pub fn convert(bytes: &[u8], name: &str) -> Result { + indd::convert(bytes, name) +} From 47bf5650b0d503b97bfb5e841746c4957897d11f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 01:31:26 -0500 Subject: [PATCH 6/8] Expose shared InDesign signature --- crates/indd/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/indd/src/lib.rs b/crates/indd/src/lib.rs index 2cce05c0d..657b417fc 100644 --- a/crates/indd/src/lib.rs +++ b/crates/indd/src/lib.rs @@ -3,11 +3,12 @@ //! InDesign's binary document model is converted to IDML here. Applications can import the //! resulting package through [`craft_idml`] while retaining only their own model and UI mapping. +pub use indd::header::SIGNATURE; pub use indd::{Conversion, Error, Preview, Warning}; /// Whether `bytes` begins with the fixed signature of an InDesign document. pub fn is_indd(bytes: &[u8]) -> bool { - bytes.starts_with(&indd::header::SIGNATURE) + bytes.starts_with(&SIGNATURE) } /// Convert an InDesign document to IDML. `name` is the file name stored in the package. From 55f6235c5b50b6e987ef2886ddca84070e1ca541 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 02:54:31 -0500 Subject: [PATCH 7/8] Simplify gridline no-fill check --- crates/drawingml/src/chart/read.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/drawingml/src/chart/read.rs b/crates/drawingml/src/chart/read.rs index 9919433e9..38ce9f605 100644 --- a/crates/drawingml/src/chart/read.rs +++ b/crates/drawingml/src/chart/read.rs @@ -443,7 +443,7 @@ fn read_axes<'a>(c: &mut Chart, pa: &'a El, primary_val: &Option) -> (Op // A gridline whose `c:spPr/a:ln` has `a:noFill` is not drawn, though the element is there. let grid = |a: &El, k: &str| { a.child(Local(k)) - .is_some_and(|g| !g.child(Local("spPr")).and_then(|p| p.child(Local("ln"))).is_some_and(|l| l.child(Local("noFill")).is_some())) + .is_some_and(|g| g.child(Local("spPr")).and_then(|p| p.child(Local("ln"))).is_none_or(|l| l.child(Local("noFill")).is_none())) }; if c.kind == ChartType::Combo && c.series.iter().any(|s| s.secondary_axis) { let primary_id = val_ax.and_then(|a| a.child(Local("axId")).and_then(|c| c.attr_local("val"))); From f6cab1980bbe422cc00c4b19d9f0f9ba337e1316 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 02:54:31 -0500 Subject: [PATCH 8/8] Preserve SVG picture fallbacks in the Presentation model --- crates/drawingml/src/read.rs | 6 ++++++ crates/drawingml/src/style.rs | 3 +++ crates/drawingml/src/write.rs | 8 ++++++++ 3 files changed, 17 insertions(+) diff --git a/crates/drawingml/src/read.rs b/crates/drawingml/src/read.rs index 496870774..9e9f8ff3e 100644 --- a/crates/drawingml/src/read.rs +++ b/crates/drawingml/src/read.rs @@ -166,6 +166,12 @@ pub fn blip_fill(src: &mut dyn Source, bf: &El) -> Option { { pf.artistic = Some(crate::style::Artistic { effect, original }); } + pf.svg = blip.and_then(|b| b.child(Local("extLst"))).and_then(|extensions| { + extensions + .elements() + .filter(|extension| extension.local() == "ext" && extension.attr_local("uri") == Some("{96DAC541-7B7A-43D3-8B79-37D633B846F1}")) + .find_map(|extension| extension.child(Local("svgBlip")).and_then(|svg| media_ref(src, svg))) + }); if let Some(b) = blip { for e in b.elements() { match e.local() { diff --git a/crates/drawingml/src/style.rs b/crates/drawingml/src/style.rs index 291ee4e76..36b5a6922 100644 --- a/crates/drawingml/src/style.rs +++ b/crates/drawingml/src/style.rs @@ -184,6 +184,9 @@ impl Default for PictureMode { #[serde(default, rename_all = "camelCase")] pub struct PictureFill { pub media: MediaId, + /// Optional vector SVG version of `media`; the regular media remains the bitmap fallback. + #[serde(skip_serializing_if = "Option::is_none")] + pub svg: Option, /// Source crop as fractions of the image (left, top, right, bottom); negative values pad. pub crop: [f64; 4], pub mode: PictureMode, diff --git a/crates/drawingml/src/write.rs b/crates/drawingml/src/write.rs index 0d9b83b1a..25bfb89db 100644 --- a/crates/drawingml/src/write.rs +++ b/crates/drawingml/src/write.rs @@ -107,6 +107,7 @@ pub fn blip_fill(w: &mut Writer, out: &mut dyn Sink, tag: &str, pf: &PictureFill w.tag(tag).attr("rotWithShape", if pf.upright { "0" } else { "1" }).open(); let adj = &pf.adjust; let has_kids = pf.alpha.is_some() + || pf.svg.is_some() || adj.brightness != 0.0 || adj.contrast != 0.0 || adj.grayscale @@ -168,6 +169,13 @@ pub fn blip_fill(w: &mut Writer, out: &mut dyn Sink, tag: &str, pf: &PictureFill w.close(); w.close(); } + if let Some(svg) = pf.svg.and_then(|id| out.media_rel(id, "image")) { + w.open("a:extLst", &[]); + w.tag("a:ext").attr("uri", "{96DAC541-7B7A-43D3-8B79-37D633B846F1}").open(); + w.tag("asvg:svgBlip").attr("xmlns:asvg", "http://schemas.microsoft.com/office/drawing/2016/SVG/main").attr("r:embed", &svg).empty(); + w.close(); + w.close(); + } w.close(); } else { w.tag("a:blip").list(a).empty();