maimai 0.1.1

Markup-based meme generator
Documentation
//! Partial meme definition types that are parsed from TOML files
//! and then merged into a [`Meme`](crate::Meme).

use std::hash::Hash;

use conflate::MergeFrom;
use indexmap::IndexMap;

use super::{Color, HAlign, VAlign};

/// A meme definition, usually writting in TOML.
///
/// May extend other meme definitions and inherits all properties recursively
/// that aren't overwritten. This allows creating meme templates using the same
/// format. The templates can define text boxes, that are filled by memes that
/// inherit from the template.
#[non_exhaustive]
#[derive(Clone, Debug, conflate::Merge)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(rename = "Meme", deny_unknown_fields))]
pub struct PartialMeme {
    /// The base layer of the meme.
    #[merge(strategy = always_overwrite)]
    #[cfg_attr(feature = "schemars", schemars(flatten))]
    pub base: PartialMemeBase,
    /// The text that will be placed on top of the base.
    #[merge(strategy = append_or_recurse_indexmap)]
    #[cfg_attr(feature = "schemars", schemars(default))]
    pub text: IndexMap<String, PartialText>,
}

/// The base layer of a meme.
#[non_exhaustive]
#[derive(Clone, Debug)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(
    feature = "schemars",
    schemars(rename = "MemeBase", rename_all = "kebab-case")
)]
pub enum PartialMemeBase {
    /// Draw the meme onto a blank canvas.
    Canvas(PartialCanvas),
    /// Draw the meme onto an image.
    Image(String),
    /// Extend another meme definition (e.g. a template).
    Extends(String),
}

#[cfg(feature = "serde")]
impl PartialMemeBase {
    fn as_key(&self) -> PartialMemeKey {
        match self {
            PartialMemeBase::Canvas(_) => PartialMemeKey::Canvas,
            PartialMemeBase::Image(_) => PartialMemeKey::Image,
            PartialMemeBase::Extends(_) => PartialMemeKey::Extends,
        }
    }
}

#[cfg(feature = "serde")]
#[derive(serde::Deserialize, strum::VariantNames, strum::IntoStaticStr, strum::Display)]
#[serde(field_identifier, rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
enum PartialMemeKey {
    #[serde(rename = "$schema")]
    #[strum(to_string = "$schema")]
    Schema,
    Canvas,
    Image,
    Extends,
    Text,
}

#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for PartialMeme {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        struct PartialMemeVisitor;

        impl<'de> serde::de::Visitor<'de> for PartialMemeVisitor {
            type Value = PartialMeme;

            fn expecting(&self, _: &mut std::fmt::Formatter) -> std::fmt::Result {
                unimplemented!()
            }
            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
            where
                A: serde::de::MapAccess<'de>,
            {
                let mut base: Option<PartialMemeBase> = None;
                let mut text = None;

                while let Some(key) = map.next_key()? {
                    match key {
                        PartialMemeKey::Schema => continue,
                        PartialMemeKey::Canvas => {
                            if let Some(base) = base {
                                return Err(serde::de::Error::custom(format!(
                                    "conflicting field: {}",
                                    base.as_key()
                                )));
                            }
                            base = Some(PartialMemeBase::Canvas(map.next_value()?));
                        }
                        PartialMemeKey::Image => {
                            if let Some(base) = base {
                                return Err(serde::de::Error::custom(format!(
                                    "conflicting field: {}",
                                    base.as_key()
                                )));
                            }
                            base = Some(PartialMemeBase::Image(map.next_value()?));
                        }
                        PartialMemeKey::Extends => {
                            if let Some(base) = base {
                                return Err(serde::de::Error::custom(format!(
                                    "conflicting field: {}",
                                    base.as_key()
                                )));
                            }
                            base = Some(PartialMemeBase::Extends(map.next_value()?));
                        }
                        PartialMemeKey::Text => {
                            if text.is_some() {
                                return Err(serde::de::Error::duplicate_field(key.into()));
                            }
                            text = Some(map.next_value()?);
                        }
                    }
                }

                Ok(PartialMeme {
                    base: base.ok_or_else(|| {
                        serde::de::Error::custom(format!(
                            "missing field, expected one of `{}`, `{}`, `{}`",
                            PartialMemeKey::Canvas,
                            PartialMemeKey::Image,
                            PartialMemeKey::Extends,
                        ))
                    })?,
                    text: text.unwrap_or_default(),
                })
            }
        }

        deserializer.deserialize_struct(
            "Meme",
            <PartialMemeKey as strum::VariantNames>::VARIANTS,
            PartialMemeVisitor,
        )
    }
}

/// A blank canvas that a meme will be drawn on.
#[non_exhaustive]
#[derive(Clone, Debug, Default, conflate::Merge)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "serde", derive(serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename = "Canvas", deny_unknown_fields))]
#[merge(strategy = conflate::option::overwrite_none)]
pub struct PartialCanvas {
    /// Background color of the canvas as a CSS color string.
    #[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
    pub color: Option<Color>,
    /// Dimensions of the canvas as a `width, height` pair.
    pub size: Option<(u32, u32)>,
}

/// Either the text content itself or the text box parameters.
#[non_exhaustive]
#[derive(Clone, Debug)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(rename = "Text", untagged))]
pub enum PartialText {
    /// Just the text content.
    ///
    /// All other text properties must be defined in another layer
    /// (e.g. a template that is extended).
    Text(String),
    /// A text box that is placed on top of the base layer.
    TextBox(PartialTextBox),
}

impl Default for PartialText {
    fn default() -> Self {
        Self::TextBox(Default::default())
    }
}

#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for PartialText {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        serde_untagged::UntaggedEnumVisitor::new()
            .string(|s| Ok(PartialText::Text(s.to_owned())))
            .map(|map| map.deserialize().map(PartialText::TextBox))
            .deserialize(deserializer)
    }
}

impl conflate::Merge for PartialText {
    fn merge(&mut self, parent: Self) {
        *self = match (std::mem::take(self), parent) {
            (Self::Text(text), Self::TextBox(text_box)) => Self::TextBox(PartialTextBox {
                text: Some(text),
                ..text_box
            }),
            (Self::TextBox(text_box), Self::TextBox(parent_text_box)) => {
                Self::TextBox(text_box.merge_from(parent_text_box))
            }
            (Self::TextBox(mut text_box), Self::Text(parent_text)) if text_box.text.is_none() => {
                text_box.text = Some(parent_text);
                Self::TextBox(text_box)
            }
            (slf, _) => slf,
        }
    }
}

/// A text box that is placed on top of the base layer.
#[non_exhaustive]
#[derive(Clone, Debug, Default, conflate::Merge)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "serde", derive(serde::Deserialize))]
#[cfg_attr(
    feature = "serde",
    serde(rename = "TextBox", deny_unknown_fields, rename_all = "kebab-case")
)]
#[merge(strategy = conflate::option::overwrite_none)]
pub struct PartialTextBox {
    /// Text content.
    pub text: Option<String>,

    /// Top-left position of the text box as a `x, y` pair
    /// (before rotation is applied).
    pub position: Option<(f32, f32)>,
    /// Dimensions of the text box as a `width, height` pair.
    pub size: Option<(f32, f32)>,
    /// Clockwise rotation around the center of the text box in degrees.
    ///
    /// Defaults to no rotation.
    pub rotate: Option<f32>,

    /// Font family name. Defaults to `sans-serif`.
    pub font: Option<String>,
    /// Write the entire text content in capital letters.
    ///
    /// Defaults to `false`.
    pub caps: Option<bool>,
    /// Text color as a CSS color string.
    #[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
    pub color: Option<Color>,
    /// Draw a colored outline around the text glyphs.
    #[merge(strategy = conflate::option::recurse)]
    pub outline: Option<PartialTextOutline>,
    /// Maximum font size. Defaults to the text box height minus the outline
    /// width.
    ///
    /// If the text doesn't fit inside the text box, the font size is
    /// appropriately reduced.
    pub font_size: Option<f32>,
    /// Line height relative to the font size.
    ///
    /// Defaults to `1.0`.
    pub line_height: Option<f32>,

    /// How to align the text horizontally inside the text box.
    ///
    /// Defaults to `center`.
    pub halign: Option<HAlign>,
    /// How to align the text vertically inside the text box.
    ///
    /// Defaults to `center`.
    pub valign: Option<VAlign>,
}

/// Either a boolean to enable or disable the outline or the outline parameters.
#[non_exhaustive]
#[derive(Clone, Debug)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schemars", schemars(rename = "TextOutline", untagged))]
pub enum PartialTextOutline {
    Enabled(bool),
    Parameters(PartialTextOutlineParameters),
}

impl Default for PartialTextOutline {
    fn default() -> Self {
        Self::Parameters(Default::default())
    }
}

#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for PartialTextOutline {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        serde_untagged::UntaggedEnumVisitor::new()
            .bool(|v| Ok(PartialTextOutline::Enabled(v)))
            .map(|v| v.deserialize().map(PartialTextOutline::Parameters))
            .deserialize(deserializer)
    }
}

impl conflate::Merge for PartialTextOutline {
    fn merge(&mut self, parent: Self) {
        *self = match (std::mem::take(self), parent) {
            (Self::Enabled(enabled), Self::Parameters(parent_params)) => {
                Self::Parameters(PartialTextOutlineParameters {
                    enabled,
                    ..parent_params
                })
            }
            (Self::Parameters(params), Self::Parameters(parent_params)) => {
                Self::Parameters(params.merge_from(parent_params))
            }
            (slf, _) => slf,
        };
    }
}

#[non_exhaustive]
#[derive(Clone, Debug, Default, conflate::Merge)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "serde", derive(serde::Deserialize))]
#[merge(strategy = conflate::option::overwrite_none)]
#[cfg_attr(
    feature = "serde",
    serde(
        rename = "TextOutlineParameters",
        deny_unknown_fields,
        rename_all = "kebab-case"
    )
)]
pub struct PartialTextOutlineParameters {
    /// Enables an outline around text glyphs.
    #[merge(skip)]
    #[cfg_attr(feature = "serde", serde(default = "true_"))]
    pub enabled: bool,
    /// Color of the outline as a CSS color string.
    #[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
    pub color: Option<Color>,
    /// Width of the outline.
    ///
    /// Values >=1.0 are interpreted an absolute size in Pixels, values <1.0 are
    /// interpreted relative to the font size.
    ///
    /// Defaults to `0.075`.
    pub width: Option<f32>,
}

#[cfg(feature = "serde")]
const fn true_() -> bool {
    true
}

fn always_overwrite<T>(left: &mut T, right: T) {
    *left = right;
}

fn append_or_recurse_indexmap<K: Eq + Hash, V: conflate::Merge>(
    left: &mut IndexMap<K, V>,
    right: IndexMap<K, V>,
) {
    use indexmap::map::Entry;

    for (k, v) in right {
        match left.entry(k) {
            Entry::Occupied(mut existing) => existing.get_mut().merge(v),
            Entry::Vacant(empty) => {
                empty.insert(v);
            }
        }
    }
}