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    let middle_c = 22;
498    let mut pitches = vec![natural_at_diatonic_number(middle_c)?];
499    for number in numbers {
500        pitches.push(natural_at_diatonic_number(
501            middle_c + IntegerType::from(*number) - 1,
502        )?);
503    }
504    let spelled = Chord::new(pitches.as_slice())?;
505    let root = spelled
506        .root()
507        .ok_or_else(|| Error::Chord("figured bass column has no root".to_string()))?;
508    let distance = root.diatonic_note_number() - middle_c;
509    let count = IntegerType::from(cardinality);
510    let bass = (IntegerType::from(degree) - distance).rem_euclid(count);
511    Ok(if bass == 0 { cardinality } else { bass as u8 })
512}
513
514/// music21's `_setImpliedQualityFromString`: the quality symbol in front of
515/// the inversion digits, and the digits left after it.
516///
517/// The crate's own `dim`, `aug` and `m7b5` spellings are read here too, since
518/// a figure written that way is one it has always accepted.
519pub(super) fn implied_quality_from_string(
520    roman: &str,
521    suffix: &str,
522    case_matters: bool,
523) -> (ImpliedQuality, String) {
524    if let Some(rest) = suffix.strip_prefix('o') {
525        return (ImpliedQuality::Diminished, rest.to_string());
526    }
527    if let Some(rest) = suffix.strip_prefix('\u{00f8}') {
528        return (ImpliedQuality::HalfDiminished, rest.to_string());
529    }
530    if let Some(rest) = suffix.strip_prefix('+') {
531        return (ImpliedQuality::Augmented, rest.to_string());
532    }
533    let lower = suffix.to_ascii_lowercase();
534    if lower.contains("m7b5") {
535        return (ImpliedQuality::HalfDiminished, suffix.to_string());
536    }
537    if lower.contains("dim") {
538        return (ImpliedQuality::Diminished, suffix.to_string());
539    }
540    if lower.contains("aug") {
541        return (ImpliedQuality::Augmented, suffix.to_string());
542    }
543    // A `d` before the inversion figure is music21's dominant seventh: `Vd7`
544    // and `IVd65` are dominant sevenths whatever the scale spells.
545    if let Some((leading, figure)) = suffix.rsplit_once('d')
546        && matches!(
547            figure,
548            "7" | "65" | "6/5" | "43" | "4/3" | "42" | "4/2" | "2"
549        )
550    {
551        return (
552            ImpliedQuality::DominantSeventh,
553            format!("{leading}{figure}"),
554        );
555    }
556    if !case_matters {
557        return (ImpliedQuality::Unstated, suffix.to_string());
558    }
559    let quality = if roman.chars().next().is_some_and(char::is_uppercase) {
560        ImpliedQuality::Major
561    } else {
562        ImpliedQuality::Minor
563    };
564    (quality, suffix.to_string())
565}
566
567/// music21's `sharpen`: raises the alteration in front of a numeral, taking
568/// a sharp already written on the root out of the figure so the note is not
569/// raised twice.
570pub(super) fn sharpen_figure(figure: &mut String) {
571    if figure.contains("##") {
572        *figure = figure.replace("##8", "#8");
573    } else if figure.contains("#2") {
574        *figure = figure.replace("#2", "2");
575    } else if figure.contains("#4") {
576        *figure = figure.replace("#4", "4");
577    } else if figure.contains("#6") {
578        *figure = figure.replace("#6", "6");
579    } else {
580        *figure = figure.replace("#8", "");
581    }
582}
583
584/// Splits the leading roman numeral off a figure, returning the numeral and
585/// what follows it. Errors when the value does not begin with one.
586pub fn split_roman_prefix(value: &str) -> Result<(&str, &str)> {
587    let end = value
588        .char_indices()
589        .find_map(|(idx, ch)| (!matches!(ch, 'I' | 'V' | 'X' | 'i' | 'v' | 'x')).then_some(idx))
590        .unwrap_or(value.len());
591
592    if end == 0 {
593        return Err(Error::Chord(format!("No roman numeral found in '{value}'")));
594    }
595
596    Ok((&value[..end], &value[end..]))
597}
598
599pub(super) fn roman_degree(roman: &str) -> Result<u8> {
600    match roman.to_ascii_uppercase().as_str() {
601        "I" => Ok(1),
602        "II" => Ok(2),
603        "III" => Ok(3),
604        "IV" => Ok(4),
605        "V" => Ok(5),
606        "VI" => Ok(6),
607        "VII" => Ok(7),
608        _ => Err(Error::Chord(format!("unsupported roman numeral {roman:?}"))),
609    }
610}
611
612pub(super) fn suffix_has_seventh(suffix: &str) -> bool {
613    let suffix = strip_roman_addition_groups(suffix);
614    suffix.contains('7')
615        || suffix.contains('9')
616        || suffix.contains("11")
617        || suffix.contains("13")
618        || suffix.contains("65")
619        || suffix.contains("43")
620        || suffix.contains("42")
621}
622
623/// music21's `roman.figureShorthands`, mapping a full figured-bass string to
624/// the abbreviation musicians actually write.
625///
626/// The inversion logic normalizes a figure through this table rather than
627/// probing it for substrings, so `642` reads as the third inversion it is and
628/// not as the `64` inside it.
629pub(super) const FIGURE_SHORTHANDS: [(&str, &str); 20] = [
630    ("53", ""),
631    ("3", ""),
632    ("63", "6"),
633    ("753", "7"),
634    ("75", "7"),
635    ("73", "7"),
636    ("9753", "9"),
637    ("975", "9"),
638    ("953", "9"),
639    ("97", "9"),
640    ("95", "9"),
641    ("93", "9"),
642    ("653", "65"),
643    ("6b53", "6b5"),
644    ("643", "43"),
645    ("642", "42"),
646    ("bb7b5b3", "o7"),
647    ("b7b5b3", "\u{00f8}7"),
648    ("bb7b53", "o7"),
649    ("b7b53", "\u{00f8}7"),
650];
651
652/// Returns the shorthand for a figure, or the figure itself when it has none.
653pub(super) fn normalize_figure(figure: &str) -> &str {
654    FIGURE_SHORTHANDS
655        .iter()
656        .find(|(full, _)| *full == figure)
657        .map_or(figure, |(_, short)| *short)
658}
659
660pub(super) fn parse_inversion(suffix: &str) -> u8 {
661    let suffix = strip_roman_addition_groups(suffix);
662    // Only the figured-bass digits decide the inversion; quality marks such as
663    // `o`, `+` and the half-diminished sign ride along in the suffix.
664    let digits: String = suffix.chars().filter(char::is_ascii_digit).collect();
665
666    match normalize_figure(&digits) {
667        "42" => 3,
668        "43" | "64" => 2,
669        "65" | "6" => 1,
670        _ => 0,
671    }
672}
673
674pub(super) fn strip_roman_addition_groups(suffix: &str) -> String {
675    let mut stripped = String::with_capacity(suffix.len());
676    let mut rest = suffix;
677    while let Some(index) = rest.find("add(") {
678        stripped.push_str(&rest[..index]);
679        let addition = &rest[index + 4..];
680        let Some(end) = addition.find(')') else {
681            rest = addition;
682            continue;
683        };
684        rest = &addition[end + 1..];
685    }
686    stripped.push_str(rest);
687    stripped
688}
689
690pub(crate) fn degree_to_roman(degree: u8) -> &'static str {
691    match degree {
692        1 => "I",
693        2 => "II",
694        3 => "III",
695        4 => "IV",
696        5 => "V",
697        6 => "VI",
698        7 => "VII",
699        _ => "I",
700    }
701}