diff --git a/crates/fontsrc/README.md b/crates/fontsrc/README.md index c3249d84..cd767df1 100644 --- a/crates/fontsrc/README.md +++ b/crates/fontsrc/README.md @@ -12,7 +12,7 @@ fn load(source: &dyn FontSource) -> Result { } ``` -The format modules expose UFO, Designspace, and Glyphs-native values. Browser/Node adapters and TypeScript bindings remain independent layers. +The format modules expose UFO, Designspace, and Glyphs-native values. Multi-file hosts implement `fontsrc::FileSource` over a native directory, immutable byte map, archive, browser selection, or remote project tree. Browser/Node adapters and TypeScript bindings remain independent layers. ## License diff --git a/crates/fontsrc/docs/DOCS.md b/crates/fontsrc/docs/DOCS.md index 3fe39108..83cb1a82 100644 --- a/crates/fontsrc/docs/DOCS.md +++ b/crates/fontsrc/docs/DOCS.md @@ -10,7 +10,7 @@ Format-native reading and writing for authored font sources across native, in-me **Architecture Invariant:** Format modules expose format-native values before any optional unified model. The `ufo` module uses Norad's `Font`, `FontSource`, and `FontSink` contracts without converting them into Shift's authored model. WHY: format-specific data and unknown `lib` content must survive without being narrowed to one editor's domain. -**Architecture Invariant:** Core format operations receive source-owned bytes through source and sink traits rather than assuming native paths. Filesystem implementations are adapters, not the parsing boundary. WHY: the same parser must operate over directories, archives, in-memory files, browser selections, and remote project trees. +**Architecture Invariant:** Core format operations receive source-owned bytes through `FileSource` and format-native sink traits rather than assuming native paths. Source paths are normalized and relative to one host-owned project root. Filesystem implementations are adapters, not the parsing boundary. WHY: the same parser must operate over directories, archives, in-memory files, browser selections, and remote project trees without permitting reads outside the selected project. **Architecture Invariant:** Unreleased upstream source/sink APIs are pinned to an exact Git revision. WHY: browser-safe I/O is currently ahead of Norad's published crate API and must not drift underneath reproducible builds. @@ -32,7 +32,8 @@ The [README](../README.md) provides the minimal Rust usage and incubation scope. ## Key Types -- `designspace::DesignSpaceDocument` — Norad's format-native Designspace document model, loadable from any buffered byte reader. +- `FileSource` — reads normalized project-relative paths from a native directory, immutable byte map, archive, browser selection, or remote tree. +- `designspace::DesignSpaceDocument` — Norad's format-native Designspace document model, loadable from any buffered byte reader or `FileSource`. - `glyphs::Font` — glyphs-reader's normalized, format-native Glyphs 2 and 3 model. - `ufo::FontSource` — reads UFO-relative files from any synchronous backing source. - `ufo::FontSink` — writes UFO-relative files without assuming a destination filesystem. @@ -41,7 +42,7 @@ The [README](../README.md) provides the minimal Rust usage and incubation scope. ## How it works -A host provides Designspace XML through a buffered byte reader, a Glyphs file through an owned string, and UFO project files through a `FontSource` whose paths are relative to each UFO root. The format libraries parse those inputs into native `DesignSpaceDocument` and `Font` models. Native callers may use paths and directories; browser and remote adapters can first gather files asynchronously and then expose immutable content synchronously to the parser or worker. +A host provides a project tree through `FileSource`. Designspace and Glyphs loaders resolve normalized relative paths within that tree; UFO loaders use the same underlying trait relative to an individual UFO root. The format libraries parse those inputs into native `DesignSpaceDocument` and `Font` models. Native callers may use paths and directories; browser and remote adapters can first gather files asynchronously and then expose immutable content synchronously to the parser or worker. Writing follows the inverse boundary: `DesignSpaceDocument::save_to_writer` serializes XML to a host-owned writer, while `Font::save_to_sink` serializes UFO-relative files through a host-owned `FontSink`. Destination replacement, stale-file cleanup, upload, and persistence remain host responsibilities. @@ -59,7 +60,8 @@ Writing follows the inverse boundary: `DesignSpaceDocument::save_to_writer` seri ## Gotchas - `FontSource` is synchronous. Browser adapters should perform asynchronous file acquisition outside the parser, preferably in a worker, and expose an immutable in-memory source while parsing. -- `glyphs::Font::load_from_string` supports browser-owned `.glyphs` files. The upstream `.glyphspackage` loader still requires a native directory; a source-backed package boundary must land before browser package support is complete. +- `glyphs::load_from_source` supports browser-owned `.glyphs` files. It loads `.glyphspackage` from filesystem-backed sources, but returns `Unsupported` for in-memory packages until glyphs-reader exposes its package parser through a source abstraction. +- `FileSource` is re-exported from Norad's source trait. UFO code may continue using the format-local `ufo::FontSource` name for the same contract. - UFO data and image directories require `FontSource::list_dir`; sources that omit enumeration intentionally produce empty stores. - `FontSink` does not remove stale destination files. Hosts must clear or replace destinations when complete replacement semantics are required. diff --git a/crates/fontsrc/src/lib.rs b/crates/fontsrc/src/lib.rs index 5f8b070d..ddba3dce 100644 --- a/crates/fontsrc/src/lib.rs +++ b/crates/fontsrc/src/lib.rs @@ -4,15 +4,109 @@ //! source and sink abstractions that work with native files, in-memory maps, and //! browser-provided bytes without depending on Shift crates. +use std::io; +use std::path::{Component, Path}; + +pub use norad::DirEntry; +/// A host-owned tree of relative font-project files. +/// +/// Implementations may read from native directories, immutable in-memory maps, +/// archives, or browser-provided files. Paths received by implementations are +/// normalized and relative to the source root. +pub use norad::FontSource as FileSource; + /// Designspace document reading, writing, and format-native values. pub mod designspace { + use std::io::Cursor; + use std::path::Path; + pub use norad::designspace::*; pub use norad::error::{DesignSpaceLoadError, DesignSpaceSaveError}; + + use crate::FileSource; + + /// Loads a Designspace document from a host-owned project tree. + /// + /// `path` is relative to the [`FileSource`] root. Referenced UFO paths + /// remain relative to the Designspace document and are not loaded eagerly. + /// + /// # Errors + /// + /// Returns [`DesignSpaceLoadError`] when the path is not normalized, the + /// source cannot read it, or the XML is malformed. + pub fn load_from_source( + path: &Path, + source: &dyn FileSource, + ) -> Result { + let bytes = crate::read_source_file(path, source).map_err(DesignSpaceLoadError::Io)?; + DesignSpaceDocument::load_from_reader(Cursor::new(bytes)) + } } /// Glyphs source reading and format-native values. pub mod glyphs { + use std::io; + pub use glyphs_reader::*; + + use crate::FileSource; + + /// Loads a Glyphs file or native Glyphs package from a host-owned project tree. + /// + /// Browser-owned `.glyphs` files are read directly from `source`. Until + /// glyphs-reader exposes source-backed package parsing, `.glyphspackage` + /// loading requires a filesystem-backed [`FileSource`]. + /// + /// # Errors + /// + /// Returns [`error::Error`] when the path is not normalized, has an + /// unsupported extension, cannot be read, is not UTF-8, or contains an + /// invalid Glyphs source. In-memory `.glyphspackage` sources return an + /// [`io::ErrorKind::Unsupported`] I/O error. + pub fn load_from_source( + path: &std::path::Path, + source: &dyn FileSource, + ) -> Result { + crate::validate_source_path(path)?; + + match path + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("glyphs") => load_file_from_source(path, source), + Some("glyphspackage") => load_package_from_source(path, source), + _ => Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("unsupported Glyphs source path {}", path.display()), + ) + .into()), + } + } + + fn load_file_from_source( + path: &std::path::Path, + source: &dyn FileSource, + ) -> Result { + let bytes = crate::read_source_file(path, source)?; + let content = String::from_utf8(bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + Font::load_from_string(&content) + } + + fn load_package_from_source( + path: &std::path::Path, + source: &dyn FileSource, + ) -> Result { + let root = source.as_path().ok_or_else(|| { + io::Error::new( + io::ErrorKind::Unsupported, + "glyphs-reader does not yet support source-backed Glyphs packages", + ) + })?; + Font::load(&root.join(path)) + } } /// Unified Font Object reading, writing, and format-native values. @@ -20,3 +114,33 @@ pub mod ufo { pub use norad::error::{FontLoadError, FontWriteError}; pub use norad::{DataRequest, DirEntry, Font, FontSink, FontSource, WriteOptions}; } + +fn read_source_file(path: &Path, source: &dyn FileSource) -> io::Result> { + validate_source_path(path)?; + source.read(path) +} + +fn validate_source_path(path: &Path) -> io::Result<()> { + if path.as_os_str().is_empty() + || path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir + | Component::CurDir + | Component::RootDir + | Component::Prefix(_) + ) + }) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "source path must be normalized and relative: {}", + path.display() + ), + )); + } + + Ok(()) +} diff --git a/crates/fontsrc/tests/designspace_source.rs b/crates/fontsrc/tests/designspace_source.rs index 641491b9..11d2c070 100644 --- a/crates/fontsrc/tests/designspace_source.rs +++ b/crates/fontsrc/tests/designspace_source.rs @@ -3,7 +3,7 @@ mod support; use std::io::Cursor; use std::path::{Path, PathBuf}; -use fontsrc::designspace::DesignSpaceDocument; +use fontsrc::designspace::{load_from_source, DesignSpaceDocument}; use fontsrc::ufo::{DataRequest, Font}; use support::MemorySource; @@ -45,6 +45,27 @@ fn every_referenced_ufo_loads_from_memory() { } } +#[test] +fn project_source_matches_native_file() { + let path = fixture(); + let root = path.parent().unwrap(); + let expected = DesignSpaceDocument::load(&path).unwrap(); + let source = MemorySource::read(root).unwrap(); + + let actual = load_from_source(Path::new("MutatorSans.designspace"), &source).unwrap(); + + assert_eq!(actual, expected); +} + +#[test] +fn project_source_rejects_non_normalized_paths() { + let source = MemorySource::read(fixture().parent().unwrap()).unwrap(); + + let result = load_from_source(Path::new("../MutatorSans.designspace"), &source); + + assert!(result.is_err()); +} + #[test] fn in_memory_document_round_trips_complete_model() { let expected = DesignSpaceDocument::load(fixture()).unwrap(); diff --git a/crates/fontsrc/tests/glyphs_source.rs b/crates/fontsrc/tests/glyphs_source.rs index 2e026ecd..7ebe25eb 100644 --- a/crates/fontsrc/tests/glyphs_source.rs +++ b/crates/fontsrc/tests/glyphs_source.rs @@ -1,6 +1,10 @@ +mod support; + +use std::io; use std::path::{Path, PathBuf}; -use fontsrc::glyphs::Font; +use fontsrc::glyphs::{load_from_source, Font}; +use support::MemorySource; fn fixture(name: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -25,11 +29,14 @@ fn in_memory_files_match_native_loading() { fn assert_in_memory_file_matches_native(path: &Path) { let expected = Font::load(path).unwrap(); - let source = std::fs::read_to_string(path).unwrap(); + let content = std::fs::read_to_string(path).unwrap(); + let source = MemorySource::read(path.parent().unwrap()).unwrap(); - let actual = Font::load_from_string(&source).unwrap(); + let from_string = Font::load_from_string(&content).unwrap(); + let from_source = load_from_source(Path::new(path.file_name().unwrap()), &source).unwrap(); - assert_eq!(actual, expected); + assert_eq!(from_string, expected); + assert_eq!(from_source, expected); } #[test] @@ -49,11 +56,30 @@ fn native_package_matches_equivalent_in_memory_file() { std::fs::write(glyphs.join("A_.glyph"), GLYPH).unwrap(); let expected = Font::load_from_string(COMPLETE_FILE).unwrap(); - let actual = Font::load(&package).unwrap(); + let root = temporary.path(); + let actual = load_from_source(Path::new("Test.glyphspackage"), &root).unwrap(); assert_eq!(actual, expected); } +#[test] +fn in_memory_package_returns_unsupported_until_upstream_supports_it() { + let temporary = tempfile::tempdir().unwrap(); + let package = temporary.path().join("Test.glyphspackage"); + std::fs::create_dir_all(package.join("glyphs")).unwrap(); + std::fs::write(package.join("fontinfo.plist"), FONT_INFO).unwrap(); + std::fs::write(package.join("glyphs/A_.glyph"), GLYPH).unwrap(); + let source = MemorySource::read(temporary.path()).unwrap(); + + let error = load_from_source(Path::new("Test.glyphspackage"), &source).unwrap_err(); + + assert!(matches!( + error, + fontsrc::glyphs::error::Error::IoError(error) + if error.kind() == io::ErrorKind::Unsupported + )); +} + const FONT_INFO: &str = r#"{ familyName = "Package Font"; unitsPerEm = 1000;