mdict-rs 0.1.4

Library-first Rust parser for MDict .mdx and .mdd dictionaries
Documentation
use std::collections::BTreeMap;

use crate::checksum::verify_adler32;
use crate::cursor::Cursor;
use crate::encoding::TextEncoding;
use crate::error::{Error, Result};
use crate::limits::MAX_HEADER_XML_BYTES;
use crate::source::FileSource;
use crate::types::{ContainerKind, Header};

#[derive(Debug, Clone)]
pub struct HeaderSection {
    pub header: Header,
    pub keyword_section_offset: u64,
}

/// Parses the top-level MDict header from a file-backed source.
pub fn parse_header(source: &FileSource, kind: ContainerKind) -> Result<HeaderSection> {
    let len_bytes = source.read_exact_at(0, 4, "header length")?;
    let mut cursor = Cursor::new(&len_bytes);
    let xml_len = cursor.read_u32_be("header length")? as usize;
    let total_len = 4usize
        .checked_add(xml_len)
        .and_then(|value| value.checked_add(4))
        .ok_or(Error::InvalidFormat("header size overflow"))?;
    let bytes = source.read_exact_at(0, total_len, "header section")?;
    parse_header_bytes(&bytes, kind)
}

/// Parses the top-level MDict header from raw file bytes.
///
/// The provided slice must contain at least the full `header_sect`
/// (`u32 BE length` + UTF-16LE XML + `u32 LE` ADLER32).
pub fn parse_header_bytes(bytes: &[u8], kind: ContainerKind) -> Result<HeaderSection> {
    let mut cursor = Cursor::new(bytes);
    let xml_len = cursor.read_u32_be("header length")? as usize;
    if xml_len > MAX_HEADER_XML_BYTES {
        return Err(Error::LimitExceeded {
            limit: "header_xml_bytes",
            value: xml_len as u64,
            max: MAX_HEADER_XML_BYTES as u64,
        });
    }

    let xml_bytes = cursor.read_bytes(xml_len, "header xml")?;
    let expected = cursor.read_u32_le("header checksum")?;
    verify_adler32("header xml", xml_bytes, expected)?;

    let raw_xml = TextEncoding::Utf16Le.decode(xml_bytes, "header xml")?;
    let raw_xml = raw_xml.trim_matches('\0').trim().to_owned();
    let (tag_name, attributes) = parse_single_tag(&raw_xml)?;
    build_header_section(kind, xml_len, raw_xml, tag_name, attributes)
}

fn build_header_section(
    kind: ContainerKind,
    xml_len: usize,
    raw_xml: String,
    tag_name: String,
    attributes: BTreeMap<String, String>,
) -> Result<HeaderSection> {
    match (kind, tag_name.as_str()) {
        (ContainerKind::Mdx, "Dictionary") | (ContainerKind::Mdd, "Library_Data") => {}
        _ => return Err(Error::InvalidFormat("unexpected top-level header tag")),
    }

    let generated_by_engine_version = attributes
        .get("GeneratedByEngineVersion")
        .cloned()
        .ok_or(Error::InvalidFormat("missing GeneratedByEngineVersion"))?;
    let required_engine_version = attributes
        .get("RequiredEngineVersion")
        .cloned()
        .unwrap_or_else(|| generated_by_engine_version.clone());

    let encrypted = attributes
        .get("Encrypted")
        .and_then(|value| {
            if value.eq_ignore_ascii_case("yes") {
                Some(1)
            } else if value.eq_ignore_ascii_case("no") {
                Some(0)
            } else {
                value.parse::<u8>().ok()
            }
        })
        .unwrap_or(0);

    let header = Header {
        raw_xml,
        tag_name,
        generated_by_engine_version,
        required_engine_version,
        encoding_label: attributes
            .get("Encoding")
            .cloned()
            .filter(|value| !value.is_empty()),
        format: attributes
            .get("Format")
            .cloned()
            .filter(|value| !value.is_empty()),
        key_case_sensitive: parse_yes_no(attributes.get("KeyCaseSensitive")),
        strip_key: parse_yes_no(attributes.get("StripKey")),
        encrypted,
        register_by: attributes
            .get("RegisterBy")
            .cloned()
            .filter(|value| !value.is_empty()),
        reg_code: attributes
            .get("RegCode")
            .cloned()
            .filter(|value| !value.is_empty()),
        description: attributes
            .get("Description")
            .cloned()
            .filter(|value| !value.is_empty()),
        title: attributes
            .get("Title")
            .cloned()
            .filter(|value| !value.is_empty()),
        creation_date: attributes
            .get("CreationDate")
            .cloned()
            .filter(|value| !value.is_empty()),
        attributes,
    };

    Ok(HeaderSection {
        header,
        keyword_section_offset: 4 + xml_len as u64 + 4,
    })
}

fn parse_single_tag(xml: &str) -> Result<(String, BTreeMap<String, String>)> {
    let bytes = xml.as_bytes();
    if !bytes.starts_with(b"<") {
        return Err(Error::InvalidFormat("header xml must start with '<'"));
    }
    let mut index = 1usize;
    while index < bytes.len() && !bytes[index].is_ascii_whitespace() && bytes[index] != b'>' {
        index += 1;
    }
    let tag_name = xml[1..index].to_owned();
    let mut attrs = BTreeMap::new();

    loop {
        skip_ascii_whitespace(bytes, &mut index);
        if index >= bytes.len() {
            return Err(Error::InvalidFormat("unterminated header xml tag"));
        }
        match bytes[index] {
            b'/' => {
                index += 1;
                if bytes.get(index) == Some(&b'>') {
                    break;
                }
                return Err(Error::InvalidFormat("invalid self-closing header tag"));
            }
            b'>' => break,
            _ => {}
        }

        let name_start = index;
        while index < bytes.len()
            && !bytes[index].is_ascii_whitespace()
            && bytes[index] != b'='
            && bytes[index] != b'>'
        {
            index += 1;
        }
        let name = xml[name_start..index].to_owned();
        skip_ascii_whitespace(bytes, &mut index);
        if bytes.get(index) != Some(&b'=') {
            return Err(Error::InvalidFormat("expected '=' in header xml attribute"));
        }
        index += 1;
        skip_ascii_whitespace(bytes, &mut index);
        if bytes.get(index) != Some(&b'"') {
            return Err(Error::InvalidFormat("expected quoted header xml attribute"));
        }
        index += 1;
        let value_start = index;
        while index < bytes.len() && bytes[index] != b'"' {
            index += 1;
        }
        if index >= bytes.len() {
            return Err(Error::InvalidFormat(
                "unterminated header xml attribute value",
            ));
        }
        let value = unescape_xml(&xml[value_start..index])?;
        attrs.insert(name, value);
        index += 1;
    }

    Ok((tag_name, attrs))
}

fn skip_ascii_whitespace(bytes: &[u8], index: &mut usize) {
    while *index < bytes.len() && bytes[*index].is_ascii_whitespace() {
        *index += 1;
    }
}

fn parse_yes_no(value: Option<&String>) -> bool {
    value
        .map(|value| value.eq_ignore_ascii_case("yes"))
        .unwrap_or(false)
}

fn unescape_xml(value: &str) -> Result<String> {
    let mut out = String::with_capacity(value.len());
    let mut chars = value.chars().peekable();
    while let Some(ch) = chars.next() {
        if ch != '&' {
            out.push(ch);
            continue;
        }

        let mut entity = String::new();
        while let Some(next) = chars.next() {
            if next == ';' {
                break;
            }
            entity.push(next);
        }
        match entity.as_str() {
            "amp" => out.push('&'),
            "lt" => out.push('<'),
            "gt" => out.push('>'),
            "quot" => out.push('"'),
            "apos" => out.push('\''),
            _ if entity.starts_with("#x") => {
                let codepoint = u32::from_str_radix(&entity[2..], 16)
                    .map_err(|_| Error::InvalidFormat("invalid hex xml entity"))?;
                let ch = char::from_u32(codepoint)
                    .ok_or(Error::InvalidFormat("invalid hex xml codepoint"))?;
                out.push(ch);
            }
            _ if entity.starts_with('#') => {
                let codepoint = entity[1..]
                    .parse::<u32>()
                    .map_err(|_| Error::InvalidFormat("invalid decimal xml entity"))?;
                let ch = char::from_u32(codepoint)
                    .ok_or(Error::InvalidFormat("invalid decimal xml codepoint"))?;
                out.push(ch);
            }
            _ => return Err(Error::InvalidFormat("unsupported xml entity")),
        }
    }
    Ok(out)
}

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

    #[test]
    fn parses_single_tag_attributes() {
        let xml =
            r#"<Dictionary Encoding="UTF-8" StripKey="Yes" Description="a &lt;b&gt;c&lt;/b&gt;"/>"#;
        let (tag, attrs) = parse_single_tag(xml).unwrap();
        assert_eq!(tag, "Dictionary");
        assert_eq!(attrs.get("Encoding").unwrap(), "UTF-8");
        assert_eq!(attrs.get("StripKey").unwrap(), "Yes");
        assert_eq!(attrs.get("Description").unwrap(), "a <b>c</b>");
    }
}