Skip to content

parley_engine: Make font selection infallible - #722

Open
tomcur wants to merge 4 commits into
linebender:mainfrom
tomcur:push-tqzwkknvsuyv
Open

parley_engine: Make font selection infallible#722
tomcur wants to merge 4 commits into
linebender:mainfrom
tomcur:push-tqzwkknvsuyv

Conversation

@tomcur

@tomcur tomcur commented Jul 30, 2026

Copy link
Copy Markdown
Member

This makes the font selection callback parley_engine takes infallible. That's important, because a font is needed to be able to give font metrics to a ShapedRun. As my intention is to move to a ShapedText model where the entire source text is covered, to simplify the API and reduce some bookkeeping, that means there shouldn't be gaps in ShapedRuns.

parley is 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).

@nicoburns

Copy link
Copy Markdown
Collaborator

What happens if the collection is empty?

@tomcur

tomcur commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

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

@tomcur
tomcur force-pushed the push-tqzwkknvsuyv branch from 28e5c93 to cf0a275 Compare July 31, 2026 08:35
@tomcur
tomcur force-pushed the push-tqzwkknvsuyv branch from cf0a275 to d082907 Compare July 31, 2026 08:53
@tomcur
tomcur force-pushed the push-tqzwkknvsuyv branch from a91af22 to a379fac Compare July 31, 2026 08:59
@tomcur tomcur changed the title Make font selection infallible parley_engine: Make font selection infallible Jul 31, 2026
Comment thread parley/src/shape/mod.rs Outdated
Comment thread parley/src/shape/mod.rs
@@ -4,24 +4,26 @@
//! Text shaping implementation using `harfrust`for shaping

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Funny branch name "tomcur:push-tqzwkknvsuyv" 😆 😛

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

That's a jj thing (if you're too lazy to actually specify a name).

Comment thread parley/src/shape/mod.rs
/// 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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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"

@tomcur tomcur Aug 2, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment thread parley/src/shape/mod.rs
Comment on lines +306 to +311
let name = fcx.collection.family_names().next()?.to_owned();
let font = fcx
.collection
.family_by_name(&name)?
.default_font()?
.clone();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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"
        );
    }
}

Comment thread parley/src/shape/mod.rs
}
return;
}
let fallback_font = fallback_font.unwrap();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

3 participants