chordlib 0.15.0

Work with chord-and-lyrics songs: parse, transform, and render them to multiple formats
Documentation
use encoding_rs::WINDOWS_1252;

use crate::Error;

#[derive(Clone, Copy)]
struct State {
    skip_destination: bool,
    unicode_fallback: usize,
}

impl Default for State {
    fn default() -> Self {
        Self {
            skip_destination: false,
            unicode_fallback: 1,
        }
    }
}

pub(crate) fn decode(input: &[u8]) -> Result<String, Error> {
    if !input.starts_with(b"{\\rtf") {
        return Err(Error::Parse(
            "ProPresenter text element does not contain an RTF document".into(),
        ));
    }

    let mut output = String::new();
    let mut stack = vec![State::default()];
    let mut index = 0usize;
    let mut fallback_left = 0usize;
    let mut unicode_units = Vec::<u16>::new();

    while index < input.len() {
        match input[index] {
            b'{' => {
                let state = stack
                    .last()
                    .copied()
                    .ok_or_else(|| Error::Parse("invalid empty ProPresenter RTF state".into()))?;
                stack.push(state);
                index += 1;
            }
            b'}' => {
                if stack.len() == 1 {
                    return Err(Error::Parse(
                        "unbalanced closing brace in ProPresenter RTF".into(),
                    ));
                }
                flush_unicode(&mut output, &mut unicode_units)?;
                stack.pop();
                index += 1;
            }
            b'\\' => {
                index += 1;
                if index >= input.len() {
                    return Err(Error::Parse(
                        "trailing backslash in ProPresenter RTF".into(),
                    ));
                }
                let state = stack
                    .last_mut()
                    .ok_or_else(|| Error::Parse("invalid empty ProPresenter RTF state".into()))?;
                match input[index] {
                    b'\\' | b'{' | b'}' => {
                        emit_byte(
                            input[index],
                            state.skip_destination,
                            &mut fallback_left,
                            &mut output,
                            &mut unicode_units,
                        )?;
                        index += 1;
                    }
                    b'\'' => {
                        if index + 2 >= input.len() {
                            return Err(Error::Parse("truncated hexadecimal RTF escape".into()));
                        }
                        let value = hex(input[index + 1])?
                            .checked_mul(16)
                            .and_then(|high| hex(input[index + 2]).ok().map(|low| high + low))
                            .ok_or_else(|| Error::Parse("invalid hexadecimal RTF escape".into()))?;
                        emit_cp1252(
                            value,
                            state.skip_destination,
                            &mut fallback_left,
                            &mut output,
                            &mut unicode_units,
                        )?;
                        index += 3;
                    }
                    b'*' => {
                        state.skip_destination = true;
                        index += 1;
                    }
                    symbol if !symbol.is_ascii_alphabetic() => {
                        if !state.skip_destination {
                            match symbol {
                                b'~' => emit_char(
                                    '\u{00a0}',
                                    &mut fallback_left,
                                    &mut output,
                                    &mut unicode_units,
                                )?,
                                b'_' => emit_char(
                                    '\u{2011}',
                                    &mut fallback_left,
                                    &mut output,
                                    &mut unicode_units,
                                )?,
                                b'-' => {}
                                _ => {}
                            }
                        }
                        index += 1;
                    }
                    _ => {
                        let word_start = index;
                        while index < input.len() && input[index].is_ascii_alphabetic() {
                            index += 1;
                        }
                        let word =
                            std::str::from_utf8(&input[word_start..index]).map_err(|_| {
                                Error::Parse("invalid ProPresenter RTF control word".into())
                            })?;
                        let mut sign = 1i32;
                        if input.get(index) == Some(&b'-') {
                            sign = -1;
                            index += 1;
                        }
                        let number_start = index;
                        while index < input.len() && input[index].is_ascii_digit() {
                            index += 1;
                        }
                        let number = if number_start == index {
                            None
                        } else {
                            Some(
                                std::str::from_utf8(&input[number_start..index])
                                    .map_err(|_| {
                                        Error::Parse("invalid ProPresenter RTF number".into())
                                    })?
                                    .parse::<i32>()
                                    .map_err(|_| {
                                        Error::Parse("RTF control number is too large".into())
                                    })?
                                    * sign,
                            )
                        };
                        if input.get(index) == Some(&b' ') {
                            index += 1;
                        }

                        if is_destination(word) {
                            state.skip_destination = true;
                        } else if word == "uc" {
                            let value = number.ok_or_else(|| {
                                Error::Parse("RTF \\uc control has no value".into())
                            })?;
                            state.unicode_fallback = usize::try_from(value).map_err(|_| {
                                Error::Parse("RTF \\uc value must be non-negative".into())
                            })?;
                        } else if word == "u" && !state.skip_destination {
                            let value = number.ok_or_else(|| {
                                Error::Parse("RTF \\u control has no value".into())
                            })?;
                            let unit = (value as i16) as u16;
                            unicode_units.push(unit);
                            fallback_left = state.unicode_fallback;
                        } else if matches!(word, "par" | "line") && !state.skip_destination {
                            emit_char('\n', &mut fallback_left, &mut output, &mut unicode_units)?;
                        } else if word == "tab" && !state.skip_destination {
                            emit_char('\t', &mut fallback_left, &mut output, &mut unicode_units)?;
                        } else if word == "bin" {
                            let count = number.ok_or_else(|| {
                                Error::Parse("RTF \\bin control has no value".into())
                            })?;
                            let count = usize::try_from(count).map_err(|_| {
                                Error::Parse("RTF \\bin value must be non-negative".into())
                            })?;
                            index = index.checked_add(count).ok_or_else(|| {
                                Error::Parse("RTF binary data length overflow".into())
                            })?;
                            if index > input.len() {
                                return Err(Error::Parse("truncated RTF binary data".into()));
                            }
                        }
                    }
                }
            }
            b'\r' | b'\n' => index += 1,
            byte => {
                let skip = stack
                    .last()
                    .ok_or_else(|| Error::Parse("invalid empty ProPresenter RTF state".into()))?
                    .skip_destination;
                if byte.is_ascii() {
                    emit_byte(
                        byte,
                        skip,
                        &mut fallback_left,
                        &mut output,
                        &mut unicode_units,
                    )?;
                } else {
                    emit_cp1252(
                        byte,
                        skip,
                        &mut fallback_left,
                        &mut output,
                        &mut unicode_units,
                    )?;
                }
                index += 1;
            }
        }
    }

    if stack.len() != 1 {
        return Err(Error::Parse(
            "unbalanced opening brace in ProPresenter RTF".into(),
        ));
    }
    flush_unicode(&mut output, &mut unicode_units)?;
    Ok(output.trim_end_matches('\n').to_string())
}

fn is_destination(word: &str) -> bool {
    matches!(
        word,
        "fonttbl"
            | "colortbl"
            | "stylesheet"
            | "info"
            | "pict"
            | "object"
            | "header"
            | "footer"
            | "xmlnstbl"
            | "listtable"
            | "listoverridetable"
            | "revtbl"
            | "datastore"
            | "themedata"
            | "generator"
    )
}

fn hex(byte: u8) -> Result<u8, Error> {
    match byte {
        b'0'..=b'9' => Ok(byte - b'0'),
        b'a'..=b'f' => Ok(byte - b'a' + 10),
        b'A'..=b'F' => Ok(byte - b'A' + 10),
        _ => Err(Error::Parse("invalid hexadecimal RTF escape".into())),
    }
}

fn emit_byte(
    byte: u8,
    skip_destination: bool,
    fallback_left: &mut usize,
    output: &mut String,
    unicode_units: &mut Vec<u16>,
) -> Result<(), Error> {
    if skip_destination {
        return Ok(());
    }
    emit_char(byte as char, fallback_left, output, unicode_units)
}

fn emit_cp1252(
    byte: u8,
    skip_destination: bool,
    fallback_left: &mut usize,
    output: &mut String,
    unicode_units: &mut Vec<u16>,
) -> Result<(), Error> {
    if skip_destination {
        return Ok(());
    }
    let input = [byte];
    let (decoded, _, _) = WINDOWS_1252.decode(&input);
    for character in decoded.chars() {
        emit_char(character, fallback_left, output, unicode_units)?;
    }
    Ok(())
}

fn emit_char(
    character: char,
    fallback_left: &mut usize,
    output: &mut String,
    unicode_units: &mut Vec<u16>,
) -> Result<(), Error> {
    if *fallback_left > 0 {
        *fallback_left -= 1;
        return Ok(());
    }
    flush_unicode(output, unicode_units)?;
    output.push(character);
    Ok(())
}

fn flush_unicode(output: &mut String, units: &mut Vec<u16>) -> Result<(), Error> {
    for decoded in char::decode_utf16(units.drain(..)) {
        output
            .push(decoded.map_err(|_| Error::Parse("invalid UTF-16 in ProPresenter RTF".into()))?);
    }
    Ok(())
}

pub(crate) fn encode(text: &str) -> Vec<u8> {
    let mut output = String::from(
        "{\\rtf1\\ansi\\ansicpg1252\\deff0{\\fonttbl{\\f0 Arial;}}\\viewkind4\\uc1\\pard\\qc\\f0\\fs144 ",
    );
    for character in text.chars() {
        match character {
            '\\' => output.push_str("\\\\"),
            '{' => output.push_str("\\{"),
            '}' => output.push_str("\\}"),
            '\n' => output.push_str("\\line "),
            '\t' => output.push_str("\\tab "),
            character if character.is_ascii() => output.push(character),
            character => {
                let mut buffer = [0u16; 2];
                for unit in character.encode_utf16(&mut buffer) {
                    output.push_str("\\u");
                    output.push_str(&((*unit as i16) as i32).to_string());
                    output.push('?');
                }
            }
        }
    }
    output.push('}');
    output.into_bytes()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn round_trips_unicode_and_reserved_characters() {
        let text = "Grüße 😊\\{test}\nnext\tcolumn";
        assert_eq!(decode(&encode(text)).expect("decode generated RTF"), text);
    }

    #[test]
    fn decodes_destinations_hex_and_controls() {
        let input =
            br#"{\rtf1\ansi\ansicpg1252{\fonttbl{\f0 Arial;}}\uc1 A\'e4 \u214?\line B\tab C}"#;
        assert_eq!(decode(input).expect("decode RTF"), "Aä Ö\nB\tC");
    }

    #[test]
    fn rejects_unbalanced_and_non_rtf_input() {
        assert!(decode(b"plain").is_err());
        assert!(decode(br#"{\rtf1 broken"#).is_err());
        assert!(decode(br#"{\rtf1 broken}}"#).is_err());
    }
}