Add shared file format detection

This commit is contained in:
Dylan M. Taylor committed 2026-11-13 00:30:56 -05:00
1 parent 5056c13ef7
commit 79c2003ac9
4 files changed
+115

No files matched your search

Generated
+7
View File
@@ -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"
+1
View File
@@ -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" }
+14
View File
@@ -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
+93
View File
@@ -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<Format> {
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"<svg") || (xml.starts_with(b"<?xml") && xml.windows(4).any(|w| w == b"<svg")) {
return Some(Format::Svg);
}
if dxf_ascii_prefix(head) || head.starts_with(b"AutoCAD Binary DXF\r\n\x1a\0") {
return Some(Format::Dxf);
}
None
}
/// Resolve a file extension to a family, case-insensitively.
pub fn from_extension(ext: &str) -> Option<Format> {
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"))
}