diff --git a/Cargo.lock b/Cargo.lock index 4d53b54..09107d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,7 +235,7 @@ dependencies = [ [[package]] name = "rustitch" -version = "0.2.0" +version = "0.2.1" dependencies = [ "png", "thiserror", diff --git a/data/pes.xml b/data/pes.xml index a9f3dae..c4f77ea 100644 --- a/data/pes.xml +++ b/data/pes.xml @@ -23,4 +23,19 @@ VP3 Pfaff embroidery file + + PEC Brother embroidery file + + + + + + + XXX Singer embroidery file + + + + SEW Janome embroidery file + + diff --git a/data/stitch-peek.thumbnailer b/data/stitch-peek.thumbnailer index 30548e0..f7368e6 100644 --- a/data/stitch-peek.thumbnailer +++ b/data/stitch-peek.thumbnailer @@ -1,4 +1,4 @@ [Thumbnailer Entry] TryExec=stitch-peek Exec=stitch-peek -i %i -o %o -s %s -MimeType=application/x-pes;application/x-dst;application/x-exp;application/x-jef;application/x-vp3 +MimeType=application/x-pes;application/x-dst;application/x-exp;application/x-jef;application/x-vp3;application/x-pec;application/x-xxx;application/x-sew diff --git a/rustitch/src/dst/mod.rs b/rustitch/src/dst/mod.rs index ca2cc8c..9c0da58 100644 --- a/rustitch/src/dst/mod.rs +++ b/rustitch/src/dst/mod.rs @@ -37,8 +37,10 @@ pub fn parse(data: &[u8]) -> Result, Error> { let b2 = stitch_data[i + 2]; i += 3; - // End of file: byte 2 bits 0 and 1 both set, and specific pattern - if b2 & 0x03 == 0x03 { + // End of file: standard DST EOF pattern (0x00, 0x00, 0xF3) + // Bits 0 and 1 of byte 2 are always set in valid DST records, + // so we must check the full EOF pattern, not just those bits. + if b0 == 0x00 && b1 == 0x00 && b2 == 0xF3 { commands.push(StitchCommand::End); break; } @@ -46,14 +48,17 @@ pub fn parse(data: &[u8]) -> Result, Error> { let dx = decode_dx(b0, b1, b2); let dy = decode_dy(b0, b1, b2); + // Mask off the always-set bits 0,1 to get control flags + let flags = b2 & 0xFC; + // Color change: byte 2 bit 7 - if b2 & 0x80 != 0 { + if flags & 0x80 != 0 { commands.push(StitchCommand::ColorChange); continue; } // Jump: byte 2 bit 6 - if b2 & 0x40 != 0 { + if flags & 0x40 != 0 { commands.push(StitchCommand::Jump { dx, dy }); continue; } @@ -166,8 +171,8 @@ mod tests { #[test] fn decode_end_marker() { - // b2 = 0x03 means end - let data = [0x00, 0x00, 0x03]; + // DST EOF = 0x00, 0x00, 0xF3 + let data = [0x00, 0x00, 0xF3]; let cmds = parse(&data).unwrap_err(); assert!(matches!(cmds, Error::NoStitchData)); } @@ -175,9 +180,9 @@ mod tests { #[test] fn decode_simple_stitch() { // A normal stitch followed by end - // dx=+1: b0 bit 0. dy=+1: b0 bit 7. b2=0x00 (normal stitch) - // Then end marker - let data = [0x81, 0x00, 0x00, 0x00, 0x00, 0x03]; + // dx=+1: b0 bit 0. dy=+1: b0 bit 7. b2=0x03 (always-set bits) + // Then end marker 0x00, 0x00, 0xF3 + let data = [0x81, 0x00, 0x03, 0x00, 0x00, 0xF3]; let cmds = parse(&data).unwrap(); assert!(matches!(cmds[0], StitchCommand::Stitch { dx: 1, dy: -1 })); assert!(matches!(cmds[1], StitchCommand::End)); @@ -185,16 +190,16 @@ mod tests { #[test] fn decode_jump() { - // b2 bit 6 = jump - let data = [0x01, 0x00, 0x40, 0x00, 0x00, 0x03]; + // b2 bit 6 = jump, with always-set bits 0,1 + let data = [0x01, 0x00, 0x43, 0x00, 0x00, 0xF3]; let cmds = parse(&data).unwrap(); assert!(matches!(cmds[0], StitchCommand::Jump { dx: 1, dy: 0 })); } #[test] fn decode_color_change() { - // b2 bit 7 = color change - let data = [0x00, 0x00, 0x80, 0x01, 0x00, 0x00, 0x00, 0x00, 0x03]; + // b2 bit 7 = color change, with always-set bits 0,1 + let data = [0x00, 0x00, 0x83, 0x01, 0x00, 0x03, 0x00, 0x00, 0xF3]; let cmds = parse(&data).unwrap(); assert!(matches!(cmds[0], StitchCommand::ColorChange)); } diff --git a/rustitch/src/format.rs b/rustitch/src/format.rs index a07e429..f962750 100644 --- a/rustitch/src/format.rs +++ b/rustitch/src/format.rs @@ -7,6 +7,9 @@ pub enum Format { Exp, Jef, Vp3, + Pec, + Xxx, + Sew, } /// Detect format from file content (magic bytes). @@ -17,6 +20,9 @@ pub fn detect_from_bytes(data: &[u8]) -> Option { if data.len() >= 5 && &data[0..5] == b"%vsm%" { return Some(Format::Vp3); } + if data.len() >= 8 && &data[0..8] == b"#PEC0001" { + return Some(Format::Pec); + } None } @@ -29,6 +35,9 @@ pub fn detect_from_extension(path: &Path) -> Option { "exp" => Some(Format::Exp), "jef" => Some(Format::Jef), "vp3" => Some(Format::Vp3), + "pec" => Some(Format::Pec), + "xxx" => Some(Format::Xxx), + "sew" => Some(Format::Sew), _ => None, } } diff --git a/rustitch/src/lib.rs b/rustitch/src/lib.rs index 31587ee..2d7efd0 100644 --- a/rustitch/src/lib.rs +++ b/rustitch/src/lib.rs @@ -6,8 +6,11 @@ pub mod types; pub mod dst; pub mod exp; pub mod jef; +pub mod pec; pub mod pes; +pub mod sew; pub mod vp3; +pub mod xxx; mod render; mod resolve; @@ -40,5 +43,8 @@ fn parse_and_resolve(data: &[u8], fmt: Format) -> Result Format::Exp => exp::parse_and_resolve(data), Format::Jef => jef::parse_and_resolve(data), Format::Vp3 => vp3::parse_and_resolve(data), + Format::Pec => pec::parse_and_resolve(data), + Format::Xxx => xxx::parse_and_resolve(data), + Format::Sew => sew::parse_and_resolve(data), } } diff --git a/rustitch/src/palette.rs b/rustitch/src/palette.rs index 0c3fd2f..d11b2df 100644 --- a/rustitch/src/palette.rs +++ b/rustitch/src/palette.rs @@ -85,6 +85,11 @@ pub const DEFAULT_PALETTE: [(u8, u8, u8); 12] = [ (106, 28, 138), // Violet ]; +/// Look up a PEC palette color by index, clamping to valid range. +pub fn pec_color(idx: u8) -> (u8, u8, u8) { + PEC_PALETTE[(idx as usize).min(PEC_PALETTE.len() - 1)] +} + /// Build a color list for `n` thread slots by cycling through `DEFAULT_PALETTE`. pub fn default_colors(n: usize) -> Vec<(u8, u8, u8)> { (0..n) diff --git a/rustitch/src/pec.rs b/rustitch/src/pec.rs new file mode 100644 index 0000000..7b08911 --- /dev/null +++ b/rustitch/src/pec.rs @@ -0,0 +1,29 @@ +use crate::error::Error; +use crate::pes::pec::{decode_stitches, parse_pec_header}; +use crate::types::{ResolvedDesign, StitchCommand}; + +/// Parse a standalone PEC file (`#PEC0001` prefix + PEC data). +pub fn parse(data: &[u8]) -> Result<(Vec, Vec<(u8, u8, u8)>), Error> { + if data.len() < 8 || &data[0..8] != b"#PEC0001" { + return Err(Error::InvalidHeader("missing #PEC0001 magic".into())); + } + + let pec_data = &data[8..]; + let (header, stitch_offset) = parse_pec_header(pec_data)?; + let commands = decode_stitches(&pec_data[stitch_offset..])?; + + // Map PEC palette indices to RGB colors + let colors: Vec<(u8, u8, u8)> = header + .color_indices + .iter() + .map(|&idx| crate::palette::pec_color(idx)) + .collect(); + + Ok((commands, colors)) +} + +/// Parse a standalone PEC file and resolve to a renderable design. +pub fn parse_and_resolve(data: &[u8]) -> Result { + let (commands, colors) = parse(data)?; + crate::resolve::resolve(&commands, colors) +} diff --git a/rustitch/src/pes/mod.rs b/rustitch/src/pes/mod.rs index 44e6c37..c9fa33c 100644 --- a/rustitch/src/pes/mod.rs +++ b/rustitch/src/pes/mod.rs @@ -1,5 +1,5 @@ mod header; -mod pec; +pub mod pec; pub use header::PesHeader; pub use pec::PecHeader; diff --git a/rustitch/src/sew.rs b/rustitch/src/sew.rs new file mode 100644 index 0000000..cb5f019 --- /dev/null +++ b/rustitch/src/sew.rs @@ -0,0 +1,186 @@ +use crate::error::Error; +use crate::types::{ResolvedDesign, StitchCommand}; + +const STITCH_DATA_OFFSET: usize = 0x1D78; + +/// Janome SEW thread color palette (first 80 entries). +const SEW_PALETTE: [(u8, u8, u8); 80] = [ + (0, 0, 0), // 0: Unknown + (0, 0, 0), // 1: Black + (255, 255, 255), // 2: White + (255, 255, 23), // 3: Sunflower + (250, 160, 96), // 4: Hazel + (92, 118, 73), // 5: Green Dust + (64, 192, 48), // 6: Green + (101, 194, 200), // 7: Sky + (172, 128, 190), // 8: Purple + (245, 188, 203), // 9: Pink + (255, 0, 0), // 10: Red + (192, 128, 0), // 11: Brown + (0, 0, 240), // 12: Blue + (228, 195, 93), // 13: Gold + (165, 42, 42), // 14: Dark Brown + (213, 176, 212), // 15: Pale Violet + (252, 242, 148), // 16: Pale Yellow + (240, 208, 192), // 17: Pale Pink + (255, 192, 0), // 18: Peach + (201, 164, 128), // 19: Beige + (155, 61, 75), // 20: Wine Red + (160, 184, 204), // 21: Pale Sky + (127, 194, 28), // 22: Yellow Green + (185, 185, 185), // 23: Silver Grey + (160, 160, 160), // 24: Grey + (152, 214, 189), // 25: Pale Aqua + (184, 240, 240), // 26: Baby Blue + (54, 139, 160), // 27: Powder Blue + (79, 131, 171), // 28: Bright Blue + (56, 106, 145), // 29: Slate Blue + (0, 32, 107), // 30: Navy Blue + (229, 197, 202), // 31: Salmon Pink + (249, 103, 107), // 32: Coral + (227, 49, 31), // 33: Burnt Orange + (226, 161, 136), // 34: Cinnamon + (181, 148, 116), // 35: Umber + (228, 207, 153), // 36: Blonde + (225, 203, 0), // 37: Sunflower + (225, 173, 212), // 38: Orchid Pink + (195, 0, 126), // 39: Peony Purple + (128, 0, 75), // 40: Burgundy + (160, 96, 176), // 41: Royal Purple + (192, 64, 32), // 42: Cardinal Red + (202, 224, 192), // 43: Opal Green + (137, 152, 86), // 44: Moss Green + (0, 170, 0), // 45: Meadow Green + (33, 138, 33), // 46: Dark Green + (93, 174, 148), // 47: Aquamarine + (76, 191, 143), // 48: Emerald Green + (0, 119, 114), // 49: Peacock Green + (112, 112, 112), // 50: Dark Grey + (242, 255, 255), // 51: Ivory White + (177, 88, 24), // 52: Hazel + (203, 138, 7), // 53: Toast + (247, 146, 123), // 54: Salmon + (152, 105, 45), // 55: Cocoa Brown + (162, 113, 72), // 56: Sienna + (123, 85, 74), // 57: Sepia + (79, 57, 70), // 58: Dark Sepia + (82, 58, 151), // 59: Violet Blue + (0, 0, 160), // 60: Blue Ink + (0, 150, 222), // 61: Solar Blue + (178, 221, 83), // 62: Green Dust + (250, 143, 187), // 63: Crimson + (222, 100, 158), // 64: Floral Pink + (181, 80, 102), // 65: Wine + (94, 87, 71), // 66: Olive Drab + (76, 136, 31), // 67: Meadow + (228, 220, 121), // 68: Canary Yellow + (203, 138, 26), // 69: Toast + (198, 170, 66), // 70: Beige + (236, 176, 44), // 71: Honey Dew + (248, 128, 64), // 72: Tangerine + (255, 229, 5), // 73: Ocean Blue + (250, 122, 122), // 74: Sepia + (209, 164, 255), // 75: Sepia (alt) + (140, 90, 48), // 76: Unknown + (48, 80, 140), // 77: Unknown + (100, 160, 100), // 78: Unknown + (200, 100, 50), // 79: Unknown +]; + +/// Parse a SEW (Janome) embroidery file. +/// +/// Format: +/// - u16 LE color count at offset 0x00 +/// - color_count × u16 LE thread palette indices at offset 0x02 +/// - Graphical preview bitmap +/// - Stitch data at fixed offset 0x1D78 +/// - Escape byte 0x80, control in next byte: +/// - control & 1: color change (skip 2 bytes) +/// - 0x02/0x04: jump/move (read 2 signed bytes) +/// - 0x10: normal stitch (read 2 signed bytes) +/// - other: end +/// - Y is negated +pub fn parse(data: &[u8]) -> Result<(Vec, Vec<(u8, u8, u8)>), Error> { + if data.len() < STITCH_DATA_OFFSET + 4 { + return Err(Error::TooShort { + expected: STITCH_DATA_OFFSET + 4, + actual: data.len(), + }); + } + + let color_count = u16::from_le_bytes([data[0], data[1]]) as usize; + if color_count == 0 { + return Err(Error::InvalidHeader("zero color count".into())); + } + + // Read thread palette indices + let colors: Vec<(u8, u8, u8)> = (0..color_count) + .map(|i| { + let off = 2 + i * 2; + if off + 1 < data.len() { + let idx = u16::from_le_bytes([data[off], data[off + 1]]) as usize; + SEW_PALETTE[idx % SEW_PALETTE.len()] + } else { + (0, 0, 0) + } + }) + .collect(); + + let mut commands = Vec::new(); + let mut i = STITCH_DATA_OFFSET; + + while i + 1 < data.len() { + let b0 = data[i]; + let b1 = data[i + 1]; + i += 2; + + if b0 != 0x80 { + let dx = b0 as i8 as i16; + let dy = -(b1 as i8 as i16); + commands.push(StitchCommand::Stitch { dx, dy }); + continue; + } + + // Escape: b0 == 0x80, b1 is the control byte + if i + 1 >= data.len() { + break; + } + let c0 = data[i]; + let c1 = data[i + 1]; + i += 2; + + if b1 & 1 != 0 { + // Color change + commands.push(StitchCommand::ColorChange); + } else if b1 == 0x04 || b1 == 0x02 { + // Move/jump + let dx = c0 as i8 as i16; + let dy = -(c1 as i8 as i16); + commands.push(StitchCommand::Jump { dx, dy }); + } else if b1 == 0x10 { + // Stitch with preceding escape + let dx = c0 as i8 as i16; + let dy = -(c1 as i8 as i16); + commands.push(StitchCommand::Stitch { dx, dy }); + } else { + // Unknown control or end + break; + } + } + + if commands.is_empty() { + return Err(Error::NoStitchData); + } + + if !matches!(commands.last(), Some(StitchCommand::End)) { + commands.push(StitchCommand::End); + } + + Ok((commands, colors)) +} + +/// Parse a SEW file and resolve to a renderable design. +pub fn parse_and_resolve(data: &[u8]) -> Result { + let (commands, colors) = parse(data)?; + crate::resolve::resolve(&commands, colors) +} diff --git a/rustitch/src/vp3.rs b/rustitch/src/vp3.rs index ce88b36..b87d84d 100644 --- a/rustitch/src/vp3.rs +++ b/rustitch/src/vp3.rs @@ -4,14 +4,24 @@ use crate::types::{ResolvedDesign, StitchCommand}; /// Parse a VP3 (Pfaff/Viking) file from raw bytes. /// /// VP3 is a hierarchical format: -/// - File header with "%vsm%" magic (or similar signature) -/// - Design metadata section -/// - One or more color sections, each containing: -/// - Thread color (RGB) -/// - Stitch data block +/// - `%vsm%` magic + null byte +/// - UTF-16 BE producer string +/// - Design metadata (including center coordinates) +/// - `xxPP` section marker +/// - Producer string (again) +/// - Color count +/// - Color blocks, each containing: +/// - 3-byte marker `\x00\x05\x00` +/// - 4-byte block size (u32 BE) +/// - Start position (2 × i32 BE, units ÷ 100, Y negated) +/// - Thread info (RGB, catalog, name, brand) +/// - 15 bytes metadata + 3 bytes preamble (`\x0A\xF6\x00`) +/// - Stitch data /// -/// Byte order: mixed, but length-prefixed strings and section sizes use big-endian. -/// Stitch encoding: variable-length (1 byte for small moves, 3 bytes for large). +/// Stitch encoding: 2-byte signed pairs (i8 dx, i8 dy). +/// Escape byte 0x80: next byte is sub-command: +/// - 0x01: extended move (2 × i16 BE dx, dy), followed by 2 bytes to skip +/// - 0x03: trim type ParseResult = Result<(Vec, Vec<(u8, u8, u8)>), Error>; pub fn parse(data: &[u8]) -> ParseResult { @@ -22,25 +32,28 @@ pub fn parse(data: &[u8]) -> ParseResult { }); } - let mut reader = Reader::new(data); + if &data[0..5] != b"%vsm%" { + return Err(Error::InvalidHeader("missing %vsm% magic".into())); + } - // VP3 files start with a magic/signature section - // Skip the initial header to find the design data - // The format starts with a variable-length producer string, then design sections - skip_vp3_header(&mut reader)?; + let xxpp_pos = find_marker(data, b"xxPP") + .ok_or_else(|| Error::InvalidHeader("missing xxPP section marker".into()))?; + + let mut reader = Reader::new(data); + reader.pos = xxpp_pos + 4; + + // Skip 2 bytes + producer string after xxPP + reader.skip(2)?; + skip_string(&mut reader)?; + + let color_count = reader.read_u16_be()? as usize; let mut colors = Vec::new(); let mut commands = Vec::new(); + let mut cursor = (0i32, 0i32); - // Read color sections - let color_section_count = reader.read_u16_be()?; - - for _ in 0..color_section_count { - if reader.remaining() < 4 { - break; - } - - let color = read_color_section(&mut reader, &mut commands)?; + for ci in 0..color_count { + let color = read_color_block(&mut reader, &mut commands, &mut cursor, ci > 0)?; colors.push(color); } @@ -61,163 +74,139 @@ pub fn parse_and_resolve(data: &[u8]) -> Result { crate::resolve::resolve(&commands, colors) } -fn skip_vp3_header(reader: &mut Reader) -> Result<(), Error> { - // Skip magic/producer string at start - // VP3 starts with a string like "%vsm%" or similar, followed by metadata - // Find the start of actual design data by looking for patterns - - // Read and skip the initial producer/signature string - skip_string(reader)?; - - // Skip design metadata: dimensions and other header fields - // After the producer string there are typically coordinate fields (i32 BE) - // and additional metadata strings - if reader.remaining() < 38 { - return Err(Error::TooShort { - expected: 38, - actual: reader.remaining(), - }); - } - - // Skip: design size fields (4x i32 = 16 bytes) + unknown bytes (4) + unknown (4) - reader.skip(24)?; - - // Skip design notes/comments strings - skip_string(reader)?; // x-offset or notes - skip_string(reader)?; // y-offset or notes - - // Skip remaining header fields before color sections - // There are typically 6 more bytes of header data - if reader.remaining() >= 6 { - reader.skip(6)?; - } - - // Skip another potential string - if reader.remaining() >= 2 { - let peek = reader.peek_u16_be(); - if let Ok(len) = peek - && len < 1000 - && (len as usize) + 2 <= reader.remaining() - { - skip_string(reader)?; - } - } - - Ok(()) +fn find_marker(data: &[u8], marker: &[u8]) -> Option { + data.windows(marker.len()).position(|w| w == marker) } -fn read_color_section( +fn read_color_block( reader: &mut Reader, commands: &mut Vec, + cursor: &mut (i32, i32), + add_color_change: bool, ) -> Result<(u8, u8, u8), Error> { - // Color change between sections (except first) - if !commands.is_empty() { + // 3-byte marker: \x00\x05\x00 + reader.skip(3)?; + + // 4-byte block size (distance to next block from current position) + let block_size = reader.read_u32_be()? as usize; + let block_end = reader.pos + block_size; + + // Start position: i32 BE x, i32 BE y (units ÷ 100, Y negated) + let start_x_raw = reader.read_i32_be()?; + let start_y_raw = reader.read_i32_be()?; + let start_x = start_x_raw / 100; + let start_y = -(start_y_raw / 100); + + // Jump to section start position if cursor is not already there + let jump_dx = start_x - cursor.0; + let jump_dy = start_y - cursor.1; + if jump_dx != 0 || jump_dy != 0 { + commands.push(StitchCommand::Trim); + commands.push(StitchCommand::Jump { + dx: jump_dx.clamp(-32768, 32767) as i16, + dy: jump_dy.clamp(-32768, 32767) as i16, + }); + cursor.0 = start_x; + cursor.1 = start_y; + } + + if add_color_change { commands.push(StitchCommand::ColorChange); } - // Skip section start marker/offset bytes - // Color sections start with coordinate offset data - if reader.remaining() < 12 { - return Err(Error::TooShort { - expected: 12, - actual: reader.remaining(), - }); - } + // Read thread info + let (r, g, b) = read_thread_info(reader)?; - // Skip section offset/position data (2x i32 = 8 bytes) - reader.skip(8)?; + // Skip 15 bytes post-thread metadata + 3 bytes preamble (\x0A\xF6\x00) + reader.skip(18)?; - // Skip thread info string - skip_string(reader)?; + // Decode stitches until block end + decode_vp3_stitches(reader, commands, block_end, cursor); - // Read thread color: RGB (3 bytes) - if reader.remaining() < 3 { - return Err(Error::TooShort { - expected: 3, - actual: reader.remaining(), - }); - } - let r = reader.read_u8()?; - let g = reader.read_u8()?; - let b = reader.read_u8()?; - - // Skip remaining thread metadata (thread type, weight, catalog info) - // Skip to stitch data: look for the stitch count field - skip_string(reader)?; // thread catalog number - skip_string(reader)?; // thread description - - // Skip thread brand and additional metadata - // There's typically some padding/unknown bytes here - if reader.remaining() >= 18 { - reader.skip(18)?; - } - - // Read stitch data - let stitch_byte_count = if reader.remaining() >= 4 { - reader.read_u32_be()? as usize - } else { - return Ok((r, g, b)); - }; - - if stitch_byte_count == 0 || stitch_byte_count > reader.remaining() { - // Skip what we can - return Ok((r, g, b)); - } - - let stitch_end = reader.pos + stitch_byte_count; - decode_vp3_stitches(reader, commands, stitch_end); - - // Ensure we're at the right position after stitch data - if reader.pos < stitch_end { - reader.pos = stitch_end; - } + reader.pos = block_end; Ok((r, g, b)) } -fn decode_vp3_stitches(reader: &mut Reader, commands: &mut Vec, end: usize) { - while reader.pos < end && reader.remaining() >= 2 { - let b1 = reader.data[reader.pos]; +fn read_thread_info(reader: &mut Reader) -> Result<(u8, u8, u8), Error> { + // Color table: count of sub-colors, transition byte + let colors_count = reader.read_u8()?; + let _transition = reader.read_u8()?; - // Check for 3-byte extended coordinates (high bit set on first byte) - if b1 & 0x80 != 0 { - if reader.remaining() < 4 { - break; + let mut r = 0u8; + let mut g = 0u8; + let mut b = 0u8; + + for _ in 0..colors_count { + r = reader.read_u8()?; + g = reader.read_u8()?; + b = reader.read_u8()?; + let _parts = reader.read_u8()?; + let _color_length = reader.read_u16_be()?; + } + + // Thread type + weight + reader.skip(2)?; + + // 3 strings: catalog number, color name, brand name + skip_string(reader)?; + skip_string(reader)?; + skip_string(reader)?; + + Ok((r, g, b)) +} + +fn decode_vp3_stitches( + reader: &mut Reader, + commands: &mut Vec, + end: usize, + cursor: &mut (i32, i32), +) { + while reader.pos + 1 < end && reader.pos + 1 < reader.data.len() { + let bx = reader.data[reader.pos] as i8; + let by = reader.data[reader.pos + 1] as i8; + reader.pos += 2; + + if (bx as u8) != 0x80 { + // Normal stitch + let dx = bx as i16; + let dy = by as i16; + cursor.0 += dx as i32; + cursor.1 += dy as i32; + commands.push(StitchCommand::Stitch { dx, dy }); + continue; + } + + // Escape byte 0x80 — check sub-command + match by as u8 { + 0x01 => { + // Extended move: 2 × i16 BE + if reader.pos + 4 <= end { + let dx = read_i16_be(reader.data, reader.pos); + reader.pos += 2; + let dy = read_i16_be(reader.data, reader.pos); + reader.pos += 2; + cursor.0 += dx as i32; + cursor.1 += dy as i32; + commands.push(StitchCommand::Stitch { dx, dy }); + // Skip trailing 0x80 0x02 + if reader.pos + 2 <= end { + reader.pos += 2; + } + } } - let dx = read_i16_be(reader.data, reader.pos); - reader.pos += 2; - let dy = read_i16_be(reader.data, reader.pos); - reader.pos += 2; - - // Large moves are jumps - commands.push(StitchCommand::Jump { dx, dy: -dy }); - } else { - // 1-byte per coordinate - let dx = reader.data[reader.pos] as i8 as i16; - reader.pos += 1; - if reader.pos >= end { - break; - } - let dy = -(reader.data[reader.pos] as i8 as i16); - reader.pos += 1; - - if dx == 0 && dy == 0 { - // Zero-length stitch can be a trim marker + 0x03 => { + // Trim commands.push(StitchCommand::Trim); - } else { - commands.push(StitchCommand::Stitch { dx, dy }); + } + _ => { + // Unknown or no-op (0x00, 0x02, etc.) } } } } fn skip_string(reader: &mut Reader) -> Result<(), Error> { - if reader.remaining() < 2 { - return Err(Error::TooShort { - expected: reader.pos + 2, - actual: reader.data.len(), - }); - } let len = reader.read_u16_be()? as usize; if len > reader.remaining() { return Err(Error::InvalidHeader(format!( @@ -272,19 +261,6 @@ impl<'a> Reader<'a> { Ok(v) } - fn peek_u16_be(&self) -> Result { - if self.pos + 2 > self.data.len() { - return Err(Error::TooShort { - expected: self.pos + 2, - actual: self.data.len(), - }); - } - Ok(u16::from_be_bytes([ - self.data[self.pos], - self.data[self.pos + 1], - ])) - } - fn read_u32_be(&mut self) -> Result { if self.pos + 4 > self.data.len() { return Err(Error::TooShort { @@ -302,6 +278,23 @@ impl<'a> Reader<'a> { Ok(v) } + fn read_i32_be(&mut self) -> Result { + if self.pos + 4 > self.data.len() { + return Err(Error::TooShort { + expected: self.pos + 4, + actual: self.data.len(), + }); + } + let v = i32::from_be_bytes([ + self.data[self.pos], + self.data[self.pos + 1], + self.data[self.pos + 2], + self.data[self.pos + 3], + ]); + self.pos += 4; + Ok(v) + } + fn skip(&mut self, n: usize) -> Result<(), Error> { if self.pos + n > self.data.len() { return Err(Error::TooShort { @@ -321,26 +314,44 @@ mod tests { #[test] fn decode_small_stitch() { let mut commands = Vec::new(); - // Two small stitches: (10, -20) and (5, -3) let data = [0x0A, 0x14, 0x05, 0x03]; let mut reader = Reader::new(&data); - decode_vp3_stitches(&mut reader, &mut commands, data.len()); + let mut cursor = (0i32, 0i32); + decode_vp3_stitches(&mut reader, &mut commands, data.len(), &mut cursor); assert_eq!(commands.len(), 2); assert!(matches!( commands[0], - StitchCommand::Stitch { dx: 10, dy: -20 } + StitchCommand::Stitch { dx: 10, dy: 20 } )); } #[test] - fn decode_large_jump() { + fn decode_escape_trim() { let mut commands = Vec::new(); - // Large move: high bit set, 2-byte BE dx and dy - // dx = 0x8100 = -32512 as i16, dy = 0x0100 = 256 - let data = [0x81, 0x00, 0x01, 0x00]; + let data = [0x80, 0x03, 0x05, 0x03]; let mut reader = Reader::new(&data); - decode_vp3_stitches(&mut reader, &mut commands, data.len()); + let mut cursor = (0i32, 0i32); + decode_vp3_stitches(&mut reader, &mut commands, data.len(), &mut cursor); + assert_eq!(commands.len(), 2); + assert!(matches!(commands[0], StitchCommand::Trim)); + assert!(matches!( + commands[1], + StitchCommand::Stitch { dx: 5, dy: 3 } + )); + } + + #[test] + fn decode_extended_move() { + // 0x80 0x01 + i16 BE dx(0x0100=256) + i16 BE dy(0xFF00=-256) + 0x80 0x02 + let data = [0x80, 0x01, 0x01, 0x00, 0xFF, 0x00, 0x80, 0x02]; + let mut commands = Vec::new(); + let mut reader = Reader::new(&data); + let mut cursor = (0i32, 0i32); + decode_vp3_stitches(&mut reader, &mut commands, data.len(), &mut cursor); assert_eq!(commands.len(), 1); - assert!(matches!(commands[0], StitchCommand::Jump { .. })); + assert!(matches!( + commands[0], + StitchCommand::Stitch { dx: 256, dy: -256 } + )); } } diff --git a/rustitch/src/xxx.rs b/rustitch/src/xxx.rs new file mode 100644 index 0000000..ef20987 --- /dev/null +++ b/rustitch/src/xxx.rs @@ -0,0 +1,130 @@ +use crate::error::Error; +use crate::types::{ResolvedDesign, StitchCommand}; + +const HEADER_SIZE: usize = 256; + +/// Parse an XXX (Singer) embroidery file. +/// +/// Format: +/// - 256-byte header ("XXX" at offset 0xB6) +/// - Color count at offset 0x27 (LE u16) +/// - Stitch data at offset 0x100: 2-byte signed pairs (i8 dx, i8 dy), Y negated +/// - Escape byte 0x7F, followed by sub-command + 2 data bytes: +/// - 0x01: jump/move +/// - 0x03: trim (with optional move) +/// - 0x08 or 0x0A..0x17: color change +/// - 0x7F: end of data +/// - Color table after stitch data: skip 2 bytes, then color_count × i32 BE (0x00RRGGBB) +pub fn parse(data: &[u8]) -> Result<(Vec, Vec<(u8, u8, u8)>), Error> { + if data.len() < HEADER_SIZE + 2 { + return Err(Error::TooShort { + expected: HEADER_SIZE + 2, + actual: data.len(), + }); + } + + let color_count = u16::from_le_bytes([data[0x27], data[0x28]]) as usize; + if color_count == 0 { + return Err(Error::InvalidHeader("zero color count".into())); + } + + let mut commands = Vec::new(); + let mut i = HEADER_SIZE; + let mut color_table_start = data.len(); + + while i < data.len() { + let b1 = data[i]; + i += 1; + + // Big jump codes (0x7D, 0x7E) + if b1 == 0x7D || b1 == 0x7E { + if i + 4 > data.len() { + break; + } + let x = i16::from_le_bytes([data[i], data[i + 1]]); + let y = -i16::from_le_bytes([data[i + 2], data[i + 3]]); + i += 4; + commands.push(StitchCommand::Jump { dx: x, dy: y }); + continue; + } + + if i >= data.len() { + break; + } + let b2 = data[i]; + i += 1; + + if b1 != 0x7F { + let dx = b1 as i8 as i16; + let dy = -(b2 as i8 as i16); + commands.push(StitchCommand::Stitch { dx, dy }); + continue; + } + + // Escape: b1 == 0x7F + if i + 2 > data.len() { + break; + } + let b3 = data[i]; + let b4 = data[i + 1]; + i += 2; + + if b2 == 0x01 { + // Move/jump + let dx = b3 as i8 as i16; + let dy = -(b4 as i8 as i16); + commands.push(StitchCommand::Jump { dx, dy }); + } else if b2 == 0x03 { + // Trim with optional move + commands.push(StitchCommand::Trim); + let dx = b3 as i8 as i16; + let dy = -(b4 as i8 as i16); + if dx != 0 || dy != 0 { + commands.push(StitchCommand::Jump { dx, dy }); + } + } else if b2 == 0x08 || (0x0A..=0x17).contains(&b2) { + // Color change + commands.push(StitchCommand::ColorChange); + } else if b2 == 0x7F || b2 == 0x18 { + // End — color table follows after 2 bytes + color_table_start = i + 2; + break; + } + } + + if commands.is_empty() { + return Err(Error::NoStitchData); + } + + commands.push(StitchCommand::End); + + // Read color table: color_count × i32 BE (0x00RRGGBB) + let colors = if color_table_start + color_count * 4 <= data.len() { + (0..color_count) + .map(|c| { + let base = color_table_start + c * 4; + let rgb = u32::from_be_bytes([ + data[base], + data[base + 1], + data[base + 2], + data[base + 3], + ]); + ( + ((rgb >> 16) & 0xFF) as u8, + ((rgb >> 8) & 0xFF) as u8, + (rgb & 0xFF) as u8, + ) + }) + .collect() + } else { + crate::palette::default_colors(color_count) + }; + + Ok((commands, colors)) +} + +/// Parse an XXX file and resolve to a renderable design. +pub fn parse_and_resolve(data: &[u8]) -> Result { + let (commands, colors) = parse(data)?; + crate::resolve::resolve(&commands, colors) +} diff --git a/rustitch/tests/fixtures/0.3x1 INCHES.DST b/rustitch/tests/fixtures/0.3x1 INCHES.DST new file mode 100644 index 0000000..f25098f Binary files /dev/null and b/rustitch/tests/fixtures/0.3x1 INCHES.DST differ diff --git a/rustitch/tests/fixtures/0.3x1 INCHES.HUS b/rustitch/tests/fixtures/0.3x1 INCHES.HUS new file mode 100644 index 0000000..25caa0b Binary files /dev/null and b/rustitch/tests/fixtures/0.3x1 INCHES.HUS differ diff --git a/rustitch/tests/fixtures/0.3x1 INCHES.INF b/rustitch/tests/fixtures/0.3x1 INCHES.INF new file mode 100644 index 0000000..bb8881a Binary files /dev/null and b/rustitch/tests/fixtures/0.3x1 INCHES.INF differ diff --git a/rustitch/tests/fixtures/0.3x1 INCHES.PEC b/rustitch/tests/fixtures/0.3x1 INCHES.PEC new file mode 100644 index 0000000..8338bb1 Binary files /dev/null and b/rustitch/tests/fixtures/0.3x1 INCHES.PEC differ diff --git a/rustitch/tests/fixtures/0.3x1 INCHES.SEW b/rustitch/tests/fixtures/0.3x1 INCHES.SEW new file mode 100644 index 0000000..f6357e9 Binary files /dev/null and b/rustitch/tests/fixtures/0.3x1 INCHES.SEW differ diff --git a/rustitch/tests/fixtures/0.3x1 INCHES.VP3 b/rustitch/tests/fixtures/0.3x1 INCHES.VP3 new file mode 100644 index 0000000..47cf7df Binary files /dev/null and b/rustitch/tests/fixtures/0.3x1 INCHES.VP3 differ diff --git a/rustitch/tests/fixtures/0.3x1 INCHES.XXX b/rustitch/tests/fixtures/0.3x1 INCHES.XXX new file mode 100644 index 0000000..0119138 Binary files /dev/null and b/rustitch/tests/fixtures/0.3x1 INCHES.XXX differ diff --git a/stitch-peek/src/main.rs b/stitch-peek/src/main.rs index ac31467..edffe7b 100644 --- a/stitch-peek/src/main.rs +++ b/stitch-peek/src/main.rs @@ -5,7 +5,7 @@ use std::fs; #[derive(Parser)] #[command( name = "stitch-peek", - about = "Embroidery file thumbnailer (PES, DST, EXP, JEF, VP3)" + about = "Embroidery file thumbnailer (PES, DST, EXP, JEF, VP3, PEC, XXX, SEW)" )] struct Args { /// Input embroidery file path