Skip to main content

music21_rs/
style.rs

1//! How an object is drawn: music21's `style` objects, as far as the crate
2//! keeps them.
3//!
4//! A [`TextStyle`] says where a piece of text stands, in what font and
5//! colour, and how it is aligned and justified, as MusicXML's print-style,
6//! alignment and justify attributes say it.
7
8use crate::defaults::FloatType;
9use crate::error::{Error, Result};
10
11/// A value of a style as music21 keeps the attribute it read: a whole
12/// number where the text is one, to within a millionth, a number with a
13/// fraction where it is that, and the text itself where it is no number --
14/// music21's `numToIntOrFloat`, which a MusicXML font size written as a CSS
15/// size, `medium`, passes over.
16#[derive(Clone, Debug, PartialEq)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18pub enum StyleValue {
19    /// A whole number.
20    Whole(i64),
21    /// A number with a fraction.
22    Decimal(FloatType),
23    /// Text that is no number.
24    Text(String),
25}
26
27impl StyleValue {
28    /// The value an attribute's text gives.
29    pub fn read(text: &str) -> Self {
30        let Ok(value) = text.trim().parse::<FloatType>() else {
31            return Self::Text(text.to_string());
32        };
33        let whole = value.round();
34        if value.is_finite() && (whole - value).abs() <= 1e-6 && whole.abs() < 9.0e15 {
35            Self::Whole(whole as i64)
36        } else {
37            Self::Decimal(value)
38        }
39    }
40
41    /// The value as a number, where it is one.
42    pub fn number(&self) -> Option<FloatType> {
43        match self {
44            Self::Whole(whole) => Some(*whole as FloatType),
45            Self::Decimal(value) => Some(*value),
46            Self::Text(_) => None,
47        }
48    }
49}
50
51impl std::fmt::Display for StyleValue {
52    /// As Python writes the `int`, `float` or string.
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            Self::Whole(whole) => write!(f, "{whole}"),
56            Self::Decimal(value) => write!(f, "{value}"),
57            Self::Text(text) => f.write_str(text),
58        }
59    }
60}
61
62fn format_error(message: impl Into<String>) -> Error {
63    Error::Text(message.into())
64}
65
66/// Where a piece of text stands and how it is drawn: music21's
67/// `TextStyle`, the part of it MusicXML's print style, alignment and
68/// justification say.
69#[derive(Clone, Debug, Default, PartialEq)]
70#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
71pub struct TextStyle {
72    /// Across from where it is placed, in tenths: `absoluteX`,
73    /// MusicXML's `default-x`.
74    pub absolute_x: Option<StyleValue>,
75    /// Up from where it is placed: `absoluteY`, `default-y`.
76    pub absolute_y: Option<StyleValue>,
77    /// Across from its default place: `relativeX`, `relative-x`.
78    pub relative_x: Option<StyleValue>,
79    /// Up from its default place: `relativeY`, `relative-y`.
80    pub relative_y: Option<StyleValue>,
81    /// The fonts it may be set in, first choice first: `fontFamily`.
82    pub font_family: Option<Vec<String>>,
83    font_style: Option<String>,
84    /// Its size, in points or as a CSS size: `fontSize`.
85    pub font_size: Option<StyleValue>,
86    font_weight: Option<String>,
87    /// Its colour, as written: `color`.
88    pub color: Option<String>,
89    justify: Option<String>,
90    align_horizontal: Option<String>,
91    align_vertical: Option<String>,
92}
93
94impl TextStyle {
95    /// `italic`, `normal`, `bold` or `bolditalic`: music21's `fontStyle`.
96    pub fn font_style(&self) -> Option<&str> {
97        self.font_style.as_deref()
98    }
99
100    /// Sets the font style, read without regard to case.
101    ///
102    /// # Errors
103    ///
104    /// A style music21 does not name.
105    pub fn set_font_style(&mut self, value: Option<&str>) -> Result<()> {
106        self.font_style = checked(
107            value,
108            &["italic", "normal", "bold", "bolditalic"],
109            true,
110            |value| format!("Not a supported fontStyle: {value:?}"),
111        )?;
112        Ok(())
113    }
114
115    /// `normal` or `bold`: music21's `fontWeight`.
116    pub fn font_weight(&self) -> Option<&str> {
117        self.font_weight.as_deref()
118    }
119
120    /// Sets the font weight, read without regard to case.
121    ///
122    /// # Errors
123    ///
124    /// A weight music21 does not name.
125    pub fn set_font_weight(&mut self, value: Option<&str>) -> Result<()> {
126        self.font_weight = checked(value, &["normal", "bold"], true, |value| {
127            format!("Not a supported fontWeight: {value}")
128        })?;
129        Ok(())
130    }
131
132    /// `left`, `center`, `right` or `full`: music21's `justify`.
133    pub fn justify(&self) -> Option<&str> {
134        self.justify.as_deref()
135    }
136
137    /// Sets the justification, read without regard to case.
138    ///
139    /// # Errors
140    ///
141    /// A justification music21 does not name.
142    pub fn set_justify(&mut self, value: Option<&str>) -> Result<()> {
143        self.justify = checked(value, &["left", "center", "right", "full"], true, |value| {
144            format!("Not a supported justification: {value:?}")
145        })?;
146        Ok(())
147    }
148
149    /// `left`, `right` or `center`: music21's `alignHorizontal`, MusicXML's
150    /// `halign`.
151    pub fn align_horizontal(&self) -> Option<&str> {
152        self.align_horizontal.as_deref()
153    }
154
155    /// Sets the horizontal alignment.
156    ///
157    /// # Errors
158    ///
159    /// An alignment music21 does not name.
160    pub fn set_align_horizontal(&mut self, value: Option<&str>) -> Result<()> {
161        self.align_horizontal = checked(value, &["left", "right", "center"], false, |value| {
162            format!("Invalid horizontal align: {value:?}")
163        })?;
164        Ok(())
165    }
166
167    /// `top`, `middle`, `bottom` or `baseline`: music21's `alignVertical`,
168    /// MusicXML's `valign`.
169    pub fn align_vertical(&self) -> Option<&str> {
170        self.align_vertical.as_deref()
171    }
172
173    /// Sets the vertical alignment.
174    ///
175    /// # Errors
176    ///
177    /// An alignment music21 does not name.
178    pub fn set_align_vertical(&mut self, value: Option<&str>) -> Result<()> {
179        self.align_vertical = checked(
180            value,
181            &["top", "middle", "bottom", "baseline"],
182            false,
183            |value| format!("Invalid vertical align: {value:?}"),
184        )?;
185        Ok(())
186    }
187}
188
189/// A value checked against the ones music21 accepts, lowercased first
190/// where music21 lowercases it.
191fn checked(
192    value: Option<&str>,
193    allowed: &[&str],
194    lowercase: bool,
195    message: impl Fn(&str) -> String,
196) -> Result<Option<String>> {
197    let Some(value) = value else {
198        return Ok(None);
199    };
200    let read = if lowercase {
201        value.to_lowercase()
202    } else {
203        value.to_string()
204    };
205    if allowed.contains(&read.as_str()) {
206        Ok(Some(read))
207    } else {
208        Err(format_error(message(value)))
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn a_value_is_read_as_music21_reads_it() {
218        assert_eq!(StyleValue::read("12"), StyleValue::Whole(12));
219        assert_eq!(StyleValue::read("12.0000001"), StyleValue::Whole(12));
220        assert_eq!(StyleValue::read("12.5"), StyleValue::Decimal(12.5));
221        assert_eq!(
222            StyleValue::read("medium"),
223            StyleValue::Text("medium".to_string())
224        );
225        assert_eq!(StyleValue::Decimal(12.5).to_string(), "12.5");
226    }
227
228    #[test]
229    fn styles_music21_does_not_name_are_refused() {
230        let mut style = TextStyle::default();
231        style.set_justify(Some("Center")).unwrap();
232        assert_eq!(style.justify(), Some("center"));
233        assert!(style.set_justify(Some("middle")).is_err());
234        assert!(style.set_align_horizontal(Some("Center")).is_err());
235        style.set_align_vertical(Some("baseline")).unwrap();
236        assert!(style.set_font_style(Some("oblique")).is_err());
237        style.set_font_weight(Some("BOLD")).unwrap();
238        assert_eq!(style.font_weight(), Some("bold"));
239    }
240}