rustitch 0.2.0

PES embroidery file parser and thumbnail renderer
Documentation
use crate::error::Error;
use crate::palette::default_colors;
use crate::types::{ResolvedDesign, StitchCommand};

/// Parse an EXP (Melco) file from raw bytes into stitch commands.
///
/// EXP format: 2 bytes per stitch (signed i8 dx, dy).
/// Escape byte 0x80 followed by a control byte:
///   0x01 = color change
///   0x02 = color change (variant)
///   0x04 = jump (next 2 bytes are jump dx, dy)
///   0x80 = trim
pub fn parse(data: &[u8]) -> Result<Vec<StitchCommand>, Error> {
    if data.len() < 2 {
        return Err(Error::TooShort {
            expected: 2,
            actual: data.len(),
        });
    }

    let mut commands = Vec::new();
    let mut i = 0;

    while i + 1 < data.len() {
        let b1 = data[i];
        let b2 = data[i + 1];

        if b1 == 0x80 {
            match b2 {
                0x01 | 0x02 => {
                    commands.push(StitchCommand::ColorChange);
                    i += 2;
                }
                0x80 => {
                    commands.push(StitchCommand::Trim);
                    i += 2;
                }
                0x04 => {
                    // Jump: next 2 bytes are the movement
                    i += 2;
                    if i + 1 >= data.len() {
                        break;
                    }
                    let dx = data[i] as i8 as i16;
                    let dy = data[i + 1] as i8 as i16;
                    commands.push(StitchCommand::Jump { dx, dy });
                    i += 2;
                }
                _ => {
                    // Unknown escape, skip
                    i += 2;
                }
            }
        } else {
            let dx = b1 as i8 as i16;
            let dy = b2 as i8 as i16;
            commands.push(StitchCommand::Stitch { dx, dy });
            i += 2;
        }
    }

    commands.push(StitchCommand::End);

    if commands.len() <= 1 {
        return Err(Error::NoStitchData);
    }

    Ok(commands)
}

/// Parse an EXP file and resolve to a renderable design.
pub fn parse_and_resolve(data: &[u8]) -> Result<ResolvedDesign, Error> {
    let commands = parse(data)?;
    let color_count = commands
        .iter()
        .filter(|c| matches!(c, StitchCommand::ColorChange))
        .count()
        + 1;
    let colors = default_colors(color_count);
    crate::resolve::resolve(&commands, colors)
}

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

    #[test]
    fn parse_simple_stitches() {
        let data = [0x0A, 0x14, 0x05, 0x03];
        let cmds = parse(&data).unwrap();
        assert!(matches!(cmds[0], StitchCommand::Stitch { dx: 10, dy: 20 }));
        assert!(matches!(cmds[1], StitchCommand::Stitch { dx: 5, dy: 3 }));
        assert!(matches!(cmds[2], StitchCommand::End));
    }

    #[test]
    fn parse_negative_coords() {
        // -10 as i8 = 0xF6, -20 as i8 = 0xEC
        let data = [0xF6, 0xEC];
        let cmds = parse(&data).unwrap();
        assert!(matches!(
            cmds[0],
            StitchCommand::Stitch { dx: -10, dy: -20 }
        ));
    }

    #[test]
    fn parse_color_change() {
        let data = [0x0A, 0x14, 0x80, 0x01, 0x05, 0x03];
        let cmds = parse(&data).unwrap();
        assert!(matches!(cmds[0], StitchCommand::Stitch { .. }));
        assert!(matches!(cmds[1], StitchCommand::ColorChange));
        assert!(matches!(cmds[2], StitchCommand::Stitch { dx: 5, dy: 3 }));
    }

    #[test]
    fn parse_jump() {
        let data = [0x80, 0x04, 0x0A, 0x14];
        let cmds = parse(&data).unwrap();
        assert!(matches!(cmds[0], StitchCommand::Jump { dx: 10, dy: 20 }));
    }

    #[test]
    fn parse_trim() {
        let data = [0x0A, 0x14, 0x80, 0x80, 0x05, 0x03];
        let cmds = parse(&data).unwrap();
        assert!(matches!(cmds[1], StitchCommand::Trim));
    }
}