diff --git a/Cargo.toml b/Cargo.toml index eedb7cc9..f96d6360 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ default = ["object-libs"] object-libs = ["uuid"] kurbo = ["dep:kurbo"] rayon = ["dep:rayon"] +ufoz = ["zip"] [target.'cfg(not(target_family = "wasm"))'.dependencies] uuid = { version = "1.2", features = ["v4"], optional = true } @@ -43,6 +44,7 @@ base64 = "0.22" close_already = "0.3" log = "0.4" smol_str = { version = "0.3", features = ["serde"] } +zip = { version = "2", default-features = false, features = ["deflate"], optional = true } [dev-dependencies] failure = "0.1.6" diff --git a/examples/load_save.rs b/examples/load_save.rs index 97ad0f4f..0a8159db 100644 --- a/examples/load_save.rs +++ b/examples/load_save.rs @@ -1,7 +1,6 @@ //! A small program that times the loading and saving of a UFO file. use std::env; -use std::ffi::OsStr; use std::path::PathBuf; use std::time::{Duration, Instant}; @@ -58,11 +57,7 @@ struct Args { impl Args { fn get_from_env_or_exit() -> Self { let mut args = env::args().skip(1); - let path = match args.next().map(PathBuf::from) { - Some(ref p) if p.exists() && p.extension() == Some(OsStr::new("ufo")) => p.to_owned(), - Some(ref p) => exit_err!("path {:?} is not an existing .ufo file, exiting", p), - None => exit_err!("Please supply a path to a .ufo file"), - }; + let path = args.next().map(PathBuf::from).expect("missing path argument"); let outpath = args.next().map(PathBuf::from); if outpath.as_ref().map(|p| p.exists()).unwrap_or(false) { diff --git a/src/error.rs b/src/error.rs index db40f6e5..a4b4b832 100644 --- a/src/error.rs +++ b/src/error.rs @@ -143,6 +143,10 @@ pub enum FontLoadError { /// The underlying error. source: PlistError, }, + /// The file is not a valid zip archive. + #[cfg(feature = "ufoz")] + #[error("failed to open UFO as zip archive")] + InvalidZipFile(#[source] zip::result::ZipError), /// Norad can currently only open UFO (directory) packages. #[error("only UFO (directory) packages are supported")] UfoNotADir, diff --git a/src/font.rs b/src/font.rs index 938cd457..ad27c508 100644 --- a/src/font.rs +++ b/src/font.rs @@ -217,11 +217,17 @@ impl Font { fn load_impl(path: &Path, request: DataRequest) -> Result { let metadata = path.metadata().map_err(FontLoadError::AccessUfoDir)?; - if !metadata.is_dir() { - return Err(FontLoadError::UfoNotADir); + if metadata.is_dir() { + return Self::load_from_source(&request, &path); } - Self::load_from_source(&request, &path) + #[cfg(feature = "ufoz")] + if metadata.is_file() { + let source = crate::zip_source::ZipSource::open(path)?; + return Self::load_from_source(&request, &source); + } + + Err(FontLoadError::UfoNotADir) } /// Returns a [`Font`] loaded from the given [`FontSource`]. diff --git a/src/lib.rs b/src/lib.rs index ab077a93..97a228e7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -84,6 +84,8 @@ mod shared_types; mod upconversion; pub(crate) mod util; mod write; +#[cfg(feature = "ufoz")] +mod zip_source; pub use data_request::DataRequest; pub use font::{Font, FormatVersion, MetaInfo}; diff --git a/src/zip_source.rs b/src/zip_source.rs new file mode 100644 index 00000000..6bf194ae --- /dev/null +++ b/src/zip_source.rs @@ -0,0 +1,333 @@ +//! [`FontSource`] implementation for reading UFO data from zip archives. + +use std::collections::{HashMap, HashSet}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +use crate::error::FontLoadError; +use crate::font_source::FontSource; + +/// A UFO source backed by a zip archive loaded into memory. +pub(crate) struct ZipSource { + entries: HashMap>, +} + +impl ZipSource { + /// Open a zip archive at the given path and load all file entries into memory. + pub fn open(path: &Path) -> Result { + let file = std::fs::File::open(path).map_err(FontLoadError::AccessUfoDir)?; + let mut archive = zip::ZipArchive::new(file).map_err(FontLoadError::InvalidZipFile)?; + + let prefix = detect_zip_root(&mut archive); + + let mut entries = HashMap::new(); + for i in 0..archive.len() { + let mut entry = archive.by_index(i).map_err(FontLoadError::InvalidZipFile)?; + + if entry.is_dir() { + continue; + } + + let raw_path = PathBuf::from(entry.name().to_string()); + + let rel_path = if let Some(ref pfx) = prefix { + match raw_path.strip_prefix(pfx) { + Ok(stripped) => stripped.to_path_buf(), + Err(_) => continue, + } + } else { + raw_path + }; + + if rel_path.as_os_str().is_empty() { + continue; + } + + let mut data = Vec::with_capacity(entry.size() as usize); + entry.read_to_end(&mut data).map_err(FontLoadError::AccessUfoDir)?; + entries.insert(rel_path, data); + } + + Ok(ZipSource { entries }) + } +} + +impl FontSource for ZipSource { + fn try_read(&self, path: &Path) -> Option, io::Error>> { + self.entries.get(path).cloned().map(Ok) + } + + fn list_dir(&self, path: &Path) -> Result, io::Error> { + let mut dirs = HashSet::new(); + let mut files = Vec::new(); + + for key in self.entries.keys() { + let rel = match key.strip_prefix(path) { + Ok(r) => r, + Err(_) => continue, + }; + + let mut components = rel.components(); + let first = match components.next() { + Some(c) => c, + None => continue, + }; + + if components.next().is_some() { + // Has more components — first is a directory. + dirs.insert(PathBuf::from(first.as_os_str())); + } else { + // Single component — it's a file. + files.push((PathBuf::from(first.as_os_str()), false)); + } + } + + let mut result: Vec<(PathBuf, bool)> = dirs.into_iter().map(|d| (d, true)).collect(); + result.append(&mut files); + Ok(result) + } +} + +/// Detect if the zip has a single top-level directory wrapping all contents. +/// +/// If so, return that directory name as the prefix to strip. Otherwise return +/// `None`, meaning the zip contents are at the root level. +pub(crate) fn detect_zip_root( + archive: &mut zip::ZipArchive, +) -> Option { + let mut top_level_dirs = HashSet::new(); + let mut has_root_files = false; + + for i in 0..archive.len() { + let Ok(entry) = archive.by_index_raw(i) else { continue }; + let name = entry.name(); + + // Skip macOS metadata. + if name.starts_with("__MACOSX") { + continue; + } + + let path = PathBuf::from(name); + let mut components = path.components(); + if let Some(first) = components.next() { + if components.next().is_none() && !entry.is_dir() { + has_root_files = true; + } else { + top_level_dirs.insert(PathBuf::from(first.as_os_str())); + } + } + } + + if !has_root_files && top_level_dirs.len() == 1 { + top_level_dirs.into_iter().next() + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + use tempfile::NamedTempFile; + + /// Write a zip archive containing the given entries to a temp file. + fn write_zip_to_tempfile(entries: &[(&str, &[u8])]) -> NamedTempFile { + let tmp = NamedTempFile::new().unwrap(); + let mut writer = zip::ZipWriter::new(std::fs::File::create(tmp.path()).unwrap()); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + for (name, data) in entries { + writer.start_file(*name, options).unwrap(); + std::io::Write::write_all(&mut writer, data).unwrap(); + } + writer.finish().unwrap(); + tmp + } + + fn minimal_metainfo() -> Vec { + br#" + + +creatororg.linebender.norad +formatVersion3 +"# + .to_vec() + } + + fn minimal_layercontents() -> Vec { + br#" + + +public.defaultglyphs +"# + .to_vec() + } + + fn minimal_contents() -> Vec { + br#" + +"# + .to_vec() + } + + #[test] + fn open_minimal_zip() { + let meta = minimal_metainfo(); + let lc = minimal_layercontents(); + let contents = minimal_contents(); + let tmp = write_zip_to_tempfile(&[ + ("metainfo.plist", &meta), + ("layercontents.plist", &lc), + ("glyphs/contents.plist", &contents), + ]); + + let source = ZipSource::open(tmp.path()).unwrap(); + assert_eq!(source.entries.len(), 3); + assert!(source.entries.contains_key(Path::new("metainfo.plist"))); + assert!(source.entries.contains_key(Path::new("layercontents.plist"))); + assert!(source.entries.contains_key(Path::new("glyphs/contents.plist"))); + } + + #[test] + fn detect_root_strips_single_dir() { + let meta = minimal_metainfo(); + let tmp = write_zip_to_tempfile(&[ + ("MyFont.ufo/metainfo.plist", &meta), + ("MyFont.ufo/glyphs/contents.plist", &minimal_contents()), + ]); + + let source = ZipSource::open(tmp.path()).unwrap(); + assert!(source.entries.contains_key(Path::new("metainfo.plist"))); + assert!(source.entries.contains_key(Path::new("glyphs/contents.plist"))); + assert!(!source.entries.keys().any(|k| k.starts_with("MyFont.ufo"))); + } + + #[test] + fn detect_root_no_strip_when_multiple_top_dirs() { + let tmp = write_zip_to_tempfile(&[("A/file1", b"a"), ("B/file2", b"b")]); + let source = ZipSource::open(tmp.path()).unwrap(); + assert!(source.entries.contains_key(Path::new("A/file1"))); + assert!(source.entries.contains_key(Path::new("B/file2"))); + } + + #[test] + fn detect_root_no_strip_when_root_files_exist() { + let tmp = + write_zip_to_tempfile(&[("readme.txt", b"hi"), ("MyFont.ufo/metainfo.plist", b"x")]); + let source = ZipSource::open(tmp.path()).unwrap(); + assert!(source.entries.contains_key(Path::new("readme.txt"))); + assert!(source.entries.contains_key(Path::new("MyFont.ufo/metainfo.plist"))); + } + + #[test] + fn try_read_missing() { + let tmp = write_zip_to_tempfile(&[("metainfo.plist", b"x")]); + let source = ZipSource::open(tmp.path()).unwrap(); + assert!(source.try_read(Path::new("nonexistent")).is_none()); + } + + #[test] + fn list_dir_root() { + let meta = minimal_metainfo(); + let lc = minimal_layercontents(); + let contents = minimal_contents(); + let tmp = write_zip_to_tempfile(&[ + ("metainfo.plist", &meta), + ("layercontents.plist", &lc), + ("glyphs/contents.plist", &contents), + ("glyphs/A_.glif", b""), + ("data/foo.txt", b"hello"), + ]); + + let source = ZipSource::open(tmp.path()).unwrap(); + let mut entries = source.list_dir(Path::new("")).unwrap(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + let names: Vec<_> = + entries.iter().map(|(p, is_dir)| (p.to_string_lossy().to_string(), *is_dir)).collect(); + assert!(names.contains(&("data".into(), true))); + assert!(names.contains(&("glyphs".into(), true))); + assert!(names.contains(&("metainfo.plist".into(), false))); + assert!(names.contains(&("layercontents.plist".into(), false))); + } + + #[test] + fn list_dir_subdirectory() { + let tmp = write_zip_to_tempfile(&[ + ("metainfo.plist", b"meta"), + ("glyphs/contents.plist", b"x"), + ("glyphs/A_.glif", b"y"), + ]); + + let source = ZipSource::open(tmp.path()).unwrap(); + let mut entries = source.list_dir(Path::new("glyphs")).unwrap(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + assert_eq!(entries.len(), 2); + assert_eq!(entries[0], (PathBuf::from("A_.glif"), false)); + assert_eq!(entries[1], (PathBuf::from("contents.plist"), false)); + } + + #[test] + fn list_dir_empty() { + let tmp = write_zip_to_tempfile(&[("metainfo.plist", b"x")]); + let source = ZipSource::open(tmp.path()).unwrap(); + let entries = source.list_dir(Path::new("images")).unwrap(); + assert!(entries.is_empty()); + } + + #[test] + fn open_invalid_zip() { + let tmp = NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), b"not a zip file at all").unwrap(); + let result = ZipSource::open(tmp.path()); + assert!(matches!(result, Err(FontLoadError::InvalidZipFile(_)))); + } + + #[test] + fn open_nonexistent_file() { + let result = ZipSource::open(Path::new("/tmp/norad_test_nonexistent_ufoz.zip")); + assert!(matches!(result, Err(FontLoadError::AccessUfoDir(_)))); + } + + #[test] + fn detect_root_direct() { + // Test detect_zip_root directly with an in-memory zip. + let mut buf = Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut buf); + let opts = zip::write::SimpleFileOptions::default(); + writer.start_file("Root.ufo/metainfo.plist", opts).unwrap(); + std::io::Write::write_all(&mut writer, b"data").unwrap(); + writer.start_file("Root.ufo/glyphs/A_.glif", opts).unwrap(); + std::io::Write::write_all(&mut writer, b"glyph").unwrap(); + writer.finish().unwrap(); + } + buf.set_position(0); + let mut archive = zip::ZipArchive::new(buf).unwrap(); + assert_eq!(detect_zip_root(&mut archive), Some(PathBuf::from("Root.ufo"))); + } + + #[test] + fn dir_entries_skipped() { + // Create a zip with explicit directory entries. + let tmp = NamedTempFile::new().unwrap(); + { + let mut writer = zip::ZipWriter::new(std::fs::File::create(tmp.path()).unwrap()); + let opts = zip::write::SimpleFileOptions::default(); + writer.add_directory("glyphs/", opts).unwrap(); + writer.start_file("glyphs/A_.glif", opts).unwrap(); + std::io::Write::write_all(&mut writer, b"glyph").unwrap(); + writer.start_file("metainfo.plist", opts).unwrap(); + std::io::Write::write_all(&mut writer, b"meta").unwrap(); + writer.finish().unwrap(); + } + + let source = ZipSource::open(tmp.path()).unwrap(); + // Should have only file entries, not directory entries. + assert_eq!(source.entries.len(), 2); + assert!(source.entries.contains_key(Path::new("glyphs/A_.glif"))); + assert!(source.entries.contains_key(Path::new("metainfo.plist"))); + } +}