Skip to content

Add in-memory UFO loader and path helpers#387

Open
yanone wants to merge 3 commits intolinebender:masterfrom
yanone:hasmap-loading
Open

Add in-memory UFO loader and path helpers#387
yanone wants to merge 3 commits intolinebender:masterfrom
yanone:hasmap-loading

Conversation

@yanone
Copy link

@yanone yanone commented Feb 23, 2026

Introduce Font::load_entries to load a Font from in-memory UFO entries (HashMap of relative paths -> contents). The implementation normalizes virtual paths, detects an effective UFO root, parses metainfo/fontinfo/lib/groups/kerning/features, reads layer descriptors and glyph GLIFs, and normalizes negative openTypeOS2WinDescent values. Added utility functions normalize_virtual_path and join_virtual_path, plus necessary imports (Cursor, BTreeMap, HashMap). Also added DesignSpaceDocument::load_str and unit tests covering basic entry loading and negative OS/2 winDescent handling.

The purpose for this addition is that it allows tools with no file system access (such as wasm components) to parse .ufo/.designspace fonts.

I had opened an issue for that and thought I'd try to add some momentum with a PR. Cheers.

Introduce Font::load_entries to load a Font from in-memory UFO entries (HashMap of relative paths -> contents). The implementation normalizes virtual paths, detects an effective UFO root, parses metainfo/fontinfo/lib/groups/kerning/features, reads layer descriptors and glyph GLIFs, and normalizes negative openTypeOS2WinDescent values. Added utility functions normalize_virtual_path and join_virtual_path, plus necessary imports (Cursor, BTreeMap, HashMap). Also added DesignSpaceDocument::load_str and unit tests covering basic entry loading and negative OS/2 winDescent handling.
src/font.rs Outdated
FontLoadError::ParsePlist { name: FONTINFO_FILE, source }
})?;

if let Some(dict) = fontinfo_value.as_dictionary_mut() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is curious, why is this manipulating data here?

Copy link
Author

Choose a reason for hiding this comment

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

That was a compatibility layer that accidentally remained in the code where I had to deal with a font with a negative winDescent value which broke the parsing. I just patched it.

Copy link
Author

Choose a reason for hiding this comment

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

I also updated the formatting. All checks pass now.

@RickyDaMa
Copy link
Collaborator

Broader question than this PR: did we ought to have some file system abstraction trait in norad, to allow various use cases like this one? IIRC fontTools does something similar to allow it to read UFOs and UFOZ easily. A public trait would also then mean that other file system support wouldn't even necessarily need to be maintained here, but one could have their own crate norad-wasm to add the support they need. Just thinking aloud.

@madig
Copy link
Collaborator

madig commented Feb 25, 2026

Are there existing filesystem abstractions in wider use?

@RickyDaMa
Copy link
Collaborator

The closest thing would be accepting impl io::Read and similar, instead of FS-specific types

Copy link
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.

Sorry to let this sit! Overall I think that supporting this use-case makes sense, but I have some general questions about what the API should look like. I've left various questions inline, and once I make sure I'm understanding various decisions we can figure out what the best API would be.

pub fn load_entries(
path: impl AsRef<Path>,
entries: &HashMap<String, String>,
) -> Result<Font, FontLoadError> {
Copy link
Member

Choose a reason for hiding this comment

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

I want to think a bit about the API here and make sure that we get it right.

  • Is there a reason to use String instead of PathBuf as a way of identifying the files? to me PathBuf would make more sense, even if we aren't referring to files on disk, because it handles all the logic for handling components, separators, etc.

  • Do we need a root path, or could it be implied? Is it mostly useful for error messages?

  • Instead of needing to pass in a HashMap, another option would be for this constructor to take a closure that would then return various resources, e.g. it could look like,

    fn load_entries(entires: impl Fn(&Path) -> Option<String>) -> Result<Font, Error>

    this isn't a huge difference, but it just makes the API more flexible at the call site, instead of requiring the caller to always put everything in a hashmap.

Curious to hear your thoughts, and we can iterate a bit from there?

Comment on lines +235 to +241
let rooted_metainfo_key = join_virtual_path(&root, METAINFO_FILE);
let effective_root =
if normalized_entries.contains_key(&rooted_metainfo_key) { root.as_str() } else { "" };

let metainfo_key = join_virtual_path(effective_root, METAINFO_FILE);
let metainfo_str =
normalized_entries.get(&metainfo_key).ok_or(FontLoadError::MissingMetaInfoFile)?;
Copy link
Member

Choose a reason for hiding this comment

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

so is the idea here that the caller can provide either MyFont.ufo/metainfo.plist or just metainfo.plist? I would prefer to enforce stricter requirements on the caller, and just insist that all paths are relative to the (implied) root of the ufo directory?

ufo.meta = plist::from_reader(Cursor::new(metainfo_str.as_bytes()))
.map_err(|source| FontLoadError::ParsePlist { name: METAINFO_FILE, source })?;

let fontinfo_key = join_virtual_path(effective_root, FONTINFO_FILE);
Copy link
Member

Choose a reason for hiding this comment

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

yea it would be nice if we were just always using FONTINFO_FILE as the key and not worrying about a possible root..

@yanone
Copy link
Author

yanone commented Mar 11, 2026

I'll propose another solution in a couple of days, maybe tomorrow, that's independent of the hashmaps, along the lines of what @RickyDaMa proposed. A general file system abstraction trait where I implement the hashmaps on my side while norad uses a file system wrapper that could wrap anything other than the native file system. It would also be the smallest-possible code change to norad. I'll be back.

@cmyr
Copy link
Member

cmyr commented Mar 11, 2026

So if you're going this route I will point you to how we handled this case in fea-rs (our FEA parser): SourceResolver is the abstraction, and then we have a particular implementation for ufos on the file system here at FileSystemResolver.

@yanone
Copy link
Author

yanone commented Mar 12, 2026

@cmyr

Here are two proposals. I didn't create PRs yet, but you can compare the branches. Both are a bit verbose because norad reads files in many different places.

The first follows your suggestion and implements SourceResolver.

Pro: explicit abstraction, cleaner long-term architecture, easiest to extend (different backends).
Con: larger API surface and slightly heavier review/maintenance burden.

The second is a minimal callback-reader approach. Less verbose changes, but SourceResolver appears to be the better architecture.

Pro: smaller and more merge-friendly; keeps current APIs mostly intact with one extra loading hook.
Con: less structured than a trait-based abstraction; weaker as a reusable public abstraction.

@cmyr
Copy link
Member

cmyr commented Mar 12, 2026

Honestly they both seem fine, so I'm happy with whichever you prefer. It might be that the trait-based abstraction has additional features that are not necessary for this case, which might suggest that the callback makes sense? But no strong feelings, feel free to open a PR with whichever you prefer :)

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.

4 participants