diff --git a/parley/src/resolve/mod.rs b/parley/src/resolve/mod.rs index a75ff65c..6c35da4a 100644 --- a/parley/src/resolve/mod.rs +++ b/parley/src/resolve/mod.rs @@ -11,8 +11,8 @@ pub(crate) use range::RangedStyleBuilder; use alloc::{vec, vec::Vec}; use super::style::{ - Brush, FontFamily, FontFamilyName, FontFeature, FontSettings, FontStyle, FontVariation, - FontWeight, FontWidth, StyleProperty, + Brush, FontFamily, FontFamilyName, FontFeature, FontFeatures, FontStyle, FontVariation, + FontVariations, FontWeight, FontWidth, StyleProperty, }; use crate::font::FontContext; use crate::style::TextStyle; @@ -262,15 +262,15 @@ impl ResolveContext { /// Resolves font variation settings. pub(crate) fn resolve_variations( &mut self, - variations: &FontSettings<'_, FontVariation>, + variations: &FontVariations<'_>, ) -> Resolved { match variations { - FontSettings::Source(source) => { + FontVariations::Source(source) => { self.tmp_variations.clear(); self.tmp_variations - .extend(FontVariation::parse_list(source)); + .extend(FontVariation::parse_css_list(source).map_while(Result::ok)); } - FontSettings::List(settings) => { + FontVariations::List(settings) => { self.tmp_variations.clear(); self.tmp_variations.extend_from_slice(settings); } @@ -287,14 +287,15 @@ impl ResolveContext { /// Resolves font feature settings. pub(crate) fn resolve_features( &mut self, - features: &FontSettings<'_, FontFeature>, + features: &FontFeatures<'_>, ) -> Resolved { match features { - FontSettings::Source(source) => { + FontFeatures::Source(source) => { self.tmp_features.clear(); - self.tmp_features.extend(FontFeature::parse_list(source)); + self.tmp_features + .extend(FontFeature::parse_css_list(source).map_while(Result::ok)); } - FontSettings::List(settings) => { + FontFeatures::List(settings) => { self.tmp_features.clear(); self.tmp_features.extend_from_slice(settings); } diff --git a/parley/src/setting.rs b/parley/src/setting.rs index b991354b..3a0f02b6 100644 --- a/parley/src/setting.rs +++ b/parley/src/setting.rs @@ -3,4 +3,4 @@ //! OpenType settings (features and variations). -pub use text_primitives::{Setting, Tag}; +pub use text_primitives::{FontFeature, FontVariation, Tag}; diff --git a/parley/src/style/font.rs b/parley/src/style/font.rs index 158b1dd8..57f8007c 100644 --- a/parley/src/style/font.rs +++ b/parley/src/style/font.rs @@ -2,50 +2,75 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT use alloc::borrow::Cow; -use alloc::borrow::ToOwned; -use core::fmt; - -use crate::setting::Setting; +pub use crate::setting::{FontFeature, FontVariation}; pub use fontique::{FontStyle, FontWeight, FontWidth, GenericFamily}; pub use text_primitives::{FontFamily, FontFamilyName}; -/// Setting for a font variation. -pub type FontVariation = Setting; +/// Font variation settings that can be supplied as a raw source string or a parsed slice. +#[derive(Clone, PartialEq, Debug)] +pub enum FontVariations<'a> { + /// Setting source in CSS format. + Source(Cow<'a, str>), + /// List of settings. + List(Cow<'a, [FontVariation]>), +} + +impl<'a> FontVariations<'a> { + /// Creates an empty list of font variations. + pub const fn empty() -> Self { + Self::List(Cow::Borrowed(&[])) + } +} + +impl<'a> From<&'a str> for FontVariations<'a> { + fn from(value: &'a str) -> Self { + Self::Source(Cow::Borrowed(value)) + } +} + +impl<'a> From<&'a [FontVariation]> for FontVariations<'a> { + fn from(value: &'a [FontVariation]) -> Self { + Self::List(Cow::Borrowed(value)) + } +} -/// Setting for a font feature. -pub type FontFeature = Setting; +impl<'a, const N: usize> From<&'a [FontVariation; N]> for FontVariations<'a> { + fn from(value: &'a [FontVariation; N]) -> Self { + Self::List(Cow::Borrowed(&value[..])) + } +} -/// Font settings that can be supplied as a raw source string or -/// a parsed slice. +/// Font feature settings that can be supplied as a raw source string or a parsed slice. #[derive(Clone, PartialEq, Debug)] -pub enum FontSettings<'a, T> -where - [T]: ToOwned, - <[T] as ToOwned>::Owned: fmt::Debug + PartialEq + Clone, -{ +pub enum FontFeatures<'a> { /// Setting source in CSS format. Source(Cow<'a, str>), /// List of settings. - List(Cow<'a, [T]>), + List(Cow<'a, [FontFeature]>), } -impl<'a, T> From<&'a str> for FontSettings<'a, T> -where - [T]: ToOwned, - <[T] as ToOwned>::Owned: fmt::Debug + PartialEq + Clone, -{ +impl<'a> FontFeatures<'a> { + /// Creates an empty list of font features. + pub const fn empty() -> Self { + Self::List(Cow::Borrowed(&[])) + } +} + +impl<'a> From<&'a str> for FontFeatures<'a> { fn from(value: &'a str) -> Self { Self::Source(Cow::Borrowed(value)) } } -impl<'a, T> From<&'a [T]> for FontSettings<'a, T> -where - [T]: ToOwned, - <[T] as ToOwned>::Owned: fmt::Debug + PartialEq + Clone, -{ - fn from(value: &'a [T]) -> Self { +impl<'a> From<&'a [FontFeature]> for FontFeatures<'a> { + fn from(value: &'a [FontFeature]) -> Self { Self::List(Cow::Borrowed(value)) } } + +impl<'a, const N: usize> From<&'a [FontFeature; N]> for FontFeatures<'a> { + fn from(value: &'a [FontFeature; N]) -> Self { + Self::List(Cow::Borrowed(&value[..])) + } +} diff --git a/parley/src/style/mod.rs b/parley/src/style/mod.rs index 930f074d..328121bc 100644 --- a/parley/src/style/mod.rs +++ b/parley/src/style/mod.rs @@ -11,8 +11,8 @@ use alloc::borrow::Cow; pub use brush::*; pub use font::{ - FontFamily, FontFamilyName, FontFeature, FontSettings, FontStyle, FontVariation, FontWeight, - FontWidth, GenericFamily, + FontFamily, FontFamilyName, FontFeature, FontFeatures, FontStyle, FontVariation, + FontVariations, FontWeight, FontWidth, GenericFamily, }; pub use styleset::StyleSet; pub use text_primitives::{OverflowWrap, TextWrapMode, WordBreak}; @@ -81,9 +81,9 @@ pub enum StyleProperty<'a, B: Brush> { /// Font weight. FontWeight(FontWeight), /// Font variation settings. - FontVariations(FontSettings<'a, FontVariation>), + FontVariations(FontVariations<'a>), /// Font feature settings. - FontFeatures(FontSettings<'a, FontFeature>), + FontFeatures(FontFeatures<'a>), /// Locale. Locale(Option<&'a str>), /// Brush for rendering text. @@ -132,9 +132,9 @@ pub struct TextStyle<'a, B: Brush> { /// Font weight. pub font_weight: FontWeight, /// Font variation settings. - pub font_variations: FontSettings<'a, FontVariation>, + pub font_variations: FontVariations<'a>, /// Font feature settings. - pub font_features: FontSettings<'a, FontFeature>, + pub font_features: FontFeatures<'a>, /// Locale. pub locale: Option<&'a str>, /// Brush for rendering text. @@ -177,8 +177,8 @@ impl Default for TextStyle<'_, B> { font_width: FontWidth::default(), font_style: FontStyle::default(), font_weight: FontWeight::default(), - font_variations: FontSettings::List(Cow::Borrowed(&[])), - font_features: FontSettings::List(Cow::Borrowed(&[])), + font_variations: FontVariations::empty(), + font_features: FontFeatures::empty(), locale: None, brush: B::default(), has_underline: false, @@ -217,6 +217,18 @@ impl<'a, B: Brush> From> for StyleProperty<'a, B> { } } +impl<'a, B: Brush> From> for StyleProperty<'a, B> { + fn from(value: FontVariations<'a>) -> Self { + StyleProperty::FontVariations(value) + } +} + +impl<'a, B: Brush> From> for StyleProperty<'a, B> { + fn from(value: FontFeatures<'a>) -> Self { + StyleProperty::FontFeatures(value) + } +} + impl From for StyleProperty<'_, B> { fn from(f: GenericFamily) -> Self { StyleProperty::FontFamily(f.into()) diff --git a/parley/src/tests/test_basic.rs b/parley/src/tests/test_basic.rs index 32e708f6..3ac0b3b9 100644 --- a/parley/src/tests/test_basic.rs +++ b/parley/src/tests/test_basic.rs @@ -1,8 +1,6 @@ // Copyright 2024 the Parley Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use std::borrow::Cow; - use peniko::{ color::{AlphaColor, Srgb, palette}, kurbo::Size, @@ -11,10 +9,10 @@ use peniko::{ use super::utils::{ ColorBrush, FONT_FAMILY_LIST, TestEnv, asserts::assert_eq_layout_data_alignments, }; -use crate::setting::Setting; +use crate::setting::{FontFeature, FontVariation}; use crate::{ - Alignment, AlignmentOptions, ContentWidths, FontFamily, FontSettings, InlineBox, Layout, - LineHeight, StyleProperty, TextStyle, WhiteSpaceCollapse, test_name, + Alignment, AlignmentOptions, ContentWidths, FontFamily, FontFeatures, FontVariations, + InlineBox, Layout, LineHeight, StyleProperty, TextStyle, WhiteSpaceCollapse, test_name, }; #[test] @@ -615,17 +613,17 @@ fn font_features() { let text = "fi ".repeat(4); let mut builder = env.ranged_builder(&text); builder.push( - StyleProperty::FontFeatures(FontSettings::List(Cow::Borrowed(&[Setting { + FontFeatures::from(&[FontFeature { tag: crate::setting::Tag::new(b"liga"), value: 1, - }]))), + }]), 0..5, ); builder.push( - StyleProperty::FontFeatures(FontSettings::List(Cow::Borrowed(&[Setting { + FontFeatures::from(&[FontFeature { tag: crate::setting::Tag::new(b"liga"), value: 0, - }]))), + }]), 5..10, ); let mut layout = builder.build(&text); @@ -643,12 +641,10 @@ fn variable_fonts() { for wght in [100., 500., 1000.] { let mut builder = env.ranged_builder(text); builder.push_default(FontFamily::named("Arimo")); - builder.push_default(StyleProperty::FontVariations(FontSettings::List( - Cow::Borrowed(&[Setting { - tag: crate::setting::Tag::new(b"wght"), - value: wght, - }]), - ))); + builder.push_default(FontVariations::from(&[FontVariation::new( + crate::setting::Tag::new(b"wght"), + wght, + )])); let mut layout = builder.build(text); layout.break_all_lines(Some(100.0)); layout.align(None, Alignment::Start, AlignmentOptions::default()); diff --git a/parley/src/tests/test_builders.rs b/parley/src/tests/test_builders.rs index f47bbb77..810cf3b3 100644 --- a/parley/src/tests/test_builders.rs +++ b/parley/src/tests/test_builders.rs @@ -5,14 +5,13 @@ use fontique::{FontStyle, FontWeight, FontWidth}; use peniko::color::palette; -use std::borrow::Cow; use super::utils::{ ColorBrush, FONT_FAMILY_LIST, asserts::assert_eq_layout_data, create_font_context, }; use crate::{ - FontContext, FontFamily, FontSettings, Layout, LayoutContext, LineHeight, OverflowWrap, - RangedBuilder, StyleProperty, TextStyle, TextWrapMode, TreeBuilder, WordBreak, + FontContext, FontFamily, FontFeatures, FontVariations, Layout, LayoutContext, LineHeight, + OverflowWrap, RangedBuilder, StyleProperty, TextStyle, TextWrapMode, TreeBuilder, WordBreak, }; /// Set of options for [`build_layout_with_ranged`]. @@ -168,8 +167,8 @@ fn create_root_style() -> TextStyle<'static, ColorBrush> { font_width: FontWidth::CONDENSED, font_style: FontStyle::Italic, font_weight: FontWeight::BOLD, - font_variations: FontSettings::List(Cow::Borrowed(&[])), // TODO: Set a non-default value - font_features: FontSettings::List(Cow::Borrowed(&[])), // TODO: Set a non-default value + font_variations: FontVariations::empty(), // TODO: Set a non-default value + font_features: FontFeatures::empty(), // TODO: Set a non-default value locale: Some("en-US"), brush: ColorBrush::new(palette::css::GREEN), has_underline: true, @@ -198,12 +197,8 @@ fn set_root_style(rb: &mut RangedBuilder<'_, ColorBrush>) { rb.push_default(StyleProperty::FontWidth(FontWidth::CONDENSED)); rb.push_default(StyleProperty::FontStyle(FontStyle::Italic)); rb.push_default(StyleProperty::FontWeight(FontWeight::BOLD)); - rb.push_default(StyleProperty::FontVariations(FontSettings::List( - Cow::Borrowed(&[]), - ))); - rb.push_default(StyleProperty::FontFeatures(FontSettings::List( - Cow::Borrowed(&[]), - ))); + rb.push_default(FontVariations::empty()); + rb.push_default(FontFeatures::empty()); rb.push_default(StyleProperty::Locale(Some("en-US"))); rb.push_default(StyleProperty::Brush(ColorBrush::new(palette::css::GREEN))); rb.push_default(StyleProperty::Underline(true)); diff --git a/text_primitives/src/lib.rs b/text_primitives/src/lib.rs index 9e3b9d9e..eeebb0ad 100644 --- a/text_primitives/src/lib.rs +++ b/text_primitives/src/lib.rs @@ -53,5 +53,5 @@ pub use font::{FontStyle, FontWeight, FontWidth}; pub use font_family::{FontFamily, FontFamilyName, ParseFontFamilyError, ParseFontFamilyErrorKind}; pub use generic_family::GenericFamily; pub use language::{Language, ParseLanguageError}; -pub use tag::{Setting, Tag}; +pub use tag::{FontFeature, FontVariation, ParseSettingsError, ParseSettingsErrorKind, Tag}; pub use text::{BaseDirection, OverflowWrap, TextWrapMode, WordBreak}; diff --git a/text_primitives/src/tag.rs b/text_primitives/src/tag.rs index c0b04cc6..220ba83c 100644 --- a/text_primitives/src/tag.rs +++ b/text_primitives/src/tag.rs @@ -45,88 +45,237 @@ impl fmt::Display for Tag { } } -/// A single OpenType setting (tag + value). +/// Kinds of errors that can occur when parsing OpenType settings source strings. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum ParseSettingsErrorKind { + /// The source string does not conform to the supported syntax. + InvalidSyntax, + /// A quoted tag was invalid. + InvalidTag, + /// A numeric value was out of range for the target type. + OutOfRange, +} + +/// Error returned when parsing OpenType settings source strings. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ParseSettingsError { + kind: ParseSettingsErrorKind, + at: usize, + span: Option<(usize, usize)>, +} + +impl ParseSettingsError { + const fn new(kind: ParseSettingsErrorKind, at: usize) -> Self { + Self { + kind, + at, + span: None, + } + } + + const fn with_span(mut self, span: (usize, usize)) -> Self { + self.span = Some(span); + self + } + + /// Returns the error kind. + pub const fn kind(self) -> ParseSettingsErrorKind { + self.kind + } + + /// Returns the byte offset into the source where the error was detected. + pub const fn byte_offset(self) -> usize { + self.at + } + + /// Returns the byte span (start, end) for the token associated with this error, if available. + pub const fn byte_span(self) -> Option<(usize, usize)> { + self.span + } +} + +impl fmt::Display for ParseSettingsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let msg = match self.kind { + ParseSettingsErrorKind::InvalidSyntax => "invalid settings syntax", + ParseSettingsErrorKind::InvalidTag => "invalid OpenType tag", + ParseSettingsErrorKind::OutOfRange => "value out of range", + }; + write!(f, "{msg} at byte {}", self.at) + } +} + +impl core::error::Error for ParseSettingsError {} + +/// OpenType font feature setting (tag + `u16` value). #[derive(Clone, Copy, PartialEq, Debug)] -pub struct Setting { +pub struct FontFeature { /// The OpenType tag for this setting. pub tag: Tag, - /// The setting value. - pub value: T, + /// The feature value. + pub value: u16, } -impl Setting { - /// Creates a new setting. - pub const fn new(tag: Tag, value: T) -> Self { +impl FontFeature { + /// Creates a new feature setting. + pub const fn new(tag: Tag, value: u16) -> Self { Self { tag, value } } -} -impl Setting { /// Parses a comma-separated list of feature settings according to the CSS grammar. - pub fn parse_list(s: &str) -> impl Iterator + '_ + Clone { - ParseList::new(s) - .map(|(_, tag, value_str)| { - let (ok, value) = match value_str { - "on" | "" => (true, 1), - "off" => (true, 0), - _ => match value_str.parse::() { - Ok(value) => (true, value), - _ => (false, 0), - }, - }; - (ok, tag, value) - }) - .take_while(|(ok, _, _)| *ok) - .map(|(_, tag, value)| Self { tag, value }) - } -} - -impl Setting { + /// + /// On success, yields a sequence of settings. On failure, yields a [`ParseSettingsError`]. + /// + /// Supported syntax is a comma-separated list of entries: + /// - tags are required and must be quoted: `"liga" on` or `'liga' on` + /// - values are optional: + /// - `on`/omitted => `1` + /// - `off` => `0` + /// - a numeric value is parsed as `u16` + /// + /// Grammar (simplified): + /// `list := ws? entry (ws? ',' ws? entry)* (ws? ',')?` + /// + /// Whitespace is ignored and a trailing comma is permitted, but empty entries (such as `,,`) + /// are rejected. + pub fn parse_css_list( + s: &str, + ) -> impl Iterator> + '_ + Clone { + ParseCssList::new(s).map(|parsed| { + let (tag, value_str, value_at) = parsed?; + let span = (value_at, value_at + value_str.len()); + let value = parse_u16_feature_value(value_str) + .map_err(|kind| ParseSettingsError::new(kind, value_at).with_span(span))?; + Ok(Self { tag, value }) + }) + } +} + +fn parse_u16_feature_value(value_str: &str) -> Result { + match value_str { + "" | "on" => Ok(1), + "off" => Ok(0), + _ => { + if !value_str.as_bytes().iter().all(|b| b.is_ascii_digit()) { + return Err(ParseSettingsErrorKind::InvalidSyntax); + } + let mut value: u32 = 0; + for &b in value_str.as_bytes() { + let digit = (b - b'0') as u32; + value = value + .checked_mul(10) + .and_then(|v| v.checked_add(digit)) + .ok_or(ParseSettingsErrorKind::OutOfRange)?; + if value > u16::MAX as u32 { + return Err(ParseSettingsErrorKind::OutOfRange); + } + } + u16::try_from(value).map_err(|_| ParseSettingsErrorKind::OutOfRange) + } + } +} + +/// OpenType font variation setting (tag + `f32` value). +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct FontVariation { + /// The OpenType tag for this setting. + pub tag: Tag, + /// The variation value. + pub value: f32, +} + +impl FontVariation { + /// Creates a new variation setting. + pub const fn new(tag: Tag, value: f32) -> Self { + Self { tag, value } + } + /// Parses a comma-separated list of variation settings according to the CSS grammar. - pub fn parse_list(s: &str) -> impl Iterator + '_ + Clone { - ParseList::new(s) - .map(|(_, tag, value_str)| { - let (ok, value) = match value_str.parse::() { - Ok(value) => (true, value), - _ => (false, 0.0), - }; - (ok, tag, value) - }) - .take_while(|(ok, _, _)| *ok) - .map(|(_, tag, value)| Self { tag, value }) + /// + /// On success, yields a sequence of settings. On failure, yields a [`ParseSettingsError`]. + /// + /// Supported syntax is a comma-separated list of entries: + /// - tags are required and must be quoted: `"wght" 700` or `'wght' 700` + /// - values are required and are parsed as `f32` + /// + /// Grammar (simplified): + /// `list := ws? entry (ws? ',' ws? entry)* (ws? ',')?` + /// + /// Whitespace is ignored and a trailing comma is permitted, but empty entries (such as `,,`) + /// are rejected. + pub fn parse_css_list( + s: &str, + ) -> impl Iterator> + '_ + Clone { + ParseCssList::new(s).map(|parsed| { + let (tag, value_str, value_at) = parsed?; + let span = (value_at, value_at + value_str.len()); + if value_str.is_empty() { + return Err(ParseSettingsError::new( + ParseSettingsErrorKind::InvalidSyntax, + value_at, + )); + } + let value = value_str.parse::().map_err(|_| { + ParseSettingsError::new(ParseSettingsErrorKind::InvalidSyntax, value_at) + .with_span(span) + })?; + Ok(Self { tag, value }) + }) + } +} + +fn trim_ascii_whitespace(bytes: &[u8], mut start: usize, mut end: usize) -> (usize, usize) { + while start < end && bytes[start].is_ascii_whitespace() { + start += 1; + } + while end > start && bytes[end - 1].is_ascii_whitespace() { + end -= 1; } + (start, end) } #[derive(Clone)] -struct ParseList<'a> { +struct ParseCssList<'a> { source: &'a [u8], len: usize, pos: usize, + done: bool, } -impl<'a> ParseList<'a> { +impl<'a> ParseCssList<'a> { fn new(source: &'a str) -> Self { Self { source: source.as_bytes(), len: source.len(), pos: 0, + done: false, } } } -impl<'a> Iterator for ParseList<'a> { - type Item = (usize, Tag, &'a str); +impl<'a> Iterator for ParseCssList<'a> { + type Item = Result<(Tag, &'a str, usize), ParseSettingsError>; fn next(&mut self) -> Option { + if self.done { + return None; + } + let mut pos = self.pos; - while pos < self.len && { - let ch = self.source[pos]; - ch.is_ascii_whitespace() || ch == b',' - } { + while pos < self.len && self.source[pos].is_ascii_whitespace() { pos += 1; } + if pos < self.len && self.source[pos] == b',' { + self.done = true; + return Some(Err(ParseSettingsError::new( + ParseSettingsErrorKind::InvalidSyntax, + pos, + ))); + } self.pos = pos; if pos >= self.len { + self.done = true; return None; } let first = self.source[pos]; @@ -137,44 +286,244 @@ impl<'a> Iterator for ParseList<'a> { start += 1; first } - _ => return None, + _ => { + self.done = true; + return Some(Err(ParseSettingsError::new( + ParseSettingsErrorKind::InvalidSyntax, + pos, + ))); + } }; + let mut tag_str = None; while pos < self.len { if self.source[pos] == quote { - tag_str = core::str::from_utf8(self.source.get(start..pos)?).ok(); + tag_str = Some(pos); pos += 1; break; } pos += 1; } - self.pos = pos; - let tag_str = tag_str?; - if !tag_str.is_ascii() { - return None; + if tag_str.is_none() { + self.done = true; + return Some(Err(ParseSettingsError::new( + ParseSettingsErrorKind::InvalidSyntax, + start.saturating_sub(1), + ))); } - let tag = Tag::new(&tag_str.as_bytes().try_into().ok()?); - while pos < self.len { - if !self.source[pos].is_ascii_whitespace() { - break; + self.pos = pos; + + let end = tag_str.unwrap(); + let tag_bytes = match self.source.get(start..end) { + Some(bytes) => bytes, + None => { + self.done = true; + return Some(Err(ParseSettingsError::new( + ParseSettingsErrorKind::InvalidSyntax, + start, + ))); + } + }; + let tag_str = match core::str::from_utf8(tag_bytes) { + Ok(s) => s, + Err(_) => { + self.done = true; + return Some(Err(ParseSettingsError::new( + ParseSettingsErrorKind::InvalidSyntax, + start, + ))); + } + }; + let tag = match Tag::parse(tag_str) { + Some(tag) => tag, + None => { + self.done = true; + return Some(Err(ParseSettingsError::new( + ParseSettingsErrorKind::InvalidTag, + start, + ) + .with_span((start, end)))); } + }; + + while pos < self.len && self.source[pos].is_ascii_whitespace() { pos += 1; } - self.pos = pos; start = pos; - let mut end = start; + let mut value_end = start; while pos < self.len { if self.source[pos] == b',' { pos += 1; break; } pos += 1; - end += 1; + value_end += 1; } - let value = core::str::from_utf8(self.source.get(start..end)?) - .ok()? - .trim(); self.pos = pos; - Some((pos, tag, value)) + + let (trim_start, trim_end) = trim_ascii_whitespace(self.source, start, value_end); + let value_slice = match self.source.get(trim_start..trim_end) { + Some(slice) => slice, + None => { + self.done = true; + return Some(Err(ParseSettingsError::new( + ParseSettingsErrorKind::InvalidSyntax, + start, + ))); + } + }; + let value_str = match core::str::from_utf8(value_slice) { + Ok(s) => s, + Err(_) => { + self.done = true; + return Some(Err(ParseSettingsError::new( + ParseSettingsErrorKind::InvalidSyntax, + start, + ))); + } + }; + + Some(Ok((tag, value_str, trim_start))) + } +} + +#[cfg(test)] +mod tests { + use super::{FontFeature, FontVariation, ParseSettingsErrorKind, Tag}; + extern crate alloc; + use alloc::vec::Vec; + + #[test] + fn parse_feature_settings_css_list_ok() { + let parsed: Result, _> = + FontFeature::parse_css_list(r#""liga" on, 'kern', "dlig" off, "salt" 3,"#).collect(); + let settings = parsed.unwrap(); + + assert_eq!(settings.len(), 4); + assert_eq!(settings[0].tag, Tag::parse("liga").unwrap()); + assert_eq!(settings[0].value, 1); + assert_eq!(settings[1].tag, Tag::parse("kern").unwrap()); + assert_eq!(settings[1].value, 1); + assert_eq!(settings[2].tag, Tag::parse("dlig").unwrap()); + assert_eq!(settings[2].value, 0); + assert_eq!(settings[3].tag, Tag::parse("salt").unwrap()); + assert_eq!(settings[3].value, 3); + } + + #[test] + fn parse_feature_settings_css_list_rejects_empty_entries() { + let err = FontFeature::parse_css_list(r#""liga" on,, "kern""#) + .collect::, _>>() + .unwrap_err(); + assert_eq!(err.kind(), ParseSettingsErrorKind::InvalidSyntax); + assert_eq!(err.byte_offset(), 10); + } + + #[test] + fn parse_feature_settings_css_list_out_of_range_reports_span() { + let err = FontFeature::parse_css_list(r#""liga" 70000"#) + .next() + .unwrap() + .unwrap_err(); + assert_eq!(err.kind(), ParseSettingsErrorKind::OutOfRange); + assert_eq!(err.byte_offset(), 7); + assert_eq!(err.byte_span(), Some((7, 12))); + } + + #[test] + fn parse_feature_settings_css_list_invalid_value_reports_span() { + let err = FontFeature::parse_css_list(r#""liga" nope"#) + .next() + .unwrap() + .unwrap_err(); + assert_eq!(err.kind(), ParseSettingsErrorKind::InvalidSyntax); + assert_eq!(err.byte_offset(), 7); + assert_eq!(err.byte_span(), Some((7, 11))); + } + + #[test] + fn parse_feature_settings_css_list_very_large_number_is_out_of_range() { + let s = r#""liga" 999999999999999999999"#; + let err = FontFeature::parse_css_list(s).next().unwrap().unwrap_err(); + assert_eq!(err.kind(), ParseSettingsErrorKind::OutOfRange); + assert_eq!(err.byte_offset(), 7); + assert_eq!(err.byte_span(), Some((7, s.len()))); + } + + #[test] + fn parse_feature_settings_css_list_rejects_leading_comma() { + let err = FontFeature::parse_css_list(r#", "liga" on"#) + .next() + .unwrap() + .unwrap_err(); + assert_eq!(err.kind(), ParseSettingsErrorKind::InvalidSyntax); + assert_eq!(err.byte_offset(), 0); + } + + #[test] + fn parse_feature_settings_css_list_rejects_separator_soup() { + let s = r#""liga" on,,, , ,,, 'kern', "dlig" off, "salt" 3,"#; + let err = FontFeature::parse_css_list(s) + .collect::, _>>() + .unwrap_err(); + assert_eq!(err.kind(), ParseSettingsErrorKind::InvalidSyntax); + let second_comma = s.find(",,").unwrap() + 1; + assert_eq!(err.byte_offset(), second_comma); + assert_eq!(err.byte_span(), None); + } + + #[test] + fn parse_feature_settings_css_list_requires_quotes() { + let err = FontFeature::parse_css_list("liga on") + .next() + .unwrap() + .unwrap_err(); + assert_eq!(err.kind(), ParseSettingsErrorKind::InvalidSyntax); + assert_eq!(err.byte_offset(), 0); + assert_eq!(err.byte_span(), None); + } + + #[test] + fn parse_feature_settings_css_list_invalid_tag_reports_span() { + let err = FontFeature::parse_css_list(r#""lig" on"#) + .next() + .unwrap() + .unwrap_err(); + assert_eq!(err.kind(), ParseSettingsErrorKind::InvalidTag); + assert_eq!(err.byte_offset(), 1); + assert_eq!(err.byte_span(), Some((1, 4))); + } + + #[test] + fn parse_variation_settings_css_list_ok() { + let parsed: Result, _> = + FontVariation::parse_css_list(r#""wght" 700, "wdth" 125.5,"#).collect(); + let settings = parsed.unwrap(); + assert_eq!(settings.len(), 2); + assert_eq!(settings[0].tag, Tag::parse("wght").unwrap()); + assert_eq!(settings[0].value, 700.0); + assert_eq!(settings[1].tag, Tag::parse("wdth").unwrap()); + assert_eq!(settings[1].value, 125.5); + } + + #[test] + fn parse_variation_settings_css_list_requires_value() { + let err = FontVariation::parse_css_list(r#""wght""#) + .next() + .unwrap() + .unwrap_err(); + assert_eq!(err.kind(), ParseSettingsErrorKind::InvalidSyntax); + assert_eq!(err.byte_offset(), 6); + } + + #[test] + fn parse_variation_settings_css_list_invalid_number_reports_span() { + let err = FontVariation::parse_css_list(r#""wght" nope"#) + .next() + .unwrap() + .unwrap_err(); + assert_eq!(err.kind(), ParseSettingsErrorKind::InvalidSyntax); + assert_eq!(err.byte_offset(), 7); + assert_eq!(err.byte_span(), Some((7, 11))); } }