From fccc0082711b8ae4e19824dd20339a8473d47309 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 01:44:49 -0500 Subject: [PATCH] Detect DesignCraft and IDML document content --- Cargo.lock | 4 +++ crates/file-formats/Cargo.toml | 4 +++ crates/file-formats/src/lib.rs | 66 ++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 87ef135e7..8f13c4412 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1459,7 +1459,11 @@ name = "craft-file-formats" version = "0.1.0" dependencies = [ "craft-eps", + "craft-idml", "craft-svg", + "craft-zip", + "serde", + "serde_json", ] [[package]] diff --git a/crates/file-formats/Cargo.toml b/crates/file-formats/Cargo.toml index 00d3b388a..a1a605918 100644 --- a/crates/file-formats/Cargo.toml +++ b/crates/file-formats/Cargo.toml @@ -9,7 +9,11 @@ rust-version.workspace = true [dependencies] craft-eps = { workspace = true } +craft-idml = { workspace = true } craft-svg = { workspace = true } +craft-zip = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } [lints] workspace = true diff --git a/crates/file-formats/src/lib.rs b/crates/file-formats/src/lib.rs index c6ac77540..1bdfc3b5c 100644 --- a/crates/file-formats/src/lib.rs +++ b/crates/file-formats/src/lib.rs @@ -34,6 +34,8 @@ pub enum Format { Pcraft, Idml, DesignCraft, + /// Legacy DesignCraft documents serialized as one JSON object. + DesignCraftJson, Indd, Docx, /// Legacy OLE Compound File Word document; identified by its extension only. @@ -51,6 +53,15 @@ pub enum Format { /// Resolve a name and file bytes to a format. Strong signatures take precedence over /// extension hints. Only format-specific bounded probes and extension tables live here. pub fn detect(name: &str, bytes: &[u8]) -> Option { + if craft_idml::is_idml(bytes) { + return Some(Format::Idml); + } + if craft_zip::stored_mimetype(bytes) == Some(b"application/vnd.designcraft+zip") || designcraft_zip_has_document(bytes) { + return Some(Format::DesignCraft); + } + if is_designcraft_json(bytes) { + return Some(Format::DesignCraftJson); + } if bytes.starts_with(&INDD_SIGNATURE) { return Some(Format::Indd); } @@ -111,6 +122,61 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option { None } +/// DesignCraft's older ZIPs predate their leading `mimetype`; they are identified by the +/// `document.json` member. The directory is bounded before it is indexed and no entry is inflated. +fn designcraft_zip_has_document(bytes: &[u8]) -> bool { + if !bytes.starts_with(b"PK") { + return false; + } + craft_zip::Archive::open(bytes, craft_zip::Limits { max_entries: 100_000, max_total_bytes: 0 }) + .is_ok_and(|archive| archive.contains("document.json")) +} + +/// Read only a bounded prefix, stopping as soon as the model's required top-level keys have +/// appeared. This distinguishes legacy DesignCraft JSON from arbitrary JSON without decoding the +/// document or walking its arrays and assets. +fn is_designcraft_json(bytes: &[u8]) -> bool { + const LIMIT: usize = 1 << 20; + const REQUIRED: u16 = (1 << 10) - 1; + const KEYS: [&str; 10] = ["title", "settings", "spreads", "parents", "layers", "stories", "styles", "swatches", "sections", "next_id"]; + + struct Probe; + impl<'de> serde::de::Visitor<'de> for Probe { + type Value = bool; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a DesignCraft document object") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut found = 0u16; + while let Some(key) = map.next_key::()? { + if let Some(index) = KEYS.iter().position(|candidate| *candidate == key) { + found |= 1 << index; + } + map.next_value::()?; + if found == REQUIRED { + return Ok(true); + } + } + Ok(false) + } + } + + use serde::de::DeserializeSeed; + struct Seed; + impl<'de> DeserializeSeed<'de> for Seed { + type Value = bool; + fn deserialize>(self, deserializer: D) -> Result { + deserializer.deserialize_map(Probe) + } + } + + let prefix = bytes.get(..bytes.len().min(LIMIT)).unwrap_or(bytes); + let mut deserializer = serde_json::Deserializer::from_slice(prefix); + Seed.deserialize(&mut deserializer).unwrap_or(false) +} + /// Resolve a file extension to a family, case-insensitively. pub fn from_extension(ext: &str) -> Option { match ext.trim_start_matches('.').to_ascii_lowercase().as_str() {