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/Cargo.toml b/rustitch/Cargo.toml
index 3eff2d1..c4b4f28 100644
--- a/rustitch/Cargo.toml
+++ b/rustitch/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "rustitch"
-version = "0.2.1"
+version = "0.2.2"
edition = "2024"
description = "PES embroidery file parser and thumbnail renderer"
license = "MIT"
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/Cargo.toml b/stitch-peek/Cargo.toml
index 4c02c01..6f3c1c0 100644
--- a/stitch-peek/Cargo.toml
+++ b/stitch-peek/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "stitch-peek"
-version = "0.1.3"
+version = "0.1.4"
edition = "2024"
description = "Nautilus thumbnail generator for PES embroidery files"
license = "MIT"
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