From 40ccf9ded450249e1c6d366f40bd37c09190fa0b Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 31 Mar 2026 08:08:36 +0200 Subject: [PATCH 1/6] added DST, EXP, JEF, VP3 --- rustitch/src/dst/mod.rs | 180 +++++++++++++++++++ rustitch/src/error.rs | 23 +++ rustitch/src/exp.rs | 135 ++++++++++++++ rustitch/src/format.rs | 34 ++++ rustitch/src/jef/mod.rs | 173 ++++++++++++++++++ rustitch/src/jef/palette.rs | 82 +++++++++ rustitch/src/lib.rs | 35 +++- rustitch/src/palette.rs | 93 ++++++++++ rustitch/src/pes/header.rs | 7 +- rustitch/src/pes/mod.rs | 119 +------------ rustitch/src/pes/palette.rs | 71 +------- rustitch/src/pes/pec.rs | 73 +------- rustitch/src/render.rs | 5 +- rustitch/src/resolve.rs | 75 ++++++++ rustitch/src/types.rs | 29 +++ rustitch/src/vp3.rs | 343 ++++++++++++++++++++++++++++++++++++ 16 files changed, 1215 insertions(+), 262 deletions(-) create mode 100644 rustitch/src/dst/mod.rs create mode 100644 rustitch/src/error.rs create mode 100644 rustitch/src/exp.rs create mode 100644 rustitch/src/format.rs create mode 100644 rustitch/src/jef/mod.rs create mode 100644 rustitch/src/jef/palette.rs create mode 100644 rustitch/src/palette.rs create mode 100644 rustitch/src/resolve.rs create mode 100644 rustitch/src/types.rs create mode 100644 rustitch/src/vp3.rs diff --git a/rustitch/src/dst/mod.rs b/rustitch/src/dst/mod.rs new file mode 100644 index 0000000..a73ec6c --- /dev/null +++ b/rustitch/src/dst/mod.rs @@ -0,0 +1,180 @@ +use crate::error::Error; +use crate::palette::default_colors; +use crate::types::{ResolvedDesign, StitchCommand}; + +/// Parse a DST (Tajima) file from raw bytes into stitch commands. +/// +/// DST format: 3 bytes per stitch record with bit-packed dx, dy, and control flags. +/// The standard Tajima bit layout maps specific bits across all 3 bytes to coordinate values. +pub fn parse(data: &[u8]) -> Result, Error> { + if data.len() < 3 { + return Err(Error::TooShort { + expected: 3, + actual: data.len(), + }); + } + + // DST files may have a 512-byte header; stitch data can start at byte 512 + // if the file is large enough, or at byte 0 for raw stitch streams. + // The header contains "LA:" at offset 0 if present. + let offset = if data.len() > 512 && &data[0..3] == b"LA:" { + 512 + } else { + 0 + }; + + let stitch_data = &data[offset..]; + if stitch_data.len() < 3 { + return Err(Error::NoStitchData); + } + + let mut commands = Vec::new(); + let mut i = 0; + + while i + 2 < stitch_data.len() { + let b0 = stitch_data[i]; + let b1 = stitch_data[i + 1]; + 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 { + commands.push(StitchCommand::End); + break; + } + + let dx = decode_dx(b0, b1, b2); + let dy = decode_dy(b0, b1, b2); + + // Color change: byte 2 bit 7 + if b2 & 0x80 != 0 { + commands.push(StitchCommand::ColorChange); + continue; + } + + // Jump: byte 2 bit 6 + if b2 & 0x40 != 0 { + commands.push(StitchCommand::Jump { dx, dy }); + continue; + } + + commands.push(StitchCommand::Stitch { dx, dy }); + } + + if commands.is_empty() || (commands.len() == 1 && matches!(commands[0], StitchCommand::End)) { + return Err(Error::NoStitchData); + } + + // Ensure we have an End marker + if !matches!(commands.last(), Some(StitchCommand::End)) { + commands.push(StitchCommand::End); + } + + Ok(commands) +} + +/// Decode X displacement from the 3-byte Tajima record. +/// Standard bit layout for dx across bytes b0, b1, b2. +fn decode_dx(b0: u8, b1: u8, b2: u8) -> i16 { + let mut x: i16 = 0; + if b0 & 0x01 != 0 { x += 1; } + if b0 & 0x02 != 0 { x -= 1; } + if b0 & 0x04 != 0 { x += 9; } + if b0 & 0x08 != 0 { x -= 9; } + if b1 & 0x01 != 0 { x += 3; } + if b1 & 0x02 != 0 { x -= 3; } + if b1 & 0x04 != 0 { x += 27; } + if b1 & 0x08 != 0 { x -= 27; } + if b2 & 0x04 != 0 { x += 81; } + if b2 & 0x08 != 0 { x -= 81; } + x +} + +/// Decode Y displacement from the 3-byte Tajima record. +/// Standard bit layout for dy across bytes b0, b1, b2. +fn decode_dy(b0: u8, b1: u8, b2: u8) -> i16 { + let mut y: i16 = 0; + if b0 & 0x80 != 0 { y += 1; } + if b0 & 0x40 != 0 { y -= 1; } + if b0 & 0x20 != 0 { y += 9; } + if b0 & 0x10 != 0 { y -= 9; } + if b1 & 0x80 != 0 { y += 3; } + if b1 & 0x40 != 0 { y -= 3; } + if b1 & 0x20 != 0 { y += 27; } + if b1 & 0x10 != 0 { y -= 27; } + if b2 & 0x20 != 0 { y += 81; } + if b2 & 0x10 != 0 { y -= 81; } + // DST Y axis is inverted (positive = up in machine coords, down in screen coords) + -y +} + +/// Parse a DST file and resolve to a renderable design. +pub fn parse_and_resolve(data: &[u8]) -> Result { + 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 decode_end_marker() { + // b2 = 0x03 means end + let data = [0x00, 0x00, 0x03]; + let cmds = parse(&data).unwrap_err(); + assert!(matches!(cmds, Error::NoStitchData)); + } + + #[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]; + let cmds = parse(&data).unwrap(); + assert!(matches!(cmds[0], StitchCommand::Stitch { dx: 1, dy: -1 })); + assert!(matches!(cmds[1], StitchCommand::End)); + } + + #[test] + fn decode_jump() { + // b2 bit 6 = jump + let data = [0x01, 0x00, 0x40, 0x00, 0x00, 0x03]; + 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]; + let cmds = parse(&data).unwrap(); + assert!(matches!(cmds[0], StitchCommand::ColorChange)); + } + + #[test] + fn decode_dx_values() { + assert_eq!(decode_dx(0x01, 0x00, 0x00), 1); + assert_eq!(decode_dx(0x02, 0x00, 0x00), -1); + assert_eq!(decode_dx(0x04, 0x00, 0x00), 9); + assert_eq!(decode_dx(0x00, 0x04, 0x00), 27); + assert_eq!(decode_dx(0x00, 0x00, 0x04), 81); + assert_eq!(decode_dx(0x05, 0x05, 0x04), 1 + 9 + 27 + 81); // 118 + } + + #[test] + fn decode_dy_values() { + assert_eq!(decode_dy(0x80, 0x00, 0x00), -1); + assert_eq!(decode_dy(0x40, 0x00, 0x00), 1); + assert_eq!(decode_dy(0x20, 0x00, 0x00), -9); + assert_eq!(decode_dy(0x00, 0x20, 0x00), -27); + assert_eq!(decode_dy(0x00, 0x00, 0x20), -81); + } +} diff --git a/rustitch/src/error.rs b/rustitch/src/error.rs new file mode 100644 index 0000000..f325741 --- /dev/null +++ b/rustitch/src/error.rs @@ -0,0 +1,23 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("invalid PES magic: expected #PES, got {0:?}")] + InvalidPesMagic([u8; 4]), + #[error("file too short: need {expected} bytes, got {actual}")] + TooShort { expected: usize, actual: usize }, + #[error("invalid PEC offset: {0} exceeds file length {1}")] + InvalidPecOffset(u32, usize), + #[error("invalid header: {0}")] + InvalidHeader(String), + #[error("no stitch data found")] + NoStitchData, + #[error("empty design: no stitch segments produced")] + EmptyDesign, + #[error("unsupported format")] + UnsupportedFormat, + #[error("render error: {0}")] + Render(String), + #[error("PNG encoding error: {0}")] + PngEncode(#[from] png::EncodingError), +} diff --git a/rustitch/src/exp.rs b/rustitch/src/exp.rs new file mode 100644 index 0000000..6ff0b58 --- /dev/null +++ b/rustitch/src/exp.rs @@ -0,0 +1,135 @@ +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, 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 { + 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)); + } +} diff --git a/rustitch/src/format.rs b/rustitch/src/format.rs new file mode 100644 index 0000000..a07e429 --- /dev/null +++ b/rustitch/src/format.rs @@ -0,0 +1,34 @@ +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + Pes, + Dst, + Exp, + Jef, + Vp3, +} + +/// Detect format from file content (magic bytes). +pub fn detect_from_bytes(data: &[u8]) -> Option { + if data.len() >= 4 && &data[0..4] == b"#PES" { + return Some(Format::Pes); + } + if data.len() >= 5 && &data[0..5] == b"%vsm%" { + return Some(Format::Vp3); + } + None +} + +/// Detect format from file extension. +pub fn detect_from_extension(path: &Path) -> Option { + let ext = path.extension()?.to_str()?; + match ext.to_ascii_lowercase().as_str() { + "pes" => Some(Format::Pes), + "dst" => Some(Format::Dst), + "exp" => Some(Format::Exp), + "jef" => Some(Format::Jef), + "vp3" => Some(Format::Vp3), + _ => None, + } +} diff --git a/rustitch/src/jef/mod.rs b/rustitch/src/jef/mod.rs new file mode 100644 index 0000000..bcedee5 --- /dev/null +++ b/rustitch/src/jef/mod.rs @@ -0,0 +1,173 @@ +mod palette; + +use crate::error::Error; +use crate::types::{ResolvedDesign, StitchCommand}; +use palette::JEF_PALETTE; + +/// Parse a JEF (Janome) file from raw bytes into stitch commands and color info. +/// +/// JEF header layout (little-endian): +/// 0..4: stitch data offset (u32) +/// 4..8: flags/format indicator +/// 24..28: color count (u32) +/// 28..32: stitch count (u32) +/// 116+: color table (each entry: i32 palette index) +/// +/// Stitch data: 2 bytes per stitch (signed i8 dx, dy). +/// Control codes: 0x80 0x01 = color change, 0x80 0x02 = jump, 0x80 0x10 = end. +pub fn parse(data: &[u8]) -> Result<(Vec, Vec<(u8, u8, u8)>), Error> { + if data.len() < 116 { + return Err(Error::TooShort { + expected: 116, + actual: data.len(), + }); + } + + let stitch_offset = read_u32_le(data, 0) as usize; + let color_count = read_u32_le(data, 24) as usize; + + if stitch_offset > data.len() { + return Err(Error::InvalidHeader(format!( + "stitch data offset {} exceeds file length {}", + stitch_offset, + data.len() + ))); + } + + // Read color table starting at offset 116 + let color_table_start = 116; + let mut colors = Vec::with_capacity(color_count); + for i in 0..color_count { + let entry_offset = color_table_start + i * 4; + if entry_offset + 4 > data.len() { + break; + } + let idx = read_i32_le(data, entry_offset); + let palette_idx = if idx >= 0 && (idx as usize) < JEF_PALETTE.len() { + idx as usize + } else { + 0 + }; + colors.push(JEF_PALETTE[palette_idx]); + } + + if colors.is_empty() { + colors.push((0, 0, 0)); + } + + // Parse stitch data + let stitch_data = &data[stitch_offset..]; + let commands = decode_stitches(stitch_data)?; + + Ok((commands, colors)) +} + +fn decode_stitches(data: &[u8]) -> Result, Error> { + 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 => { + commands.push(StitchCommand::ColorChange); + i += 2; + } + 0x02 => { + // Jump: next 2 bytes are 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; + } + 0x10 => { + commands.push(StitchCommand::End); + break; + } + _ => { + 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; + } + } + + if commands.is_empty() { + return Err(Error::NoStitchData); + } + + if !matches!(commands.last(), Some(StitchCommand::End)) { + commands.push(StitchCommand::End); + } + + Ok(commands) +} + +/// Parse a JEF 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) +} + +fn read_u32_le(data: &[u8], offset: usize) -> u32 { + u32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]) +} + +fn read_i32_le(data: &[u8], offset: usize) -> i32 { + i32::from_le_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_simple_stitches() { + let data = [0x0A, 0x14, 0x05, 0x03, 0x80, 0x10]; + let cmds = decode_stitches(&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 decode_color_change() { + let data = [0x0A, 0x14, 0x80, 0x01, 0x05, 0x03, 0x80, 0x10]; + let cmds = decode_stitches(&data).unwrap(); + assert!(matches!(cmds[1], StitchCommand::ColorChange)); + } + + #[test] + fn decode_jump() { + let data = [0x80, 0x02, 0x0A, 0x14, 0x80, 0x10]; + let cmds = decode_stitches(&data).unwrap(); + assert!(matches!( + cmds[0], + StitchCommand::Jump { dx: 10, dy: -20 } + )); + } +} diff --git a/rustitch/src/jef/palette.rs b/rustitch/src/jef/palette.rs new file mode 100644 index 0000000..0b91fbc --- /dev/null +++ b/rustitch/src/jef/palette.rs @@ -0,0 +1,82 @@ +/// Janome thread color palette (78 entries). +/// Index 0 is a fallback; indices 1-77 correspond to standard Janome thread colors. +pub const JEF_PALETTE: [(u8, u8, u8); 78] = [ + (0, 0, 0), // 0: Unknown / Black + (0, 0, 0), // 1: Black + (255, 255, 255), // 2: White + (255, 255, 23), // 3: Yellow + (250, 160, 96), // 4: Orange + (92, 118, 73), // 5: Olive Green + (64, 192, 48), // 6: Green + (101, 194, 200), // 7: Sky Blue + (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 Blue + (127, 194, 28), // 22: Yellow Green + (185, 185, 185), // 23: Silver Gray + (160, 160, 160), // 24: Gray + (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: Blond + (225, 203, 0), // 37: Sunflower + (225, 173, 212), // 38: Orchid Pink + (195, 0, 126), // 39: Peony + (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 Gray + (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: Mustard + (203, 138, 26), // 69: Yellow Ochre + (198, 170, 66), // 70: Old Gold + (236, 176, 44), // 71: Honeydew + (248, 128, 64), // 72: Tangerine + (255, 229, 5), // 73: Canary Yellow + (250, 122, 122), // 74: Vermilion + (107, 224, 0), // 75: Bright Green + (56, 108, 174), // 76: Ocean Blue + (227, 196, 180), // 77: Beige Gray +]; diff --git a/rustitch/src/lib.rs b/rustitch/src/lib.rs index aa9ad3b..a51b4c2 100644 --- a/rustitch/src/lib.rs +++ b/rustitch/src/lib.rs @@ -1,12 +1,37 @@ -pub mod pes; -mod render; +pub mod error; +pub mod format; +pub mod palette; +pub mod types; +pub mod pes; + +mod render; +mod resolve; + +pub use error::Error; +pub use format::Format; pub use render::render_thumbnail; +pub use types::{BoundingBox, ResolvedDesign, StitchCommand, StitchSegment}; /// Parse a PES file and render a thumbnail PNG of the given size. -pub fn thumbnail(pes_data: &[u8], size: u32) -> Result, pes::Error> { +pub fn thumbnail(pes_data: &[u8], size: u32) -> Result, Error> { let design = pes::parse(pes_data)?; let resolved = pes::resolve(&design)?; - let png_bytes = render::render_thumbnail(&resolved, size)?; - Ok(png_bytes) + render::render_thumbnail(&resolved, size) +} + +/// Parse any supported format and render a thumbnail PNG. +pub fn thumbnail_format(data: &[u8], size: u32, fmt: Format) -> Result, Error> { + let resolved = parse_and_resolve(data, fmt)?; + render::render_thumbnail(&resolved, size) +} + +fn parse_and_resolve(data: &[u8], fmt: Format) -> Result { + match fmt { + Format::Pes => { + let design = pes::parse(data)?; + pes::resolve(&design) + } + _ => Err(Error::UnsupportedFormat), + } } diff --git a/rustitch/src/palette.rs b/rustitch/src/palette.rs new file mode 100644 index 0000000..0c3fd2f --- /dev/null +++ b/rustitch/src/palette.rs @@ -0,0 +1,93 @@ +/// Brother PEC thread color palette (65 entries). +/// Index 0 is a fallback; indices 1-64 correspond to standard Brother thread colors. +pub const PEC_PALETTE: [(u8, u8, u8); 65] = [ + (0, 0, 0), // 0: Unknown + (14, 31, 124), // 1: Prussian Blue + (10, 85, 163), // 2: Blue + (0, 135, 119), // 3: Teal Green + (75, 107, 175), // 4: Cornflower Blue + (237, 23, 31), // 5: Red + (209, 92, 0), // 6: Reddish Brown + (145, 54, 151), // 7: Magenta + (228, 154, 203), // 8: Light Lilac + (145, 95, 172), // 9: Lilac + (158, 214, 125), // 10: Mint Green + (232, 169, 0), // 11: Deep Gold + (254, 186, 53), // 12: Orange + (255, 255, 0), // 13: Yellow + (112, 188, 31), // 14: Lime Green + (186, 152, 0), // 15: Brass + (168, 168, 168), // 16: Silver + (125, 111, 0), // 17: Russet Brown + (255, 255, 179), // 18: Cream Brown + (79, 85, 86), // 19: Pewter + (0, 0, 0), // 20: Black + (11, 61, 145), // 21: Ultramarine + (119, 1, 118), // 22: Royal Purple + (41, 49, 51), // 23: Dark Gray + (42, 19, 1), // 24: Dark Brown + (246, 74, 138), // 25: Deep Rose + (178, 118, 36), // 26: Light Brown + (252, 187, 197), // 27: Salmon Pink + (254, 55, 15), // 28: Vermilion + (240, 240, 240), // 29: White + (106, 28, 138), // 30: Violet + (168, 221, 196), // 31: Seacrest + (37, 132, 187), // 32: Sky Blue + (254, 179, 67), // 33: Pumpkin + (255, 243, 107), // 34: Cream Yellow + (208, 166, 96), // 35: Khaki + (209, 84, 0), // 36: Clay Brown + (102, 186, 73), // 37: Leaf Green + (19, 74, 70), // 38: Peacock Blue + (135, 135, 135), // 39: Gray + (216, 204, 198), // 40: Warm Gray + (67, 86, 7), // 41: Dark Olive + (253, 217, 222), // 42: Flesh Pink + (249, 147, 188), // 43: Pink + (0, 56, 34), // 44: Deep Green + (178, 175, 212), // 45: Lavender + (104, 106, 176), // 46: Wisteria Violet + (239, 227, 185), // 47: Beige + (247, 56, 102), // 48: Carmine + (181, 75, 100), // 49: Amber Red + (19, 43, 26), // 50: Olive Green + (199, 1, 86), // 51: Dark Fuchsia + (254, 158, 50), // 52: Tangerine + (168, 222, 235), // 53: Light Blue + (0, 103, 62), // 54: Emerald Green + (78, 41, 144), // 55: Purple + (47, 126, 32), // 56: Moss Green + (255, 204, 204), // 57: Flesh Pink + (255, 217, 17), // 58: Harvest Gold + (9, 91, 166), // 59: Electric Blue + (240, 249, 112), // 60: Lemon Yellow + (227, 243, 91), // 61: Fresh Green + (255, 153, 0), // 62: Orange + (255, 240, 141), // 63: Cream Yellow + (255, 200, 200), // 64: Applique +]; + +/// Default high-contrast palette for formats without embedded color info (DST, EXP). +/// Colors cycle on each color change. +pub const DEFAULT_PALETTE: [(u8, u8, u8); 12] = [ + (0, 0, 0), // Black + (237, 23, 31), // Red + (10, 85, 163), // Blue + (0, 135, 119), // Teal Green + (254, 186, 53), // Orange + (145, 54, 151), // Magenta + (112, 188, 31), // Lime Green + (42, 19, 1), // Dark Brown + (37, 132, 187), // Sky Blue + (246, 74, 138), // Deep Rose + (186, 152, 0), // Brass + (106, 28, 138), // Violet +]; + +/// 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) + .map(|i| DEFAULT_PALETTE[i % DEFAULT_PALETTE.len()]) + .collect() +} diff --git a/rustitch/src/pes/header.rs b/rustitch/src/pes/header.rs index 60a215e..b9b84dc 100644 --- a/rustitch/src/pes/header.rs +++ b/rustitch/src/pes/header.rs @@ -1,4 +1,4 @@ -use super::Error; +use crate::error::Error; #[derive(Debug)] pub struct PesHeader { @@ -18,7 +18,7 @@ pub fn parse_header(data: &[u8]) -> Result { if magic != b"#PES" { let mut m = [0u8; 4]; m.copy_from_slice(magic); - return Err(Error::InvalidMagic(m)); + return Err(Error::InvalidPesMagic(m)); } let mut version = [0u8; 4]; @@ -41,7 +41,6 @@ mod tests { let mut data = vec![0u8; 20]; data[0..4].copy_from_slice(b"#PES"); data[4..8].copy_from_slice(b"0001"); - // PEC offset = 16 (little-endian) data[8..12].copy_from_slice(&16u32.to_le_bytes()); let header = parse_header(&data).unwrap(); @@ -53,7 +52,7 @@ mod tests { fn reject_invalid_magic() { let data = b"NOTPES0001\x10\x00\x00\x00"; let err = parse_header(data).unwrap_err(); - assert!(matches!(err, Error::InvalidMagic(_))); + assert!(matches!(err, Error::InvalidPesMagic(_))); } #[test] diff --git a/rustitch/src/pes/mod.rs b/rustitch/src/pes/mod.rs index a305109..44e6c37 100644 --- a/rustitch/src/pes/mod.rs +++ b/rustitch/src/pes/mod.rs @@ -1,30 +1,13 @@ mod header; -mod palette; mod pec; pub use header::PesHeader; -pub use palette::PEC_PALETTE; -pub use pec::{PecHeader, StitchCommand}; +pub use pec::PecHeader; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum Error { - #[error("invalid PES magic: expected #PES, got {0:?}")] - InvalidMagic([u8; 4]), - #[error("file too short: need {expected} bytes, got {actual}")] - TooShort { expected: usize, actual: usize }, - #[error("invalid PEC offset: {0} exceeds file length {1}")] - InvalidPecOffset(u32, usize), - #[error("no stitch data found")] - NoStitchData, - #[error("empty design: no stitch segments produced")] - EmptyDesign, - #[error("render error: {0}")] - Render(String), - #[error("PNG encoding error: {0}")] - PngEncode(#[from] png::EncodingError), -} +// Re-export shared types for backward compatibility +pub use crate::error::Error; +pub use crate::palette::PEC_PALETTE; +pub use crate::types::{BoundingBox, ResolvedDesign, StitchCommand, StitchSegment}; pub struct PesDesign { pub header: PesHeader, @@ -32,27 +15,6 @@ pub struct PesDesign { pub commands: Vec, } -pub struct StitchSegment { - pub x0: f32, - pub y0: f32, - pub x1: f32, - pub y1: f32, - pub color_index: usize, -} - -pub struct BoundingBox { - pub min_x: f32, - pub max_x: f32, - pub min_y: f32, - pub max_y: f32, -} - -pub struct ResolvedDesign { - pub segments: Vec, - pub colors: Vec<(u8, u8, u8)>, - pub bounds: BoundingBox, -} - /// Parse a PES file from raw bytes. pub fn parse(data: &[u8]) -> Result { let header = header::parse_header(data)?; @@ -73,66 +35,8 @@ pub fn parse(data: &[u8]) -> Result { }) } -/// Convert parsed commands into renderable segments with absolute coordinates. +/// Convert parsed PES design into renderable segments with absolute coordinates. pub fn resolve(design: &PesDesign) -> Result { - let mut segments = Vec::new(); - let mut x: f32 = 0.0; - let mut y: f32 = 0.0; - let mut color_idx: usize = 0; - let mut pen_down = true; - - for cmd in &design.commands { - match cmd { - StitchCommand::Stitch { dx, dy } => { - let nx = x + *dx as f32; - let ny = y + *dy as f32; - if pen_down { - segments.push(StitchSegment { - x0: x, - y0: y, - x1: nx, - y1: ny, - color_index: color_idx, - }); - } - x = nx; - y = ny; - pen_down = true; - } - StitchCommand::Jump { dx, dy } => { - x += *dx as f32; - y += *dy as f32; - pen_down = false; - } - StitchCommand::Trim => { - pen_down = false; - } - StitchCommand::ColorChange => { - color_idx += 1; - pen_down = false; - } - StitchCommand::End => break, - } - } - - if segments.is_empty() { - return Err(Error::EmptyDesign); - } - - // Compute bounding box - let mut min_x = f32::MAX; - let mut max_x = f32::MIN; - let mut min_y = f32::MAX; - let mut max_y = f32::MIN; - - for seg in &segments { - min_x = min_x.min(seg.x0).min(seg.x1); - max_x = max_x.max(seg.x0).max(seg.x1); - min_y = min_y.min(seg.y0).min(seg.y1); - max_y = max_y.max(seg.y0).max(seg.y1); - } - - // Resolve colors from palette indices let colors: Vec<(u8, u8, u8)> = design .pec_header .color_indices @@ -143,14 +47,5 @@ pub fn resolve(design: &PesDesign) -> Result { }) .collect(); - Ok(ResolvedDesign { - segments, - colors, - bounds: BoundingBox { - min_x, - max_x, - min_y, - max_y, - }, - }) + crate::resolve::resolve(&design.commands, colors) } diff --git a/rustitch/src/pes/palette.rs b/rustitch/src/pes/palette.rs index ea6002b..423c1bc 100644 --- a/rustitch/src/pes/palette.rs +++ b/rustitch/src/pes/palette.rs @@ -1,69 +1,2 @@ -/// Brother PEC thread color palette (65 entries). -/// Index 0 is a fallback; indices 1–64 correspond to standard Brother thread colors. -pub const PEC_PALETTE: [(u8, u8, u8); 65] = [ - (0, 0, 0), // 0: Unknown - (14, 31, 124), // 1: Prussian Blue - (10, 85, 163), // 2: Blue - (0, 135, 119), // 3: Teal Green - (75, 107, 175), // 4: Cornflower Blue - (237, 23, 31), // 5: Red - (209, 92, 0), // 6: Reddish Brown - (145, 54, 151), // 7: Magenta - (228, 154, 203), // 8: Light Lilac - (145, 95, 172), // 9: Lilac - (158, 214, 125), // 10: Mint Green - (232, 169, 0), // 11: Deep Gold - (254, 186, 53), // 12: Orange - (255, 255, 0), // 13: Yellow - (112, 188, 31), // 14: Lime Green - (186, 152, 0), // 15: Brass - (168, 168, 168), // 16: Silver - (125, 111, 0), // 17: Russet Brown - (255, 255, 179), // 18: Cream Brown - (79, 85, 86), // 19: Pewter - (0, 0, 0), // 20: Black - (11, 61, 145), // 21: Ultramarine - (119, 1, 118), // 22: Royal Purple - (41, 49, 51), // 23: Dark Gray - (42, 19, 1), // 24: Dark Brown - (246, 74, 138), // 25: Deep Rose - (178, 118, 36), // 26: Light Brown - (252, 187, 197), // 27: Salmon Pink - (254, 55, 15), // 28: Vermilion - (240, 240, 240), // 29: White - (106, 28, 138), // 30: Violet - (168, 221, 196), // 31: Seacrest - (37, 132, 187), // 32: Sky Blue - (254, 179, 67), // 33: Pumpkin - (255, 243, 107), // 34: Cream Yellow - (208, 166, 96), // 35: Khaki - (209, 84, 0), // 36: Clay Brown - (102, 186, 73), // 37: Leaf Green - (19, 74, 70), // 38: Peacock Blue - (135, 135, 135), // 39: Gray - (216, 204, 198), // 40: Warm Gray - (67, 86, 7), // 41: Dark Olive - (253, 217, 222), // 42: Flesh Pink - (249, 147, 188), // 43: Pink - (0, 56, 34), // 44: Deep Green - (178, 175, 212), // 45: Lavender - (104, 106, 176), // 46: Wisteria Violet - (239, 227, 185), // 47: Beige - (247, 56, 102), // 48: Carmine - (181, 75, 100), // 49: Amber Red - (19, 43, 26), // 50: Olive Green - (199, 1, 86), // 51: Dark Fuchsia - (254, 158, 50), // 52: Tangerine - (168, 222, 235), // 53: Light Blue - (0, 103, 62), // 54: Emerald Green - (78, 41, 144), // 55: Purple - (47, 126, 32), // 56: Moss Green - (255, 204, 204), // 57: Flesh Pink - (255, 217, 17), // 58: Harvest Gold - (9, 91, 166), // 59: Electric Blue - (240, 249, 112), // 60: Lemon Yellow - (227, 243, 91), // 61: Fresh Green - (255, 153, 0), // 62: Orange - (255, 240, 141), // 63: Cream Yellow - (255, 200, 200), // 64: Applique -]; +// Re-export from crate root for backward compatibility +pub use crate::palette::PEC_PALETTE; diff --git a/rustitch/src/pes/pec.rs b/rustitch/src/pes/pec.rs index b3c95d1..25cf2dd 100644 --- a/rustitch/src/pes/pec.rs +++ b/rustitch/src/pes/pec.rs @@ -1,4 +1,5 @@ -use super::Error; +use crate::error::Error; +use crate::types::StitchCommand; pub struct PecHeader { pub label: String, @@ -6,19 +7,9 @@ pub struct PecHeader { pub color_indices: Vec, } -#[derive(Debug, Clone)] -pub enum StitchCommand { - Stitch { dx: i16, dy: i16 }, - Jump { dx: i16, dy: i16 }, - Trim, - ColorChange, - End, -} - /// Parse the PEC header starting at the PEC section offset. /// Returns the header and the byte offset (relative to pec_data start) where stitch data begins. pub fn parse_pec_header(pec_data: &[u8]) -> Result<(PecHeader, usize), Error> { - // PEC section starts with "LA:" label field (19 bytes total) if pec_data.len() < 532 { return Err(Error::TooShort { expected: 532, @@ -26,57 +17,15 @@ pub fn parse_pec_header(pec_data: &[u8]) -> Result<(PecHeader, usize), Error> { }); } - // Label: bytes 0..19, starts with "LA:" let label_raw = &pec_data[3..19]; let label = std::str::from_utf8(label_raw) .unwrap_or("") .trim() .to_string(); - // Color count at offset 48 from PEC start let color_count = pec_data[48] + 1; - - // Color indices follow at offset 49 let color_indices: Vec = pec_data[49..49 + color_count as usize].to_vec(); - // Stitch data starts at offset 532 from PEC section start - // (48 bytes header + 463 bytes padding/thumbnail = 512, plus 20 bytes of graphic data = 532) - // Actually the standard offset is 512 + the two thumbnail sections. - // The typical approach: skip to offset 48 + 1 + color_count + padding to 512, then skip thumbnails. - // Simplified: PEC stitch data offset = 512 + 20 (for the stitch data header that contains the graphic offsets) - // A more robust approach: read the stitch data offset from the header. - - // At PEC offset + 20..24 there are two u16 LE values for the thumbnail image offsets. - // The stitch data typically starts after a fixed 532-byte header region. - // Let's use the more standard approach from libpes: - // Offset 514-515 (relative to PEC start): thumbnail1 image offset (u16 LE, relative) - // But the simplest reliable approach is to find the stitch data after the fixed header. - - // The standard PEC header is 512 bytes, followed by two thumbnail images. - // Thumbnail 1: 6 bytes wide × 38 bytes high = 228 bytes (48×38 pixel, 1bpp padded) - // Actually, typical PEC has: after the 512-byte block, there are two graphics sections. - // The stitch data starts after those graphics. - // - // More robust: bytes 514..516 give the thumbnail offset (little-endian u16). - // We can derive stitch data from there, but let's use the standard fixed sizes. - // Thumbnail 1: at offset 512, size = ceil(width*2/8) * height, with default 48×38 = 6*38=228 - // Thumbnail 2: at offset 512+228=740, size = ceil(width*2/8) * height, default 96×76=12*76=912 - // Stitch data at: 512 + 228 + 912 = 1652? That doesn't seem right. - // - // Actually from libpes wiki: PEC header is 20 bytes, then color info, then padding to - // reach a 512-byte boundary. At byte 512 is the beginning of the PEC graphic section. - // After the graphics come the stitch data. But graphic sizes vary. - // - // The correct approach: at PEC_start + 514 (bytes 514-515), read a u16 LE which gives - // the absolute offset from PEC_start to the first thumbnail. Then after thumbnails come stitches. - // BUT actually, the standard approach used by most parsers is simpler: - // - // pyembroidery approach: seek to PEC_start + 532, that's where stitch data starts. - // The 532 = 512 + 20 (20 bytes for graphic header). - // - // Let's verify: pyembroidery's PecReader reads stitches starting 532 bytes after PEC start. - // Let's go with 532. - let stitch_data_offset = 532; if pec_data.len() <= stitch_data_offset { @@ -101,31 +50,26 @@ pub fn decode_stitches(data: &[u8]) -> Result, Error> { while i < data.len() { let b1 = data[i]; - // End marker if b1 == 0xFF { commands.push(StitchCommand::End); break; } - // Color change if b1 == 0xFE { commands.push(StitchCommand::ColorChange); - i += 2; // skip the 0xFE and the following byte (typically 0xB0) + i += 2; continue; } - // Parse dx let (dx, dx_flags, bytes_dx) = decode_coordinate(data, i)?; i += bytes_dx; - // Parse dy let (dy, dy_flags, bytes_dy) = decode_coordinate(data, i)?; i += bytes_dy; let flags = dx_flags | dy_flags; if flags & 0x20 != 0 { - // Trim + jump commands.push(StitchCommand::Trim); commands.push(StitchCommand::Jump { dx, dy }); } else if flags & 0x10 != 0 { @@ -155,7 +99,6 @@ fn decode_coordinate(data: &[u8], pos: usize) -> Result<(i16, u8, usize), Error> let b = data[pos]; if b & 0x80 != 0 { - // Extended 12-bit encoding (2 bytes) if pos + 1 >= data.len() { return Err(Error::TooShort { expected: pos + 2, @@ -163,7 +106,7 @@ fn decode_coordinate(data: &[u8], pos: usize) -> Result<(i16, u8, usize), Error> }); } let b2 = data[pos + 1]; - let flags = b & 0x70; // bits 6-4 for jump/trim flags + let flags = b & 0x70; let raw = (((b & 0x0F) as u16) << 8) | (b2 as u16); let value = if raw > 0x7FF { raw as i16 - 0x1000 @@ -172,7 +115,6 @@ fn decode_coordinate(data: &[u8], pos: usize) -> Result<(i16, u8, usize), Error> }; Ok((value, flags, 2)) } else { - // 7-bit encoding (1 byte) let value = if b > 0x3F { b as i16 - 0x80 } else { b as i16 }; Ok((value, 0, 1)) } @@ -192,7 +134,6 @@ mod tests { #[test] fn decode_simple_stitch() { - // dx=10 (0x0A), dy=20 (0x14), then end let data = [0x0A, 0x14, 0xFF]; let cmds = decode_stitches(&data).unwrap(); assert_eq!(cmds.len(), 2); @@ -207,7 +148,6 @@ mod tests { #[test] fn decode_negative_7bit() { - // dx=0x50 (80 decimal, > 0x3F so value = 80-128 = -48), dy=0x60 (96-128=-32), end let data = [0x50, 0x60, 0xFF]; let cmds = decode_stitches(&data).unwrap(); match &cmds[0] { @@ -229,9 +169,6 @@ mod tests { #[test] fn decode_extended_12bit() { - // Extended dx: high bit set, flags=0x10 (jump), value = 0x100 = 256 - // byte1 = 0x80 | 0x10 | 0x01 = 0x91, byte2 = 0x00 -> raw = 0x100 = 256 - // dy: simple 0x05 = 5 let data = [0x91, 0x00, 0x05, 0xFF]; let cmds = decode_stitches(&data).unwrap(); assert!(matches!(cmds[0], StitchCommand::Jump { dx: 256, dy: 5 })); @@ -239,8 +176,6 @@ mod tests { #[test] fn decode_trim_jump() { - // dx with trim flag (0x20): byte1 = 0x80 | 0x20 | 0x00 = 0xA0, byte2 = 0x0A -> raw=10 - // dy: simple 0x05 let data = [0xA0, 0x0A, 0x05, 0xFF]; let cmds = decode_stitches(&data).unwrap(); assert!(matches!(cmds[0], StitchCommand::Trim)); diff --git a/rustitch/src/render.rs b/rustitch/src/render.rs index a6621a0..f2e59f8 100644 --- a/rustitch/src/render.rs +++ b/rustitch/src/render.rs @@ -1,6 +1,7 @@ use tiny_skia::{LineCap, Paint, PathBuilder, Pixmap, Stroke, Transform}; -use crate::pes::{Error, ResolvedDesign}; +use crate::error::Error; +use crate::types::ResolvedDesign; /// Render a resolved embroidery design to a PNG image of the given size. pub fn render_thumbnail(design: &ResolvedDesign, size: u32) -> Result, Error> { @@ -23,7 +24,6 @@ pub fn render_thumbnail(design: &ResolvedDesign, size: u32) -> Result, E let line_width = (scale * 0.3).max(1.0); - // Group segments by color index and draw each group let max_color = design .segments .iter() @@ -82,7 +82,6 @@ fn encode_png(pixmap: &Pixmap) -> Result, Error> { let height = pixmap.height(); let src = pixmap.data(); - // Unpremultiply alpha let mut data = Vec::with_capacity(src.len()); for chunk in src.chunks_exact(4) { let (r, g, b, a) = (chunk[0], chunk[1], chunk[2], chunk[3]); diff --git a/rustitch/src/resolve.rs b/rustitch/src/resolve.rs new file mode 100644 index 0000000..bd4371f --- /dev/null +++ b/rustitch/src/resolve.rs @@ -0,0 +1,75 @@ +use crate::error::Error; +use crate::types::{BoundingBox, ResolvedDesign, StitchCommand, StitchSegment}; + +/// Convert parsed stitch commands into renderable segments with absolute coordinates. +pub fn resolve( + commands: &[StitchCommand], + colors: Vec<(u8, u8, u8)>, +) -> Result { + let mut segments = Vec::new(); + let mut x: f32 = 0.0; + let mut y: f32 = 0.0; + let mut color_idx: usize = 0; + let mut pen_down = true; + + for cmd in commands { + match cmd { + StitchCommand::Stitch { dx, dy } => { + let nx = x + *dx as f32; + let ny = y + *dy as f32; + if pen_down { + segments.push(StitchSegment { + x0: x, + y0: y, + x1: nx, + y1: ny, + color_index: color_idx, + }); + } + x = nx; + y = ny; + pen_down = true; + } + StitchCommand::Jump { dx, dy } => { + x += *dx as f32; + y += *dy as f32; + pen_down = false; + } + StitchCommand::Trim => { + pen_down = false; + } + StitchCommand::ColorChange => { + color_idx += 1; + pen_down = false; + } + StitchCommand::End => break, + } + } + + if segments.is_empty() { + return Err(Error::EmptyDesign); + } + + let mut min_x = f32::MAX; + let mut max_x = f32::MIN; + let mut min_y = f32::MAX; + let mut max_y = f32::MIN; + + for seg in &segments { + min_x = min_x.min(seg.x0).min(seg.x1); + max_x = max_x.max(seg.x0).max(seg.x1); + min_y = min_y.min(seg.y0).min(seg.y1); + max_y = max_y.max(seg.y0).max(seg.y1); + } + + Ok(ResolvedDesign { + segments, + colors, + bounds: BoundingBox { + min_x, + max_x, + min_y, + max_y, + }, + }) +} diff --git a/rustitch/src/types.rs b/rustitch/src/types.rs new file mode 100644 index 0000000..bb8face --- /dev/null +++ b/rustitch/src/types.rs @@ -0,0 +1,29 @@ +#[derive(Debug, Clone)] +pub enum StitchCommand { + Stitch { dx: i16, dy: i16 }, + Jump { dx: i16, dy: i16 }, + Trim, + ColorChange, + End, +} + +pub struct StitchSegment { + pub x0: f32, + pub y0: f32, + pub x1: f32, + pub y1: f32, + pub color_index: usize, +} + +pub struct BoundingBox { + pub min_x: f32, + pub max_x: f32, + pub min_y: f32, + pub max_y: f32, +} + +pub struct ResolvedDesign { + pub segments: Vec, + pub colors: Vec<(u8, u8, u8)>, + pub bounds: BoundingBox, +} diff --git a/rustitch/src/vp3.rs b/rustitch/src/vp3.rs new file mode 100644 index 0000000..902c02a --- /dev/null +++ b/rustitch/src/vp3.rs @@ -0,0 +1,343 @@ +use crate::error::Error; +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 +/// +/// 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). +pub fn parse(data: &[u8]) -> Result<(Vec, Vec<(u8, u8, u8)>), Error> { + if data.len() < 20 { + return Err(Error::TooShort { + expected: 20, + actual: data.len(), + }); + } + + let mut reader = Reader::new(data); + + // 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 mut colors = Vec::new(); + let mut commands = Vec::new(); + + // 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)?; + colors.push(color); + } + + if commands.is_empty() { + return Err(Error::NoStitchData); + } + + if !matches!(commands.last(), Some(StitchCommand::End)) { + commands.push(StitchCommand::End); + } + + Ok((commands, colors)) +} + +/// Parse a VP3 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) +} + +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 { + if len < 1000 && (len as usize) + 2 <= reader.remaining() { + skip_string(reader)?; + } + } + } + + Ok(()) +} + +fn read_color_section( + reader: &mut Reader, + commands: &mut Vec, +) -> Result<(u8, u8, u8), Error> { + // Color change between sections (except first) + if !commands.is_empty() { + 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(), + }); + } + + // Skip section offset/position data (2x i32 = 8 bytes) + reader.skip(8)?; + + // Skip thread info string + skip_string(reader)?; + + // 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; + } + + 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]; + + // Check for 3-byte extended coordinates (high bit set on first byte) + if b1 & 0x80 != 0 { + if reader.remaining() < 4 { + break; + } + 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 + commands.push(StitchCommand::Trim); + } else { + commands.push(StitchCommand::Stitch { dx, dy }); + } + } + } +} + +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!( + "string length {} exceeds remaining data {}", + len, + reader.remaining() + ))); + } + reader.skip(len)?; + Ok(()) +} + +fn read_i16_be(data: &[u8], pos: usize) -> i16 { + i16::from_be_bytes([data[pos], data[pos + 1]]) +} + +struct Reader<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + + fn remaining(&self) -> usize { + self.data.len().saturating_sub(self.pos) + } + + fn read_u8(&mut self) -> Result { + if self.pos >= self.data.len() { + return Err(Error::TooShort { + expected: self.pos + 1, + actual: self.data.len(), + }); + } + let v = self.data[self.pos]; + self.pos += 1; + Ok(v) + } + + fn read_u16_be(&mut self) -> Result { + if self.pos + 2 > self.data.len() { + return Err(Error::TooShort { + expected: self.pos + 2, + actual: self.data.len(), + }); + } + let v = u16::from_be_bytes([self.data[self.pos], self.data[self.pos + 1]]); + self.pos += 2; + 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 { + expected: self.pos + 4, + actual: self.data.len(), + }); + } + let v = u32::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 { + expected: self.pos + n, + actual: self.data.len(), + }); + } + self.pos += n; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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()); + assert_eq!(commands.len(), 2); + assert!(matches!( + commands[0], + StitchCommand::Stitch { dx: 10, dy: -20 } + )); + } + + #[test] + fn decode_large_jump() { + 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 mut reader = Reader::new(&data); + decode_vp3_stitches(&mut reader, &mut commands, data.len()); + assert_eq!(commands.len(), 1); + assert!(matches!(commands[0], StitchCommand::Jump { .. })); + } +} -- 2.49.1 From 69d02692705097aca58a53f7a513168fd8137c1b Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 31 Mar 2026 08:12:12 +0200 Subject: [PATCH 2/6] version bump --- rustitch/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rustitch/Cargo.toml b/rustitch/Cargo.toml index ba80fc4..579abfe 100644 --- a/rustitch/Cargo.toml +++ b/rustitch/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustitch" -version = "0.1.2" +version = "0.2.0" edition = "2024" description = "PES embroidery file parser and thumbnail renderer" license = "MIT" -- 2.49.1 From 7c8ecda29aa4a2a9e55fb5a22d6ecb39520de8b0 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 31 Mar 2026 12:18:53 +0200 Subject: [PATCH 3/6] fixed bug with color layering issue --- Cargo.lock | 2 +- data/pes.xml | 16 +++++++ data/stitch-peek.thumbnailer | 2 +- rustitch/src/dst/mod.rs | 82 +++++++++++++++++++++++++++--------- rustitch/src/exp.rs | 10 +---- rustitch/src/jef/mod.rs | 14 +++--- rustitch/src/lib.rs | 9 +++- rustitch/src/pes/pec.rs | 13 ++++++ rustitch/src/vp3.rs | 13 +++--- stitch-peek/Cargo.toml | 2 +- stitch-peek/src/main.rs | 14 ++++-- 11 files changed, 126 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ff33dad..e0c2016 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,7 +235,7 @@ dependencies = [ [[package]] name = "rustitch" -version = "0.1.2" +version = "0.2.0" dependencies = [ "png", "thiserror", diff --git a/data/pes.xml b/data/pes.xml index b188082..a9f3dae 100644 --- a/data/pes.xml +++ b/data/pes.xml @@ -7,4 +7,20 @@ + + DST Tajima embroidery file + + + + EXP Melco embroidery file + + + + JEF Janome embroidery file + + + + VP3 Pfaff embroidery file + + diff --git a/data/stitch-peek.thumbnailer b/data/stitch-peek.thumbnailer index 7b0c6c7..30548e0 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 +MimeType=application/x-pes;application/x-dst;application/x-exp;application/x-jef;application/x-vp3 diff --git a/rustitch/src/dst/mod.rs b/rustitch/src/dst/mod.rs index a73ec6c..ca2cc8c 100644 --- a/rustitch/src/dst/mod.rs +++ b/rustitch/src/dst/mod.rs @@ -77,16 +77,36 @@ pub fn parse(data: &[u8]) -> Result, Error> { /// Standard bit layout for dx across bytes b0, b1, b2. fn decode_dx(b0: u8, b1: u8, b2: u8) -> i16 { let mut x: i16 = 0; - if b0 & 0x01 != 0 { x += 1; } - if b0 & 0x02 != 0 { x -= 1; } - if b0 & 0x04 != 0 { x += 9; } - if b0 & 0x08 != 0 { x -= 9; } - if b1 & 0x01 != 0 { x += 3; } - if b1 & 0x02 != 0 { x -= 3; } - if b1 & 0x04 != 0 { x += 27; } - if b1 & 0x08 != 0 { x -= 27; } - if b2 & 0x04 != 0 { x += 81; } - if b2 & 0x08 != 0 { x -= 81; } + if b0 & 0x01 != 0 { + x += 1; + } + if b0 & 0x02 != 0 { + x -= 1; + } + if b0 & 0x04 != 0 { + x += 9; + } + if b0 & 0x08 != 0 { + x -= 9; + } + if b1 & 0x01 != 0 { + x += 3; + } + if b1 & 0x02 != 0 { + x -= 3; + } + if b1 & 0x04 != 0 { + x += 27; + } + if b1 & 0x08 != 0 { + x -= 27; + } + if b2 & 0x04 != 0 { + x += 81; + } + if b2 & 0x08 != 0 { + x -= 81; + } x } @@ -94,16 +114,36 @@ fn decode_dx(b0: u8, b1: u8, b2: u8) -> i16 { /// Standard bit layout for dy across bytes b0, b1, b2. fn decode_dy(b0: u8, b1: u8, b2: u8) -> i16 { let mut y: i16 = 0; - if b0 & 0x80 != 0 { y += 1; } - if b0 & 0x40 != 0 { y -= 1; } - if b0 & 0x20 != 0 { y += 9; } - if b0 & 0x10 != 0 { y -= 9; } - if b1 & 0x80 != 0 { y += 3; } - if b1 & 0x40 != 0 { y -= 3; } - if b1 & 0x20 != 0 { y += 27; } - if b1 & 0x10 != 0 { y -= 27; } - if b2 & 0x20 != 0 { y += 81; } - if b2 & 0x10 != 0 { y -= 81; } + if b0 & 0x80 != 0 { + y += 1; + } + if b0 & 0x40 != 0 { + y -= 1; + } + if b0 & 0x20 != 0 { + y += 9; + } + if b0 & 0x10 != 0 { + y -= 9; + } + if b1 & 0x80 != 0 { + y += 3; + } + if b1 & 0x40 != 0 { + y -= 3; + } + if b1 & 0x20 != 0 { + y += 27; + } + if b1 & 0x10 != 0 { + y -= 27; + } + if b2 & 0x20 != 0 { + y += 81; + } + if b2 & 0x10 != 0 { + y -= 81; + } // DST Y axis is inverted (positive = up in machine coords, down in screen coords) -y } @@ -166,7 +206,7 @@ mod tests { assert_eq!(decode_dx(0x04, 0x00, 0x00), 9); assert_eq!(decode_dx(0x00, 0x04, 0x00), 27); assert_eq!(decode_dx(0x00, 0x00, 0x04), 81); - assert_eq!(decode_dx(0x05, 0x05, 0x04), 1 + 9 + 27 + 81); // 118 + assert_eq!(decode_dx(0x05, 0x05, 0x04), 1 + 9 + 3 + 27 + 81); // 121 } #[test] diff --git a/rustitch/src/exp.rs b/rustitch/src/exp.rs index 6ff0b58..19bf7c6 100644 --- a/rustitch/src/exp.rs +++ b/rustitch/src/exp.rs @@ -88,10 +88,7 @@ mod tests { 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[0], StitchCommand::Stitch { dx: 10, dy: 20 })); assert!(matches!(cmds[1], StitchCommand::Stitch { dx: 5, dy: 3 })); assert!(matches!(cmds[2], StitchCommand::End)); } @@ -120,10 +117,7 @@ mod tests { fn parse_jump() { let data = [0x80, 0x04, 0x0A, 0x14]; let cmds = parse(&data).unwrap(); - assert!(matches!( - cmds[0], - StitchCommand::Jump { dx: 10, dy: 20 } - )); + assert!(matches!(cmds[0], StitchCommand::Jump { dx: 10, dy: 20 })); } #[test] diff --git a/rustitch/src/jef/mod.rs b/rustitch/src/jef/mod.rs index bcedee5..22ce9d4 100644 --- a/rustitch/src/jef/mod.rs +++ b/rustitch/src/jef/mod.rs @@ -15,7 +15,9 @@ use palette::JEF_PALETTE; /// /// Stitch data: 2 bytes per stitch (signed i8 dx, dy). /// Control codes: 0x80 0x01 = color change, 0x80 0x02 = jump, 0x80 0x10 = end. -pub fn parse(data: &[u8]) -> Result<(Vec, Vec<(u8, u8, u8)>), Error> { +type ParseResult = Result<(Vec, Vec<(u8, u8, u8)>), Error>; + +pub fn parse(data: &[u8]) -> ParseResult { if data.len() < 116 { return Err(Error::TooShort { expected: 116, @@ -146,10 +148,7 @@ mod tests { fn decode_simple_stitches() { let data = [0x0A, 0x14, 0x05, 0x03, 0x80, 0x10]; let cmds = decode_stitches(&data).unwrap(); - assert!(matches!( - cmds[0], - StitchCommand::Stitch { dx: 10, dy: -20 } - )); + 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)); } @@ -165,9 +164,6 @@ mod tests { fn decode_jump() { let data = [0x80, 0x02, 0x0A, 0x14, 0x80, 0x10]; let cmds = decode_stitches(&data).unwrap(); - assert!(matches!( - cmds[0], - StitchCommand::Jump { dx: 10, dy: -20 } - )); + assert!(matches!(cmds[0], StitchCommand::Jump { dx: 10, dy: -20 })); } } diff --git a/rustitch/src/lib.rs b/rustitch/src/lib.rs index a51b4c2..31587ee 100644 --- a/rustitch/src/lib.rs +++ b/rustitch/src/lib.rs @@ -3,7 +3,11 @@ pub mod format; pub mod palette; pub mod types; +pub mod dst; +pub mod exp; +pub mod jef; pub mod pes; +pub mod vp3; mod render; mod resolve; @@ -32,6 +36,9 @@ fn parse_and_resolve(data: &[u8], fmt: Format) -> Result let design = pes::parse(data)?; pes::resolve(&design) } - _ => Err(Error::UnsupportedFormat), + Format::Dst => dst::parse_and_resolve(data), + Format::Exp => exp::parse_and_resolve(data), + Format::Jef => jef::parse_and_resolve(data), + Format::Vp3 => vp3::parse_and_resolve(data), } } diff --git a/rustitch/src/pes/pec.rs b/rustitch/src/pes/pec.rs index 25cf2dd..c520f7d 100644 --- a/rustitch/src/pes/pec.rs +++ b/rustitch/src/pes/pec.rs @@ -64,6 +64,19 @@ pub fn decode_stitches(data: &[u8]) -> Result, Error> { let (dx, dx_flags, bytes_dx) = decode_coordinate(data, i)?; i += bytes_dx; + // Check for special bytes at dy position — color change or end markers + // can appear between dx and dy when the preceding stitch ends on an + // odd byte boundary relative to the next control byte. + if i < data.len() && data[i] == 0xFF { + commands.push(StitchCommand::End); + break; + } + if i < data.len() && data[i] == 0xFE { + commands.push(StitchCommand::ColorChange); + i += 2; + continue; + } + let (dy, dy_flags, bytes_dy) = decode_coordinate(data, i)?; i += bytes_dy; diff --git a/rustitch/src/vp3.rs b/rustitch/src/vp3.rs index 902c02a..ce88b36 100644 --- a/rustitch/src/vp3.rs +++ b/rustitch/src/vp3.rs @@ -12,7 +12,9 @@ use crate::types::{ResolvedDesign, StitchCommand}; /// /// 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). -pub fn parse(data: &[u8]) -> Result<(Vec, Vec<(u8, u8, u8)>), Error> { +type ParseResult = Result<(Vec, Vec<(u8, u8, u8)>), Error>; + +pub fn parse(data: &[u8]) -> ParseResult { if data.len() < 20 { return Err(Error::TooShort { expected: 20, @@ -93,10 +95,11 @@ fn skip_vp3_header(reader: &mut Reader) -> Result<(), Error> { // Skip another potential string if reader.remaining() >= 2 { let peek = reader.peek_u16_be(); - if let Ok(len) = peek { - if len < 1000 && (len as usize) + 2 <= reader.remaining() { - skip_string(reader)?; - } + if let Ok(len) = peek + && len < 1000 + && (len as usize) + 2 <= reader.remaining() + { + skip_string(reader)?; } } diff --git a/stitch-peek/Cargo.toml b/stitch-peek/Cargo.toml index 8c016a2..ba4449c 100644 --- a/stitch-peek/Cargo.toml +++ b/stitch-peek/Cargo.toml @@ -11,6 +11,6 @@ categories = ["graphics", "command-line-utilities"] readme = "README.md" [dependencies] -rustitch = { version = "0.1.1", path = "../rustitch" } +rustitch = { version = "0.2", path = "../rustitch" } clap = { version = "4", features = ["derive"] } anyhow = "1" diff --git a/stitch-peek/src/main.rs b/stitch-peek/src/main.rs index a2dba8f..ac31467 100644 --- a/stitch-peek/src/main.rs +++ b/stitch-peek/src/main.rs @@ -3,9 +3,12 @@ use clap::Parser; use std::fs; #[derive(Parser)] -#[command(name = "stitch-peek", about = "PES embroidery file thumbnailer")] +#[command( + name = "stitch-peek", + about = "Embroidery file thumbnailer (PES, DST, EXP, JEF, VP3)" +)] struct Args { - /// Input PES file path + /// Input embroidery file path #[arg(short = 'i', long = "input")] input: std::path::PathBuf, @@ -21,11 +24,14 @@ struct Args { fn main() -> Result<()> { let args = Args::parse(); + let format = rustitch::format::detect_from_extension(&args.input) + .with_context(|| format!("unsupported file extension: {}", args.input.display()))?; + let data = fs::read(&args.input) .with_context(|| format!("failed to read {}", args.input.display()))?; - let png = - rustitch::thumbnail(&data, args.size).with_context(|| "failed to generate thumbnail")?; + let png = rustitch::thumbnail_format(&data, args.size, format) + .with_context(|| "failed to generate thumbnail")?; fs::write(&args.output, &png) .with_context(|| format!("failed to write {}", args.output.display()))?; -- 2.49.1 From 156365fa8f8a14019e286a006ce7748ae9eb1b34 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 31 Mar 2026 12:20:15 +0200 Subject: [PATCH 4/6] version bump to 0.1.3 --- stitch-peek/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stitch-peek/Cargo.toml b/stitch-peek/Cargo.toml index ba4449c..4c02c01 100644 --- a/stitch-peek/Cargo.toml +++ b/stitch-peek/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stitch-peek" -version = "0.1.2" +version = "0.1.3" edition = "2024" description = "Nautilus thumbnail generator for PES embroidery files" license = "MIT" -- 2.49.1 From 08aafaa3c3c831395f8e95a81a009c3724302663 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 31 Mar 2026 12:20:36 +0200 Subject: [PATCH 5/6] cargo lock --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index e0c2016..4d53b54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -250,7 +250,7 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "stitch-peek" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "clap", -- 2.49.1 From 473da90b01ba886f3ce6818669e3cab79b264879 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 31 Mar 2026 12:41:36 +0200 Subject: [PATCH 6/6] fixed rotation issues + updated readme --- README.md | 43 +++++++++++++++------ rustitch/Cargo.toml | 2 +- rustitch/README.md | 46 +++++++++++++++++++---- rustitch/src/exp.rs | 17 ++++----- rustitch/src/pes/pec.rs | 38 +++++++++++-------- rustitch/tests/fixtures/0.3x1 INCHES.EXP | Bin 0 -> 1346 bytes rustitch/tests/fixtures/0.3x1 INCHES.JEF | Bin 0 -> 1660 bytes rustitch/tests/fixtures/0.3x1 INCHES.PES | Bin 0 -> 6502 bytes stitch-peek/README.md | 16 +++++--- 9 files changed, 112 insertions(+), 50 deletions(-) create mode 100644 rustitch/tests/fixtures/0.3x1 INCHES.EXP create mode 100644 rustitch/tests/fixtures/0.3x1 INCHES.JEF create mode 100644 rustitch/tests/fixtures/0.3x1 INCHES.PES diff --git a/README.md b/README.md index 6fa27d2..739bf62 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,15 @@ [![docs.rs](https://img.shields.io/docsrs/rustitch)](https://docs.rs/rustitch) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -A Nautilus/GNOME thumbnailer for **PES embroidery files**. Browse your embroidery designs in the file manager with automatic thumbnail previews. +A Nautilus/GNOME thumbnailer for **embroidery files**. Browse your embroidery designs in the file manager with automatic thumbnail previews. + +Supported formats: **PES**, **DST**, **EXP**, **JEF**, **VP3** Built as two crates: | Crate | Description | |-------|-------------| -| [**rustitch**](rustitch/) | Library for parsing PES files and rendering stitch data to images | +| [**rustitch**](rustitch/) | Library for parsing embroidery files and rendering stitch data to images | | [**stitch-peek**](stitch-peek/) | CLI thumbnailer that integrates with GNOME/Nautilus | @@ -68,7 +70,7 @@ nautilus -q ### As a thumbnailer -Once installed, Nautilus will automatically generate thumbnails for `.pes` files. No manual action needed -- just open a folder containing PES files. +Once installed, Nautilus will automatically generate thumbnails for embroidery files. No manual action needed -- just open a folder containing `.pes`, `.dst`, `.exp`, `.jef`, or `.vp3` files. ### Standalone CLI @@ -76,11 +78,12 @@ Generate a thumbnail manually: ```sh stitch-peek -i design.pes -o preview.png -s 256 +stitch-peek -i pattern.dst -o preview.png -s 256 ``` | Flag | Description | Default | |------|-------------|---------| -| `-i` | Input PES file | required | +| `-i` | Input embroidery file | required | | `-o` | Output PNG path | required | | `-s` | Thumbnail size (pixels) | 128 | @@ -94,16 +97,26 @@ rustitch = "0.1" ``` ```rust +// PES (auto-detected) let pes_data = std::fs::read("design.pes")?; let png_bytes = rustitch::thumbnail(&pes_data, 256)?; -std::fs::write("preview.png", &png_bytes)?; + +// Any supported format (explicit) +let dst_data = std::fs::read("pattern.dst")?; +let png_bytes = rustitch::thumbnail_format(&dst_data, 256, rustitch::Format::Dst)?; ``` See the [rustitch README](rustitch/README.md) for more API examples. ## Supported formats -**PES** (Brother PE-Design) embroidery files, versions 1 through 10. The PEC section containing stitch data is consistent across versions. +| Format | Manufacturer | Colors | Notes | +|--------|-------------|--------|-------| +| **PES** | Brother PE-Design | Embedded (PEC palette) | Versions 1-10 | +| **DST** | Tajima | Default palette | 3-byte bit-packed records | +| **EXP** | Melco/Bernina | Default palette | Simple 2-byte encoding | +| **JEF** | Janome | Embedded (Janome palette) | Structured header with color table | +| **VP3** | Pfaff/Viking | Embedded (RGB) | Hierarchical format with per-section colors | ## Project structure @@ -112,16 +125,22 @@ stitch-peek-rs/ ├── rustitch/ # Library crate │ └── src/ │ ├── lib.rs # Public API -│ ├── pes/ # PES format parser -│ │ ├── header.rs # File header (#PES magic, version, PEC offset) -│ │ ├── pec.rs # PEC section (colors, stitch decoding) -│ │ └── palette.rs # Brother 65-color thread palette -│ └── render.rs # tiny-skia renderer +│ ├── types.rs # Shared types (StitchCommand, ResolvedDesign, ...) +│ ├── error.rs # Error types +│ ├── format.rs # Format detection (magic bytes, extension) +│ ├── palette.rs # Thread color palettes (PEC, default) +│ ├── resolve.rs # Stitch command to segment resolver +│ ├── render.rs # tiny-skia renderer +│ ├── pes/ # PES (Brother) parser +│ ├── dst/ # DST (Tajima) parser +│ ├── exp.rs # EXP (Melco) parser +│ ├── jef/ # JEF (Janome) parser +│ └── vp3.rs # VP3 (Pfaff/Viking) parser ├── stitch-peek/ # Binary crate (CLI thumbnailer) │ └── src/main.rs └── data/ ├── stitch-peek.thumbnailer # Nautilus integration - └── pes.xml # MIME type definition + └── pes.xml # MIME type definitions ``` ## Development diff --git a/rustitch/Cargo.toml b/rustitch/Cargo.toml index 579abfe..3eff2d1 100644 --- a/rustitch/Cargo.toml +++ b/rustitch/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustitch" -version = "0.2.0" +version = "0.2.1" edition = "2024" description = "PES embroidery file parser and thumbnail renderer" license = "MIT" diff --git a/rustitch/README.md b/rustitch/README.md index a549440..8158179 100644 --- a/rustitch/README.md +++ b/rustitch/README.md @@ -4,7 +4,9 @@ [![docs.rs](https://img.shields.io/docsrs/rustitch)](https://docs.rs/rustitch) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE) -A Rust library for parsing **PES embroidery files** and rendering stitch data to images. +A Rust library for parsing **embroidery files** and rendering stitch data to images. + +Supported formats: **PES**, **DST**, **EXP**, **JEF**, **VP3** Part of the [stitch-peek-rs](https://git.narl.io/nvrl/stitch-peek-rs) project. @@ -14,18 +16,24 @@ Add `rustitch` to your `Cargo.toml`: ```toml [dependencies] -rustitch = "0.1" +rustitch = "0.2" ``` ### Generate a thumbnail ```rust +// PES files (backward-compatible API) let pes_data = std::fs::read("design.pes")?; let png_bytes = rustitch::thumbnail(&pes_data, 256)?; std::fs::write("preview.png", &png_bytes)?; + +// Any supported format +let dst_data = std::fs::read("pattern.dst")?; +let png_bytes = rustitch::thumbnail_format(&dst_data, 256, rustitch::Format::Dst)?; +std::fs::write("preview.png", &png_bytes)?; ``` -### Parse and inspect a design +### Parse and inspect a PES design ```rust use rustitch::pes::{self, StitchCommand}; @@ -61,15 +69,39 @@ let png_bytes = rustitch::render_thumbnail(&resolved, 512)?; std::fs::write("large_preview.png", &png_bytes)?; ``` +### Format detection + +```rust +use rustitch::format::{self, Format}; +use std::path::Path; + +// Detect from file extension +let fmt = format::detect_from_extension(Path::new("design.jef")); +assert_eq!(fmt, Some(Format::Jef)); + +// Detect from file content (magic bytes) +let data = std::fs::read("design.pes")?; +let fmt = format::detect_from_bytes(&data); +assert_eq!(fmt, Some(Format::Pes)); +``` + ## Supported formats -**PES** (Brother PE-Design) embroidery files, versions 1 through 10. The PEC section containing stitch data is consistent across versions. +| Format | Manufacturer | Colors | Notes | +|--------|-------------|--------|-------| +| **PES** | Brother PE-Design | Embedded (PEC palette) | Versions 1-10 | +| **DST** | Tajima | Default palette | 3-byte bit-packed records | +| **EXP** | Melco/Bernina | Default palette | Simple 2-byte encoding | +| **JEF** | Janome | Embedded (Janome palette) | Structured header with color table | +| **VP3** | Pfaff/Viking | Embedded (RGB) | Hierarchical format with per-section colors | + +Formats without embedded color info (DST, EXP) use a default palette of 12 high-contrast colors, cycling on each color change. ## How it works -1. **Parse** the PES binary header to locate the PEC section -2. **Decode** the PEC stitch byte stream (7-bit and 12-bit encoded relative movements, jumps, trims, color changes) -3. **Resolve** relative movements into absolute coordinate segments grouped by thread color, using the 65-color Brother PEC palette +1. **Detect** the file format from magic bytes or extension +2. **Parse** the format-specific binary encoding into a common `StitchCommand` stream (stitch, jump, trim, color change, end) +3. **Resolve** relative movements into absolute coordinate segments grouped by thread color 4. **Render** anti-aliased line segments with [tiny-skia](https://github.com/nickel-org/tiny-skia), scaled to fit the requested size 5. **Encode** as PNG with proper alpha handling diff --git a/rustitch/src/exp.rs b/rustitch/src/exp.rs index 19bf7c6..0c654e8 100644 --- a/rustitch/src/exp.rs +++ b/rustitch/src/exp.rs @@ -42,7 +42,7 @@ pub fn parse(data: &[u8]) -> Result, Error> { break; } let dx = data[i] as i8 as i16; - let dy = data[i + 1] as i8 as i16; + let dy = -(data[i + 1] as i8 as i16); commands.push(StitchCommand::Jump { dx, dy }); i += 2; } @@ -53,7 +53,7 @@ pub fn parse(data: &[u8]) -> Result, Error> { } } else { let dx = b1 as i8 as i16; - let dy = b2 as i8 as i16; + let dy = -(b2 as i8 as i16); commands.push(StitchCommand::Stitch { dx, dy }); i += 2; } @@ -88,8 +88,8 @@ mod tests { 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[0], StitchCommand::Stitch { dx: 10, dy: -20 })); + assert!(matches!(cmds[1], StitchCommand::Stitch { dx: 5, dy: -3 })); assert!(matches!(cmds[2], StitchCommand::End)); } @@ -98,10 +98,7 @@ mod tests { // -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 } - )); + assert!(matches!(cmds[0], StitchCommand::Stitch { dx: -10, dy: 20 })); } #[test] @@ -110,14 +107,14 @@ mod tests { 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 })); + 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 })); + assert!(matches!(cmds[0], StitchCommand::Jump { dx: 10, dy: -20 })); } #[test] diff --git a/rustitch/src/pes/pec.rs b/rustitch/src/pes/pec.rs index c520f7d..c41a29a 100644 --- a/rustitch/src/pes/pec.rs +++ b/rustitch/src/pes/pec.rs @@ -61,12 +61,13 @@ pub fn decode_stitches(data: &[u8]) -> Result, Error> { continue; } - let (dx, dx_flags, bytes_dx) = decode_coordinate(data, i)?; - i += bytes_dx; + // PEC encodes coordinates as (Y, X) — read first value as vertical, + // second as horizontal, then swap to (dx, dy) for screen coordinates. + let (val1, flags1, bytes1) = decode_coordinate(data, i)?; + i += bytes1; - // Check for special bytes at dy position — color change or end markers - // can appear between dx and dy when the preceding stitch ends on an - // odd byte boundary relative to the next control byte. + // Check for special bytes at second coordinate position — color change + // or end markers can appear between the two coordinates. if i < data.len() && data[i] == 0xFF { commands.push(StitchCommand::End); break; @@ -77,10 +78,12 @@ pub fn decode_stitches(data: &[u8]) -> Result, Error> { continue; } - let (dy, dy_flags, bytes_dy) = decode_coordinate(data, i)?; - i += bytes_dy; + let (val2, flags2, bytes2) = decode_coordinate(data, i)?; + i += bytes2; - let flags = dx_flags | dy_flags; + let flags = flags1 | flags2; + let dx = val2; + let dy = val1; if flags & 0x20 != 0 { commands.push(StitchCommand::Trim); @@ -147,13 +150,14 @@ mod tests { #[test] fn decode_simple_stitch() { + // PEC stores (Y, X): first=10 → dy, second=20 → dx let data = [0x0A, 0x14, 0xFF]; let cmds = decode_stitches(&data).unwrap(); assert_eq!(cmds.len(), 2); match &cmds[0] { StitchCommand::Stitch { dx, dy } => { - assert_eq!(*dx, 10); - assert_eq!(*dy, 20); + assert_eq!(*dx, 20); + assert_eq!(*dy, 10); } _ => panic!("expected Stitch"), } @@ -161,12 +165,13 @@ mod tests { #[test] fn decode_negative_7bit() { + // PEC stores (Y, X): first=0x50(-48) → dy, second=0x60(-32) → dx let data = [0x50, 0x60, 0xFF]; let cmds = decode_stitches(&data).unwrap(); match &cmds[0] { StitchCommand::Stitch { dx, dy } => { - assert_eq!(*dx, -48); - assert_eq!(*dy, -32); + assert_eq!(*dx, -32); + assert_eq!(*dy, -48); } _ => panic!("expected Stitch"), } @@ -174,24 +179,27 @@ mod tests { #[test] fn decode_color_change() { + // PEC stores (Y, X): first=10 → dy, second=20 → dx let data = [0xFE, 0xB0, 0x0A, 0x14, 0xFF]; let cmds = decode_stitches(&data).unwrap(); assert!(matches!(cmds[0], StitchCommand::ColorChange)); - assert!(matches!(cmds[1], StitchCommand::Stitch { dx: 10, dy: 20 })); + assert!(matches!(cmds[1], StitchCommand::Stitch { dx: 20, dy: 10 })); } #[test] fn decode_extended_12bit() { + // PEC stores (Y, X): first=0x91,0x00(256 with jump flag) → dy, second=0x05(5) → dx let data = [0x91, 0x00, 0x05, 0xFF]; let cmds = decode_stitches(&data).unwrap(); - assert!(matches!(cmds[0], StitchCommand::Jump { dx: 256, dy: 5 })); + assert!(matches!(cmds[0], StitchCommand::Jump { dx: 5, dy: 256 })); } #[test] fn decode_trim_jump() { + // PEC stores (Y, X): first=0xA0,0x0A(10 with trim flag) → dy, second=0x05(5) → dx let data = [0xA0, 0x0A, 0x05, 0xFF]; let cmds = decode_stitches(&data).unwrap(); assert!(matches!(cmds[0], StitchCommand::Trim)); - assert!(matches!(cmds[1], StitchCommand::Jump { dx: 10, dy: 5 })); + assert!(matches!(cmds[1], StitchCommand::Jump { dx: 5, dy: 10 })); } } diff --git a/rustitch/tests/fixtures/0.3x1 INCHES.EXP b/rustitch/tests/fixtures/0.3x1 INCHES.EXP new file mode 100644 index 0000000000000000000000000000000000000000..7b95a5aefd0757bcf7dc7337ef6b4315c6b13f49 GIT binary patch literal 1346 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 literal 0 HcmV?d00001 diff --git a/stitch-peek/README.md b/stitch-peek/README.md index 2395650..7e60fbc 100644 --- a/stitch-peek/README.md +++ b/stitch-peek/README.md @@ -3,9 +3,11 @@ [![crates.io](https://img.shields.io/crates/v/stitch-peek)](https://crates.io/crates/stitch-peek) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE) -A CLI tool and **Nautilus/GNOME thumbnailer** for PES embroidery files. Generates PNG previews of embroidery designs directly in your file manager. +A CLI tool and **Nautilus/GNOME thumbnailer** for embroidery files. Generates PNG previews of embroidery designs directly in your file manager. -Part of the [stitch-peek-rs](https://git.narl.io/nvrl/stitch-peek-rs) project. Uses [rustitch](https://crates.io/crates/rustitch) for PES parsing and rendering. +Supported formats: **PES**, **DST**, **EXP**, **JEF**, **VP3** + +Part of the [stitch-peek-rs](https://git.narl.io/nvrl/stitch-peek-rs) project. Uses [rustitch](https://crates.io/crates/rustitch) for parsing and rendering. ## Installation @@ -49,23 +51,27 @@ nautilus -q ### As a thumbnailer -Once installed with the `.thumbnailer` file in place, Nautilus automatically generates thumbnails for `.pes` files. No manual action needed. +Once installed with the `.thumbnailer` file in place, Nautilus automatically generates thumbnails for `.pes`, `.dst`, `.exp`, `.jef`, and `.vp3` files. No manual action needed. ### Standalone CLI ```sh stitch-peek -i design.pes -o preview.png -s 256 +stitch-peek -i pattern.dst -o preview.png +stitch-peek -i motif.jef -o preview.png -s 512 ``` | Flag | Description | Default | |------|-------------|---------| -| `-i` | Input PES file | required | +| `-i` | Input embroidery file | required | | `-o` | Output PNG path | required | | `-s` | Thumbnail size (pixels) | 128 | +The format is detected automatically from the file extension. + ## How it works -The tool follows the [freedesktop thumbnail specification](https://specifications.freedesktop.org/thumbnail/latest/). Nautilus calls it with an input file, output path, and requested size. It parses the PES file, renders the stitch pattern as anti-aliased colored lines on a transparent background, and writes a PNG. +The tool follows the [freedesktop thumbnail specification](https://specifications.freedesktop.org/thumbnail/latest/). Nautilus calls it with an input file, output path, and requested size. It detects the embroidery format, parses the stitch data, renders the pattern as anti-aliased colored lines on a transparent background, and writes a PNG. ## License -- 2.49.1