Skip to content

SourceResolver loading#390

Open
yanone wants to merge 2 commits intolinebender:masterfrom
yanone:source-resolver-loading
Open

SourceResolver loading#390
yanone wants to merge 2 commits intolinebender:masterfrom
yanone:source-resolver-loading

Conversation

@yanone
Copy link
Copy Markdown

@yanone yanone commented Mar 13, 2026

Summary
This PR introduces a path-based source abstraction for UFO loading so norad can load from non-filesystem backends without exposing a HashMap-specific API.

Motivation
Downstream wasm and in-memory workflows need to provide UFO files from virtual storage.
The goal is to keep norad’s public API backend-agnostic while still supporting those use cases cleanly.

What changed
Added a source resolver abstraction for reading UFO resources by relative path.
Added a filesystem resolver implementation for current disk-based behavior.
Added resolver-based font loading flow, used by existing load entry points.
Added resolver-specific error handling for missing files and IO failures.
Kept default filesystem behavior intact for existing callers.

Compatibility
Existing filesystem loading behavior is preserved.
No HashMap-specific type is introduced in the public API.
This creates a clear extension point for custom loaders in downstream crates.

Copy link
Copy Markdown
Member

@cmyr cmyr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so... lots of notes inline, but I think the general shape of this is okay?

  • going through this does make me wonder if the closure-based version might not be preferable, since it's easier to get right without needing a lot of back-and-forth?
  • In either case I think I'd like to see all of this new code live in a new module, like non_file_loading.rs or similar, just so that we can keep it a bit more contained.

Comment on lines +316 to +319
/// Returns a [`DesignSpaceDocument`] loaded from an XML string.
pub fn load_str(contents: &str) -> Result<DesignSpaceDocument, DesignSpaceLoadError> {
quick_xml::de::from_str(contents).map_err(DesignSpaceLoadError::DeError)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

total nit but I would prefer this to be right below the main load fn, above.

/// The requested path.
path: PathBuf,
/// The underlying error.
source: IoError,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think io::Error is the right error type, here; the error type on SourceResolver also isn't right, see below.

/// Return UTF-8 contents for the provided path.
///
/// Returning `Ok(None)` indicates a missing file.
fn get_contents(&self, path: &Path) -> Result<Option<String>, FontLoadError>;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What we want here is some very customizable error type that the implementor can use to communicate why the given resource isn't available; FontLoadError covers a lot of specific cases that aren't relevant to the job of "give me a string for this path".

The 'best' option is probably an associated type, so this would look like:

trait SourceResolver {
    type Error;
    fn get_contents(&self, path: &Path) -> Result<Option<String>, Self::Error>;
}

but this might be a bit fiddly if you're not that comfortable with rust? Because we would probably need bounds on the error type elsewhere, so that the top-level Resolver variant would contain a Box<dyn std::error::Error>...

Comment on lines +49 to +57
/// Resolve a raw request path.
fn resolve_raw_path(&self, path: &Path, _requested_from: Option<&Path>) -> PathBuf {
path.to_path_buf()
}

/// Canonicalize a path if needed.
fn canonicalize(&self, path: &Path) -> Result<PathBuf, FontLoadError> {
Ok(path.to_path_buf())
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

both of these methods are important for resolving include statements in a FEA file but are (I believe) irrelevant for your use-case, and can be removed.

Comment on lines +69 to +105
/// Filesystem-backed [`SourceResolver`].
#[derive(Default)]
pub struct FileSystemResolver {
project_root: PathBuf,
}

impl FileSystemResolver {
/// Create a resolver rooted at `project_root`.
pub fn new(project_root: PathBuf) -> Self {
Self { project_root }
}
}

impl SourceResolver for FileSystemResolver {
fn get_contents(&self, path: &Path) -> Result<Option<String>, FontLoadError> {
match fs::read_to_string(path) {
Ok(contents) => Ok(Some(contents)),
Err(source) if source.kind() == ErrorKind::NotFound => Ok(None),
Err(source) => Err(FontLoadError::ResolverIo { path: path.to_path_buf(), source }),
}
}

fn resolve_raw_path(&self, path: &Path, requested_from: Option<&Path>) -> PathBuf {
if path.is_absolute() {
return path.to_path_buf();
}
if let Some(parent) = requested_from.and_then(Path::parent) {
return parent.join(path);
}
self.project_root.join(path)
}

fn canonicalize(&self, path: &Path) -> Result<PathBuf, FontLoadError> {
Ok(path.to_path_buf())
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you even need a file-system backed resolver? this is used in fea-rs because the resolver API is the only API for handling include statements, but norad already has an API for loading from the file system...

Comment on lines +298 to +301
fn load_with_resolver_impl(
request: DataRequest,
resolver: &dyn SourceResolver,
) -> Result<Font, FontLoadError> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

have a separate _impl method that we delegate too is unnecessary here (and is actually unnecessary in our main load fn too now, I believe it's a relic from an earlier time)

request: DataRequest,
resolver: &dyn SourceResolver,
) -> Result<Font, FontLoadError> {
let metainfo_str = required_file(resolver, Path::new(METAINFO_FILE), None)?;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this required_file method is only called once, i'd just inline that bit?

}

let mut lib = if request.lib {
match optional_file(resolver, Path::new(LIB_FILE), None)? {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think optional_file can also just be removed, we don't need to worry about canonicalization etc so should just call resolver.get_contents directly?


let mut lib = if request.lib {
match optional_file(resolver, Path::new(LIB_FILE), None)? {
Some(lib_str) => plist::Value::from_reader(Cursor::new(lib_str.as_bytes()))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you shouldn't need Cursor here, just Value::from_reader_xml(lib_str.as_bytes())?


let layers = load_layer_set_from_resolver(resolver, &request.layers)?;

let (groups, kerning) = (groups, kerning);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

@yanone
Copy link
Copy Markdown
Author

yanone commented Mar 23, 2026

I'm addressing all of your remarks in a new branch. Before making a PR, see diffs here: https://github.com/linebender/norad/compare/master...yanone:norad:non-file-io-save-and-includes?expand=1

This branch is a follow-up over both #387 and #390.

Compared to #387, it keeps the non-file use case but drops the HashMap-specific API shape in favor of closure-based source/sink callbacks over UFO-root-relative paths.

Compared to #390, it removes the public resolver/filesystem-resolver abstraction and moves the non-file code into a separate module. It also generalizes the error path away from io::Error/resolver-specific types.

In addition, it goes beyond both earlier PRs in two ways that matter for my editor use case:

  • feature file include() support from the same non-file source, while preserving included .fea files structurally instead of flattening them on save
  • non-file writing APIs for both UFOs and designspace documents

So the current branch is basically: closure-based non-file load/save, isolated implementation, include-aware feature handling, and structural round-tripping of included feature files.

Please review. If you're generally happy with this, I'll make a PR and then we can discuss details.

@cmyr
Copy link
Copy Markdown
Member

cmyr commented Apr 2, 2026

yan please just open the new PR and I can review there :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants