caj2pdf-core 0.5.0

Platform-neutral CAJ-family conversion core
Documentation
// SPDX-License-Identifier: MIT

//! Strict GB18030 character and bounded outline-title decoding.
//!
//! The mapping data in `tables` was independently generated by querying
//! Python 3.13.5's `gb18030` codec as a black box; that module records how.
//! No codec source or mapping table from another project was copied. The CAJ
//! and HN callers bound input using their fixed-width title fields.

mod tables;

use tables::{FOUR_BYTE_RANGES, TWO_BYTE_VALUES};

/// Decode exactly the supplied bytes; malformed or incomplete input is
/// rejected with the title byte offset where the bad sequence starts.
/// The caller removes the title-field NUL padding before calling this.
pub(crate) fn decode(bytes: &[u8]) -> Result<String, usize> {
    let mut decoded = String::with_capacity(bytes.len());
    let mut cursor = 0;
    while cursor < bytes.len() {
        let lead = bytes[cursor];
        if lead <= 0x7f {
            decoded.push(char::from(lead));
            cursor += 1;
            continue;
        }
        if !(0x81..=0xfe).contains(&lead) {
            return Err(cursor);
        }
        let second = *bytes.get(cursor + 1).ok_or(cursor)?;
        let (codepoint, width) = if (0x30..=0x39).contains(&second) {
            let third = *bytes.get(cursor + 2).ok_or(cursor)?;
            if !(0x81..=0xfe).contains(&third) {
                return Err(cursor);
            }
            let fourth = *bytes.get(cursor + 3).ok_or(cursor)?;
            if !(0x30..=0x39).contains(&fourth) {
                return Err(cursor);
            }
            let pointer = (((u32::from(lead - 0x81) * 10 + u32::from(second - 0x30)) * 126
                + u32::from(third - 0x81))
                * 10)
                + u32::from(fourth - 0x30);
            let index = FOUR_BYTE_RANGES.partition_point(|&(start, _, _)| start <= pointer);
            // The first range starts at pointer zero, so `index >= 1`.
            let (start, end, initial) = FOUR_BYTE_RANGES[index - 1];
            if pointer > end {
                return Err(cursor);
            }
            (initial + pointer - start, 4)
        } else {
            let character = decode_two_byte(lead, second).ok_or(cursor)?;
            (u32::from(character), 2)
        };
        let character = char::from_u32(codepoint).ok_or(cursor)?;
        decoded.push(character);
        cursor += width;
    }
    Ok(decoded)
}

/// Decode one two-byte GB18030 sequence without allocating an outline string.
pub(crate) fn decode_two_byte(lead: u8, second: u8) -> Option<char> {
    if !(0x81..=0xfe).contains(&lead)
        || !((0x40..=0x7e).contains(&second) || (0x80..=0xfe).contains(&second))
    {
        return None;
    }
    let trail = if second <= 0x7e {
        second - 0x40
    } else {
        second - 0x41
    };
    let index = usize::from(lead - 0x81) * 190 + usize::from(trail);
    char::from_u32(u32::from(TWO_BYTE_VALUES[index]))
}

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

    #[test]
    fn decodes_ascii_gbk_and_four_byte_characters_together() {
        let input = b"Chapter 1: \xc4\xe3 \xa2\xe3 \x81\x39\xee\x39 \x95\x34\xb2\x35";
        assert_eq!(decode(input).unwrap(), "Chapter 1: 你 € 㐀 𠮷");
    }

    #[test]
    fn decodes_four_byte_boundaries() {
        assert_eq!(decode(b"\x81\x30\x81\x30").unwrap(), "\u{80}");
        assert_eq!(decode(b"\x84\x31\xa4\x39").unwrap(), "\u{ffff}");
        assert_eq!(decode(b"\x90\x30\x81\x30").unwrap(), "\u{10000}");
        assert_eq!(decode(b"\xe3\x32\x9a\x35").unwrap(), "\u{10ffff}");
    }

    #[test]
    fn decodes_two_byte_trail_boundaries() {
        assert_eq!(decode(b"\x81\x40").unwrap(), "\u{4e02}");
        assert_eq!(decode(b"\x81\x7e").unwrap(), "\u{4e8a}");
        assert_eq!(decode(b"\x81\x80").unwrap(), "\u{4e90}");
        assert_eq!(decode(b"\xfe\xfe").unwrap(), "\u{e4c5}");
    }

    #[test]
    fn rejects_gaps_and_bad_trails_without_replacement() {
        for bytes in [
            b"\x81\x7f".as_slice(),
            b"\xff\x40",
            b"\x81\x30\x80\x30",
            b"\x81\x30\x81\x40",
            b"\x84\x31\xa5\x30",
            b"\xe3\x32\x9a\x36",
        ] {
            assert_eq!(decode(bytes).unwrap_err(), 0);
        }
    }

    #[test]
    fn reports_the_start_of_a_truncated_sequence() {
        for bytes in [b"A\x81".as_slice(), b"A\x81\x30", b"A\x81\x30\x81"] {
            assert_eq!(decode(bytes).unwrap_err(), 1);
        }
    }

    #[test]
    fn reports_the_title_byte_offset() {
        assert_eq!(decode(b"ok\xff").unwrap_err(), 2);
        assert_eq!(decode(b"abc\x95\x32\x82").unwrap_err(), 3);
    }

    #[test]
    fn preserves_duplicate_text_and_does_not_trim_title_bytes() {
        assert_eq!(decode(b"\xd5\xaa\xd2\xaa ").unwrap(), "摘要 ");
        assert_eq!(decode(b"\0").unwrap(), "\0");
    }
}