takumi 1.7.0

Render UI component trees to images.
Documentation
use std::fmt;
use std::string::ToString;

use cssparser::{Parser, match_ignore_ascii_case};
use parley::{
  FontFamily as ParleyFontFamily, FontFamilyName, GenericFamily, fontique::QueryFamily,
};

use crate::layout::style::{
  CssSyntaxKind, CssToken, FromCss, MakeComputed, ParseResult, ToCss, properties::write_css_string,
  tw::TailwindPropertyParser,
};

/// Represents a font family for text rendering.
/// Multi value fallback is supported.
#[derive(Debug, Clone, PartialEq)]
pub struct FontFamily(Box<[FontFamilyToken]>);

#[derive(Debug, Clone, PartialEq)]
pub(crate) enum FontFamilyToken {
  Owned(String),
  Generic(GenericFamily),
}

impl MakeComputed for FontFamily {}

impl FontFamily {
  pub(crate) fn query_families(&self) -> impl Iterator<Item = QueryFamily<'_>> + Clone {
    self.0.iter().map(|token| match token {
      FontFamilyToken::Owned(name) => QueryFamily::Named(name.as_str()),
      FontFamilyToken::Generic(generic) => QueryFamily::Generic(*generic),
    })
  }
}

impl<'i> FromCss<'i> for FontFamilyToken {
  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
    if let Ok(name) = input.try_parse(|input| input.expect_string().map(ToString::to_string)) {
      return Ok(Self::Owned(name));
    }

    let mut family_name = input.expect_ident()?.to_string();

    while let Ok(ident) = input.try_parse(Parser::expect_ident_cloned) {
      family_name.push(' ');
      family_name.push_str(&ident);
    }

    if let Some(generic) = GenericFamily::parse(&family_name) {
      return Ok(Self::Generic(generic));
    }

    Ok(Self::Owned(family_name))
  }

  const VALID_TOKENS: &'static [CssToken] = &[
    CssToken::Syntax(CssSyntaxKind::FamilyName),
    CssToken::Syntax(CssSyntaxKind::GenericName),
  ];
}

impl<'i> FromCss<'i> for FontFamily {
  fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
    let list = input.parse_comma_separated(FontFamilyToken::from_css)?;

    Ok(Self(list.into_boxed_slice()))
  }

  const VALID_TOKENS: &'static [CssToken] = FontFamilyToken::VALID_TOKENS;
}

impl TailwindPropertyParser for FontFamily {
  fn parse_tw(token: &str) -> Option<Self> {
    match_ignore_ascii_case! {token,
      "sans" => Some(GenericFamily::SansSerif.into()),
      "serif" => Some(GenericFamily::Serif.into()),
      "mono" => Some(GenericFamily::Monospace.into()),
      _ => None,
    }
  }
}

impl Default for FontFamily {
  fn default() -> Self {
    GenericFamily::SansSerif.into()
  }
}

impl<'a> From<FontFamily> for ParleyFontFamily<'a> {
  fn from(family: FontFamily) -> Self {
    ParleyFontFamily::List(
      family
        .0
        .into_iter()
        .map(|token| match token {
          FontFamilyToken::Owned(name) => FontFamilyName::Named(name.into()),
          FontFamilyToken::Generic(generic) => FontFamilyName::Generic(generic),
        })
        .collect(),
    )
  }
}

impl<'a> From<&'a FontFamily> for ParleyFontFamily<'a> {
  fn from(family: &'a FontFamily) -> Self {
    ParleyFontFamily::List(
      family
        .0
        .iter()
        .map(|token| match token {
          FontFamilyToken::Owned(name) => FontFamilyName::Named(name.as_str().into()),
          FontFamilyToken::Generic(generic) => FontFamilyName::Generic(*generic),
        })
        .collect(),
    )
  }
}

impl From<GenericFamily> for FontFamily {
  fn from(generic: GenericFamily) -> Self {
    Self(Box::new([FontFamilyToken::Generic(generic)]))
  }
}

impl ToCss for FontFamilyToken {
  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
    match self {
      Self::Owned(name) => {
        let needs_quoting = name.contains(' ')
          || name.contains('"')
          || name.contains('\\')
          || name.chars().next().is_some_and(|c| c.is_ascii_digit());
        if needs_quoting {
          write_css_string(dest, name)
        } else {
          dest.write_str(name)
        }
      }
      Self::Generic(generic) => dest.write_str(match generic {
        GenericFamily::Serif => "serif",
        GenericFamily::SansSerif => "sans-serif",
        GenericFamily::Monospace => "monospace",
        GenericFamily::Cursive => "cursive",
        GenericFamily::Fantasy => "fantasy",
        GenericFamily::SystemUi => "system-ui",
        GenericFamily::Emoji => "emoji",
        _ => "sans-serif",
      }),
    }
  }
}

impl ToCss for FontFamily {
  fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
    let mut first = true;
    for token in self.0.iter() {
      if !first {
        dest.write_str(", ")?;
      }
      first = false;
      token.to_css(dest)?;
    }
    Ok(())
  }
}

#[cfg(test)]
mod tests {
  use parley::GenericFamily;

  use super::{FontFamily, FontFamilyToken};
  use crate::layout::style::{FromCss, tw::TailwindPropertyParser};

  #[test]
  fn parses_single_generic_family() {
    assert_eq!(
      FontFamily::from_str("serif"),
      Ok(FontFamily(Box::new([FontFamilyToken::Generic(
        GenericFamily::Serif,
      )])))
    );
  }

  #[test]
  fn parses_fallback_family_list() {
    assert_eq!(
      FontFamily::from_str("\"Inter\", Arial, serif"),
      Ok(FontFamily(Box::new([
        FontFamilyToken::Owned(String::from("Inter")),
        FontFamilyToken::Owned(String::from("Arial")),
        FontFamilyToken::Generic(GenericFamily::Serif),
      ])))
    );
  }

  #[test]
  fn parses_unquoted_multi_word_family_name() {
    assert_eq!(
      FontFamily::from_str("Noto Sans TC"),
      Ok(FontFamily(Box::new([FontFamilyToken::Owned(
        "Noto Sans TC".to_string()
      )])))
    );
  }

  #[test]
  fn parses_tailwind_aliases() {
    assert_eq!(
      FontFamily::parse_tw("sans"),
      Some(FontFamily(Box::new([FontFamilyToken::Generic(
        GenericFamily::SansSerif,
      )])))
    );
    assert_eq!(
      FontFamily::parse_tw("mono"),
      Some(FontFamily(Box::new([FontFamilyToken::Generic(
        GenericFamily::Monospace,
      )])))
    );
    assert_eq!(FontFamily::parse_tw("display"), None);
  }
}