Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/fontsrc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ fn load(source: &dyn FontSource) -> Result<Font, fontsrc::ufo::FontLoadError> {
}
```

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

Expand Down
10 changes: 6 additions & 4 deletions crates/fontsrc/docs/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand All @@ -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.

Expand All @@ -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.

Expand Down
124 changes: 124 additions & 0 deletions crates/fontsrc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,143 @@
//! 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<DesignSpaceDocument, DesignSpaceLoadError> {
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<Font, error::Error> {
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<Font, error::Error> {
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<Font, error::Error> {
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.
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<Vec<u8>> {
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(())
}
23 changes: 22 additions & 1 deletion crates/fontsrc/tests/designspace_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down
36 changes: 31 additions & 5 deletions crates/fontsrc/tests/glyphs_source.rs
Original file line number Diff line number Diff line change
@@ -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"))
Expand All @@ -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]
Expand All @@ -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;
Expand Down
Loading