Skip to content

Add support for OpenType features in text (e.g. ligatures, smallcaps) #19020

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from

Conversation

hansler
Copy link

@hansler hansler commented May 1, 2025

Objective

OpenType features include things like smallcaps, lined vs old-style numbers, ligatures, stylistic alternate characters, fractional numbers (numerator placed above the denominator), forced monospacing for numbers, and more. There are >100 possible OpenType feature tags; see https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist for the up-to-date list. This provides a way for Bevy users to use these features when using .otf fonts that support them.

Solution

OpenType features are now supported in cosmic-text, so this just provides a way to pass them through. A few notes:

  • I extended the existing "text" example to showcase a few different OpenType features.
  • OpenType features are only available for .otf fonts. Since there weren't any existing .otf fonts in the asset/ folder, I've added an SIL-licenced font so that we can showcase this in example code.
  • I added a "FontFeatures" struct. cosmic-text does already include its own FontFeatures struct, but 1) it does not implement Reflect, which is required by TextFont, and 2) the one I added has a couple ergonomics improvements for the builder methods compared to cosmic-text's.
  • OpenType font features are four characters strings, e.g. "liga". I considered representing these within an enum, but decided against this since there are hundreds of possible features, and more get added frequently, so this would require quite a bit of ongoing maintenance. Since these features are typically referred to by their four-letter name in documentation, I think the [u8; 4] representation is appropriate, and this mirrors what cosmic-text does as well. I added some consts for commonly used features.

Testing

I extended the "text" example. Run:

cargo run --example text


Showcase

Screenshot:
opentype_features

OpenType features are now supported in cosmic-text, so this provides a
way to pass them through. A few notes:

- I extended the existing "text" example to showcase a few different
  OpenType features.
- OpenType features are only available for .otf fonts. Since there
  weren't any existing .otf fonts in the asset/ folder, I've added an
  SIL-licenced font so that we can showcase this in example code.
- I added a "FontFeatures" struct. cosmic-text does already include its
  own FontFeatures struct, but 1) it does not implement Reflect, which
  is required by TextFont, and 2) the one I added has a couple
  ergonomics improvements for the public API.
- OpenType font features are four characters strings, e.g. "liga". I
  considered representing these within an enum, but decided against this
  since there are hundreds of possible features, and more get added
  frequently, so this would require quite a bit of ongoing maintenance.
  Since these features are typically referred to by their four-letter
  name in documentation, I think the &[u8; 4] representation is
  appropriate, and this is what cosmic-text does as well.
Copy link
Contributor

github-actions bot commented May 1, 2025

Welcome, new contributor!

Please make sure you've read our contributing guide and we look forward to reviewing your pull request shortly ✨

@ickshonpe ickshonpe added A-Text Rendering and layout for characters C-Feature A new feature, making something new possible D-Straightforward Simple bug fixes and API improvements, docs, test and examples M-Needs-Release-Note Work that should be called out in the blog due to impact S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels May 2, 2025
Copy link
Contributor

github-actions bot commented May 2, 2025

It looks like your PR has been selected for a highlight in the next release blog post, but you didn't provide a release note.

Please review the instructions for writing release notes, then expand or revise the content in the release notes directory to showcase your changes.

@ickshonpe ickshonpe removed the M-Needs-Release-Note Work that should be called out in the blog due to impact label May 2, 2025
features: HashMap<[u8; 4], u32>,
}

impl FontFeatures {
Copy link
Contributor

Choose a reason for hiding this comment

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

Agree that an enum with 100+ variants might be burdensome to maintain, but maybe some constants for commonly used features might be useful like:

Suggested change
impl FontFeatures {
impl FontFeatures {
pub const SUPERSCRIPT: [u8; 4] = b"sups";

and so on

Copy link
Author

Choose a reason for hiding this comment

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

Sure, will do! I think that will be helpful. I'll update the PR on Monday.

/// these features, use [`FontFeatures::set`] to assign a specific value.
#[derive(Clone, Debug, Default, Reflect)]
pub struct FontFeatures {
features: HashMap<[u8; 4], u32>,
Copy link
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 about using a hashmap, maybe we should just use a vec like they do in cosmic text and rustybuzz. Maybe it doesn't matter though.

Copy link
Author

Choose a reason for hiding this comment

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

Sure, I can switch this to a vec. My original thinking is that something like this would probably result in undefined behavior:

FontFeatures::new()
  .set(b"wght", 200)
  .set(b"wght", 600)

With a HashMap, we'd always respect the most recently set value. But it might be overkill to try to prevent that here, and instead should just defer to cosmic-text to decide what should happen.

Copy link
Contributor

Choose a reason for hiding this comment

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

It's a list that's applied in order, with duplicates overwriting the previous value so there's no problem, except the obvious worry that someone naively uses set or enable every frame leaking a huge list of features.

Maybe we could remove the enable and set functions and instead change it so that FontFeatures has to be constructed from a builder, iterator or From impl with the list of features being immutable after construction? Font features are almost always just set once at creation, with in-place mutation being relatively rare.

Copy link
Author

Choose a reason for hiding this comment

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

Thanks @ickshonpe! That makes sense. I just updated the PR, so now FontFeatures must either be constructed from FontFeaturesBuilder or an IntoIterator From impl.

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe the FontFeaturesBuilder constructor should be on FontFeatures for better discoverability, so instead of:

font_features: FontFeaturesBuilder::new().enable(SLASHED_ZERO).build()

you'd use:

font_features: FontFeatures::new().enable(SLASHED_ZERO).build()

Sorry I'm being way too fussy over this. Perhaps the hashmap was the right approach. If rust supported varidics this would be trivial hehe.

Copy link
Author

Choose a reason for hiding this comment

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

I appreciate the fussiness! I myself was debating whether to instantiate the builder from the FontFeatures impl or not. I just updated the PR to do this. One change: I named the method builder() instead of new(), because if I use new(), I bump into a lint error that says "new() usually returns Self". So builder() seems like the most common convention here.

John Hansler added 5 commits May 5, 2025 12:22
- FontFeatures can now only be constructed via builder, "From" impl, or "Default" impl.
- Switched from storing feature values in a HashMap to using a Vec instead.
- Added consts for commonly-used OpenType features.
- Added release notes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-Text Rendering and layout for characters C-Feature A new feature, making something new possible D-Straightforward Simple bug fixes and API improvements, docs, test and examples S-Needs-Review Needs reviewer attention (from anyone!) to move forward
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants