From 79c2003ac9c665bfd6a0dbc00c256d42cc3ad1e3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 00:29:48 -0500 Subject: [PATCH] Add shared file format detection --- Cargo.lock | 7 +++ Cargo.toml | 1 + crates/file-formats/Cargo.toml | 14 +++++ crates/file-formats/src/lib.rs | 93 ++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 crates/file-formats/Cargo.toml create mode 100644 crates/file-formats/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index cf0e0bd47..5e004a7ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1454,6 +1454,13 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-file-formats" +version = "0.1.0" +dependencies = [ + "craft-eps", +] + [[package]] name = "craft-flags" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 629bab64c..4af6f586a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ craft-docx = { path = "crates/docx" } craft-dxf = { path = "crates/dxf" } craft-drawingml = { path = "crates/drawingml" } craft-eps = { path = "crates/eps" } +craft-file-formats = { path = "crates/file-formats" } craft-doc = { path = "crates/doc" } craft-docpath = { path = "crates/docpath" } craft-dpx = { path = "crates/dpx" } diff --git a/crates/file-formats/Cargo.toml b/crates/file-formats/Cargo.toml new file mode 100644 index 000000000..0a5be5e76 --- /dev/null +++ b/crates/file-formats/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "craft-file-formats" +description = "Shared bounded file signature and extension resolution for Craft applications" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +craft-eps = { workspace = true } + +[lints] +workspace = true diff --git a/crates/file-formats/src/lib.rs b/crates/file-formats/src/lib.rs new file mode 100644 index 000000000..b51fc399c --- /dev/null +++ b/crates/file-formats/src/lib.rs @@ -0,0 +1,93 @@ +//! Bounded file kind detection and extension resolution shared by Craft applications. +//! +//! This crate identifies a format; it does not parse, validate, or decode the file. +//! Callers remain responsible for choosing which identified formats their product supports. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +/// A supported file family, independent of the application model that consumes it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + Pdf, + Eps, + PostScript, + Illustrator, + Dxf, + Svg, + Raster, + Studio, + StencilCraft, +} + +/// 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 let Some(found) = craft_eps::sniff(bytes) { + return Some(match (found.kind, found.illustrator) { + (_, true) => Format::Illustrator, + (craft_eps::Kind::Pdf, _) => Format::Pdf, + (craft_eps::Kind::Eps, _) => Format::Eps, + (craft_eps::Kind::PostScript, _) => Format::PostScript, + }); + } + if raster_signature(bytes) { + return Some(Format::Raster); + } + if bytes.starts_with(b"silhouette04;") || bytes.starts_with(b"silhouette05;") { + return Some(Format::Studio); + } + if let Some(ext) = extension(name) { + if let Some(format) = from_extension(ext) { + return Some(format); + } + } + let head = bytes.get(..4096).unwrap_or(bytes); + let xml = head.trim_ascii_start(); + if xml.starts_with(b" Option { + match ext.trim_start_matches('.').to_ascii_lowercase().as_str() { + "pdf" => Some(Format::Pdf), + "eps" => Some(Format::Eps), + "ps" => Some(Format::PostScript), + "ai" => Some(Format::Illustrator), + "dxf" => Some(Format::Dxf), + "svg" | "svgz" => Some(Format::Svg), + "png" | "jpg" | "jpeg" | "bmp" | "gif" | "tif" | "tiff" | "webp" | "heic" | "heif" => Some(Format::Raster), + "studio" | "studio3" => Some(Format::Studio), + "stencilcraft" => Some(Format::StencilCraft), + _ => None, + } +} + +fn extension(name: &str) -> Option<&str> { + Some(name.rsplit(['/', '\\']).next()?.rsplit_once('.')?.1) +} + +fn dxf_ascii_prefix(bytes: &[u8]) -> bool { + let text = String::from_utf8_lossy(bytes); + let mut lines = text.lines().map(str::trim); + matches!((lines.next(), lines.next(), lines.next(), lines.next()), (Some("0"), Some("SECTION"), Some("2"), Some(section)) if matches!(section, "HEADER" | "CLASSES" | "TABLES" | "BLOCKS" | "ENTITIES" | "OBJECTS")) +} + +fn raster_signature(bytes: &[u8]) -> bool { + let head = bytes.get(..16).unwrap_or(bytes); + head.starts_with(b"\x89PNG\r\n\x1a\n") + || head.starts_with(b"\xff\xd8\xff") + || head.starts_with(b"BM") + || head.starts_with(b"GIF87a") + || head.starts_with(b"GIF89a") + || head.starts_with(b"II*\0") + || head.starts_with(b"MM\0*") + || head.starts_with(b"II+\0") + || head.starts_with(b"MM\0+") + || (head.starts_with(b"RIFF") && head.get(8..12) == Some(b"WEBP")) +}