diff --git a/Cargo.lock b/Cargo.lock index 76838bbe..1c5f7957 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1772,6 +1772,7 @@ dependencies = [ name = "shift-workspace" version = "0.1.0" dependencies = [ + "shift-backends", "shift-font", "shift-source", "shift-store", diff --git a/crates/shift-source/src/package.rs b/crates/shift-source/src/package.rs index 575331ea..e6e73340 100644 --- a/crates/shift-source/src/package.rs +++ b/crates/shift-source/src/package.rs @@ -35,6 +35,13 @@ pub struct ShiftSourcePackage { } impl ShiftSourcePackage { + pub fn is_package_path(path: impl AsRef) -> bool { + path.as_ref() + .extension() + .and_then(|extension| extension.to_str()) + == Some("shift") + } + pub fn create_empty(path: impl AsRef) -> Result { let path = path.as_ref(); validate_shift_extension(path)?; @@ -79,7 +86,7 @@ impl ShiftSourcePackage { } fn validate_shift_extension(path: &Path) -> Result<(), SourcePackageError> { - if path.extension().and_then(|extension| extension.to_str()) == Some("shift") { + if ShiftSourcePackage::is_package_path(path) { Ok(()) } else { Err(SourcePackageError::InvalidExtension(path.to_path_buf())) diff --git a/crates/shift-workspace/Cargo.toml b/crates/shift-workspace/Cargo.toml index 48f15406..760e2226 100644 --- a/crates/shift-workspace/Cargo.toml +++ b/crates/shift-workspace/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +shift-backends = { workspace = true } shift-font = { workspace = true } shift-source = { workspace = true } shift-store = { workspace = true } diff --git a/crates/shift-workspace/docs/DOCS.md b/crates/shift-workspace/docs/DOCS.md index 68d83605..f77b776e 100644 --- a/crates/shift-workspace/docs/DOCS.md +++ b/crates/shift-workspace/docs/DOCS.md @@ -21,13 +21,16 @@ crates/shift-workspace/src/ - `FontWorkspace` -- live backend object for one open font project. - `NewWorkspace` -- options used when creating a new source package and working store. +- `WorkspaceSource` -- explicit source state: saved `.shift` package or imported external file. - `WorkspaceError` -- source-package and store failures. ## How it works -`FontWorkspace::create_new(source_path, store_path, options)` creates a placeholder `.shift` package, opens the working SQLite store, writes initial font metadata, and starts with an empty `shift-font::Font`. +`FontWorkspace::create(source_path, store_path, options)` creates a placeholder `.shift` package, opens the working SQLite store, writes initial font metadata, and starts with an empty `shift-font::Font`. -`FontWorkspace::open(source_path, store_path)` validates the existing source package, opens the working store, and starts with an empty in-memory font until source/store hydration is implemented. +`FontWorkspace::open(path, store_path)` detects `.shift` paths as source packages. Other supported font paths are imported through `shift-backends` into an unsaved workspace with no save target. + +`FontWorkspace::save()` succeeds for saved `.shift` workspaces and returns `NeedsSaveAs` for imported workspaces. `save_as(path)` creates a `.shift` package and makes it the save target. ## Verification diff --git a/crates/shift-workspace/src/lib.rs b/crates/shift-workspace/src/lib.rs index bc6a4376..91ff39cf 100644 --- a/crates/shift-workspace/src/lib.rs +++ b/crates/shift-workspace/src/lib.rs @@ -2,4 +2,4 @@ mod new_workspace; mod workspace; pub use new_workspace::NewWorkspace; -pub use workspace::{FontWorkspace, WorkspaceError}; +pub use workspace::{FontWorkspace, WorkspaceError, WorkspaceSource}; diff --git a/crates/shift-workspace/src/workspace.rs b/crates/shift-workspace/src/workspace.rs index 847567c8..9d2692ce 100644 --- a/crates/shift-workspace/src/workspace.rs +++ b/crates/shift-workspace/src/workspace.rs @@ -1,5 +1,6 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; +use shift_backends::{FontExportRequest, FontExportResult, FontExporter, font_loader::FontLoader}; use shift_source::ShiftSourcePackage; use shift_store::ShiftStore; @@ -12,16 +13,34 @@ pub enum WorkspaceError { #[error("store error")] Store(#[from] shift_store::StoreError), + + #[error("font backend error")] + Backend(#[from] shift_backends::BackendError), + + #[error("font export error")] + Export(#[from] shift_backends::ExportError), + + #[error("workspace needs a save path")] + NeedsSaveAs, + + #[error("invalid UTF-8 in workspace path: {0}")] + InvalidPathUtf8(PathBuf), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum WorkspaceSource { + Package { path: PathBuf }, + Imported { original_path: PathBuf }, } pub struct FontWorkspace { font: shift_font::Font, - source_package: ShiftSourcePackage, + source: WorkspaceSource, store: ShiftStore, } impl FontWorkspace { - pub fn create_new( + pub fn create( source_path: impl AsRef, store_path: impl AsRef, new_workspace: NewWorkspace, @@ -30,9 +49,15 @@ impl FontWorkspace { let mut store = ShiftStore::open(store_path)?; store.set_font_info(new_workspace.font_info())?; + let mut font = shift_font::Font::new(); + font.metadata_mut().family_name = Some(new_workspace.family_name); + font.metrics_mut().units_per_em = new_workspace.units_per_em as f64; + Ok(Self { - font: shift_font::Font::new(), - source_package, + font, + source: WorkspaceSource::Package { + path: source_package.path().to_path_buf(), + }, store, }) } @@ -40,13 +65,73 @@ impl FontWorkspace { pub fn open( source_path: impl AsRef, store_path: impl AsRef, + ) -> Result { + let source_path = source_path.as_ref(); + if ShiftSourcePackage::is_package_path(source_path) { + Self::open_package(source_path, store_path) + } else { + Self::import_font(source_path, store_path) + } + } + + pub fn save(&mut self) -> Result<(), WorkspaceError> { + match &self.source { + WorkspaceSource::Package { path } => { + ShiftSourcePackage::open(path)?; + Ok(()) + } + WorkspaceSource::Imported { .. } => Err(WorkspaceError::NeedsSaveAs), + } + } + + pub fn save_as(&mut self, source_path: impl AsRef) -> Result<(), WorkspaceError> { + let source_package = ShiftSourcePackage::create_empty(source_path)?; + self.source = WorkspaceSource::Package { + path: source_package.path().to_path_buf(), + }; + + Ok(()) + } + + pub fn export(&self, request: FontExportRequest) -> Result { + FontExporter::new() + .export(&self.font, request) + .map_err(WorkspaceError::from) + } + + fn open_package( + source_path: impl AsRef, + store_path: impl AsRef, ) -> Result { let source_package = ShiftSourcePackage::open(source_path)?; let store = ShiftStore::open(store_path)?; Ok(Self { font: shift_font::Font::new(), - source_package, + source: WorkspaceSource::Package { + path: source_package.path().to_path_buf(), + }, + store, + }) + } + + fn import_font( + import_path: impl AsRef, + store_path: impl AsRef, + ) -> Result { + let import_path = import_path.as_ref(); + let import_path_str = import_path + .to_str() + .ok_or_else(|| WorkspaceError::InvalidPathUtf8(import_path.to_path_buf()))?; + let font = FontLoader::new().read_font(import_path_str)?; + let mut store = ShiftStore::open(store_path)?; + store.set_font_info(font_info_from_font(&font))?; + + Ok(Self { + font, + source: WorkspaceSource::Imported { + original_path: import_path.to_path_buf(), + }, store, }) } @@ -55,8 +140,15 @@ impl FontWorkspace { &self.font } - pub fn source_package(&self) -> &ShiftSourcePackage { - &self.source_package + pub fn source(&self) -> &WorkspaceSource { + &self.source + } + + pub fn save_target(&self) -> Option<&Path> { + match &self.source { + WorkspaceSource::Package { path } => Some(path), + WorkspaceSource::Imported { .. } => None, + } } pub fn store(&self) -> &ShiftStore { @@ -71,3 +163,24 @@ impl FontWorkspace { self.store.get_font_info().map_err(WorkspaceError::from) } } + +fn font_info_from_font(font: &shift_font::Font) -> shift_store::FontInfo { + let metadata = font.metadata(); + shift_store::FontInfo { + family_name: metadata.family_name.clone(), + copyright: metadata.copyright.clone(), + trademark: metadata.trademark.clone(), + description: metadata.description.clone(), + sample_text: None, + designer: metadata.designer.clone(), + designer_url: metadata.designer_url.clone(), + manufacturer: metadata.manufacturer.clone(), + manufacturer_url: metadata.manufacturer_url.clone(), + license_description: metadata.license.clone(), + license_info_url: metadata.license_url.clone(), + vendor_id: None, + version_major: metadata.version_major.map(i64::from), + version_minor: metadata.version_minor.map(i64::from), + units_per_em: font.metrics().units_per_em as i64, + } +} diff --git a/crates/shift-workspace/tests/workspace_test.rs b/crates/shift-workspace/tests/workspace_test.rs index 4de68499..6316306f 100644 --- a/crates/shift-workspace/tests/workspace_test.rs +++ b/crates/shift-workspace/tests/workspace_test.rs @@ -1,4 +1,6 @@ -use shift_workspace::{FontWorkspace, NewWorkspace}; +use std::path::PathBuf; + +use shift_workspace::{FontWorkspace, NewWorkspace, WorkspaceError, WorkspaceSource}; #[test] fn creates_workspace_with_source_package_and_working_store() { @@ -6,15 +8,21 @@ fn creates_workspace_with_source_package_and_working_store() { let source_path = temp.path().join("TestFont.shift"); let store_path = temp.path().join("working.sqlite"); - let workspace = FontWorkspace::create_new( + let workspace = FontWorkspace::create( &source_path, &store_path, NewWorkspace::with_family_name("Test Font"), ) .unwrap(); - assert_eq!(workspace.source_package().path(), source_path.as_path()); - assert!(workspace.source_package().manifest_path().is_file()); + assert_eq!( + workspace.source(), + &WorkspaceSource::Package { + path: source_path.clone() + } + ); + assert_eq!(workspace.save_target(), Some(source_path.as_path())); + assert!(source_path.join("manifest.json").is_file()); assert_eq!( workspace .font_info() @@ -24,7 +32,7 @@ fn creates_workspace_with_source_package_and_working_store() { .as_deref(), Some("Test Font") ); - assert_eq!(workspace.font().glyphs().len(), 0); + assert!(workspace.font().glyphs().is_empty()); } #[test] @@ -33,10 +41,76 @@ fn opens_existing_workspace_paths() { let source_path = temp.path().join("TestFont.shift"); let store_path = temp.path().join("working.sqlite"); - FontWorkspace::create_new(&source_path, &store_path, NewWorkspace::new()).unwrap(); + FontWorkspace::create(&source_path, &store_path, NewWorkspace::new()).unwrap(); let workspace = FontWorkspace::open(&source_path, &store_path).unwrap(); - assert_eq!(workspace.source_package().path(), source_path.as_path()); + assert_eq!(workspace.save_target(), Some(source_path.as_path())); assert!(workspace.font_info().unwrap().is_some()); } + +#[test] +fn imports_external_fonts_without_a_save_target() { + let temp = tempfile::tempdir().unwrap(); + let source_path = fixture("fixtures/fonts/mutatorsans-variable/MutatorSans.designspace"); + let store_path = temp.path().join("working.sqlite"); + + let workspace = FontWorkspace::open(&source_path, &store_path).unwrap(); + + assert_eq!( + workspace.source(), + &WorkspaceSource::Imported { + original_path: source_path + } + ); + assert_eq!(workspace.save_target(), None); + assert!(!workspace.font().glyphs().is_empty()); + assert!( + workspace + .font_info() + .unwrap() + .unwrap() + .family_name + .is_some() + ); +} + +#[test] +fn save_requires_save_as_for_imported_fonts() { + let temp = tempfile::tempdir().unwrap(); + let source_path = fixture("fixtures/fonts/mutatorsans-variable/MutatorSans.designspace"); + let store_path = temp.path().join("working.sqlite"); + + let mut workspace = FontWorkspace::open(&source_path, &store_path).unwrap(); + + let error = workspace.save().unwrap_err(); + + assert!(matches!(error, WorkspaceError::NeedsSaveAs)); +} + +#[test] +fn save_as_assigns_a_shift_package_save_target() { + let temp = tempfile::tempdir().unwrap(); + let source_path = fixture("fixtures/fonts/mutatorsans-variable/MutatorSans.designspace"); + let store_path = temp.path().join("working.sqlite"); + let save_path = temp.path().join("SavedFont.shift"); + + let mut workspace = FontWorkspace::open(&source_path, &store_path).unwrap(); + workspace.save_as(&save_path).unwrap(); + + assert_eq!( + workspace.source(), + &WorkspaceSource::Package { + path: save_path.clone() + } + ); + assert_eq!(workspace.save_target(), Some(save_path.as_path())); + assert!(save_path.join("manifest.json").is_file()); + workspace.save().unwrap(); +} + +fn fixture(path: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(path) +}