Skip to main content

music21_rs/
text.rs

1//! Text in scores: music21's `text` module. The lyrics of a stream joined
2//! into words and lines, the articles of a title moved to its front or its
3//! end, and the [`TextBox`] a page carries.
4//!
5//! ```
6//! use music21_rs::text::{assemble_lyrics, prepend_article};
7//! use music21_rs::tinynotation::from_tiny_notation;
8//!
9//! let mut line = from_tiny_notation("4/4 c4_Hi d4_there")?;
10//! assert_eq!(assemble_lyrics(&line, 1, " "), "Hi there");
11//! assert_eq!(prepend_article("Ale is Dear, The", None)?, "The Ale is Dear");
12//! # Ok::<(), music21_rs::Error>(())
13//! ```
14
15use crate::{
16    defaults::IntegerType,
17    error::{Error, Result},
18    notation::{Lyric, Syllabic},
19    stream::{Stream, StreamElement},
20};
21
22/// The articles of each language music21 knows, by its language code:
23/// music21's `articleReference`.
24pub const ARTICLES: [(&str, &[&str]); 8] = [
25    ("ar", &["al-"]),
26    ("en", &["the", "a", "an"]),
27    (
28        "de",
29        &[
30            "der", "die", "das", "des", "dem", "den", "ein", "eine", "einer", "einem", "einen",
31        ],
32    ),
33    ("nl", &["de", "het", "'t", "een"]),
34    (
35        "es",
36        &["el", "la", "los", "las", "un", "una", "unos", "unas"],
37    ),
38    ("pt", &["o", "a", "os", "as", "um", "uma", "uns", "umas"]),
39    (
40        "fr",
41        &[
42            "le", "la", "les", "l'", "un", "une", "des", "du", "de la", "des",
43        ],
44    ),
45    (
46        "it",
47        &[
48            "il", "lo", "la", "l'", "i", "gli", "le", "un'", "un", "uno", "una", "del", "dello",
49            "della", "dei", "degli", "delle",
50        ],
51    ),
52];
53
54/// The articles of a language, or of every language for none.
55fn articles(language: Option<&str>) -> Result<Vec<&'static str>> {
56    match language {
57        None => Ok(ARTICLES
58            .iter()
59            .flat_map(|(_, articles)| articles.iter().copied())
60            .collect()),
61        Some(code) => ARTICLES
62            .iter()
63            .find(|(known, _)| *known == code)
64            .map(|(_, articles)| articles.to_vec())
65            .ok_or_else(|| Error::Text(format!("no articles known for language {code:?}"))),
66    }
67}
68
69/// A title with an article written after a comma at its end put back in
70/// front, `The Ale is Dear` for `Ale is Dear, The`: music21's
71/// `prependArticle`. `language` is a code from [`ARTICLES`]; with none,
72/// every language's articles are looked for. Text with no such article is
73/// handed back as it is.
74///
75/// # Errors
76///
77/// A language [`ARTICLES`] has no articles for.
78pub fn prepend_article(text: &str, language: Option<&str>) -> Result<String> {
79    if !text.contains(',') {
80        return Ok(text.to_string());
81    }
82    let articles = articles(language)?;
83    let Some((before, trailing)) = text.rsplit_once(',') else {
84        return Ok(text.to_string());
85    };
86    let trailing = trailing.trim();
87    if articles.contains(&trailing.to_lowercase().as_str()) {
88        Ok(format!("{trailing} {before}"))
89    } else {
90        Ok(text.to_string())
91    }
92}
93
94/// A title with the article it starts with moved after a comma at its end,
95/// `Ale is Dear, The` for `The Ale is Dear`: music21's `postpendArticle`.
96/// `language` is read as [`prepend_article`] reads it.
97///
98/// # Errors
99///
100/// A language [`ARTICLES`] has no articles for.
101pub fn postpend_article(text: &str, language: Option<&str>) -> Result<String> {
102    if !text.contains(' ') {
103        return Ok(text.to_string());
104    }
105    let articles = articles(language)?;
106    let Some((leading, after)) = text.split_once(' ') else {
107        return Ok(text.to_string());
108    };
109    let leading = leading.trim();
110    if articles.contains(&leading.to_lowercase().as_str()) {
111        Ok(format!("{after}, {leading}"))
112    } else {
113        Ok(text.to_string())
114    }
115}
116
117/// The lyrics of one line of a stream, nested streams included, joined into
118/// words, the syllables of each word run together: music21's
119/// `assembleLyrics`. `line_number` counts the lyrics on each note from
120/// one, as music21 does, so nought reads each note's last lyric; a note
121/// without that many lyrics is passed over, and so is a syllable that is
122/// only an underscore, as many scores write a held syllable.
123pub fn assemble_lyrics(stream: &Stream, line_number: IntegerType, word_separator: &str) -> String {
124    let mut word: Vec<String> = Vec::new();
125    let mut words: Vec<String> = Vec::new();
126    for event in stream.flatten().events() {
127        let lyrics: &[Lyric] = match event.element() {
128            StreamElement::Note(note) => note.lyrics(),
129            StreamElement::Chord(chord) => chord.lyrics(),
130            StreamElement::Rest(rest) => rest.lyrics(),
131            _ => continue,
132        };
133        let Some(lyric) = line(lyrics, line_number) else {
134            continue;
135        };
136        let text = lyric.explicit_text();
137        if text.as_deref() == Some("_") {
138            continue;
139        }
140        match lyric.explicit_syllabic() {
141            Some(Syllabic::Begin | Syllabic::Middle) => word.extend(text),
142            Some(Syllabic::Composite) => {
143                word.extend(text);
144                let last = lyric.components().last().and_then(Lyric::explicit_syllabic);
145                if matches!(last, None | Some(Syllabic::End | Syllabic::Single)) {
146                    words.push(word.concat());
147                    word.clear();
148                }
149            }
150            None | Some(Syllabic::End | Syllabic::Single) => {
151                word.extend(text);
152                words.push(word.concat());
153                word.clear();
154            }
155        }
156    }
157    words.join(word_separator)
158}
159
160/// The lyric at a line, counted from one, nought or less counting back
161/// from the last as a Python index does.
162fn line(lyrics: &[Lyric], line_number: IntegerType) -> Option<&Lyric> {
163    let index = i64::from(line_number) - 1;
164    let index = if index < 0 {
165        index + lyrics.len() as i64
166    } else {
167        index
168    };
169    usize::try_from(index)
170        .ok()
171        .and_then(|index| lyrics.get(index))
172}
173
174/// Every line of a stream's lyrics, each assembled by [`assemble_lyrics`],
175/// one after another with `lyric_separation` before each but the first:
176/// music21's `assembleAllLyrics`. Lines one to `max_lyrics - 1` are read,
177/// as music21 reads them, and a line with no lyrics at all is left out,
178/// though a separator still comes before each line after the first.
179pub fn assemble_all_lyrics(
180    stream: &Stream,
181    max_lyrics: IntegerType,
182    lyric_separation: &str,
183    word_separator: &str,
184) -> String {
185    let mut lyrics = String::new();
186    for line_number in 1..max_lyrics {
187        let line = assemble_lyrics(stream, line_number, word_separator);
188        if !line.is_empty() {
189            if line_number > 1 {
190                lyrics.push_str(lyric_separation);
191            }
192            lyrics.push_str(&line);
193        }
194    }
195    lyrics
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::note::Note;
202
203    #[test]
204    fn articles_move_to_the_front_and_back() -> Result<()> {
205        // Each read off music21's text.prependArticle and postpendArticle.
206        assert_eq!(
207            prepend_article("Ale is Dear, The", None)?,
208            "The Ale is Dear"
209        );
210        assert_eq!(prepend_article("The Ale is Dear", None)?, "The Ale is Dear");
211        assert_eq!(
212            prepend_article("Ale is Dear, The", Some("fr"))?,
213            "Ale is Dear, The"
214        );
215        assert_eq!(
216            prepend_article("Combattimento di Tancredi e Clorinda, Il", Some("it"))?,
217            "Il Combattimento di Tancredi e Clorinda"
218        );
219        assert_eq!(
220            postpend_article("The Ale is Dear", None)?,
221            "Ale is Dear, The"
222        );
223        assert_eq!(
224            postpend_article("Ale is Dear, The", Some("en"))?,
225            "Ale is Dear, The"
226        );
227        assert!(prepend_article("Ale, The", Some("xx")).is_err());
228        Ok(())
229    }
230
231    #[test]
232    fn syllables_are_joined_into_words() {
233        let mut stream = Stream::new();
234        for (offset, (text, syllabic)) in [
235            ("Hi", Syllabic::Single),
236            ("the", Syllabic::Begin),
237            ("re", Syllabic::End),
238            ("_", Syllabic::Single),
239        ]
240        .into_iter()
241        .enumerate()
242        {
243            let mut lyric = Lyric::new(text);
244            lyric.set_syllabic(syllabic);
245            let mut note = Note::from_name("C4").expect("a note");
246            note.lyrics_mut().push(lyric);
247            stream.insert(offset as crate::defaults::FloatType, note);
248        }
249        assert_eq!(assemble_lyrics(&stream, 1, " "), "Hi there");
250        assert_eq!(assemble_lyrics(&stream, 0, "-"), "Hi-there");
251        assert_eq!(assemble_lyrics(&stream, 2, " "), "");
252        assert_eq!(assemble_all_lyrics(&stream, 10, "\n", " "), "Hi there");
253    }
254}
255
256/// Text standing anywhere on a page, its title or a header: music21's
257/// `TextBox`, MusicXML's `<credit>`.
258#[derive(Clone, Debug, PartialEq)]
259#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
260pub struct TextBox {
261    /// The text, its lines joined by newlines: `content`.
262    pub content: String,
263    /// The page it stands on, the first where nothing says: `page`.
264    pub page: i64,
265    /// Where it stands and how it is drawn: `style`.
266    pub style: crate::style::TextStyle,
267}
268
269impl TextBox {
270    /// A text box on the first page, centred across and hung from the top
271    /// at 500 tenths each way, as music21's `TextBox(content)` starts.
272    pub fn new(content: impl Into<String>) -> Self {
273        let mut style = crate::style::TextStyle::default();
274        style.absolute_x = Some(crate::style::StyleValue::Whole(500));
275        style.absolute_y = Some(crate::style::StyleValue::Whole(500));
276        style
277            .set_align_vertical(Some("top"))
278            .expect("top is an alignment");
279        style
280            .set_align_horizontal(Some("center"))
281            .expect("center is an alignment");
282        Self {
283            content: content.into(),
284            page: 1,
285            style,
286        }
287    }
288}