diff --git a/dotlottie-rs/Cargo.toml b/dotlottie-rs/Cargo.toml index a14b2017..a30aa7e7 100644 --- a/dotlottie-rs/Cargo.toml +++ b/dotlottie-rs/Cargo.toml @@ -29,7 +29,7 @@ tvg-webp = [] tvg-ttf = [] tvg-threads = [] tvg-simd = [] -dotlottie = ["dep:zip"] +dotlottie = [] state-machines = ["dotlottie"] theming = ["dotlottie"] wasm = [] # base: enables libc/C++ stubs for wasm32-unknown-unknown @@ -41,7 +41,6 @@ webgpu = ["wasm-bindgen-api", "web-sys/Gpu", "web-sys/GpuAdapter", "web-sys/GpuA [dependencies] serde_json = { version = "1.0", default-features = false, features = ["preserve_order"] } serde = { version = "1.0", features = ["derive"] } -zip = { version = "2.4.2", default-features = false, features = ["deflate"], optional = true } bitflags = { version = "2.6", optional = true } [target.wasm32-unknown-unknown.dependencies] diff --git a/dotlottie-rs/src/fms/errors.rs b/dotlottie-rs/src/fms/errors.rs deleted file mode 100644 index 91847829..00000000 --- a/dotlottie-rs/src/fms/errors.rs +++ /dev/null @@ -1,12 +0,0 @@ -#[derive(Debug)] -pub enum DotLottieError { - ArchiveOpenError, - StateMachineError, - FileFindError, - ReadContentError, - MutexLockError, - AnimationNotFound, - AnimationsNotFound, - ManifestNotFound, - InvalidUtf8Error, -} diff --git a/dotlottie-rs/src/fms/mod.rs b/dotlottie-rs/src/fms/mod.rs deleted file mode 100644 index 05375fa8..00000000 --- a/dotlottie-rs/src/fms/mod.rs +++ /dev/null @@ -1,316 +0,0 @@ -mod errors; -mod manifest; - -pub use errors::*; -pub use manifest::*; - -#[cfg(feature = "theming")] -use crate::theme::Theme; -use serde_json::Value; -use std::cell::RefCell; -use std::io::{self, Read}; -use zip::ZipArchive; - -const BASE64_CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - -const DATA_IMAGE_PREFIX: &str = "data:image/"; -const DATA_FONT_PREFIX: &str = "data:font/"; -const BASE64_PREFIX: &str = ";base64,"; -const DEFAULT_EXT: &str = "png"; -const DEFAULT_FONT_EXT: &str = "ttf"; - -pub struct DotLottieManager { - active_animation_id: Box, - manifest: Manifest, - version: u8, - archive: RefCell>>>, -} - -impl DotLottieManager { - pub fn new(dotlottie: &[u8]) -> Result { - let mut archive = ZipArchive::new(io::Cursor::new(dotlottie.to_vec())) - .map_err(|_| DotLottieError::ArchiveOpenError)?; - - let manifest = Self::read_zip_file(&mut archive, "manifest.json")?; - let manifest_str = - std::str::from_utf8(&manifest).map_err(|_| DotLottieError::ReadContentError)?; - let manifest: Manifest = - serde_json::from_str(manifest_str).map_err(|_| DotLottieError::ReadContentError)?; - - let id = manifest - .initial - .as_ref() - .and_then(|initial| initial.animation.as_ref()) - .or_else(|| manifest.animations.first().map(|a| &a.id)) - .ok_or(DotLottieError::AnimationsNotFound)? - .clone() - .into_boxed_str(); - - let version = manifest - .version - .as_deref() - .map(|v| if v == "2" { 2 } else { 1 }) - .unwrap_or(1); - - Ok(DotLottieManager { - active_animation_id: id, - manifest, - version, - archive: RefCell::new(archive), - }) - } - - #[inline] - pub fn get_active_animation(&self) -> Result { - self.get_animation(&self.active_animation_id) - } - - pub fn get_animation(&self, animation_id: &str) -> Result { - let mut archive = self.archive.borrow_mut(); - - let (json_path, lot_path) = if self.version == 2 { - ( - format!("a/{animation_id}.json"), - format!("a/{animation_id}.lot"), - ) - } else { - ( - format!("animations/{animation_id}.json"), - format!("animations/{animation_id}.lot"), - ) - }; - - let file_data = Self::read_zip_file(&mut archive, &json_path) - .or_else(|_| Self::read_zip_file(&mut archive, &lot_path))?; - - let animation_data = - std::str::from_utf8(&file_data).map_err(|_| DotLottieError::ReadContentError)?; - - let mut lottie_animation: Value = - serde_json::from_str(animation_data).map_err(|_| DotLottieError::ReadContentError)?; - - if let Some(assets) = lottie_animation - .get_mut("assets") - .and_then(|v| v.as_array_mut()) - { - let image_prefix = if self.version == 2 { "i/" } else { "images/" }; - let mut asset_path = String::with_capacity(128); // Larger initial capacity - - let embedded_flag = Value::Number(1.into()); - let empty_u = Value::String(String::new()); - - for asset in assets.iter_mut() { - if let Some(asset_obj) = asset.as_object_mut() { - if let Some(p_str) = asset_obj.get("p").and_then(|v| v.as_str()) { - if p_str.starts_with(DATA_IMAGE_PREFIX) { - asset_obj.insert("e".to_string(), embedded_flag.clone()); - } else { - asset_path.clear(); - asset_path.push_str(image_prefix); - asset_path.push_str(p_str.trim_matches('"')); - - if let Ok(mut result) = archive.by_name(&asset_path) { - let mut content = Vec::with_capacity(result.size() as usize); - if result.read_to_end(&mut content).is_ok() { - let image_ext = p_str - .rfind('.') - .map(|i| &p_str[i + 1..]) - .unwrap_or(DEFAULT_EXT); - let image_data_base64 = Self::encode_base64(&content); - - let data_url = format!( - "{DATA_IMAGE_PREFIX}{image_ext}{BASE64_PREFIX}{image_data_base64}" - ); - - asset_obj.insert("u".to_string(), empty_u.clone()); - asset_obj.insert("p".to_string(), Value::String(data_url)); - asset_obj.insert("e".to_string(), embedded_flag.clone()); - } - } - } - } - } - } - } - - if self.version == 2 { - if let Some(fonts) = lottie_animation - .get_mut("fonts") - .and_then(|v| v.as_object_mut()) - { - if let Some(font_list) = fonts.get_mut("list").and_then(|v| v.as_array_mut()) { - let mut font_path = String::with_capacity(128); - - for font in font_list.iter_mut() { - if let Some(font_obj) = font.as_object_mut() { - if let Some(f_path_str) = font_obj.get("fPath").and_then(|v| v.as_str()) - { - // only process fonts with /f/ prefix (package-internal fonts) - if f_path_str.starts_with("/f/") { - font_path.clear(); - font_path.push_str("f/"); - let path_without_prefix = - f_path_str.strip_prefix("/f/").unwrap_or(f_path_str); - font_path.push_str(path_without_prefix); - - if let Ok(mut result) = archive.by_name(&font_path) { - let mut content = - Vec::with_capacity(result.size() as usize); - if result.read_to_end(&mut content).is_ok() { - let font_ext = path_without_prefix - .rfind('.') - .map(|i| &path_without_prefix[i + 1..]) - .unwrap_or(DEFAULT_FONT_EXT); - let font_data_base64 = Self::encode_base64(&content); - - let data_url = format!( - "{DATA_FONT_PREFIX}{font_ext}{BASE64_PREFIX}{font_data_base64}" - ); - - font_obj.insert( - "fPath".to_string(), - Value::String(data_url), - ); - } - } - } - } - } - } - } - } - } - - serde_json::to_string(&lottie_animation).map_err(|_| DotLottieError::ReadContentError) - } - - #[inline] - #[cfg(feature = "state-machines")] - pub fn get_state_machine(&self, state_machine_id: &str) -> Result { - let mut archive = self.archive.borrow_mut(); - let path = format!("s/{state_machine_id}.json"); - let content = Self::read_zip_file(&mut archive, &path)?; - String::from_utf8(content).map_err(|_| DotLottieError::InvalidUtf8Error) - } - - #[inline] - pub fn manifest(&self) -> &Manifest { - &self.manifest - } - - #[inline] - pub fn active_animation_id(&self) -> String { - self.active_animation_id.to_string() - } - - #[inline] - #[cfg(feature = "theming")] - pub fn get_theme(&self, theme_id: &str) -> Result { - let mut archive = self.archive.borrow_mut(); - let path = format!("t/{theme_id}.json"); - let content = Self::read_zip_file(&mut archive, &path)?; - let theme_str = - std::str::from_utf8(&content).map_err(|_| DotLottieError::InvalidUtf8Error)?; - theme_str - .parse::() - .map_err(|_| DotLottieError::ReadContentError) - } - - #[inline] - fn encode_base64(input: &[u8]) -> String { - if input.is_empty() { - return String::new(); - } - - let output_len = input.len().div_ceil(3) * 4; - let mut result = Vec::with_capacity(output_len); - - let mut i = 0; - while i + 2 < input.len() { - let b0 = input[i] as u32; - let b1 = input[i + 1] as u32; - let b2 = input[i + 2] as u32; - let n = (b0 << 16) | (b1 << 8) | b2; - - result.push(BASE64_CHARS[((n >> 18) & 63) as usize]); - result.push(BASE64_CHARS[((n >> 12) & 63) as usize]); - result.push(BASE64_CHARS[((n >> 6) & 63) as usize]); - result.push(BASE64_CHARS[(n & 63) as usize]); - i += 3; - } - - if i < input.len() { - let b0 = input[i] as u32; - let b1 = input.get(i + 1).copied().unwrap_or(0) as u32; - let n = (b0 << 16) | (b1 << 8); - - result.push(BASE64_CHARS[((n >> 18) & 63) as usize]); - result.push(BASE64_CHARS[((n >> 12) & 63) as usize]); - result.push(if i + 1 < input.len() { - BASE64_CHARS[((n >> 6) & 63) as usize] - } else { - b'=' - }); - result.push(b'='); - } - - // safe conversion from Vec to String since we only used valid ASCII - unsafe { String::from_utf8_unchecked(result) } - } - - #[inline] - fn read_zip_file( - archive: &mut ZipArchive, - path: &str, - ) -> Result, DotLottieError> { - let mut file = archive - .by_name(path) - .map_err(|_| DotLottieError::FileFindError)?; - - let mut buf = Vec::with_capacity(file.size() as usize); - file.read_to_end(&mut buf) - .map_err(|_| DotLottieError::ReadContentError)?; - - Ok(buf) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs::File; - use std::io::Read; - - #[test] - fn test_dotlottie_manager_creation() { - let file_path = format!( - "{}{}", - env!("CARGO_MANIFEST_DIR"), - "/src/fms/tests/resources/emoji-collection.lottie" - ); - - if let Ok(mut file) = File::open(&file_path) { - let mut buffer = Vec::new(); - if file.read_to_end(&mut buffer).is_ok() { - let manager = DotLottieManager::new(&buffer); - assert!(manager.is_ok()); - - if let Ok(mgr) = manager { - assert!(!mgr.active_animation_id().is_empty()); - assert!(!mgr.manifest().animations.is_empty()); - } - } - } - } - - #[test] - fn test_base64_encoding() { - let input = b"Hello, World!"; - let result = DotLottieManager::encode_base64(input); - assert_eq!(result, "SGVsbG8sIFdvcmxkIQ=="); - - let empty_input = b""; - let empty_result = DotLottieManager::encode_base64(empty_input); - assert_eq!(empty_result, ""); - } -} diff --git a/dotlottie-rs/src/io/dotlottie/base64.rs b/dotlottie-rs/src/io/dotlottie/base64.rs new file mode 100644 index 00000000..9cbe6628 --- /dev/null +++ b/dotlottie-rs/src/io/dotlottie/base64.rs @@ -0,0 +1,52 @@ +const BASE64_CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +pub(super) fn encode_into(input: &[u8], out: &mut String) { + let output_len = input.len().div_ceil(3) * 4; + out.reserve(output_len); + + let mut i = 0; + while i + 2 < input.len() { + let b0 = input[i] as u32; + let b1 = input[i + 1] as u32; + let b2 = input[i + 2] as u32; + let n = (b0 << 16) | (b1 << 8) | b2; + + out.push(BASE64_CHARS[((n >> 18) & 63) as usize] as char); + out.push(BASE64_CHARS[((n >> 12) & 63) as usize] as char); + out.push(BASE64_CHARS[((n >> 6) & 63) as usize] as char); + out.push(BASE64_CHARS[(n & 63) as usize] as char); + i += 3; + } + + if i < input.len() { + let b0 = input[i] as u32; + let b1 = input.get(i + 1).copied().unwrap_or(0) as u32; + let n = (b0 << 16) | (b1 << 8); + + out.push(BASE64_CHARS[((n >> 18) & 63) as usize] as char); + out.push(BASE64_CHARS[((n >> 12) & 63) as usize] as char); + out.push(if i + 1 < input.len() { + BASE64_CHARS[((n >> 6) & 63) as usize] as char + } else { + '=' + }); + out.push('='); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn encode(input: &[u8]) -> String { + let mut out = String::new(); + encode_into(input, &mut out); + out + } + + #[test] + fn test_base64_encoding() { + assert_eq!(encode(b"Hello, World!"), "SGVsbG8sIFdvcmxkIQ=="); + assert_eq!(encode(b""), ""); + } +} diff --git a/dotlottie-rs/src/io/dotlottie/error.rs b/dotlottie-rs/src/io/dotlottie/error.rs new file mode 100644 index 00000000..dfc1bfcd --- /dev/null +++ b/dotlottie-rs/src/io/dotlottie/error.rs @@ -0,0 +1,49 @@ +use super::zip::ZipError; +use std::fmt; + +#[derive(Debug)] +pub enum ReaderError { + /// ZIP archive could not be opened or is malformed. + Zip(ZipError), + /// manifest.json not found in the archive. + ManifestNotFound, + /// No animations listed in the manifest. + NoAnimations, + /// Requested animation not found in the archive. + AnimationNotFound, + /// Requested file not found in the archive. + FileNotFound, + /// Content is not valid UTF-8. + InvalidUtf8(std::str::Utf8Error), + /// JSON parsing failed. + InvalidJson(serde_json::Error), +} + +impl fmt::Display for ReaderError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self:?}") + } +} + +impl std::error::Error for ReaderError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Zip(e) => Some(e), + Self::InvalidUtf8(e) => Some(e), + Self::InvalidJson(e) => Some(e), + _ => None, + } + } +} + +impl From for ReaderError { + fn from(e: std::str::Utf8Error) -> Self { + Self::InvalidUtf8(e) + } +} + +impl From for ReaderError { + fn from(e: serde_json::Error) -> Self { + Self::InvalidJson(e) + } +} diff --git a/dotlottie-rs/src/fms/manifest.rs b/dotlottie-rs/src/io/dotlottie/manifest.rs similarity index 100% rename from dotlottie-rs/src/fms/manifest.rs rename to dotlottie-rs/src/io/dotlottie/manifest.rs diff --git a/dotlottie-rs/src/io/dotlottie/mod.rs b/dotlottie-rs/src/io/dotlottie/mod.rs new file mode 100644 index 00000000..64449a8c --- /dev/null +++ b/dotlottie-rs/src/io/dotlottie/mod.rs @@ -0,0 +1,10 @@ +mod base64; +mod error; +mod manifest; +mod reader; +mod zip; + +pub use error::ReaderError; +pub use manifest::*; +pub use reader::Reader; +pub use zip::ZipError; diff --git a/dotlottie-rs/src/io/dotlottie/reader.rs b/dotlottie-rs/src/io/dotlottie/reader.rs new file mode 100644 index 00000000..d91f03db --- /dev/null +++ b/dotlottie-rs/src/io/dotlottie/reader.rs @@ -0,0 +1,260 @@ +use super::base64; +use super::error::ReaderError; +use super::manifest::Manifest; +use super::zip::{DotLottieArchive, ZipError}; +#[cfg(feature = "theming")] +use crate::theme::Theme; +use serde_json::Value; + +const DATA_IMAGE_PREFIX: &str = "data:image/"; +const DATA_FONT_PREFIX: &str = "data:font/"; +const BASE64_PREFIX: &str = ";base64,"; +const DEFAULT_EXT: &str = "png"; +const DEFAULT_FONT_EXT: &str = "ttf"; + +pub struct Reader { + initial_animation_id: Box, + manifest: Manifest, + version: u8, + archive: DotLottieArchive, +} + +impl Reader { + pub fn new(dotlottie: &[u8]) -> Result { + let archive = + DotLottieArchive::new(dotlottie.to_vec()).map_err(ReaderError::Zip)?; + + let manifest_bytes = archive.read("manifest.json").map_err(|e| match e { + ZipError::FileNotFound => ReaderError::ManifestNotFound, + other => ReaderError::Zip(other), + })?; + let manifest_str = std::str::from_utf8(&manifest_bytes)?; + let manifest: Manifest = serde_json::from_str(manifest_str)?; + + let id = manifest + .initial + .as_ref() + .and_then(|initial| initial.animation.as_ref()) + .or_else(|| manifest.animations.first().map(|a| &a.id)) + .ok_or(ReaderError::NoAnimations)? + .clone() + .into_boxed_str(); + + let version = manifest + .version + .as_deref() + .map(|v| if v == "2" { 2 } else { 1 }) + .unwrap_or(1); + + Ok(Reader { + initial_animation_id: id, + manifest, + version, + archive, + }) + } + + #[inline] + pub fn initial_animation(&self) -> Result { + self.animation(&self.initial_animation_id) + } + + pub fn animation(&self, animation_id: &str) -> Result { + let (json_path, lot_path) = if self.version == 2 { + ( + format!("a/{animation_id}.json"), + format!("a/{animation_id}.lot"), + ) + } else { + ( + format!("animations/{animation_id}.json"), + format!("animations/{animation_id}.lot"), + ) + }; + + let file_data = self + .archive + .read(&json_path) + .or_else(|e| match e { + ZipError::FileNotFound => self.archive.read(&lot_path), + other => Err(other), + }) + .map_err(|e| match e { + ZipError::FileNotFound => ReaderError::AnimationNotFound, + other => ReaderError::Zip(other), + })?; + + let animation_data = std::str::from_utf8(&file_data)?; + + let mut lottie_animation: Value = serde_json::from_str(animation_data)?; + + if let Some(assets) = lottie_animation + .get_mut("assets") + .and_then(|v| v.as_array_mut()) + { + self.embed_image_assets(assets)?; + } + + if self.version == 2 { + if let Some(fonts) = lottie_animation + .get_mut("fonts") + .and_then(|v| v.as_object_mut()) + { + if let Some(font_list) = fonts.get_mut("list").and_then(|v| v.as_array_mut()) { + self.embed_font_assets(font_list)?; + } + } + } + + Ok(serde_json::to_string(&lottie_animation)?) + } + + #[inline] + #[cfg(feature = "state-machines")] + pub fn state_machine(&self, state_machine_id: &str) -> Result { + let path = format!("s/{state_machine_id}.json"); + let content = self.archive.read(&path).map_err(|e| match e { + ZipError::FileNotFound => ReaderError::FileNotFound, + other => ReaderError::Zip(other), + })?; + String::from_utf8(content.into_owned()) + .map_err(|e| ReaderError::InvalidUtf8(e.utf8_error())) + } + + #[inline] + pub fn manifest(&self) -> &Manifest { + &self.manifest + } + + #[inline] + pub fn initial_animation_id(&self) -> String { + self.initial_animation_id.to_string() + } + + #[inline] + #[cfg(feature = "theming")] + pub fn theme(&self, theme_id: &str) -> Result { + let path = format!("t/{theme_id}.json"); + let content = self.archive.read(&path).map_err(|e| match e { + ZipError::FileNotFound => ReaderError::FileNotFound, + other => ReaderError::Zip(other), + })?; + let theme_str = std::str::from_utf8(&content)?; + Ok(theme_str.parse::()?) + } + + fn embed_image_assets(&self, assets: &mut [Value]) -> Result<(), ReaderError> { + let image_prefix = if self.version == 2 { "i/" } else { "images/" }; + let mut asset_path = String::with_capacity(128); + let mut data_url_buf = String::with_capacity(1024); + + let embedded_flag = Value::Number(1.into()); + let empty_u = Value::String(String::new()); + let key_e = "e".to_owned(); + let key_u = "u".to_owned(); + let key_p = "p".to_owned(); + + for asset in assets.iter_mut() { + if let Some(asset_obj) = asset.as_object_mut() { + if let Some(p_str) = asset_obj.get("p").and_then(|v| v.as_str()) { + if p_str.starts_with(DATA_IMAGE_PREFIX) { + asset_obj.insert(key_e.clone(), embedded_flag.clone()); + } else { + asset_path.clear(); + asset_path.push_str(image_prefix); + asset_path.push_str(p_str.trim_matches('"')); + + if let Ok(content) = self.archive.read(&asset_path) { + let image_ext = p_str + .rfind('.') + .map(|i| &p_str[i + 1..]) + .unwrap_or(DEFAULT_EXT); + + data_url_buf.clear(); + data_url_buf.push_str(DATA_IMAGE_PREFIX); + data_url_buf.push_str(image_ext); + data_url_buf.push_str(BASE64_PREFIX); + base64::encode_into(&content, &mut data_url_buf); + + asset_obj.insert(key_u.clone(), empty_u.clone()); + asset_obj.insert( + key_p.clone(), + Value::String(std::mem::take(&mut data_url_buf)), + ); + asset_obj.insert(key_e.clone(), embedded_flag.clone()); + } + } + } + } + } + Ok(()) + } + + fn embed_font_assets(&self, font_list: &mut [Value]) -> Result<(), ReaderError> { + let mut font_path = String::with_capacity(128); + let mut data_url_buf = String::with_capacity(1024); + let key_fpath = "fPath".to_owned(); + + for font in font_list.iter_mut() { + if let Some(font_obj) = font.as_object_mut() { + if let Some(f_path_str) = font_obj.get("fPath").and_then(|v| v.as_str()) { + if f_path_str.starts_with("/f/") { + font_path.clear(); + font_path.push_str("f/"); + let path_without_prefix = + f_path_str.strip_prefix("/f/").unwrap_or(f_path_str); + font_path.push_str(path_without_prefix); + + if let Ok(content) = self.archive.read(&font_path) { + let font_ext = path_without_prefix + .rfind('.') + .map(|i| &path_without_prefix[i + 1..]) + .unwrap_or(DEFAULT_FONT_EXT); + + data_url_buf.clear(); + data_url_buf.push_str(DATA_FONT_PREFIX); + data_url_buf.push_str(font_ext); + data_url_buf.push_str(BASE64_PREFIX); + base64::encode_into(&content, &mut data_url_buf); + + font_obj.insert( + key_fpath.clone(), + Value::String(std::mem::take(&mut data_url_buf)), + ); + } + } + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + use std::io::Read; + + #[test] + fn test_dotlottie_reader_creation() { + let file_path = format!( + "{}{}", + env!("CARGO_MANIFEST_DIR"), + "/src/dotlottie/tests/resources/emoji-collection.lottie" + ); + + if let Ok(mut file) = File::open(&file_path) { + let mut buffer = Vec::new(); + if file.read_to_end(&mut buffer).is_ok() { + let reader = Reader::new(&buffer); + assert!(reader.is_ok()); + + if let Ok(mgr) = reader { + assert!(!mgr.initial_animation_id().is_empty()); + assert!(!mgr.manifest().animations.is_empty()); + } + } + } + } +} diff --git a/dotlottie-rs/src/io/dotlottie/zip/decompress.rs b/dotlottie-rs/src/io/dotlottie/zip/decompress.rs new file mode 100644 index 00000000..49ecce0a --- /dev/null +++ b/dotlottie-rs/src/io/dotlottie/zip/decompress.rs @@ -0,0 +1,89 @@ +use super::inflate; +use super::parse::ZipError; +use std::borrow::Cow; + +const METHOD_STORE: u16 = 0; +const METHOD_DEFLATE: u16 = 8; + +// 256 MB — generous upper bound for any single file in a dotLottie archive +const MAX_UNCOMPRESSED_SIZE: usize = 256 * 1024 * 1024; + +pub(crate) fn decompress<'a>( + compressed: &'a [u8], + compression_method: u16, + uncompressed_size: usize, + _expected_crc32: u32, + reuse_buf: &mut Vec, +) -> Result, ZipError> { + if uncompressed_size > MAX_UNCOMPRESSED_SIZE { + return Err(ZipError::DecompressError); + } + + match compression_method { + METHOD_STORE => { + if compressed.len() != uncompressed_size { + return Err(ZipError::DecompressError); + } + Ok(Cow::Borrowed(compressed)) + } + METHOD_DEFLATE => { + reuse_buf.clear(); + reuse_buf.reserve(uncompressed_size); + inflate::inflate(compressed, reuse_buf) + .map_err(|_| ZipError::DecompressError)?; + if reuse_buf.len() != uncompressed_size { + return Err(ZipError::DecompressError); + } + Ok(Cow::Owned(std::mem::take(reuse_buf))) + } + other => Err(ZipError::UnsupportedCompression(other)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decompress_stored() { + let data = b"hello world"; + let mut buf = Vec::new(); + let result = decompress(data, METHOD_STORE, data.len(), 0, &mut buf).unwrap(); + assert_eq!(&*result, b"hello world"); + // Should be borrowed, not owned + assert!(matches!(result, Cow::Borrowed(_))); + } + + #[test] + fn test_decompress_stored_size_mismatch() { + let data = b"hello"; + let mut buf = Vec::new(); + assert!(decompress(data, METHOD_STORE, 10, 0, &mut buf).is_err()); + } + + #[test] + fn test_decompress_deflated() { + // "Hello, dotLottie!" compressed with raw deflate + let compressed: &[u8] = &[ + 243, 72, 205, 201, 201, 215, 81, 72, 201, 47, 241, 201, 47, 41, 201, 76, 85, 4, 0, + ]; + let mut buf = Vec::new(); + let result = decompress(compressed, METHOD_DEFLATE, 17, 0, &mut buf).unwrap(); + assert_eq!(&*result, b"Hello, dotLottie!"); + assert!(matches!(result, Cow::Owned(_))); + } + + #[test] + fn test_decompress_unsupported_method() { + let mut buf = Vec::new(); + let err = decompress(b"", 99, 0, 0, &mut buf).unwrap_err(); + assert!(matches!(err, ZipError::UnsupportedCompression(99))); + } + + #[test] + fn test_decompress_too_large() { + let mut buf = Vec::new(); + let err = decompress(b"", METHOD_DEFLATE, MAX_UNCOMPRESSED_SIZE + 1, 0, &mut buf).unwrap_err(); + assert!(matches!(err, ZipError::DecompressError)); + } +} diff --git a/dotlottie-rs/src/io/dotlottie/zip/inflate.rs b/dotlottie-rs/src/io/dotlottie/zip/inflate.rs new file mode 100644 index 00000000..0b962220 --- /dev/null +++ b/dotlottie-rs/src/io/dotlottie/zip/inflate.rs @@ -0,0 +1,732 @@ +//! Minimal raw DEFLATE (RFC 1951) inflater optimized for dotLottie archives. +//! +//! Supports all three block types: stored (0), fixed Huffman (1), dynamic Huffman (2). +//! Uses two-level lookup tables for fast Huffman decoding. + +use std::fmt; + +// ── Error type ────────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub(crate) enum InflateError { + InvalidBlockType, + InvalidHuffmanTable, + InvalidStoredLen, + InvalidLengthCode, + InvalidDistanceCode, + DistanceTooFarBack, + UnexpectedEof, +} + +impl fmt::Display for InflateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self:?}") + } +} + +// ── Static tables (RFC 1951 §3.2.5) ──────────────────────────────────────── + +/// Length codes 257..285 → (base_length, extra_bits) +const LENGTH_TABLE: [(u16, u8); 29] = [ + (3, 0), + (4, 0), + (5, 0), + (6, 0), + (7, 0), + (8, 0), + (9, 0), + (10, 0), + (11, 1), + (13, 1), + (15, 1), + (17, 1), + (19, 2), + (23, 2), + (27, 2), + (31, 2), + (35, 3), + (43, 3), + (51, 3), + (59, 3), + (67, 4), + (83, 4), + (99, 4), + (115, 4), + (131, 5), + (163, 5), + (195, 5), + (227, 5), + (258, 0), +]; + +/// Distance codes 0..29 → (base_distance, extra_bits) +const DISTANCE_TABLE: [(u16, u8); 30] = [ + (1, 0), + (2, 0), + (3, 0), + (4, 0), + (5, 1), + (7, 1), + (9, 2), + (13, 2), + (17, 3), + (25, 3), + (33, 4), + (49, 4), + (65, 5), + (97, 5), + (129, 6), + (193, 6), + (257, 7), + (385, 7), + (513, 8), + (769, 8), + (1025, 9), + (1537, 9), + (2049, 10), + (3073, 10), + (4097, 11), + (6145, 11), + (8193, 12), + (12289, 12), + (16385, 13), + (24577, 13), +]; + +/// Order in which code-length code lengths are transmitted (RFC 1951 §3.2.7) +const CODELEN_ORDER: [usize; 19] = [ + 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15, +]; + +// ── Bit reader ────────────────────────────────────────────────────────────── + +struct BitReader<'a> { + data: &'a [u8], + pos: usize, + buf: u32, + bits: u8, +} + +impl<'a> BitReader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { + data, + pos: 0, + buf: 0, + bits: 0, + } + } + + /// Fill the bit buffer so it contains at least `need` bits (max 25). + #[inline] + fn fill(&mut self, need: u8) { + while self.bits < need { + let byte = if self.pos < self.data.len() { + self.data[self.pos] + } else { + 0 // reads past end produce zeros; callers check EOF via results + }; + self.pos += 1; + self.buf |= (byte as u32) << self.bits; + self.bits += 8; + } + } + + #[inline] + fn peek(&mut self, n: u8) -> u16 { + self.fill(n); + (self.buf & ((1u32 << n) - 1)) as u16 + } + + #[inline] + fn consume(&mut self, n: u8) { + self.buf >>= n; + self.bits -= n; + } + + #[inline] + fn read(&mut self, n: u8) -> u16 { + let v = self.peek(n); + self.consume(n); + v + } + + /// Discard remaining bits in the current byte to align to a byte boundary. + fn align(&mut self) { + let discard = self.bits & 7; + if discard > 0 { + self.consume(discard); + } + } + + /// Read `n` bytes directly (after alignment). Returns a slice or error. + fn read_bytes(&mut self, n: usize) -> Result<&'a [u8], InflateError> { + // After alignment the bit buffer should be at a byte boundary. + // Drain any full bytes still in the bit buffer first. + while self.bits >= 8 { + // Push byte back into the stream conceptually. + self.pos -= 1; + self.bits -= 8; + self.buf >>= 8; + } + // Now bits == 0 after align() + let start = self.pos; + let end = start.checked_add(n).ok_or(InflateError::UnexpectedEof)?; + if end > self.data.len() { + return Err(InflateError::UnexpectedEof); + } + self.pos = end; + Ok(&self.data[start..end]) + } +} + +// ── Huffman table ─────────────────────────────────────────────────────────── + +/// Packed entry: bits [15:0] = symbol/sub-table offset, bits [19:16] = code length, bit 31 = sub-table flag +const SUB_TABLE_FLAG: u32 = 1 << 31; + +#[inline] +fn entry(sym: u16, len: u8) -> u32 { + (sym as u32) | ((len as u32) << 16) +} + +#[inline] +fn entry_sym(e: u32) -> u16 { + e as u16 +} + +#[inline] +fn entry_len(e: u32) -> u8 { + (e >> 16) as u8 +} + +struct HuffmanTable { + table: Vec, + primary_bits: u8, +} + +impl HuffmanTable { + /// Build a Huffman lookup table from an array of code lengths. + /// `lengths[i]` = code length for symbol `i` (0 means symbol is not used). + fn build(lengths: &[u8], primary_bits: u8) -> Result { + let max_len = lengths.iter().copied().max().unwrap_or(0); + if max_len == 0 { + // All lengths zero: empty table, only valid if never queried. + return Ok(Self { + table: vec![0; 1 << primary_bits], + primary_bits, + }); + } + if max_len > 15 { + return Err(InflateError::InvalidHuffmanTable); + } + + // Step 1: count codes of each length + let mut bl_count = [0u16; 16]; + for &len in lengths { + bl_count[len as usize] += 1; + } + bl_count[0] = 0; + + // Step 2: find the numerical value of the smallest code for each length + let mut next_code = [0u32; 16]; + let mut code = 0u32; + for bits in 1..=max_len { + code = (code + bl_count[bits as usize - 1] as u32) << 1; + next_code[bits as usize] = code; + } + + // Step 3: assign codes to symbols + let mut codes = vec![(0u32, 0u8); lengths.len()]; + for (sym, &len) in lengths.iter().enumerate() { + if len != 0 { + codes[sym] = (next_code[len as usize], len); + next_code[len as usize] += 1; + } + } + + // Step 4: build the two-level table + // Calculate total table size (primary + all sub-tables) + let primary_size = 1usize << primary_bits; + // First pass: figure out how many sub-table entries we need + let mut sub_offsets = vec![0u32; primary_size]; // temp: count of entries per sub-table root + let mut total_sub = 0usize; + + if max_len > primary_bits { + // Count how many codes fall into each sub-table bucket + for (_, &(code_val, len)) in codes.iter().enumerate() { + if len > primary_bits { + let primary_idx = (reverse_bits(code_val, len) as usize) & (primary_size - 1); + sub_offsets[primary_idx] += 1; + } + } + // Compute offsets + let mut offset = primary_size; + for i in 0..primary_size { + if sub_offsets[i] > 0 { + let sub_bits = max_len - primary_bits; + let sub_size = 1usize << sub_bits; + let start = offset; + offset += sub_size; + sub_offsets[i] = start as u32; + } else { + sub_offsets[i] = 0; + } + } + total_sub = offset - primary_size; + } + + let mut table = vec![0u32; primary_size + total_sub]; + + // Fill entries + for (sym, &(code_val, len)) in codes.iter().enumerate() { + if len == 0 { + continue; + } + + let reversed = reverse_bits(code_val, len) as usize; + + if len <= primary_bits { + // Direct entry: replicate across all matching bit patterns + let e = entry(sym as u16, len); + let step = 1 << len; + let mut idx = reversed; + while idx < primary_size { + table[idx] = e; + idx += step; + } + } else { + // Sub-table entry + let primary_idx = reversed & (primary_size - 1); + let sub_base = sub_offsets[primary_idx] as usize; + let sub_bits = max_len - primary_bits; + + // Write the redirect entry in primary table + table[primary_idx] = (sub_base as u32) | ((sub_bits as u32) << 16) | SUB_TABLE_FLAG; + + // Fill sub-table entry + let sub_idx = reversed >> primary_bits; + let e = entry(sym as u16, len); + let step = 1usize << (len - primary_bits); + let sub_size = 1usize << sub_bits; + let mut idx = sub_idx; + while idx < sub_size { + table[sub_base + idx] = e; + idx += step; + } + } + } + + Ok(Self { + table, + primary_bits, + }) + } + + /// Decode one symbol from the bit reader. + #[inline] + fn decode(&self, reader: &mut BitReader) -> Result { + reader.fill(15); // max code length + let idx = (reader.buf & ((1u32 << self.primary_bits) - 1)) as usize; + let e = self.table[idx]; + + if e & SUB_TABLE_FLAG == 0 { + let len = entry_len(e); + reader.consume(len); + Ok(entry_sym(e)) + } else { + let sub_base = (e & 0xFFFF) as usize; + let sub_bits = entry_len(e); // stored in len field for redirects + reader.consume(self.primary_bits); + let sub_idx = (reader.buf & ((1u32 << sub_bits) - 1)) as usize; + let e2 = self.table[sub_base + sub_idx]; + let len2 = entry_len(e2); + // len2 includes primary_bits already as total length; we only consume the sub-table portion + let sub_consumed = len2 - self.primary_bits; + reader.consume(sub_consumed); + Ok(entry_sym(e2)) + } + } +} + +/// Reverse the bottom `len` bits of `code`. +#[inline] +fn reverse_bits(code: u32, len: u8) -> u32 { + let mut result = 0u32; + let mut c = code; + for _ in 0..len { + result = (result << 1) | (c & 1); + c >>= 1; + } + result +} + +// ── Fixed Huffman tables ──────────────────────────────────────────────────── + +fn build_fixed_lit_table() -> HuffmanTable { + let mut lengths = [0u8; 288]; + for l in lengths.iter_mut().take(144) { + *l = 8; + } + for l in lengths.iter_mut().take(256).skip(144) { + *l = 9; + } + for l in lengths.iter_mut().take(280).skip(256) { + *l = 7; + } + for l in lengths.iter_mut().take(288).skip(280) { + *l = 8; + } + HuffmanTable::build(&lengths, 9).unwrap() +} + +fn build_fixed_dist_table() -> HuffmanTable { + let lengths = [5u8; 32]; + HuffmanTable::build(&lengths, 5).unwrap() +} + +// ── Core inflate logic ────────────────────────────────────────────────────── + +/// Inflate a raw DEFLATE stream (no zlib/gzip wrapper) into `output`. +pub(crate) fn inflate(input: &[u8], output: &mut Vec) -> Result<(), InflateError> { + let mut reader = BitReader::new(input); + + loop { + let bfinal = reader.read(1); + let btype = reader.read(2); + + match btype { + 0 => inflate_stored(&mut reader, output)?, + 1 => { + let lit_table = build_fixed_lit_table(); + let dist_table = build_fixed_dist_table(); + inflate_huffman(&mut reader, output, &lit_table, &dist_table)?; + } + 2 => inflate_dynamic(&mut reader, output)?, + _ => return Err(InflateError::InvalidBlockType), + } + + if bfinal != 0 { + break; + } + } + + Ok(()) +} + +fn inflate_stored(reader: &mut BitReader, output: &mut Vec) -> Result<(), InflateError> { + reader.align(); + let len = reader.read(16) as usize; + let nlen = reader.read(16) as usize; + + if len != (!nlen & 0xFFFF) { + return Err(InflateError::InvalidStoredLen); + } + + let bytes = reader.read_bytes(len)?; + output.extend_from_slice(bytes); + Ok(()) +} + +fn inflate_dynamic(reader: &mut BitReader, output: &mut Vec) -> Result<(), InflateError> { + let hlit = reader.read(5) as usize + 257; + let hdist = reader.read(5) as usize + 1; + let hclen = reader.read(4) as usize + 4; + + // Read code-length code lengths + let mut codelen_lengths = [0u8; 19]; + for i in 0..hclen { + codelen_lengths[CODELEN_ORDER[i]] = reader.read(3) as u8; + } + + let codelen_table = HuffmanTable::build(&codelen_lengths, 7)?; + + // Decode literal/length + distance code lengths + let total = hlit + hdist; + let mut all_lengths = vec![0u8; total]; + let mut i = 0; + + while i < total { + let sym = codelen_table.decode(reader)?; + match sym { + 0..=15 => { + all_lengths[i] = sym as u8; + i += 1; + } + 16 => { + // Repeat previous length 3-6 times + if i == 0 { + return Err(InflateError::InvalidHuffmanTable); + } + let repeat = reader.read(2) as usize + 3; + let prev = all_lengths[i - 1]; + for _ in 0..repeat { + if i >= total { + return Err(InflateError::InvalidHuffmanTable); + } + all_lengths[i] = prev; + i += 1; + } + } + 17 => { + // Repeat 0 for 3-10 times + let repeat = reader.read(3) as usize + 3; + i += repeat; + if i > total { + return Err(InflateError::InvalidHuffmanTable); + } + } + 18 => { + // Repeat 0 for 11-138 times + let repeat = reader.read(7) as usize + 11; + i += repeat; + if i > total { + return Err(InflateError::InvalidHuffmanTable); + } + } + _ => return Err(InflateError::InvalidHuffmanTable), + } + } + + let lit_table = HuffmanTable::build(&all_lengths[..hlit], 9)?; + let dist_table = HuffmanTable::build(&all_lengths[hlit..], 6)?; + + inflate_huffman(reader, output, &lit_table, &dist_table) +} + +fn inflate_huffman( + reader: &mut BitReader, + output: &mut Vec, + lit_table: &HuffmanTable, + dist_table: &HuffmanTable, +) -> Result<(), InflateError> { + loop { + let sym = lit_table.decode(reader)?; + + if sym < 256 { + output.push(sym as u8); + } else if sym == 256 { + return Ok(()); + } else { + // Length-distance pair + let len_idx = (sym - 257) as usize; + if len_idx >= LENGTH_TABLE.len() { + return Err(InflateError::InvalidLengthCode); + } + let (base_len, extra_bits) = LENGTH_TABLE[len_idx]; + let length = base_len as usize + if extra_bits > 0 { + reader.read(extra_bits) as usize + } else { + 0 + }; + + let dist_sym = dist_table.decode(reader)?; + let dist_idx = dist_sym as usize; + if dist_idx >= DISTANCE_TABLE.len() { + return Err(InflateError::InvalidDistanceCode); + } + let (base_dist, dist_extra) = DISTANCE_TABLE[dist_idx]; + let distance = base_dist as usize + if dist_extra > 0 { + reader.read(dist_extra) as usize + } else { + 0 + }; + + if distance > output.len() { + return Err(InflateError::DistanceTooFarBack); + } + + // Copy byte-by-byte to handle overlapping (distance < length) + let start = output.len() - distance; + for i in 0..length { + let byte = output[start + i]; + output.push(byte); + } + } + } +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn inflate_ok(compressed: &[u8]) -> Vec { + let mut out = Vec::new(); + inflate(compressed, &mut out).expect("inflate failed"); + out + } + + // ── Stored block (type 0) ── + + #[test] + fn test_inflate_stored_block() { + // Python: zlib.compress(b"stored data test", level=0, wbits=-15) + let compressed: &[u8] = &[ + 1, 16, 0, 239, 255, 115, 116, 111, 114, 101, 100, 32, 100, 97, 116, 97, 32, 116, + 101, 115, 116, + ]; + assert_eq!(inflate_ok(compressed), b"stored data test"); + } + + // ── Fixed Huffman (type 1) ── + + #[test] + fn test_inflate_fixed_huffman() { + // Python: zlib.compress(b"Hello, dotLottie!", level=6, wbits=-15) + let compressed: &[u8] = &[ + 243, 72, 205, 201, 201, 215, 81, 72, 201, 47, 241, 201, 47, 41, 201, 76, 85, 4, 0, + ]; + assert_eq!(inflate_ok(compressed), b"Hello, dotLottie!"); + } + + #[test] + fn test_inflate_back_reference() { + // "abcabcabcabcabcabcabcabc" — heavy LZ77 back-refs + let compressed: &[u8] = &[75, 76, 74, 78, 196, 134, 0]; + assert_eq!(inflate_ok(compressed), b"abcabcabcabcabcabcabcabc"); + } + + #[test] + fn test_inflate_repeat_single_byte() { + // "A" * 500 — extreme overlapping back-reference (distance=1) + let compressed: &[u8] = &[115, 116, 28, 5, 35, 13, 0, 0]; + let expected = vec![b'A'; 500]; + assert_eq!(inflate_ok(compressed), expected); + } + + // ── Dynamic Huffman (type 2) ── + + #[test] + fn test_inflate_dynamic_huffman() { + // A lottie-like JSON snippet + let compressed: &[u8] = &[ + 45, 140, 59, 10, 128, 48, 16, 68, 239, 50, 245, 18, 140, 95, 216, 107, 88, 138, 69, + 10, 37, 65, 141, 98, 130, 34, 33, 119, 119, 65, 171, 55, 3, 51, 47, 193, 111, 96, + 24, 239, 54, 19, 221, 238, 65, 184, 164, 55, 170, 83, 90, 178, 59, 192, 5, 97, 23, + 180, 194, 249, 4, 87, 194, 27, 220, 232, 146, 96, 127, 174, 230, 153, 206, 0, 30, + 18, 226, 3, 174, 233, 179, 246, 214, 28, 147, 88, 130, 220, 52, 97, 145, 69, 202, + 121, 204, 47, + ]; + let expected = b"{\"nm\":\"animation\",\"v\":\"5.7.1\",\"ip\":0,\"op\":60,\"fr\":30,\"w\":512,\"h\":512,\"layers\":[{\"ty\":4,\"nm\":\"Shape\",\"sr\":1,\"ks\":{}}]}"; + assert_eq!(inflate_ok(compressed), expected); + } + + // ── Empty ── + + #[test] + fn test_inflate_empty() { + // Python: zlib.compress(b"", level=6, wbits=-15) + let compressed: &[u8] = &[3, 0]; + assert_eq!(inflate_ok(compressed), b""); + } + + // ── Multiple blocks ── + + #[test] + fn test_inflate_multiple_blocks() { + // Two blocks via Z_FULL_FLUSH: "First block of data. " + "Second block of data." + let compressed: &[u8] = &[ + 114, 203, 44, 42, 46, 81, 72, 202, 201, 79, 206, 86, 200, 79, 83, 72, 73, 44, 73, + 212, 83, 0, 0, 0, 0, 255, 255, 11, 78, 77, 206, 207, 75, 81, 72, 202, 201, 79, 206, + 86, 200, 79, 83, 72, 73, 44, 73, 212, 3, 0, + ]; + assert_eq!( + inflate_ok(compressed), + b"First block of data. Second block of data." + ); + } + + // ── Error cases ── + + #[test] + fn test_inflate_invalid_block_type() { + // BFINAL=1, BTYPE=3 (reserved) → bits: 1 11 = 0b111 = 0x07 + let compressed: &[u8] = &[0x07]; + let mut out = Vec::new(); + assert!(matches!( + inflate(compressed, &mut out), + Err(InflateError::InvalidBlockType) + )); + } + + #[test] + fn test_inflate_bad_stored_len() { + // Stored block with LEN/NLEN mismatch + // BFINAL=1 BTYPE=00 → first byte = 0x01 (bits: 1 00 = stored, final) + // Then LEN=5 (0x0005), NLEN=0x0000 (should be 0xFFFA) + let compressed: &[u8] = &[0x01, 0x05, 0x00, 0x00, 0x00]; + let mut out = Vec::new(); + assert!(matches!( + inflate(compressed, &mut out), + Err(InflateError::InvalidStoredLen) + )); + } + + // ── BitReader unit tests ── + + #[test] + fn test_bit_reader_basic() { + let data = [0b10110100u8, 0b01101001u8]; + let mut r = BitReader::new(&data); + // LSB-first: first byte 0b10110100 → bits come out as 0,0,1,0,1,1,0,1 + assert_eq!(r.read(1), 0); + assert_eq!(r.read(1), 0); + assert_eq!(r.read(1), 1); + assert_eq!(r.read(3), 0b110); // next 3 bits: 0,1,1 → LSB-first = 0b110 + assert_eq!(r.read(2), 0b10); // next 2 bits: 1,0 → 0b10 + } + + #[test] + fn test_bit_reader_cross_byte() { + let data = [0xFF, 0x00]; + let mut r = BitReader::new(&data); + assert_eq!(r.read(4), 0xF); + // Next 8 bits span bytes: 4 bits of 0xFF upper + 4 bits of 0x00 lower + assert_eq!(r.read(8), 0x0F); // 1111 0000 → LSB-first + } + + #[test] + fn test_bit_reader_align() { + let data = [0xFF, 0xAB]; + let mut r = BitReader::new(&data); + r.read(3); // consume 3 bits + r.align(); // skip remaining 5 bits in first byte + assert_eq!(r.read(8), 0xAB); + } + + // ── Huffman table unit tests ── + + #[test] + fn test_huffman_fixed_lit_decode() { + let table = build_fixed_lit_table(); + // Encode literal 'A' (65) with fixed Huffman: code length 8, value 0x30+65=0xC1 → reversed + // For fixed Huffman, literals 0-143 use 8-bit codes starting at 0b00110000 + // Symbol 65: code = 0b00110000 + 65 = 0b01110001 = 0x71, reversed = 0x8E + let code: u32 = 0x30 + 65; // 0b01110001 + let reversed = reverse_bits(code, 8); + let mut data = [0u8; 4]; + data[0] = reversed as u8; + data[1] = (reversed >> 8) as u8; + let mut reader = BitReader::new(&data); + let sym = table.decode(&mut reader).unwrap(); + assert_eq!(sym, 65); // 'A' + } + + #[test] + fn test_huffman_build_simple() { + // Canonical Huffman for lengths [1, 2, 2]: + // sym0: code=0 (1-bit), reversed=0 + // sym1: code=10 (2-bit), reversed=01 + // sym2: code=11 (2-bit), reversed=11 + // Bit stream (LSB-first in byte): 0, 01, 11 → byte = 0b_11_01_0 = 0b00011010 + let lengths = [1u8, 2, 2]; + let table = HuffmanTable::build(&lengths, 2).unwrap(); + let data = [0b00011010u8, 0x00]; + let mut reader = BitReader::new(&data); + assert_eq!(table.decode(&mut reader).unwrap(), 0); + assert_eq!(table.decode(&mut reader).unwrap(), 1); + assert_eq!(table.decode(&mut reader).unwrap(), 2); + } +} diff --git a/dotlottie-rs/src/io/dotlottie/zip/mod.rs b/dotlottie-rs/src/io/dotlottie/zip/mod.rs new file mode 100644 index 00000000..4f7e039a --- /dev/null +++ b/dotlottie-rs/src/io/dotlottie/zip/mod.rs @@ -0,0 +1,61 @@ +mod decompress; +mod inflate; +mod parse; + +pub use parse::ZipError; + +use parse::CentralDirEntry; +use std::borrow::Cow; +use std::cell::Cell; +use std::fmt; + +struct DecompressState { + buf: Vec, +} + +pub(crate) struct DotLottieArchive { + data: Vec, + entries: Vec, + decompress_state: Cell>, +} + +impl fmt::Debug for DotLottieArchive { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DotLottieArchive") + .field("entries", &self.entries.len()) + .finish() + } +} + +impl DotLottieArchive { + pub fn new(data: Vec) -> Result { + let entries = parse::parse_archive(&data)?; + Ok(Self { + data, + entries, + decompress_state: Cell::new(None), + }) + } + + pub fn read(&self, name: &str) -> Result, ZipError> { + let idx = self + .entries + .binary_search_by(|e| e.name.as_ref().cmp(name)) + .map_err(|_| ZipError::FileNotFound)?; + let entry = &self.entries[idx]; + let (offset, len) = parse::locate_file_data(&self.data, entry)?; + let mut state = self + .decompress_state + .take() + .unwrap_or_else(|| DecompressState { buf: Vec::new() }); + let result = decompress::decompress( + &self.data[offset..offset + len], + entry.compression_method, + entry.uncompressed_size as usize, + entry.crc32, + &mut state.buf, + ); + self.decompress_state.set(Some(state)); + result + } +} diff --git a/dotlottie-rs/src/io/dotlottie/zip/parse.rs b/dotlottie-rs/src/io/dotlottie/zip/parse.rs new file mode 100644 index 00000000..462be6d2 --- /dev/null +++ b/dotlottie-rs/src/io/dotlottie/zip/parse.rs @@ -0,0 +1,202 @@ +use std::fmt; + +const EOCD_SIGNATURE: u32 = 0x06054b50; +const CENTRAL_DIR_SIGNATURE: u32 = 0x02014b50; +const LOCAL_FILE_HEADER_SIGNATURE: u32 = 0x04034b50; + +const EOCD_MIN_SIZE: usize = 22; +const CENTRAL_DIR_ENTRY_MIN_SIZE: usize = 46; +const LOCAL_FILE_HEADER_MIN_SIZE: usize = 30; + +// 64KB max comment + 22-byte EOCD +const EOCD_MAX_SEARCH: usize = 65557; + +#[derive(Debug)] +pub enum ZipError { + TooSmall, + EocdNotFound, + InvalidCentralDir, + InvalidLocalHeader, + UnsupportedCompression(u16), + DecompressError, + FileNotFound, + OutOfBounds, +} + +impl fmt::Display for ZipError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self:?}") + } +} + +impl std::error::Error for ZipError {} + +#[derive(Debug, Clone)] +pub(crate) struct CentralDirEntry { + pub name: Box, + pub crc32: u32, + pub compressed_size: u32, + pub uncompressed_size: u32, + pub local_header_offset: u32, + pub compression_method: u16, +} + +struct Eocd { + central_dir_offset: u32, + total_entries: u16, +} + +#[inline] +fn read_u16(data: &[u8], offset: usize) -> Result { + let end = offset.checked_add(2).ok_or(ZipError::OutOfBounds)?; + data.get(offset..end) + .map(|b| u16::from_le_bytes([b[0], b[1]])) + .ok_or(ZipError::OutOfBounds) +} + +#[inline] +fn read_u32(data: &[u8], offset: usize) -> Result { + let end = offset.checked_add(4).ok_or(ZipError::OutOfBounds)?; + data.get(offset..end) + .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + .ok_or(ZipError::OutOfBounds) +} + +fn find_eocd(data: &[u8]) -> Result { + if data.len() < EOCD_MIN_SIZE { + return Err(ZipError::TooSmall); + } + + let search_start = data.len().saturating_sub(EOCD_MAX_SEARCH); + + let mut pos = data.len() - EOCD_MIN_SIZE; + loop { + if read_u32(data, pos)? == EOCD_SIGNATURE { + let total_entries = read_u16(data, pos + 10)?; + let central_dir_offset = read_u32(data, pos + 16)?; + + if central_dir_offset as usize > data.len() { + return Err(ZipError::InvalidCentralDir); + } + + return Ok(Eocd { + central_dir_offset, + total_entries, + }); + } + if pos == search_start { + break; + } + pos -= 1; + } + + Err(ZipError::EocdNotFound) +} + +fn parse_central_directory(data: &[u8], eocd: &Eocd) -> Result, ZipError> { + let mut entries = Vec::with_capacity(eocd.total_entries as usize); + let mut offset = eocd.central_dir_offset as usize; + + for _ in 0..eocd.total_entries { + if offset + .checked_add(CENTRAL_DIR_ENTRY_MIN_SIZE) + .is_none_or(|end| end > data.len()) + { + return Err(ZipError::InvalidCentralDir); + } + + if read_u32(data, offset)? != CENTRAL_DIR_SIGNATURE { + return Err(ZipError::InvalidCentralDir); + } + + let compression_method = read_u16(data, offset + 10)?; + let crc32 = read_u32(data, offset + 16)?; + let compressed_size = read_u32(data, offset + 20)?; + let uncompressed_size = read_u32(data, offset + 24)?; + let file_name_len = read_u16(data, offset + 28)? as usize; + let extra_field_len = read_u16(data, offset + 30)? as usize; + let file_comment_len = read_u16(data, offset + 32)? as usize; + let local_header_offset = read_u32(data, offset + 42)?; + + let name_start = offset + .checked_add(CENTRAL_DIR_ENTRY_MIN_SIZE) + .ok_or(ZipError::InvalidCentralDir)?; + let name_end = name_start + .checked_add(file_name_len) + .ok_or(ZipError::InvalidCentralDir)?; + if name_end > data.len() { + return Err(ZipError::InvalidCentralDir); + } + + let name = std::str::from_utf8(&data[name_start..name_end]) + .map_err(|_| ZipError::InvalidCentralDir)?; + + // Skip directory entries (names ending with '/') + if !name.ends_with('/') { + entries.push(CentralDirEntry { + name: Box::from(name), + compression_method, + crc32, + compressed_size, + uncompressed_size, + local_header_offset, + }); + } + + offset = name_end + .checked_add(extra_field_len) + .and_then(|o| o.checked_add(file_comment_len)) + .ok_or(ZipError::InvalidCentralDir)?; + + if offset > data.len() { + return Err(ZipError::InvalidCentralDir); + } + } + + entries.sort_unstable_by(|a, b| a.name.cmp(&b.name)); + Ok(entries) +} + +pub(crate) fn locate_file_data( + data: &[u8], + entry: &CentralDirEntry, +) -> Result<(usize, usize), ZipError> { + let offset = entry.local_header_offset as usize; + + if offset + .checked_add(LOCAL_FILE_HEADER_MIN_SIZE) + .is_none_or(|end| end > data.len()) + { + return Err(ZipError::InvalidLocalHeader); + } + + if read_u32(data, offset)? != LOCAL_FILE_HEADER_SIGNATURE { + return Err(ZipError::InvalidLocalHeader); + } + + let file_name_len = read_u16(data, offset + 26)? as usize; + let extra_field_len = read_u16(data, offset + 28)? as usize; + + let data_offset = offset + .checked_add(LOCAL_FILE_HEADER_MIN_SIZE) + .and_then(|o| o.checked_add(file_name_len)) + .and_then(|o| o.checked_add(extra_field_len)) + .ok_or(ZipError::InvalidLocalHeader)?; + + let data_len = entry.compressed_size as usize; + + let end = data_offset + .checked_add(data_len) + .ok_or(ZipError::OutOfBounds)?; + + if end > data.len() { + return Err(ZipError::OutOfBounds); + } + + Ok((data_offset, data_len)) +} + +pub(crate) fn parse_archive(data: &[u8]) -> Result, ZipError> { + let eocd = find_eocd(data)?; + parse_central_directory(data, &eocd) +} diff --git a/dotlottie-rs/src/io/mod.rs b/dotlottie-rs/src/io/mod.rs new file mode 100644 index 00000000..0681d095 --- /dev/null +++ b/dotlottie-rs/src/io/mod.rs @@ -0,0 +1,2 @@ +#[cfg(feature = "dotlottie")] +pub mod dotlottie; diff --git a/dotlottie-rs/src/lib.rs b/dotlottie-rs/src/lib.rs index d82fdd91..fb210be3 100644 --- a/dotlottie-rs/src/lib.rs +++ b/dotlottie-rs/src/lib.rs @@ -1,5 +1,4 @@ -#[cfg(feature = "dotlottie")] -mod fms; +pub mod io; mod layout; mod lottie_renderer; mod markers; @@ -30,7 +29,7 @@ pub(crate) mod webgpu_stubs; pub mod wasm_bindgen_api; #[cfg(feature = "dotlottie")] -pub use fms::*; +pub use io::dotlottie; pub use layout::*; pub use lottie_renderer::*; pub use markers::*; diff --git a/dotlottie-rs/src/player.rs b/dotlottie-rs/src/player.rs index 1a600b4c..a410dfa6 100644 --- a/dotlottie-rs/src/player.rs +++ b/dotlottie-rs/src/player.rs @@ -14,7 +14,7 @@ use crate::{ }; use crate::{ColorSpace, Renderer}; #[cfg(feature = "dotlottie")] -use crate::{DotLottieManager, Manifest}; +use crate::dotlottie::{Manifest, Reader}; #[cfg(feature = "state-machines")] use crate::{StateMachineEngine, StateMachineEngineError}; @@ -99,7 +99,7 @@ pub struct DotLottiePlayer { start_time: Instant, current_loop_count: u32, #[cfg(feature = "dotlottie")] - dotlottie_manager: Option, + dotlottie_reader: Option, direction: Direction, marker_names: Vec, marker_data: Vec<(f32, f32)>, // (time, duration) @@ -179,7 +179,7 @@ impl DotLottiePlayer { #[cfg(feature = "dotlottie")] animation_id: None, #[cfg(feature = "dotlottie")] - dotlottie_manager: None, + dotlottie_reader: None, direction: Direction::Forward, marker_names: Vec::new(), marker_data: Vec::new(), @@ -391,7 +391,7 @@ impl DotLottiePlayer { #[cfg(feature = "dotlottie")] pub fn manifest(&self) -> Option<&Manifest> { - self.dotlottie_manager + self.dotlottie_reader .as_ref() .map(|manager| manager.manifest()) } @@ -404,9 +404,9 @@ impl DotLottiePlayer { pub fn get_state_machine(&self, state_machine_id: &CStr) -> Option { let id_str = state_machine_id.to_str().ok()?; - self.dotlottie_manager + self.dotlottie_reader .as_ref() - .and_then(|manager| manager.get_state_machine(id_str).ok()) + .and_then(|manager| manager.state_machine(id_str).ok()) } pub fn request_frame(&mut self) -> f32 { @@ -1049,7 +1049,7 @@ impl DotLottiePlayer { ) -> Result<(), DotLottiePlayerError> { #[cfg(feature = "dotlottie")] { - self.dotlottie_manager = None; + self.dotlottie_reader = None; self.animation_id = None; } #[cfg(feature = "theming")] @@ -1090,7 +1090,7 @@ impl DotLottiePlayer { ) -> Result<(), DotLottiePlayerError> { #[cfg(feature = "dotlottie")] { - self.dotlottie_manager = None; + self.dotlottie_reader = None; self.animation_id = None; } #[cfg(feature = "theming")] @@ -1128,20 +1128,19 @@ impl DotLottiePlayer { { self.theme_id = None; } - let manager = - DotLottieManager::new(file_data).map_err(|_| DotLottiePlayerError::Unknown)?; + let manager = Reader::new(file_data).map_err(|_| DotLottiePlayerError::Unknown)?; - let (active_animation, active_animation_id) = + let (initial_animation, initial_animation_id) = if let Some(anim_id) = self.animation_id.as_deref().and_then(|c| c.to_str().ok()) { - (manager.get_animation(anim_id), self.animation_id.clone()) + (manager.animation(anim_id), self.animation_id.clone()) } else { ( - manager.get_active_animation(), - CString::new(manager.active_animation_id()).ok(), + manager.initial_animation(), + CString::new(manager.initial_animation_id()).ok(), ) }; - let animation_data = active_animation.map_err(|_| DotLottiePlayerError::Unknown)?; + let animation_data = initial_animation.map_err(|_| DotLottiePlayerError::Unknown)?; let (names, data) = extract_markers(&animation_data); self.marker_names = names; @@ -1150,7 +1149,7 @@ impl DotLottiePlayer { let animation_data_cstr = CString::new(animation_data).map_err(|_| DotLottiePlayerError::Unknown)?; - self.dotlottie_manager = Some(manager); + self.dotlottie_reader = Some(manager); let result = self.load_animation_common( |renderer, w, h| renderer.load_data(&animation_data_cstr, w, h), @@ -1159,7 +1158,7 @@ impl DotLottiePlayer { ); if result.is_ok() { - self.animation_id = active_animation_id; + self.animation_id = initial_animation_id; } if result.is_ok() { @@ -1186,16 +1185,16 @@ impl DotLottiePlayer { .to_str() .map_err(|_| DotLottiePlayerError::InvalidParameter)?; - if let Some(manager) = &mut self.dotlottie_manager { + if let Some(manager) = &mut self.dotlottie_reader { #[cfg(feature = "theming")] let saved_theme_id = self.theme_id.clone(); let lookup_id = if anim_id_str.is_empty() { - manager.active_animation_id() + manager.initial_animation_id() } else { anim_id_str.to_string() }; - let animation_data = manager.get_animation(&lookup_id); + let animation_data = manager.animation(&lookup_id); let result = match animation_data { Ok(animation_data) => { @@ -1288,7 +1287,7 @@ impl DotLottiePlayer { return Ok(()); } - if self.dotlottie_manager.is_none() { + if self.dotlottie_reader.is_none() { return Err(DotLottiePlayerError::InsufficientCondition); } @@ -1323,9 +1322,9 @@ impl DotLottiePlayer { }; let result = self - .dotlottie_manager + .dotlottie_reader .as_mut() - .and_then(|manager| manager.get_theme(theme_id_str).ok()) + .and_then(|manager| manager.theme(theme_id_str).ok()) .map(|theme| { let anim_id_str = self .animation_id