parley_engine: Make font selection infallible - #722
Conversation
|
What happens if the collection is empty? |
That's handled the same way as if the text was empty: shaping is completely skipped. https://github.com/linebender/parley/pull/722/changes#diff-61f3c5230660ff9e41294f99bee211592e8b099810136ffba274e7ff93b9381eL40-R51 |
28e5c93 to
cf0a275
Compare
cf0a275 to
d082907
Compare
a91af22 to
a379fac
Compare
parley_engine: Make font selection infallible
| @@ -4,24 +4,26 @@ | |||
| //! Text shaping implementation using `harfrust`for shaping | |||
There was a problem hiding this comment.
Funny branch name "tomcur:push-tqzwkknvsuyv" 😆 😛
There was a problem hiding this comment.
That's a jj thing (if you're too lazy to actually specify a name).
| /// If this returns `None`, there are no fonts available at all. This can be used to have a font at | ||
| /// hand for shaping, in case more sophisticated font querying returns no fonts. | ||
| fn any_font(fcx: &mut FontContext) -> Option<FontInstance> { | ||
| let name = fcx.collection.family_names().next()?.to_owned(); |
There was a problem hiding this comment.
It's unfortunate we have to pay a String allocation for every invocation just to immediately drop it 🤔 .
I'm also unsure whether we want to enumerate every family and look up its contents to determine the first "populated" font (to address the below).
It might be best to add an API into fontique that returns the first (if any) font. An alternative solution might look at amending the query to have some "pre-run check" / "last resort" mechanism to encapsulate the "please always load at least one font"
There was a problem hiding this comment.
I agree.
As we'll be moving to a Shaper::shape_text signature where parley_engine owns the item loop, another option is for the select_font callback to remain fallible (-> Option<FontInstance>), and to instead document returning None aborts shaping.
We'll still want some sort of last-resort font mechanism, but we then only need to actually instantiate that last-resort font if the query itself fails. If looking up a last-resort also fails, then unless the user is doing strange things, the first query should've failed anyway, so we won't have wasted much shaping effort.
| let name = fcx.collection.family_names().next()?.to_owned(); | ||
| let font = fcx | ||
| .collection | ||
| .family_by_name(&name)? | ||
| .default_font()? | ||
| .clone(); |
There was a problem hiding this comment.
I'm not sure this is entirely correct because a family can have all its fonts unregistered by a consumer wanting to minimize their memory consumption. So, I think a consumer can register a few families, drain the first, and this will return None (preventing all shaping from occurring).
AI wrote me this unit test:
#[cfg(test)]
mod tests {
use std::sync::Arc;
use fontique::{Blob, Collection, CollectionOptions, FontInfoOverride};
use super::*;
#[test]
fn any_font_skips_empty_families() {
let font_path = parley_dev::font_dirs()
.nth(1)
.unwrap()
.join("Roboto-Regular.ttf");
let font_data = Blob::new(Arc::new(std::fs::read(font_path).unwrap()));
let mut collection = Collection::new(CollectionOptions {
shared: false,
system_fonts: false,
});
for family_name in ["First family", "Second family"] {
collection.register_fonts(
font_data.clone(),
Some(FontInfoOverride {
family_name: Some(family_name),
..Default::default()
}),
);
}
// Empty whichever family `any_font` will inspect first. Family names remain registered
// after their last font is removed, so the other family is still a valid fallback.
let first_family_name = collection.family_names().next().unwrap().to_owned();
let first_family = collection.family_by_name(&first_family_name).unwrap();
for font in first_family.fonts() {
assert!(collection.unregister_font(
first_family.id(),
font.width(),
font.style(),
font.weight(),
));
}
assert!(
collection
.family_by_name(&first_family_name)
.unwrap()
.fonts()
.is_empty()
);
let remaining_family_name = if first_family_name == "First family" {
"Second family"
} else {
"First family"
};
assert!(
collection
.family_by_name(remaining_family_name)
.unwrap()
.default_font()
.is_some()
);
let mut fcx = FontContext {
collection,
source_cache: Default::default(),
};
assert!(
any_font(&mut fcx).is_some(),
"a later family still contains a usable font"
);
}
}| } | ||
| return; | ||
| } | ||
| let fallback_font = fallback_font.unwrap(); |
There was a problem hiding this comment.
The name "fallback font" is a bit ambiguous because the concept also exists in fontique with a different meaning. I think "last resort font" or similar could be a better name.
Co-authored-by: Taj Pereira <taj@canva.com>
This makes the font selection callback
parley_enginetakes infallible. That's important, because a font is needed to be able to give font metrics to aShapedRun. As my intention is to move to aShapedTextmodel where the entire source text is covered, to simplify the API and reduce some bookkeeping, that means there shouldn't be gaps inShapedRuns.parleyis updated to always pass a font. This PR proposes the very simple/naive protocol of just taking any font from the collection as a final "fallback font", which is used if the font query itself doesn't return any font. If there are no fonts in the collection at all, shaping is completely skipped.#720 and #721 fixed some pre-existing bugs around empty text, which this infallible font selection surfaced (see #721 in particular).