Skip to main content

inset_embedder/
text.rs

1//! Flutter counterpart: `engine/src/flutter/lib/ui/text.dart` value types
2//! used by painting (`FontWeight`, `TextDecoration`, `TextDirection`, …).
3//! `TextStyle` / `ParagraphStyle` / `Paragraph` are in `paragraph.rs`.
4
5use std::fmt::{self, Debug, Display};
6
7use crate::{clamp_double, lerp_double, lerp_int};
8
9/// A [`TextStyle.height`](https://api.flutter.dev/flutter/dart-ui/TextStyle/height.html)
10/// value that indicates the text span should take the height defined by the
11/// font, which may not be exactly the height of `fontSize`.
12pub const K_TEXT_HEIGHT_NONE: f64 = 0.0;
13
14/// Whether to use the italic type variation of glyphs in the font.
15///
16/// Some modern fonts allow this to be selected in a more fine-grained manner.
17/// See `FontVariation.italic` for details.
18///
19/// Italic type is distinct from slanted glyphs. To control the slant of a
20/// glyph, consider the `FontVariation.slant` font feature.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
22pub enum FontStyle {
23    /// Use the upright ("Roman") glyphs.
24    Normal,
25
26    /// Use glyphs that have a more pronounced angle and typically a cursive style
27    /// ("italic type").
28    Italic,
29}
30
31/// The thickness of the glyphs used to draw the text.
32///
33/// Values must be in the range 1..1000.
34///
35/// Fonts are typically weighted on a 9-point scale, which, for historical
36/// reasons, uses the names 100 to 900. In Flutter, these are named `w100` to
37/// `w900` and have the following conventional meanings:
38///
39///  * [`w100`](FontWeight::W100): Thin, the thinnest font weight.
40///
41///  * [`w200`](FontWeight::W200): Extra light.
42///
43///  * [`w300`](FontWeight::W300): Light.
44///
45///  * [`w400`](FontWeight::W400): Normal. The constant [`NORMAL`](FontWeight::NORMAL) is an alias for this value.
46///
47///  * [`w500`](FontWeight::W500): Medium.
48///
49///  * [`w600`](FontWeight::W600): Semi-bold.
50///
51///  * [`w700`](FontWeight::W700): Bold. The constant [`BOLD`](FontWeight::BOLD) is an alias for this value.
52///
53///  * [`w800`](FontWeight::W800): Extra-bold.
54///
55///  * [`w900`](FontWeight::W900): Black, the thickest font weight.
56///
57/// For example, the font named "Roboto Medium" is typically exposed as a font
58/// with the name "Roboto" and the weight [`W500`](FontWeight::W500).
59///
60/// Some modern fonts allow the weight to be adjusted in arbitrary increments.
61/// When using these fonts, applications can specify [`FontWeight`] instances
62/// constructed using values other than the predefined values.
63#[derive(Clone, Copy, PartialEq, Eq, Hash)]
64pub struct FontWeight {
65    /// The thickness value of this font weight.
66    pub value: i32,
67}
68
69impl FontWeight {
70    /// Creates a [`FontWeight`] object, which can be added to a text style to
71    /// select the thickness of a font's glyphs.
72    pub fn new(value: i32) -> FontWeight {
73        debug_assert!(
74            (1..=1000).contains(&value),
75            "Font weight must be between 1 and 1000"
76        );
77        FontWeight { value }
78    }
79
80    /// The encoded integer value of this font weight.
81    ///
82    /// Deprecated in Dart in favor of [`value`](Self::value).
83    pub fn index(self) -> i32 {
84        ((self.value / 100) - 1).clamp(0, 8)
85    }
86
87    /// Thin, the least thick.
88    pub const W100: FontWeight = FontWeight { value: 100 };
89
90    /// Extra-light.
91    pub const W200: FontWeight = FontWeight { value: 200 };
92
93    /// Light.
94    pub const W300: FontWeight = FontWeight { value: 300 };
95
96    /// Normal / regular / plain.
97    pub const W400: FontWeight = FontWeight { value: 400 };
98
99    /// Medium.
100    pub const W500: FontWeight = FontWeight { value: 500 };
101
102    /// Semi-bold.
103    pub const W600: FontWeight = FontWeight { value: 600 };
104
105    /// Bold.
106    pub const W700: FontWeight = FontWeight { value: 700 };
107
108    /// Extra-bold.
109    pub const W800: FontWeight = FontWeight { value: 800 };
110
111    /// Black, the most thick.
112    pub const W900: FontWeight = FontWeight { value: 900 };
113
114    /// The default font weight.
115    pub const NORMAL: FontWeight = FontWeight::W400;
116
117    /// A commonly used font weight that is heavier than normal.
118    pub const BOLD: FontWeight = FontWeight::W700;
119
120    /// A list of all the named font weights.
121    pub const VALUES: [FontWeight; 9] = [
122        FontWeight::W100,
123        FontWeight::W200,
124        FontWeight::W300,
125        FontWeight::W400,
126        FontWeight::W500,
127        FontWeight::W600,
128        FontWeight::W700,
129        FontWeight::W800,
130        FontWeight::W900,
131    ];
132
133    /// Linearly interpolates between two font weights.
134    ///
135    /// If both `a` and `b` are null, then this method will return null. Otherwise,
136    /// any null values for `a` or `b` are interpreted as equivalent to [`NORMAL`](FontWeight::NORMAL)
137    /// (also known as [`W400`](FontWeight::W400)).
138    ///
139    /// The `t` argument represents position on the timeline, with 0.0 meaning
140    /// that the interpolation has not started, returning `a` (or something
141    /// equivalent to `a`), 1.0 meaning that the interpolation has finished,
142    /// returning `b` (or something equivalent to `b`), and values in between
143    /// meaning that the interpolation is at the relevant point on the timeline
144    /// between `a` and `b`. The interpolation can be extrapolated beyond 0.0 and
145    /// 1.0, so negative values and values greater than 1.0 are valid. The result
146    /// is clamped to the range [`W100`](FontWeight::W100)–[`W900`](FontWeight::W900).
147    pub fn lerp(a: Option<FontWeight>, b: Option<FontWeight>, t: f64) -> Option<FontWeight> {
148        if a.is_none() && b.is_none() {
149            return None;
150        }
151        let a = a.unwrap_or(FontWeight::NORMAL).value;
152        let b = b.unwrap_or(FontWeight::NORMAL).value;
153        Some(FontWeight::new(
154            lerp_int(a, b, t).round().clamp(100.0, 900.0) as i32,
155        ))
156    }
157}
158
159impl Debug for FontWeight {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        Display::fmt(self, f)
162    }
163}
164
165impl Display for FontWeight {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        if self.value % 100 != 0 {
168            return write!(f, "FontWeight({})", self.value);
169        }
170        let name = match self.index() {
171            0 => "w100",
172            1 => "w200",
173            2 => "w300",
174            3 => "w400",
175            4 => "w500",
176            5 => "w600",
177            6 => "w700",
178            7 => "w800",
179            8 => "w900",
180            _ => unreachable!(),
181        };
182        write!(f, "FontWeight.{name}")
183    }
184}
185
186/// Whether and how to align text horizontally.
187///
188/// The order of this enum must match the order of the values in
189/// RenderStyleConstants.h's ETextAlign.
190#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
191pub enum TextAlign {
192    /// Align the text on the left edge of the container.
193    Left,
194
195    /// Align the text on the right edge of the container.
196    Right,
197
198    /// Align the text in the center of the container.
199    Center,
200
201    /// Stretch lines of text that end with a soft line break to fill the width of
202    /// the container.
203    ///
204    /// Lines that end with hard line breaks are aligned towards the [`Start`](TextAlign::Start) edge.
205    Justify,
206
207    /// Align the text on the leading edge of the container.
208    ///
209    /// For left-to-right text ([`TextDirection::Ltr`]), this is the left edge.
210    ///
211    /// For right-to-left text ([`TextDirection::Rtl`]), this is the right edge.
212    Start,
213
214    /// Align the text on the trailing edge of the container.
215    ///
216    /// For left-to-right text ([`TextDirection::Ltr`]), this is the right edge.
217    ///
218    /// For right-to-left text ([`TextDirection::Rtl`]), this is the left edge.
219    End,
220}
221
222/// A horizontal line used for aligning text.
223#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
224pub enum TextBaseline {
225    /// The horizontal line used to align the bottom of glyphs for alphabetic characters.
226    ///
227    /// This baseline is often used for alphabetical scripts like Latin, Greek,
228    /// Cyrillic, etc.
229    ///
230    /// Characters with descenders (like 'p', 'g', or 'y') extend below this line.
231    Alphabetic,
232
233    /// The horizontal line used to align ideographic characters.
234    ///
235    /// This baseline is often used for scripts with uniform square heights,
236    /// like Chinese, Japanese, Korean, etc.
237    Ideographic,
238}
239
240/// A linear decoration to draw near the text.
241#[derive(Clone, Copy, PartialEq, Eq, Hash)]
242pub struct TextDecoration {
243    mask: i32,
244}
245
246impl TextDecoration {
247    /// Do not draw a decoration
248    pub const NONE: TextDecoration = TextDecoration { mask: 0x0 };
249
250    /// Draw a line underneath each line of text
251    pub const UNDERLINE: TextDecoration = TextDecoration { mask: 0x1 };
252
253    /// Draw a line above each line of text
254    pub const OVERLINE: TextDecoration = TextDecoration { mask: 0x2 };
255
256    /// Draw a line through each line of text
257    pub const LINE_THROUGH: TextDecoration = TextDecoration { mask: 0x4 };
258
259    /// Creates a decoration that paints the union of all the given decorations.
260    pub fn combine(decorations: &[TextDecoration]) -> TextDecoration {
261        let mut mask = 0;
262        for decoration in decorations {
263            mask |= decoration.mask;
264        }
265        TextDecoration { mask }
266    }
267
268    /// The bit mask value that represents this decoration.
269    pub fn mask_value(self) -> i32 {
270        self.mask
271    }
272
273    /// Whether this decoration will paint at least as much decoration as the given decoration.
274    pub fn contains(self, other: TextDecoration) -> bool {
275        (self.mask | other.mask) == self.mask
276    }
277}
278
279impl Debug for TextDecoration {
280    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281        Display::fmt(self, f)
282    }
283}
284
285impl Display for TextDecoration {
286    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287        if self.mask == 0 {
288            return write!(f, "TextDecoration.none");
289        }
290        let mut values = Vec::new();
291        if self.mask & TextDecoration::UNDERLINE.mask != 0 {
292            values.push("underline");
293        }
294        if self.mask & TextDecoration::OVERLINE.mask != 0 {
295            values.push("overline");
296        }
297        if self.mask & TextDecoration::LINE_THROUGH.mask != 0 {
298            values.push("lineThrough");
299        }
300        if values.len() == 1 {
301            write!(f, "TextDecoration.{}", values[0])
302        } else {
303            write!(f, "TextDecoration.combine([{}])", values.join(", "))
304        }
305    }
306}
307
308/// The style in which to draw a text decoration
309#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
310pub enum TextDecorationStyle {
311    /// Draw a solid line
312    Solid,
313
314    /// Draw two lines
315    Double,
316
317    /// Draw a dotted line
318    Dotted,
319
320    /// Draw a dashed line
321    Dashed,
322
323    /// Draw a sinusoidal line
324    Wavy,
325}
326
327/// How the leading of the text is distributed over and under the text.
328#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
329pub enum TextLeadingDistribution {
330    /// Distributes the leading of the text proportionally above and below the
331    /// text, to the font's ascent/descent ratio.
332    Proportional,
333
334    /// Distributes the leading of the text evenly above and below the text
335    /// (i.e. evenly above the font's ascender and below the descender).
336    ///
337    /// The leading can become negative when `TextStyle.height` is smaller than
338    /// 1.0.
339    ///
340    /// This is the default strategy used by CSS, known as "half-leading".
341    Even,
342}
343
344/// Defines how to apply `TextStyle.height` over and under text.
345#[derive(Clone, Copy, Debug, PartialEq)]
346pub struct TextHeightBehavior {
347    /// Whether to apply the `TextStyle.height` modifier to the ascent of the first
348    /// line in the paragraph.
349    pub apply_height_to_first_ascent: bool,
350
351    /// Whether to apply the `TextStyle.height` modifier to the descent of the last
352    /// line in the paragraph.
353    pub apply_height_to_last_descent: bool,
354
355    /// How the leading is distributed over and under the text.
356    pub leading_distribution: TextLeadingDistribution,
357}
358
359impl Default for TextHeightBehavior {
360    fn default() -> TextHeightBehavior {
361        TextHeightBehavior {
362            apply_height_to_first_ascent: true,
363            apply_height_to_last_descent: true,
364            leading_distribution: TextLeadingDistribution::Proportional,
365        }
366    }
367}
368
369/// A direction in which text flows.
370///
371/// Some languages are written from the left to the right (for example, English,
372/// Tamil, or Chinese), while others are written from the right to the left (for
373/// example Aramaic, Hebrew, or Urdu). Some are also written in a mixture, for
374/// example Arabic is mostly written right-to-left, with numerals written
375/// left-to-right.
376///
377/// The text direction must be provided to APIs that render text or lay out
378/// boxes horizontally, so that they can determine which direction to start in:
379/// either right-to-left, [`TextDirection::Rtl`]; or left-to-right,
380/// [`TextDirection::Ltr`].
381///
382/// ## Design discussion
383///
384/// Flutter is designed to address the needs of applications written in any of
385/// the world's currently-used languages, whether they use a right-to-left or
386/// left-to-right writing direction. Flutter does not support other writing
387/// modes, such as vertical text or boustrophedon text, as these are rarely used
388/// in computer programs.
389///
390/// It is common when developing user interface frameworks to pick a default
391/// text direction — typically left-to-right, the direction most familiar to the
392/// engineers working on the framework — because this simplifies the development
393/// of applications on the platform. Unfortunately, this frequently results in
394/// the platform having unexpected left-to-right biases or assumptions, as
395/// engineers will typically miss places where they need to support
396/// right-to-left text. This then results in bugs that only manifest in
397/// right-to-left environments.
398///
399/// In an effort to minimize the extent to which Flutter experiences this
400/// category of issues, the lowest levels of the Flutter framework do not have a
401/// default text reading direction. Any time a reading direction is necessary,
402/// for example when text is to be displayed, or when a
403/// writing-direction-dependent value is to be interpreted, the reading
404/// direction must be explicitly specified. Where possible, such as in `match`
405/// statements, the right-to-left case is listed first, to avoid the impression
406/// that it is an afterthought.
407///
408/// At the higher levels (specifically starting at the widgets library), an
409/// ambient `Directionality` is introduced, which provides a default. Thus, for
410/// instance, a `Text` widget in the scope of a `MaterialApp` widget does not
411/// need to be given an explicit writing direction. The `Directionality.of`
412/// static method can be used to obtain the ambient text direction for a
413/// particular `BuildContext`.
414///
415/// ### Known left-to-right biases in Flutter
416///
417/// Despite the design intent described above, certain left-to-right biases have
418/// nonetheless crept into Flutter's design. These include:
419///
420///  * The `Canvas` origin is at the top left, and the x-axis increases in a
421///    left-to-right direction.
422///
423///  * The default localization in the widgets and material libraries is
424///    American English, which is left-to-right.
425///
426/// ### Visual properties vs directional properties
427///
428/// Many classes in the Flutter framework are offered in two versions, a
429/// visually-oriented variant, and a text-direction-dependent variant. For
430/// example, `EdgeInsets` is described in terms of top, left, right, and
431/// bottom, while `EdgeInsetsDirectional` is described in terms of top, start,
432/// end, and bottom, where start and end correspond to right and left in
433/// right-to-left text and left and right in left-to-right text.
434///
435/// There are distinct use cases for each of these variants.
436///
437/// Text-direction-dependent variants are useful when developing user interfaces
438/// that should "flip" with the text direction. For example, a paragraph of text
439/// in English will typically be left-aligned and a quote will be indented from
440/// the left, while in Arabic it will be right-aligned and indented from the
441/// right. Both of these cases are described by the direction-dependent
442/// [`TextAlign::Start`] and `EdgeInsetsDirectional` start.
443///
444/// In contrast, the visual variants are useful when the text direction is known
445/// and not affected by the reading direction. For example, an application
446/// giving driving directions might show a "turn left" arrow on the left and a
447/// "turn right" arrow on the right — and would do so whether the application
448/// was localized to French (left-to-right) or Hebrew (right-to-left).
449///
450/// In practice, it is also expected that many developers will only be targeting
451/// one language, and in that case it may be simpler to think in visual terms.
452///
453/// The order of this enum must match the order of the values in
454/// `TextDirection.h`'s `TextDirection`.
455#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
456pub enum TextDirection {
457    /// The text flows from right to left (e.g. Arabic, Hebrew).
458    Rtl,
459
460    /// The text flows from left to right (e.g., English, French).
461    Ltr,
462}
463
464/// A feature tag and value that affect the selection of glyphs in a font.
465///
466/// Named constructors for individual OpenType tags (`alternative`, `fractions`,
467/// …) are deferred until a caller needs them. [`new`](FontFeature::new),
468/// [`enable`](FontFeature::enable), and [`disable`](FontFeature::disable) are
469/// the constructors Flutter's painting `TextStyle` uses.
470#[derive(Clone, Debug, PartialEq, Eq, Hash)]
471pub struct FontFeature {
472    /// The tag that identifies the effect of this feature. Must consist of 4
473    /// ASCII characters.
474    pub feature: String,
475    /// The value assigned to this feature.
476    ///
477    /// Must be a positive integer. Many features are Boolean values using 1
478    /// (on) and 0 (off).
479    pub value: i32,
480}
481
482impl FontFeature {
483    /// Creates a [`FontFeature`] object, which can be added to a text style to
484    /// change how the engine selects glyphs when rendering text.
485    pub fn new(feature: impl Into<String>, value: i32) -> FontFeature {
486        let feature = feature.into();
487        debug_assert!(
488            feature.len() == 4,
489            "Feature tag must be exactly four characters long."
490        );
491        debug_assert!(
492            value >= 0,
493            "Feature value must be zero or a positive integer."
494        );
495        FontFeature { feature, value }
496    }
497
498    /// Create a [`FontFeature`] object that enables the feature with the given
499    /// tag.
500    pub fn enable(feature: impl Into<String>) -> FontFeature {
501        FontFeature::new(feature, 1)
502    }
503
504    /// Create a [`FontFeature`] object that disables the feature with the given
505    /// tag.
506    pub fn disable(feature: impl Into<String>) -> FontFeature {
507        FontFeature::new(feature, 0)
508    }
509}
510
511impl Display for FontFeature {
512    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
513        write!(f, "FontFeature('{}', {})", self.feature, self.value)
514    }
515}
516
517/// An axis tag and value that can be used to customize variable fonts.
518///
519/// Named constructors besides [`italic`](FontVariation::italic),
520/// [`optical_size`](FontVariation::optical_size), [`slant`](FontVariation::slant),
521/// [`width`](FontVariation::width), and [`weight`](FontVariation::weight) are
522/// not added until a caller needs them.
523#[derive(Clone, Debug, PartialEq)]
524pub struct FontVariation {
525    /// The tag that identifies the design axis.
526    ///
527    /// An axis tag must consist of 4 ASCII characters.
528    pub axis: String,
529    /// The value assigned to this design axis.
530    pub value: f64,
531}
532
533impl FontVariation {
534    /// Creates a [`FontVariation`] object, which can be added to a text style to
535    /// change the variable attributes of a font.
536    pub fn new(axis: impl Into<String>, value: f64) -> FontVariation {
537        let axis = axis.into();
538        debug_assert!(
539            axis.len() == 4,
540            "Axis tag must be exactly four characters long."
541        );
542        debug_assert!(
543            (-32768.0..32768.0).contains(&value),
544            "Value must be representable as a signed 16.16 fixed-point number, i.e. it must be in this range: -32768.0 ≤ value < 32768.0"
545        );
546        FontVariation { axis, value }
547    }
548
549    /// Variable font style. (`ital`)
550    pub fn italic(value: f64) -> FontVariation {
551        debug_assert!((0.0..=1.0).contains(&value));
552        FontVariation::new("ital", value)
553    }
554
555    /// Optical size optimization. (`opsz`)
556    pub fn optical_size(value: f64) -> FontVariation {
557        debug_assert!(value > 0.0);
558        FontVariation::new("opsz", value)
559    }
560
561    /// Variable font slant. (`slnt`)
562    pub fn slant(value: f64) -> FontVariation {
563        debug_assert!(value > -90.0 && value < 90.0);
564        FontVariation::new("slnt", value)
565    }
566
567    /// Variable font width. (`wdth`)
568    pub fn width(value: f64) -> FontVariation {
569        debug_assert!(value >= 0.0);
570        FontVariation::new("wdth", value)
571    }
572
573    /// Variable font weight. (`wght`)
574    pub fn weight(value: f64) -> FontVariation {
575        debug_assert!((1.0..=1000.0).contains(&value));
576        FontVariation::new("wght", value)
577    }
578
579    /// Linearly interpolates between two font variations.
580    ///
581    /// If the two variations have different axis tags, the interpolation
582    /// switches abruptly from one to the other at t=0.5. Otherwise, the value
583    /// is interpolated.
584    pub fn lerp(
585        a: Option<&FontVariation>,
586        b: Option<&FontVariation>,
587        t: f64,
588    ) -> Option<FontVariation> {
589        if a.map(|v| v.axis.as_str()) != b.map(|v| v.axis.as_str()) || (a.is_none() && b.is_none())
590        {
591            return if t < 0.5 { a.cloned() } else { b.cloned() };
592        }
593        Some(FontVariation::new(
594            a.unwrap().axis.clone(),
595            clamp_double(
596                lerp_double(Some(a.unwrap().value), Some(b.unwrap().value), t).unwrap(),
597                -32768.0,
598                32768.0 - 1.0 / 65536.0,
599            ),
600        ))
601    }
602}
603
604impl Display for FontVariation {
605    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
606        write!(f, "FontVariation('{}', {})", self.axis, self.value)
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    #[test]
615    fn font_weight_lerp_treats_null_as_normal() {
616        assert!(FontWeight::lerp(None, None, 0.5).is_none());
617        assert_eq!(
618            FontWeight::lerp(None, Some(FontWeight::W700), 0.0),
619            Some(FontWeight::NORMAL)
620        );
621        assert_eq!(
622            FontWeight::lerp(Some(FontWeight::W100), Some(FontWeight::W900), 0.5),
623            Some(FontWeight::W500)
624        );
625    }
626
627    #[test]
628    fn text_decoration_combine_unions_masks() {
629        let combined =
630            TextDecoration::combine(&[TextDecoration::UNDERLINE, TextDecoration::LINE_THROUGH]);
631        assert!(combined.contains(TextDecoration::UNDERLINE));
632        assert!(combined.contains(TextDecoration::LINE_THROUGH));
633        assert!(!combined.contains(TextDecoration::OVERLINE));
634        assert_eq!(
635            format!("{combined}"),
636            "TextDecoration.combine([underline, lineThrough])"
637        );
638    }
639
640    #[test]
641    fn text_direction_lists_rtl_first() {
642        assert_eq!(TextDirection::Rtl as u8, 0);
643        assert_eq!(TextDirection::Ltr as u8, 1);
644    }
645
646    #[test]
647    fn font_variation_lerp_switches_axis_at_half() {
648        let a = FontVariation::weight(400.0);
649        let b = FontVariation::width(100.0);
650        assert_eq!(
651            FontVariation::lerp(Some(&a), Some(&b), 0.4).unwrap().axis,
652            "wght"
653        );
654        assert_eq!(
655            FontVariation::lerp(Some(&a), Some(&b), 0.6).unwrap().axis,
656            "wdth"
657        );
658    }
659
660    #[test]
661    fn font_variation_lerp_interpolates_same_axis() {
662        let a = FontVariation::weight(100.0);
663        let b = FontVariation::weight(900.0);
664        let mid = FontVariation::lerp(Some(&a), Some(&b), 0.5).unwrap();
665        assert_eq!(mid.axis, "wght");
666        assert!((mid.value - 500.0).abs() < 1e-9);
667    }
668}