Support no-fs saving - #412
Conversation
This is modeled on the new loading API, and allows the user to 'save' a UFO in contexts where a filesystem is not available.
RickyDaMa
left a comment
There was a problem hiding this comment.
Looks good to me, just a few questions really
| Ok(()) | ||
| } | ||
|
|
||
| fn serialize_xml(&self) -> Result<String, DesignSpaceSaveError> { |
There was a problem hiding this comment.
I like that you've made a method out of this. Thoughts on making it public API?
There was a problem hiding this comment.
no strong feelings? Would it be genuinely useful?
You can get the contents here in a slightly annoying way, by using save_to_writer to dump to a Vec, and then using String::from_utf8 🤷
There was a problem hiding this comment.
Yeah it's the fact that you can effectively do this (but in a really inefficient way) is why it could make sense to open this up and save someone (if anyone) that cruft. It doesn't really cost anything beyond the effort to write to the doc-string, so I vote we do it.
There was a problem hiding this comment.
the argument against is only a sort of general one: as a principle I prefer to avoid adding public API unless it's clear it will be used, since removing it in the future becomes a breaking change; that's the 'cost' we'd be avoiding.
Given that, let me know if it still seems preferable to you and I'm happy to make the change :)
There was a problem hiding this comment.
Yeah I think that's fair and generally speaking I agree, but when we're likely to be maintaining this internal API for the foreseeable future and it has a somewhat-plausible public API use case I like to open it up. Not a blocking issue for the PR though.
| /// } | ||
| /// ``` | ||
| pub trait FontSource: Sync { | ||
| pub trait FontSource: Send + Sync { |
There was a problem hiding this comment.
Why did this trait bound need to be introduced?
There was a problem hiding this comment.
This is also something I was talked into by claude, with a reasonably convincing argument:
Both
FontSourceandFontSinkcarry aSend + Syncsupertrait bound. OnlySyncis actually compile-required by the current code — the rayon-parallel glyph load/write paths only ever capture the source/sink as a shared&dynreference, and&T: Send + Syncfollows fromT: Syncalone.Sendwas added deliberately, for these reasons:
- Ecosystem symmetry. The closest prior-art traits are all
Send + Sync + 'static: tantivy'sDirectory,object_store, andrust-vfs. They require it because real consumers end up holding aBox<dyn _>/Arc<dyn _>and moving it across threads.- It's a breaking change to add later. Adding a supertrait bound after release breaks downstream impls. Both traits are unreleased today, so this is the one moment it's free.
- No implementor loses anything. No reasonable
FontSource/FontSinkimplementor is non-Send, so the bound costs callers nothing while keeping owned trait objects movable across threads.
'staticwas deliberately not added. That would break the borrowing-closure pattern the tests and doctests rely on — e.g. a closure capturing&Mutex<HashMap<PathBuf, Vec<u8>>>as an ad-hoc in-memory sink.
There was a problem hiding this comment.
I disagree with Claude and would say that fewer trait bounds is better where they're not needed. If I had a !Send type and I wanted to implement this trait and I saw the bound was there for consistency and not because the code can't compile, I'd be miffed. Not to mention that if the user's code requires that their type be Send for some other reason then the compiler will ensure that. I'll leave it up to you on the final call for this one though.
|
This PR looks great for the basic FontSink/memory I/O — the prepare_save() + write_to_sink_impl() refactoring is cleaner than my fork's standalone save_font_with_sink(). This PR uses &self + Send + Sync + io::Error. My old fork uses &mut self + generic Error. babelfont-rs uses FnMut closures as sinks, so &mut self is the natural fit. Feature file include() support is the missing component. That's the only real gap. Earlier you asked me to explain better:
This PR covers ~60% of my needs. The feature file includes are the remaining 40% and the actual reason I still need my fork. If Colin adds that, I can drop my fork entirely. Here are the annotated code snippets showing the full feature file include lifecycle. Wherever babelfont-rs is mentioned, this is a fork on my computer, not the live babelfont-rs. After this is done, I need to make a separate PR to babelfont to use the new API.
// src/font.rs
pub struct Font {
// ... existing fields ...
/// Structured feature files included via `include()` directives in
/// [`Font::features`].
///
/// The keys are virtual paths relative to the UFO root (e.g.
/// `"features/includes/shared.fea"`) and the values are the raw file
/// contents.
///
/// Use [`Font::features_expanded`] to get a single flattened string with
/// all includes inlined.
pub feature_files: BTreeMap<PathBuf, String>,
}
// src/non_file_io.rs
pub(crate) fn load_feature_files(
source: &dyn FontSource,
path: &Path,
) -> Result<FeatureFilesLoadResult, FontLoadError> {
// Read features.fea from the source (HashMap in WASM, directory on disk)
let Some(data) = source.try_read(path) else { return Ok(None); };
let contents = String::from_utf8(data.map_err(FontLoadError::FeatureFile)?)?;
// DFS: walk every include() directive, with cycle detection
let mut included_files = BTreeMap::new();
collect_feature_includes(source, &normalize_virtual_path(path),
&contents, &mut HashSet::new(), &mut included_files)?;
Ok(Some((contents, included_files)))
}
fn collect_feature_includes(
source: &dyn FontSource,
current_path: &Path,
contents: &str,
stack: &mut HashSet<PathBuf>,
included_files: &mut BTreeMap<PathBuf, String>,
) -> Result<(), FontLoadError> {
for line in contents.split_inclusive('\n') {
if let Some(include_target) = parse_include_target(line) {
let include_path = resolve_virtual(current_path, &include_target);
// Cycle detection
if stack.contains(&include_path) {
return Err(FontLoadError::FeatureIncludeCycle { path: include_path });
}
// Already seen this file? Skip it (DAG dedup).
if included_files.contains_key(&include_path) { continue; }
// Read from the same FontSource (HashMap or filesystem)
let data = source.try_read(&include_path)
.ok_or(FontLoadError::MissingIncludedFeatureFile { path: include_path.clone() })?;
let contents = String::from_utf8(data.map_err(FontLoadError::FeatureFile)?)?;
// Recurse into this included file's own includes
stack.insert(include_path.clone());
collect_feature_includes(source, &include_path, &contents, stack, included_files)?;
stack.remove(&include_path);
included_files.insert(include_path, contents);
}
}
}The parser for include(...) directives is deliberately simple — it matches include(...) at the start of a line, strips quotes, and normalizes the path: fn parse_include_target(line: &str) -> Option<PathBuf> {
let trimmed = line.trim();
if !trimmed.starts_with("include") || !trimmed.ends_with(';') { return None; }
let open = trimmed.find('(')?;
let close = trimmed.rfind(')')?;
if close < open { return None; }
let inner = trimmed[open + 1..close].trim().trim_matches('"').trim_matches('\'');
if inner.is_empty() { return None; }
Some(normalize_virtual_path(Path::new(inner)))
}This is called from Font::load_from_source() where features.fea is loaded: // src/font.rs (inside Font::load_from_source)
let features_path = Path::new(FEATURES_FILE);
let (features, feature_files) = if request.features {
match crate::non_file_io::load_feature_files(source, features_path)? {
Some((main, included)) => (main, included),
None => (String::new(), BTreeMap::new()),
}
} else {
(String::new(), BTreeMap::new())
};
// src/font.rs
pub fn features_expanded(&self) -> Result<String, FontLoadError> {
crate::non_file_io::expand_feature_text(&self.features, &self.feature_files)
}This walks the same include() lines, but instead of reading from a FontSource, it reads from the feature_files map: // src/non_file_io.rs
fn expand_feature_text_from_map(
current_path: &Path,
contents: &str,
feature_files: &BTreeMap<PathBuf, String>,
stack: &mut HashSet<PathBuf>,
) -> Result<String, FontLoadError> {
let mut out = String::new();
for line in contents.split_inclusive('\n') {
if let Some(include_target) = parse_include_target(line) {
let include_path = resolve_virtual(current_path, &include_target);
if !stack.insert(include_path.clone()) {
return Err(FontLoadError::FeatureIncludeCycle { path: include_path });
}
let include_contents = feature_files.get(&include_path)
.ok_or(FontLoadError::MissingIncludedFeatureFile { path: include_path.clone() })?;
out.push_str(&expand_feature_text_from_map(
&include_path, include_contents, feature_files, stack)?);
stack.remove(&include_path);
} else {
out.push_str(line); // passthrough for non-include lines
}
}
Ok(out)
}Used in babelfont-rs to feed the feature compiler: // babelfont-rs/babelfont/src/convertors/ufo.rs
let expanded_features = ufo.features_expanded()?;
font.features = Features::from_fea(&expanded_features);
// src/non_file_io.rs (inside save_font_with_sink)
if !font.features.is_empty() || !font.feature_files.is_empty() {
// Write the main features.fea
write_sink_file(sink, Path::new(FEATURES_FILE),
normalize_feature_text(&font.features).as_bytes())?;
// Write each included file as a separate entry
for (feature_path, contents) in &font.feature_files {
write_sink_file(sink, feature_path,
normalize_feature_text(contents).as_bytes())?;
}
}This is what babelfont-rs calls via save_with_sink: // babelfont-rs/babelfont/src/convertors/ufo.rs
pub fn save_entries(font: &Font) -> Result<HashMap<String, String>, BabelfontError> {
let ufo = as_norad(font, 0)?;
let mut written: HashMap<String, Vec<u8>> = HashMap::new();
let mut sink = |path: &FsPath, data: &[u8]| -> Result<(), std::io::Error> {
written.insert(path.to_string_lossy().to_string(), data.to_vec());
Ok(())
};
ufo.save_with_sink(&norad::WriteOptions::default(), &mut sink)?;
// Returns e.g. {
// "features.fea" -> "languagesystem DFLT dflt;\ninclude(includes/shared.fea);\n...",
// "includes/shared.fea" -> "@shared = [A B C];\n",
// "metainfo.plist" -> "...",
// "glyphs/contents.plist" -> "...",
// ...
// }
}
The key invariant: every file that features.fea references via include() is tracked as a separate feature_files entry, so it can be loaded, expanded, and written back independently. Without this, a UFO that uses include() would silently lose data on save, and loading from an in-memory HashMap would fail because the included files are simply not there. Note: I'll be away tomorrow (Friday) and Monday. I can react again on Tuesday. |
|
Okay, so the Your version of this feature added the sink as an alternative API,a but in this patch it's the core API, with the default file-based behaviour built on top of it, so we do want the parallelism. I acknowledge this will be a tiny bit annoying; you'll have to use interior mutability on your impl type. For the include resolution business: this just feels like a mixing of concerns, and my soft position is that this is not something that should live in norad. It should be easy enough to build on top, if needed, unless I'm missing something. Some things that are slightly confusing, that I've had to look up:
In any case, |
RickyDaMa
left a comment
There was a problem hiding this comment.
Thanks for addressing my feedback ❤️
This is modelled on the new loading API, and allows the user to 'save' a UFO in contexts where a filesystem is not available.
@yanone does this work for you?