Skip to main content

ferroui_base/media/
font_feature.rs

1use std::fmt;
2use std::str::FromStr;
3
4const DEFAULT_VALUE: i32 = 1;
5const INFINITY_END: i32 = -1;
6
7/// Font feature.
8#[derive(Clone, Debug, PartialEq, Eq, Hash)]
9pub struct FontFeature {
10    /// Gets or sets the tag.
11    pub tag: String,
12    /// Gets or sets the value.
13    pub value: i32,
14    /// Gets or sets the start.
15    pub start: i32,
16    /// Gets or sets the end.
17    pub end: i32,
18}
19
20impl Default for FontFeature {
21    /// Creates an instance of `FontFeature`.
22    fn default() -> Self {
23        Self { tag: String::new(), value: DEFAULT_VALUE, start: 0, end: INFINITY_END }
24    }
25}
26
27/// The pieces matched by the feature grammar.
28struct FeatureMatch<'a> {
29    tag: &'a str,
30    value: &'a str,
31    start: &'a str,
32    end: &'a str,
33    has_separator: bool,
34}
35
36/// A cursor over the characters of the input.
37struct Cursor<'a> {
38    s: &'a str,
39    position: usize,
40}
41
42impl<'a> Cursor<'a> {
43    fn rest(&self) -> &'a str {
44        &self.s[self.position..]
45    }
46
47    fn skip_whitespace(&mut self) {
48        let rest = self.rest();
49        self.position += rest.len() - rest.trim_start().len();
50    }
51
52    fn eat(&mut self, c: char) -> bool {
53        if self.rest().starts_with(c) {
54            self.position += c.len_utf8();
55            true
56        } else {
57            false
58        }
59    }
60
61    fn digits(&mut self) -> &'a str {
62        let rest = self.rest();
63        let length = rest.find(|c: char| !c.is_numeric()).unwrap_or(rest.len());
64        self.position += length;
65        &rest[..length]
66    }
67}
68
69fn is_word_char(c: char) -> bool {
70    c.is_alphanumeric() || c == '_'
71}
72
73/// Matches
74/// `^\s*(?<Value>[+-])?\s*(?<Tag>\w{4})\s*(\[\s*(?<Start>\d+)?(\s*(?<Separator>:)\s*)?(?<End>\d+)?\s*\])?\s*(?(Value)()|(=\s*(?<Value>\d+|on|off)))?\s*$`.
75fn match_feature(s: &str) -> Option<FeatureMatch<'_>> {
76    let mut cursor = Cursor { s, position: 0 };
77
78    cursor.skip_whitespace();
79
80    let mut value = "";
81    if cursor.rest().starts_with(['+', '-']) {
82        value = &cursor.rest()[..1];
83        cursor.position += 1;
84    }
85
86    cursor.skip_whitespace();
87
88    let tag = {
89        let rest = cursor.rest();
90        let mut end = 0;
91        let mut count = 0;
92        for c in rest.chars() {
93            if count == 4 || !is_word_char(c) {
94                break;
95            }
96            end += c.len_utf8();
97            count += 1;
98        }
99        if count != 4 {
100            return None;
101        }
102        cursor.position += end;
103        &rest[..end]
104    };
105
106    cursor.skip_whitespace();
107
108    let mut start = "";
109    let mut end = "";
110    let mut has_separator = false;
111
112    if cursor.eat('[') {
113        cursor.skip_whitespace();
114        start = cursor.digits();
115
116        let before_separator = cursor.position;
117        cursor.skip_whitespace();
118        if cursor.eat(':') {
119            has_separator = true;
120            cursor.skip_whitespace();
121        } else {
122            cursor.position = before_separator;
123        }
124
125        end = cursor.digits();
126        cursor.skip_whitespace();
127
128        if !cursor.eat(']') {
129            return None;
130        }
131    }
132
133    cursor.skip_whitespace();
134
135    if value.is_empty() && cursor.eat('=') {
136        cursor.skip_whitespace();
137        let digits = cursor.digits();
138        if !digits.is_empty() {
139            value = digits;
140        } else if cursor.rest().starts_with("on") {
141            value = "on";
142            cursor.position += 2;
143        } else if cursor.rest().starts_with("off") {
144            value = "off";
145            cursor.position += 3;
146        } else {
147            return None;
148        }
149    }
150
151    cursor.skip_whitespace();
152
153    if !cursor.rest().is_empty() {
154        return None;
155    }
156
157    Some(FeatureMatch { tag, value, start, end, has_separator })
158}
159
160/// `int.TryParse(s, NumberStyles.None, InvariantCulture)`: ASCII digits only.
161fn try_parse_digits(s: &str) -> Option<i32> {
162    if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
163        return None;
164    }
165    s.parse().ok()
166}
167
168impl FontFeature {
169    /// Parses a string to return a `FontFeature`.
170    ///
171    /// Syntax is the following:
172    ///
173    /// * Syntax — Value — Start — End
174    /// * `kern` — 1 — 0 — ∞ — Turn feature on
175    /// * `+kern` — 1 — 0 — ∞ — Turn feature on
176    /// * `-kern` — 0 — 0 — ∞ — Turn feature off
177    /// * `kern=0` — 0 — 0 — ∞ — Turn feature off
178    /// * `kern=1` — 1 — 0 — ∞ — Turn feature on
179    /// * `aalt=2` — 2 — 0 — ∞ — Choose 2nd alternate
180    /// * `kern[]` — 1 — 0 — ∞ — Turn feature on
181    /// * `kern[:]` — 1 — 0 — ∞ — Turn feature on
182    /// * `kern[5:]` — 1 — 5 — ∞ — Turn feature on, partial
183    /// * `kern[:5]` — 1 — 0 — 5 — Turn feature on, partial
184    /// * `kern[3:5]` — 1 — 3 — 5 — Turn feature on, range
185    /// * `kern[3]` — 1 — 3 — 3+1 — Turn feature on, single char
186    /// * `aalt[3:5]=2` — 2 — 3 — 5 — Turn 2nd alternate on for range
187    ///
188    /// A string that does not match gives the default (empty tag) feature.
189    pub fn parse(s: &str) -> FontFeature {
190        let Some(matched) = match_feature(s) else {
191            return FontFeature::default();
192        };
193
194        let start = try_parse_digits(matched.start);
195        let end = try_parse_digits(matched.end);
196
197        let mut string_value = matched.value;
198
199        if string_value == "-" || string_value.eq_ignore_ascii_case("OFF") {
200            string_value = "0";
201        }
202
203        if string_value == "+" || string_value.eq_ignore_ascii_case("ON") {
204            string_value = "1";
205        }
206
207        FontFeature {
208            tag: matched.tag.to_owned(),
209            start: start.unwrap_or(0),
210            end: match (end, start) {
211                (Some(end), _) => end,
212                (None, Some(start)) if !matched.has_separator => start + 1,
213                _ => INFINITY_END,
214            },
215            value: try_parse_digits(string_value).unwrap_or(DEFAULT_VALUE),
216        }
217    }
218}
219
220impl FromStr for FontFeature {
221    type Err = std::convert::Infallible;
222
223    fn from_str(s: &str) -> Result<Self, Self::Err> {
224        Ok(FontFeature::parse(s))
225    }
226}
227
228/// Gets a string representation of the `FontFeature`.
229impl fmt::Display for FontFeature {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        if self.value == 0 {
232            f.write_str("-")?;
233        }
234        f.write_str(&self.tag)?;
235
236        if self.start != 0 || self.end != INFINITY_END {
237            f.write_str("[")?;
238
239            if self.start > 0 {
240                write!(f, "{}", self.start)?;
241            }
242
243            if self.end != self.start + 1 {
244                f.write_str(":")?;
245                if self.end != INFINITY_END {
246                    write!(f, "{}", self.end)?;
247                }
248            }
249
250            f.write_str("]")?;
251        }
252
253        if self.value == DEFAULT_VALUE || self.value == 0 {
254            return Ok(());
255        }
256
257        write!(f, "={}", self.value)
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    fn feature(tag: &str, value: i32, start: i32, end: i32) -> FontFeature {
266        FontFeature { tag: tag.to_owned(), value, start, end }
267    }
268
269    #[test]
270    fn parses_documented_syntax() {
271        assert_eq!(FontFeature::parse("kern"), feature("kern", 1, 0, -1));
272        assert_eq!(FontFeature::parse("+kern"), feature("kern", 1, 0, -1));
273        assert_eq!(FontFeature::parse("-kern"), feature("kern", 0, 0, -1));
274        assert_eq!(FontFeature::parse("kern=0"), feature("kern", 0, 0, -1));
275        assert_eq!(FontFeature::parse("kern=1"), feature("kern", 1, 0, -1));
276        assert_eq!(FontFeature::parse("aalt=2"), feature("aalt", 2, 0, -1));
277        assert_eq!(FontFeature::parse("kern[]"), feature("kern", 1, 0, -1));
278        assert_eq!(FontFeature::parse("kern[:]"), feature("kern", 1, 0, -1));
279        assert_eq!(FontFeature::parse("kern[5:]"), feature("kern", 1, 5, -1));
280        assert_eq!(FontFeature::parse("kern[:5]"), feature("kern", 1, 0, 5));
281        assert_eq!(FontFeature::parse("kern[3:5]"), feature("kern", 1, 3, 5));
282        assert_eq!(FontFeature::parse("kern[3]"), feature("kern", 1, 3, 4));
283        assert_eq!(FontFeature::parse("aalt[3:5]=2"), feature("aalt", 2, 3, 5));
284        assert_eq!(FontFeature::parse(" liga = off "), feature("liga", 0, 0, -1));
285        assert_eq!(FontFeature::parse("liga=on"), feature("liga", 1, 0, -1));
286    }
287
288    #[test]
289    fn invalid_input_gives_default() {
290        assert_eq!(FontFeature::parse("kerning"), FontFeature::default());
291        assert_eq!(FontFeature::parse("ker"), FontFeature::default());
292        assert_eq!(FontFeature::parse("+kern=1"), FontFeature::default());
293        assert_eq!(FontFeature::parse("kern[1 2]"), FontFeature::default());
294    }
295
296    #[test]
297    fn display_round_trips() {
298        for s in ["kern", "-kern", "aalt=2", "kern[5:]", "kern[:5]", "kern[3:5]", "kern[3]", "aalt[3:5]=2"] {
299            assert_eq!(FontFeature::parse(s).to_string(), s);
300        }
301    }
302}