Skip to main content

music21_rs/roman/
figure.rs

1//! Reading a figure: the accidental in front of the numeral, the
2//! numeral itself, the inversion and figured-bass digits after it, the
3//! bracketed omissions, additions and alterations, and the applied part.
4
5use super::*;
6
7impl RomanNumeral {
8    /// Raises the sixth and seventh degrees of a minor key where the figure
9    /// asks for a chord that only the raised degree gives.
10    ///
11    /// This is music21's `_adjustMinorVIandVIIByQuality` at its default
12    /// setting, and it is what makes `viio7` in A minor the diminished
13    /// seventh on `G#` rather than a chord on `G` with three flats in it,
14    /// and `vi` the minor triad on `F#`. The natural degrees are what `VI`
15    /// and `VII` ask for, and those are left alone.
16    pub(super) fn raise_minor_sixth_and_seventh(&mut self, column: &mut String) -> Result<()> {
17        if !matches!(self.degree, 6 | 7) {
18            return Ok(());
19        }
20        // A numeral read against a collection takes that collection's
21        // degrees as they are written. Raising the sixth and the seventh is
22        // a rule about minor *keys* — music21 asks the thing it was given
23        // for its mode, and a scale has none.
24        if self.scale.is_some() {
25            return Ok(());
26        }
27        // Against the key the figure is actually read in, so the `vi` of a
28        // secondary numeral is judged in the key that numeral establishes.
29        if !self.case_matters {
30            return Ok(());
31        }
32        let reading = if self.degree == 6 {
33            self.sixth_minor
34        } else {
35            self.seventh_minor
36        };
37        let adjusted = adjust_minor_vi_and_vii_by_quality(
38            &self.effective_key()?,
39            reading,
40            self.implied_quality,
41            self.accidental,
42        );
43        if adjusted != self.accidental {
44            self.accidental = adjusted;
45            sharpen_figure(column);
46        }
47        Ok(())
48    }
49}
50
51/// The accidental a numeral on the sixth or seventh degree of a minor key
52/// ends up with, given how the key is read and the chord the figure asks
53/// for: music21's `adjustMinorVIandVIIByQuality`.
54///
55/// A minor triad on the sixth or a diminished chord on the seventh is one
56/// only the raised degree gives, so under the `Quality` reading such a
57/// figure takes a sharp and a major one takes the natural degree. `Flat` and
58/// `Sharp` decide without looking at the chord, and `Cautionary` reads an
59/// accidental already written as a caution rather than as a further change:
60/// a sharp says nothing more, and a flat cancels the raise, so the two meet
61/// at the natural degree. In a major key the accidental stands as written.
62pub fn adjust_minor_vi_and_vii_by_quality(
63    key: &Key,
64    reading: Minor67Default,
65    quality: ImpliedQuality,
66    accidental: i8,
67) -> i8 {
68    if key.mode() != "minor" {
69        return accidental;
70    }
71    let wants_raised = matches!(
72        quality,
73        ImpliedQuality::Minor | ImpliedQuality::Diminished | ImpliedQuality::HalfDiminished
74    );
75    let raise = match reading {
76        Minor67Default::Flat => false,
77        Minor67Default::Sharp => true,
78        Minor67Default::Quality => wants_raised,
79        Minor67Default::Cautionary => match accidental {
80            0 => wants_raised,
81            sharps if sharps >= 1 => false,
82            _ => true,
83        },
84    };
85    if raise { accidental + 1 } else { accidental }
86}
87
88/// Splits the accidentals written in front of a roman numeral off the rest of
89/// the figure, counting sharps as positive and flats as negative.
90pub fn split_roman_accidental_prefix(value: &str) -> (i8, &str) {
91    let mut accidental = 0;
92    let mut end = 0;
93    for (idx, ch) in value.char_indices() {
94        match ch {
95            '#' => {
96                accidental += 1;
97                end = idx + ch.len_utf8();
98            }
99            'b' | '-' => {
100                accidental -= 1;
101                end = idx + ch.len_utf8();
102            }
103            _ => break,
104        }
105    }
106    (accidental, &value[end..])
107}
108
109/// The chords music21 writes by name, as the figures they stand for.
110///
111/// The Neapolitan is the flattened second degree, written `N` and — the way
112/// it is nearly always used — `N6` in first inversion. The cadential
113/// six-four is the tonic triad in second inversion, and takes the case of
114/// the key it stands in.
115pub(super) fn named_figure(figure: &str, key: &Key) -> String {
116    let tonic = if key.mode() == "minor" { "i" } else { "I" };
117    match figure {
118        "N" | "N6" => "bII6".to_string(),
119        "N53" => "bII".to_string(),
120        "Cad64" => format!("{tonic}64"),
121        other => other.to_string(),
122    }
123}
124
125/// music21's "immediate fixes" to a figure as written.
126///
127/// A diminished chord is written `o`, `0`, `º` or `°`, and a half-diminished
128/// one `ø` or `/o`; each pair is folded onto one spelling, and the fold is
129/// what the numeral reports back as its figure. The zero is left alone when a
130/// digit precedes it, so `10` stays a ten.
131pub(super) fn fold_figure_symbols(figure: &str) -> String {
132    let mut folded = String::with_capacity(figure.len());
133    let mut previous: Option<char> = None;
134    for letter in figure.chars() {
135        let fixed = match letter {
136            '0' if !previous.is_some_and(|before| before.is_ascii_digit()) => 'o',
137            '\u{00ba}' | '\u{00b0}' => 'o',
138            other => other,
139        };
140        folded.push(fixed);
141        previous = Some(letter);
142    }
143    folded.replace("/o", "\u{00f8}")
144}
145
146/// music21's figure validation: letters, digits and the handful of symbols a
147/// figure is written with, and never the letters no numeral contains.
148///
149/// The parentheses and spaces of this crate's own `add(...)` and `omit(...)`
150/// groups come through as well, since a figure written that way is one it
151/// can read.
152pub(super) fn validate_figure(figure: &str) -> Result<()> {
153    let ok = figure.chars().all(|letter| {
154        letter.is_alphanumeric()
155            || matches!(
156                letter,
157                '#' | '\u{00b0}' | '+' | '-' | '/' | '[' | ']' | '(' | ')' | ' '
158            )
159    });
160    if !ok
161        || figure
162            .chars()
163            .any(|letter| matches!(letter, 'x' | 'y' | 'z'))
164    {
165        return Err(Error::Chord(format!("Invalid figure: {figure}")));
166    }
167    Ok(())
168}
169
170/// The chords a figured-bass column implies a root for.
171///
172/// music21's `FIGURES_IMPLYING_ROOT`. Every other column — `54`, say — is
173/// read as a stack over its bass, and the bass is then the root.
174pub(super) const FIGURES_IMPLYING_ROOT: &[&[u8]] = &[
175    // triads
176    &[6],
177    &[6, 3],
178    &[6, 4],
179    // seventh chords
180    &[6, 5, 3],
181    &[6, 5],
182    &[6, 4, 3],
183    &[4, 3],
184    &[6, 4, 2],
185    &[4, 2],
186    &[2],
187    // ninth chords
188    &[7, 6, 5, 3],
189    &[6, 5, 4, 3],
190    &[6, 4, 3, 2],
191    &[7, 5, 3, 2],
192    // eleventh chords
193    &[9, 7, 6, 5, 3],
194    &[7, 6, 5, 4, 3],
195    &[9, 6, 5, 4, 3],
196    &[9, 7, 6, 4, 3],
197    &[7, 6, 5, 4, 2],
198];
199
200/// Where music21 splits a secondary numeral off a figure.
201///
202/// Its own regular expression asks for a letter after the slash, and
203/// deliberately not `o` — so `vii/o7` is a half-diminished seventh and not a
204/// numeral applied to some chord `o7` — and not a digit, so the `6/5` of
205/// `Ger6/5` stays one figure.
206pub fn split_secondary(figure: &str) -> (&str, Option<String>) {
207    for (index, letter) in figure.char_indices() {
208        if letter != '/' {
209            continue;
210        }
211        let rest = &figure[index + 1..];
212        let opens = rest.chars().next().is_some_and(|next| {
213            next == '#' || (next.is_ascii_alphabetic() && next != 'o' && next != 'O')
214        });
215        if opens {
216            return (&figure[..index], Some(rest.to_string()));
217        }
218    }
219    (figure, None)
220}
221
222/// Takes the `[noN]` groups out of a figure, leaving the rest of it.
223///
224/// The steps are counted from the root and folded into one octave, so
225/// `[no11]` leaves out the fourth.
226pub fn take_omitted_steps(figure: &mut String) -> Vec<u8> {
227    let mut steps = Vec::new();
228    let mut kept = String::with_capacity(figure.len());
229    let mut remaining = figure.as_str();
230    while let Some(start) = remaining.find("[no") {
231        let Some(end) = remaining[start..].find(']') else {
232            break;
233        };
234        let group = &remaining[start + 1..start + end];
235        for part in group.split("no") {
236            if let Ok(step) = part.trim().parse::<u8>() {
237                steps.push(if step % 7 == 0 { 7 } else { step % 7 });
238            }
239        }
240        kept.push_str(&remaining[..start]);
241        remaining = remaining[start + end + 1..].trim_start();
242    }
243    kept.push_str(remaining);
244    *figure = kept;
245    steps
246}
247
248/// Takes the `[addN]` groups out of a figure, with the accidental each was
249/// written with.
250pub fn take_added_steps(figure: &mut String) -> Vec<(i8, u8)> {
251    take_bracket_groups(figure, "[add")
252}
253
254/// Takes the `[#N]` and `[bN]` groups out of a figure.
255pub fn take_bracketed_alterations(figure: &mut String) -> Vec<(i8, u8)> {
256    take_bracket_groups(figure, "[")
257}
258
259/// The shape the two share: a bracket, a prefix, some accidentals, a number,
260/// and a closing bracket. A bracket holding anything else is left where it is.
261pub(super) fn take_bracket_groups(figure: &mut String, opening: &str) -> Vec<(i8, u8)> {
262    let mut groups = Vec::new();
263    let mut kept = String::with_capacity(figure.len());
264    let mut remaining = figure.as_str();
265    while let Some(start) = remaining.find(opening) {
266        let Some(end) = remaining[start..].find(']') else {
267            break;
268        };
269        let body = &remaining[start + opening.len()..start + end];
270        let digits: String = body.chars().filter(char::is_ascii_digit).collect();
271        let well_formed = !digits.is_empty()
272            && body
273                .chars()
274                .all(|letter| matches!(letter, '#' | 'b' | '-') || letter.is_ascii_digit());
275        if !well_formed {
276            kept.push_str(&remaining[..start + end + 1]);
277            remaining = &remaining[start + end + 1..];
278            continue;
279        }
280        let alter: i8 = body
281            .chars()
282            .map(|letter| match letter {
283                '#' => 1,
284                'b' | '-' => -1,
285                _ => 0,
286            })
287            .sum();
288        if let Ok(step) = digits.parse::<u8>() {
289            groups.push((alter, step));
290        }
291        kept.push_str(&remaining[..start]);
292        remaining = remaining[start + end + 1..].trim_start();
293    }
294    kept.push_str(remaining);
295    *figure = kept;
296    groups
297}
298
299/// music21's `expandShortHand`: the figures of a column, one string each.
300pub fn expand_shorthand(shorthand: &str) -> Vec<String> {
301    let mut shorthand = shorthand.replace('/', "");
302    // A lone flat is a flattened third.
303    if shorthand == "b" || shorthand == "-" {
304        shorthand.push('3');
305    }
306    let letters: Vec<char> = shorthand.chars().collect();
307    let mut tokens = Vec::new();
308    let mut index = 0;
309    while index < letters.len() {
310        let start = index;
311        for mark in ['#', '-', 'b', 'o'] {
312            while letters.get(index) == Some(&mark) {
313                index += 1;
314            }
315        }
316        // The two-digit figures are read whole, so `13` is a thirteenth and
317        // not a one and a three.
318        let read = match letters.get(index) {
319            Some('1') if matches!(letters.get(index + 1), Some('1' | '3' | '5')) => {
320                index += 2;
321                true
322            }
323            Some(digit) if digit.is_ascii_digit() && *digit != '0' => {
324                index += 1;
325                true
326            }
327            _ => false,
328        };
329        if read {
330            tokens.push(letters[start..index].iter().collect::<String>());
331        } else {
332            index = start + 1;
333        }
334    }
335    // A column written as a third alone is a fifth and a third — the third
336    // itself, not any figure whose digits happen to end in one: `13` is a
337    // thirteenth and stands for the whole stack under it.
338    if tokens.len() == 1 && tokens[0].trim_start_matches(['#', '-', 'b', 'o']) == "3" {
339        tokens.insert(0, "5".to_string());
340    }
341    tokens
342}
343
344/// The key a secondary numeral establishes.
345///
346/// music21 reads the numeral after the slash as a chord of its own and takes
347/// the key from that chord's root and quality, so the `vi` of `V/vi` in C
348/// minor is the raised sixth degree the same way a plain `vi` would be.
349pub fn secondary_key(
350    key: &Key,
351    secondary: &str,
352    sixth_minor: Minor67Default,
353    seventh_minor: Minor67Default,
354    case_matters: bool,
355) -> Result<Key> {
356    let numeral = RomanNumeral::with_options(
357        secondary.to_string(),
358        key.clone(),
359        sixth_minor,
360        seventh_minor,
361        case_matters,
362    )?;
363    let chord = numeral.to_chord()?;
364    let root = chord
365        .root()
366        .ok_or_else(|| Error::Chord(format!("no root for secondary numeral {secondary}")))?;
367    let mode = match numeral.implied_quality {
368        ImpliedQuality::Minor => "minor",
369        ImpliedQuality::Major => "major",
370        _ if chord.semitones_from_chord_step(3) == Some(3) => "minor",
371        _ => "major",
372    };
373    Key::from_tonic_mode(&root.name(), mode)
374}
375
376/// Reads the numeral off the front of a figure.
377///
378/// music21's `_parseRNAloneAmidstAug6`, which is where the augmented sixths
379/// stop being numerals: `Ger` alone means `Ger65`, since that is the position
380/// the chord is nearly always written in, and `Fr6` means `Fr43` for the same
381/// reason.
382pub fn parse_numeral_alone(figure: &str) -> Result<NumeralAlone> {
383    if let Some(kind) = augmented_sixth_prefix(figure) {
384        let (name, default) = match kind {
385            AugmentedSixthKind::Italian => ("It", "6"),
386            AugmentedSixthKind::French => ("Fr", "43"),
387            AugmentedSixthKind::German => ("Ger", "65"),
388            AugmentedSixthKind::Swiss => ("Sw", "43"),
389        };
390        let (degree, alteration) = match kind {
391            AugmentedSixthKind::Italian | AugmentedSixthKind::German => (4, 1),
392            AugmentedSixthKind::French => (2, 0),
393            AugmentedSixthKind::Swiss => (2, 1),
394        };
395        let rest = figure[name.len()..].trim_start_matches('+');
396        // A figure written `6/5` is the same as `65`.
397        let rest = unslash_inversion(rest);
398        let first = rest.chars().next();
399        let rest = if !first.is_some_and(|digit| digit.is_ascii_digit()) {
400            format!("{default}{rest}")
401        } else if first == Some('6')
402            && kind != AugmentedSixthKind::Italian
403            && !rest
404                .chars()
405                .nth(1)
406                .is_some_and(|next| next.is_ascii_digit())
407        {
408            format!("{default}{}", &rest[1..])
409        } else {
410            rest
411        };
412        let mut bracketed = Vec::new();
413        if kind != AugmentedSixthKind::French {
414            bracketed.push((1, 1));
415        }
416        if matches!(kind, AugmentedSixthKind::French | AugmentedSixthKind::Swiss) {
417            bracketed.push((1, 3));
418        }
419        return Ok(NumeralAlone {
420            numeral: name.to_string(),
421            rest,
422            degree,
423            alteration,
424            minor: true,
425            bracketed,
426        });
427    }
428
429    let (numeral, rest) = split_roman_prefix(figure)?;
430    Ok(NumeralAlone {
431        numeral: numeral.to_string(),
432        rest: rest.to_string(),
433        degree: roman_degree(numeral)?,
434        alteration: 0,
435        minor: false,
436        bracketed: Vec::new(),
437    })
438}
439
440/// The augmented sixth a figure opens with, if it opens with one.
441pub(super) fn augmented_sixth_prefix(figure: &str) -> Option<AugmentedSixthKind> {
442    for (name, kind) in [
443        ("It", AugmentedSixthKind::Italian),
444        ("Ger", AugmentedSixthKind::German),
445        ("Fr", AugmentedSixthKind::French),
446        ("Sw", AugmentedSixthKind::Swiss),
447    ] {
448        if figure.starts_with(name) {
449            return Some(kind);
450        }
451    }
452    None
453}
454
455/// `6/5` written as `65`, which is the same figure with a slash in it.
456pub(super) fn unslash_inversion(figure: &str) -> String {
457    let letters: Vec<char> = figure.chars().collect();
458    let mut out = String::with_capacity(figure.len());
459    let mut index = 0;
460    while index < letters.len() {
461        if letters[index] == '/'
462            && index > 0
463            && letters[index - 1].is_ascii_digit()
464            && letters.get(index + 1).is_some_and(char::is_ascii_digit)
465        {
466            index += 1;
467            continue;
468        }
469        out.push(letters[index]);
470        index += 1;
471    }
472    out
473}
474
475/// Which scale degree a figured-bass column puts in the bass, for a chord
476/// whose root stands on `degree`.
477///
478/// This is music21's `bassScaleDegreeFromNotation`. It works the answer out
479/// rather than looking it up: a chord of naturals spaced by the column's own
480/// numbers is spelled, its root found, and the bass is that many steps below
481/// the root. A column that implies no root at all — `54`, say — leaves the
482/// root in the bass, which is what the degree already says.
483pub fn bass_scale_degree_from_notation(degree: u8, numbers: &[u8]) -> Result<u8> {
484    bass_scale_degree_from_notation_in(degree, numbers, 7)
485}
486
487/// The same in a collection of a given size, which for anything but a key is
488/// not seven.
489pub(super) fn bass_scale_degree_from_notation_in(
490    degree: u8,
491    numbers: &[u8],
492    cardinality: u8,
493) -> Result<u8> {
494    if !FIGURES_IMPLYING_ROOT.contains(&numbers) {
495        return Ok(degree);
496    }
497    // music21 builds the column over C3, whose diatonic note number is 22.
498    let c3 = 22;
499    let mut pitches = vec![natural_at_diatonic_number(c3)?];
500    for number in numbers {
501        pitches.push(natural_at_diatonic_number(
502            c3 + IntegerType::from(*number) - 1,
503        )?);
504    }
505    let spelled = Chord::new(pitches.as_slice())?;
506    let root = spelled
507        .root()
508        .ok_or_else(|| Error::Chord("figured bass column has no root".to_string()))?;
509    let distance = root.diatonic_note_number() - c3;
510    let count = IntegerType::from(cardinality);
511    let bass = (IntegerType::from(degree) - distance).rem_euclid(count);
512    Ok(if bass == 0 { cardinality } else { bass as u8 })
513}
514
515/// music21's `_setImpliedQualityFromString`: the quality symbol in front of
516/// the inversion digits, and the digits left after it.
517///
518/// The crate's own `dim`, `aug` and `m7b5` spellings are read here too, since
519/// a figure written that way is one it has always accepted.
520pub(super) fn implied_quality_from_string(
521    roman: &str,
522    suffix: &str,
523    case_matters: bool,
524) -> (ImpliedQuality, String) {
525    if let Some(rest) = suffix.strip_prefix('o') {
526        return (ImpliedQuality::Diminished, rest.to_string());
527    }
528    if let Some(rest) = suffix.strip_prefix('\u{00f8}') {
529        return (ImpliedQuality::HalfDiminished, rest.to_string());
530    }
531    if let Some(rest) = suffix.strip_prefix('+') {
532        return (ImpliedQuality::Augmented, rest.to_string());
533    }
534    let lower = suffix.to_ascii_lowercase();
535    if lower.contains("m7b5") {
536        return (ImpliedQuality::HalfDiminished, suffix.to_string());
537    }
538    if lower.contains("dim") {
539        return (ImpliedQuality::Diminished, suffix.to_string());
540    }
541    if lower.contains("aug") {
542        return (ImpliedQuality::Augmented, suffix.to_string());
543    }
544    // A `d` before the inversion figure is music21's dominant seventh: `Vd7`
545    // and `IVd65` are dominant sevenths whatever the scale spells.
546    if let Some((leading, figure)) = suffix.rsplit_once('d')
547        && matches!(
548            figure,
549            "7" | "65" | "6/5" | "43" | "4/3" | "42" | "4/2" | "2"
550        )
551    {
552        return (
553            ImpliedQuality::DominantSeventh,
554            format!("{leading}{figure}"),
555        );
556    }
557    if !case_matters {
558        return (ImpliedQuality::Unstated, suffix.to_string());
559    }
560    let quality = if roman.chars().next().is_some_and(char::is_uppercase) {
561        ImpliedQuality::Major
562    } else {
563        ImpliedQuality::Minor
564    };
565    (quality, suffix.to_string())
566}
567
568/// music21's `sharpen`: raises the alteration in front of a numeral, taking
569/// a sharp already written on the root out of the figure so the note is not
570/// raised twice.
571pub(super) fn sharpen_figure(figure: &mut String) {
572    if figure.contains("##") {
573        *figure = figure.replace("##8", "#8");
574    } else if figure.contains("#2") {
575        *figure = figure.replace("#2", "2");
576    } else if figure.contains("#4") {
577        *figure = figure.replace("#4", "4");
578    } else if figure.contains("#6") {
579        *figure = figure.replace("#6", "6");
580    } else {
581        *figure = figure.replace("#8", "");
582    }
583}
584
585/// Splits the leading roman numeral off a figure, returning the numeral and
586/// what follows it. Errors when the value does not begin with one, or when
587/// the numeral runs on past seven, as `VIII` does.
588pub fn split_roman_prefix(value: &str) -> Result<(&str, &str)> {
589    let first = numeral_length(value);
590    if first == 0 {
591        return Err(Error::Chord(format!("No roman numeral found in '{value}'")));
592    }
593    let rest = &value[first..];
594    if rest.starts_with(['I', 'V', 'X', 'i', 'v']) {
595        return Err(Error::Chord(format!(
596            "No roman numeral for a scale degree found in '{value}'"
597        )));
598    }
599    Ok((&value[..first], rest))
600}
601
602/// How much of the text music21's numeral pattern takes from its start:
603/// `IV`, one to three `I`s, or `V` and up to two `I`s, in either case.
604fn numeral_length(text: &str) -> usize {
605    let bytes = text.as_bytes();
606    let count = |from: usize, letter: u8, most: usize| {
607        bytes[from..]
608            .iter()
609            .take(most)
610            .take_while(|byte| **byte == letter)
611            .count()
612    };
613    for (one, five) in [(b'I', b'V'), (b'i', b'v')] {
614        if bytes.starts_with(&[one, five]) {
615            return 2;
616        }
617        let ones = count(0, one, 3);
618        if ones > 0 {
619            return ones;
620        }
621        if bytes.first() == Some(&five) {
622            return 1 + count(1, one, 2);
623        }
624    }
625    0
626}
627
628pub(super) fn roman_degree(roman: &str) -> Result<u8> {
629    match roman.to_ascii_uppercase().as_str() {
630        "I" => Ok(1),
631        "II" => Ok(2),
632        "III" => Ok(3),
633        "IV" => Ok(4),
634        "V" => Ok(5),
635        "VI" => Ok(6),
636        "VII" => Ok(7),
637        _ => Err(Error::Chord(format!("unsupported roman numeral {roman:?}"))),
638    }
639}
640
641pub(super) fn suffix_has_seventh(suffix: &str) -> bool {
642    let suffix = strip_roman_addition_groups(suffix);
643    suffix.contains('7')
644        || suffix.contains('9')
645        || suffix.contains("11")
646        || suffix.contains("13")
647        || suffix.contains("65")
648        || suffix.contains("43")
649        || suffix.contains("42")
650}
651
652/// music21's `roman.figureShorthands`, mapping a full figured-bass string to
653/// the abbreviation musicians actually write.
654///
655/// The inversion logic normalizes a figure through this table rather than
656/// probing it for substrings, so `642` reads as the third inversion it is and
657/// not as the `64` inside it.
658pub(super) const FIGURE_SHORTHANDS: [(&str, &str); 20] = [
659    ("53", ""),
660    ("3", ""),
661    ("63", "6"),
662    ("753", "7"),
663    ("75", "7"),
664    ("73", "7"),
665    ("9753", "9"),
666    ("975", "9"),
667    ("953", "9"),
668    ("97", "9"),
669    ("95", "9"),
670    ("93", "9"),
671    ("653", "65"),
672    ("6b53", "6b5"),
673    ("643", "43"),
674    ("642", "42"),
675    ("bb7b5b3", "o7"),
676    ("b7b5b3", "\u{00f8}7"),
677    ("bb7b53", "o7"),
678    ("b7b53", "\u{00f8}7"),
679];
680
681/// Returns the shorthand for a figure, or the figure itself when it has none.
682pub(super) fn normalize_figure(figure: &str) -> &str {
683    FIGURE_SHORTHANDS
684        .iter()
685        .find(|(full, _)| *full == figure)
686        .map_or(figure, |(_, short)| *short)
687}
688
689pub(super) fn parse_inversion(suffix: &str) -> u8 {
690    let suffix = strip_roman_addition_groups(suffix);
691    // Only the figured-bass digits decide the inversion; quality marks such as
692    // `o`, `+` and the half-diminished sign ride along in the suffix.
693    let digits: String = suffix.chars().filter(char::is_ascii_digit).collect();
694
695    match normalize_figure(&digits) {
696        "42" => 3,
697        "43" | "64" => 2,
698        "65" | "6" => 1,
699        _ => 0,
700    }
701}
702
703pub(super) fn strip_roman_addition_groups(suffix: &str) -> String {
704    let mut stripped = String::with_capacity(suffix.len());
705    let mut rest = suffix;
706    while let Some(index) = rest.find("add(") {
707        stripped.push_str(&rest[..index]);
708        let addition = &rest[index + 4..];
709        let Some(end) = addition.find(')') else {
710            rest = addition;
711            continue;
712        };
713        rest = &addition[end + 1..];
714    }
715    stripped.push_str(rest);
716    stripped
717}
718
719pub(crate) fn degree_to_roman(degree: u8) -> &'static str {
720    match degree {
721        1 => "I",
722        2 => "II",
723        3 => "III",
724        4 => "IV",
725        5 => "V",
726        6 => "VI",
727        7 => "VII",
728        _ => "I",
729    }
730}