Merge pull request 'release/0.2.4' (#8) from release/0.2.4 into main
Release / Build and Release (push) Successful in 37s

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-04-03 18:46:57 +02:00
22 changed files with 591 additions and 195 deletions
Generated
+1 -1
View File
@@ -235,7 +235,7 @@ dependencies = [
[[package]] [[package]]
name = "rustitch" name = "rustitch"
version = "0.2.0" version = "0.2.1"
dependencies = [ dependencies = [
"png", "png",
"thiserror", "thiserror",
+15
View File
@@ -23,4 +23,19 @@
<comment>VP3 Pfaff embroidery file</comment> <comment>VP3 Pfaff embroidery file</comment>
<glob pattern="*.vp3"/> <glob pattern="*.vp3"/>
</mime-type> </mime-type>
<mime-type type="application/x-pec">
<comment>PEC Brother embroidery file</comment>
<glob pattern="*.pec"/>
<magic priority="50">
<match type="string" offset="0" value="#PEC"/>
</magic>
</mime-type>
<mime-type type="application/x-xxx">
<comment>XXX Singer embroidery file</comment>
<glob pattern="*.xxx"/>
</mime-type>
<mime-type type="application/x-sew">
<comment>SEW Janome embroidery file</comment>
<glob pattern="*.sew"/>
</mime-type>
</mime-info> </mime-info>
+1 -1
View File
@@ -1,4 +1,4 @@
[Thumbnailer Entry] [Thumbnailer Entry]
TryExec=stitch-peek TryExec=stitch-peek
Exec=stitch-peek -i %i -o %o -s %s 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
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustitch" name = "rustitch"
version = "0.2.1" version = "0.2.2"
edition = "2024" edition = "2024"
description = "PES embroidery file parser and thumbnail renderer" description = "PES embroidery file parser and thumbnail renderer"
license = "MIT" license = "MIT"
+18 -13
View File
@@ -37,8 +37,10 @@ pub fn parse(data: &[u8]) -> Result<Vec<StitchCommand>, Error> {
let b2 = stitch_data[i + 2]; let b2 = stitch_data[i + 2];
i += 3; i += 3;
// End of file: byte 2 bits 0 and 1 both set, and specific pattern // End of file: standard DST EOF pattern (0x00, 0x00, 0xF3)
if b2 & 0x03 == 0x03 { // 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); commands.push(StitchCommand::End);
break; break;
} }
@@ -46,14 +48,17 @@ pub fn parse(data: &[u8]) -> Result<Vec<StitchCommand>, Error> {
let dx = decode_dx(b0, b1, b2); let dx = decode_dx(b0, b1, b2);
let dy = decode_dy(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 // Color change: byte 2 bit 7
if b2 & 0x80 != 0 { if flags & 0x80 != 0 {
commands.push(StitchCommand::ColorChange); commands.push(StitchCommand::ColorChange);
continue; continue;
} }
// Jump: byte 2 bit 6 // Jump: byte 2 bit 6
if b2 & 0x40 != 0 { if flags & 0x40 != 0 {
commands.push(StitchCommand::Jump { dx, dy }); commands.push(StitchCommand::Jump { dx, dy });
continue; continue;
} }
@@ -166,8 +171,8 @@ mod tests {
#[test] #[test]
fn decode_end_marker() { fn decode_end_marker() {
// b2 = 0x03 means end // DST EOF = 0x00, 0x00, 0xF3
let data = [0x00, 0x00, 0x03]; let data = [0x00, 0x00, 0xF3];
let cmds = parse(&data).unwrap_err(); let cmds = parse(&data).unwrap_err();
assert!(matches!(cmds, Error::NoStitchData)); assert!(matches!(cmds, Error::NoStitchData));
} }
@@ -175,9 +180,9 @@ mod tests {
#[test] #[test]
fn decode_simple_stitch() { fn decode_simple_stitch() {
// A normal stitch followed by end // A normal stitch followed by end
// dx=+1: b0 bit 0. dy=+1: b0 bit 7. b2=0x00 (normal stitch) // dx=+1: b0 bit 0. dy=+1: b0 bit 7. b2=0x03 (always-set bits)
// Then end marker // Then end marker 0x00, 0x00, 0xF3
let data = [0x81, 0x00, 0x00, 0x00, 0x00, 0x03]; let data = [0x81, 0x00, 0x03, 0x00, 0x00, 0xF3];
let cmds = parse(&data).unwrap(); let cmds = parse(&data).unwrap();
assert!(matches!(cmds[0], StitchCommand::Stitch { dx: 1, dy: -1 })); assert!(matches!(cmds[0], StitchCommand::Stitch { dx: 1, dy: -1 }));
assert!(matches!(cmds[1], StitchCommand::End)); assert!(matches!(cmds[1], StitchCommand::End));
@@ -185,16 +190,16 @@ mod tests {
#[test] #[test]
fn decode_jump() { fn decode_jump() {
// b2 bit 6 = jump // b2 bit 6 = jump, with always-set bits 0,1
let data = [0x01, 0x00, 0x40, 0x00, 0x00, 0x03]; let data = [0x01, 0x00, 0x43, 0x00, 0x00, 0xF3];
let cmds = parse(&data).unwrap(); let cmds = parse(&data).unwrap();
assert!(matches!(cmds[0], StitchCommand::Jump { dx: 1, dy: 0 })); assert!(matches!(cmds[0], StitchCommand::Jump { dx: 1, dy: 0 }));
} }
#[test] #[test]
fn decode_color_change() { fn decode_color_change() {
// b2 bit 7 = color change // b2 bit 7 = color change, with always-set bits 0,1
let data = [0x00, 0x00, 0x80, 0x01, 0x00, 0x00, 0x00, 0x00, 0x03]; let data = [0x00, 0x00, 0x83, 0x01, 0x00, 0x03, 0x00, 0x00, 0xF3];
let cmds = parse(&data).unwrap(); let cmds = parse(&data).unwrap();
assert!(matches!(cmds[0], StitchCommand::ColorChange)); assert!(matches!(cmds[0], StitchCommand::ColorChange));
} }
+9
View File
@@ -7,6 +7,9 @@ pub enum Format {
Exp, Exp,
Jef, Jef,
Vp3, Vp3,
Pec,
Xxx,
Sew,
} }
/// Detect format from file content (magic bytes). /// Detect format from file content (magic bytes).
@@ -17,6 +20,9 @@ pub fn detect_from_bytes(data: &[u8]) -> Option<Format> {
if data.len() >= 5 && &data[0..5] == b"%vsm%" { if data.len() >= 5 && &data[0..5] == b"%vsm%" {
return Some(Format::Vp3); return Some(Format::Vp3);
} }
if data.len() >= 8 && &data[0..8] == b"#PEC0001" {
return Some(Format::Pec);
}
None None
} }
@@ -29,6 +35,9 @@ pub fn detect_from_extension(path: &Path) -> Option<Format> {
"exp" => Some(Format::Exp), "exp" => Some(Format::Exp),
"jef" => Some(Format::Jef), "jef" => Some(Format::Jef),
"vp3" => Some(Format::Vp3), "vp3" => Some(Format::Vp3),
"pec" => Some(Format::Pec),
"xxx" => Some(Format::Xxx),
"sew" => Some(Format::Sew),
_ => None, _ => None,
} }
} }
+6
View File
@@ -6,8 +6,11 @@ pub mod types;
pub mod dst; pub mod dst;
pub mod exp; pub mod exp;
pub mod jef; pub mod jef;
pub mod pec;
pub mod pes; pub mod pes;
pub mod sew;
pub mod vp3; pub mod vp3;
pub mod xxx;
mod render; mod render;
mod resolve; mod resolve;
@@ -40,5 +43,8 @@ fn parse_and_resolve(data: &[u8], fmt: Format) -> Result<ResolvedDesign, Error>
Format::Exp => exp::parse_and_resolve(data), Format::Exp => exp::parse_and_resolve(data),
Format::Jef => jef::parse_and_resolve(data), Format::Jef => jef::parse_and_resolve(data),
Format::Vp3 => vp3::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),
} }
} }
+5
View File
@@ -85,6 +85,11 @@ pub const DEFAULT_PALETTE: [(u8, u8, u8); 12] = [
(106, 28, 138), // Violet (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`. /// Build a color list for `n` thread slots by cycling through `DEFAULT_PALETTE`.
pub fn default_colors(n: usize) -> Vec<(u8, u8, u8)> { pub fn default_colors(n: usize) -> Vec<(u8, u8, u8)> {
(0..n) (0..n)
+29
View File
@@ -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<StitchCommand>, 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<ResolvedDesign, Error> {
let (commands, colors) = parse(data)?;
crate::resolve::resolve(&commands, colors)
}
+1 -1
View File
@@ -1,5 +1,5 @@
mod header; mod header;
mod pec; pub mod pec;
pub use header::PesHeader; pub use header::PesHeader;
pub use pec::PecHeader; pub use pec::PecHeader;
+186
View File
@@ -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<StitchCommand>, 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<ResolvedDesign, Error> {
let (commands, colors) = parse(data)?;
crate::resolve::resolve(&commands, colors)
}
+182 -171
View File
@@ -4,14 +4,24 @@ use crate::types::{ResolvedDesign, StitchCommand};
/// Parse a VP3 (Pfaff/Viking) file from raw bytes. /// Parse a VP3 (Pfaff/Viking) file from raw bytes.
/// ///
/// VP3 is a hierarchical format: /// VP3 is a hierarchical format:
/// - File header with "%vsm%" magic (or similar signature) /// - `%vsm%` magic + null byte
/// - Design metadata section /// - UTF-16 BE producer string
/// - One or more color sections, each containing: /// - Design metadata (including center coordinates)
/// - Thread color (RGB) /// - `xxPP` section marker
/// - Stitch data block /// - 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: 2-byte signed pairs (i8 dx, i8 dy).
/// Stitch encoding: variable-length (1 byte for small moves, 3 bytes for large). /// 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<StitchCommand>, Vec<(u8, u8, u8)>), Error>; type ParseResult = Result<(Vec<StitchCommand>, Vec<(u8, u8, u8)>), Error>;
pub fn parse(data: &[u8]) -> ParseResult { 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 let xxpp_pos = find_marker(data, b"xxPP")
// Skip the initial header to find the design data .ok_or_else(|| Error::InvalidHeader("missing xxPP section marker".into()))?;
// The format starts with a variable-length producer string, then design sections
skip_vp3_header(&mut reader)?; 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 colors = Vec::new();
let mut commands = Vec::new(); let mut commands = Vec::new();
let mut cursor = (0i32, 0i32);
// Read color sections for ci in 0..color_count {
let color_section_count = reader.read_u16_be()?; let color = read_color_block(&mut reader, &mut commands, &mut cursor, ci > 0)?;
for _ in 0..color_section_count {
if reader.remaining() < 4 {
break;
}
let color = read_color_section(&mut reader, &mut commands)?;
colors.push(color); colors.push(color);
} }
@@ -61,163 +74,139 @@ pub fn parse_and_resolve(data: &[u8]) -> Result<ResolvedDesign, Error> {
crate::resolve::resolve(&commands, colors) crate::resolve::resolve(&commands, colors)
} }
fn skip_vp3_header(reader: &mut Reader) -> Result<(), Error> { fn find_marker(data: &[u8], marker: &[u8]) -> Option<usize> {
// Skip magic/producer string at start data.windows(marker.len()).position(|w| w == marker)
// 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 read_color_section( fn read_color_block(
reader: &mut Reader, reader: &mut Reader,
commands: &mut Vec<StitchCommand>, commands: &mut Vec<StitchCommand>,
cursor: &mut (i32, i32),
add_color_change: bool,
) -> Result<(u8, u8, u8), Error> { ) -> Result<(u8, u8, u8), Error> {
// Color change between sections (except first) // 3-byte marker: \x00\x05\x00
if !commands.is_empty() { 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); commands.push(StitchCommand::ColorChange);
} }
// Skip section start marker/offset bytes // Read thread info
// Color sections start with coordinate offset data let (r, g, b) = read_thread_info(reader)?;
if reader.remaining() < 12 {
return Err(Error::TooShort {
expected: 12,
actual: reader.remaining(),
});
}
// Skip section offset/position data (2x i32 = 8 bytes) // Skip 15 bytes post-thread metadata + 3 bytes preamble (\x0A\xF6\x00)
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)?; reader.skip(18)?;
}
// Read stitch data // Decode stitches until block end
let stitch_byte_count = if reader.remaining() >= 4 { decode_vp3_stitches(reader, commands, block_end, cursor);
reader.read_u32_be()? as usize
} else {
return Ok((r, g, b));
};
if stitch_byte_count == 0 || stitch_byte_count > reader.remaining() { reader.pos = block_end;
// 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)) Ok((r, g, b))
} }
fn decode_vp3_stitches(reader: &mut Reader, commands: &mut Vec<StitchCommand>, end: usize) { fn read_thread_info(reader: &mut Reader) -> Result<(u8, u8, u8), Error> {
while reader.pos < end && reader.remaining() >= 2 { // Color table: count of sub-colors, transition byte
let b1 = reader.data[reader.pos]; let colors_count = reader.read_u8()?;
let _transition = reader.read_u8()?;
// Check for 3-byte extended coordinates (high bit set on first byte) let mut r = 0u8;
if b1 & 0x80 != 0 { let mut g = 0u8;
if reader.remaining() < 4 { let mut b = 0u8;
break;
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<StitchCommand>,
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); let dx = read_i16_be(reader.data, reader.pos);
reader.pos += 2; reader.pos += 2;
let dy = read_i16_be(reader.data, reader.pos); let dy = read_i16_be(reader.data, reader.pos);
reader.pos += 2; reader.pos += 2;
cursor.0 += dx as i32;
// Large moves are jumps cursor.1 += dy as i32;
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 }); commands.push(StitchCommand::Stitch { dx, dy });
// Skip trailing 0x80 0x02
if reader.pos + 2 <= end {
reader.pos += 2;
}
}
}
0x03 => {
// Trim
commands.push(StitchCommand::Trim);
}
_ => {
// Unknown or no-op (0x00, 0x02, etc.)
} }
} }
} }
} }
fn skip_string(reader: &mut Reader) -> Result<(), Error> { 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; let len = reader.read_u16_be()? as usize;
if len > reader.remaining() { if len > reader.remaining() {
return Err(Error::InvalidHeader(format!( return Err(Error::InvalidHeader(format!(
@@ -272,19 +261,6 @@ impl<'a> Reader<'a> {
Ok(v) Ok(v)
} }
fn peek_u16_be(&self) -> Result<u16, Error> {
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<u32, Error> { fn read_u32_be(&mut self) -> Result<u32, Error> {
if self.pos + 4 > self.data.len() { if self.pos + 4 > self.data.len() {
return Err(Error::TooShort { return Err(Error::TooShort {
@@ -302,6 +278,23 @@ impl<'a> Reader<'a> {
Ok(v) Ok(v)
} }
fn read_i32_be(&mut self) -> Result<i32, Error> {
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> { fn skip(&mut self, n: usize) -> Result<(), Error> {
if self.pos + n > self.data.len() { if self.pos + n > self.data.len() {
return Err(Error::TooShort { return Err(Error::TooShort {
@@ -321,26 +314,44 @@ mod tests {
#[test] #[test]
fn decode_small_stitch() { fn decode_small_stitch() {
let mut commands = Vec::new(); let mut commands = Vec::new();
// Two small stitches: (10, -20) and (5, -3)
let data = [0x0A, 0x14, 0x05, 0x03]; let data = [0x0A, 0x14, 0x05, 0x03];
let mut reader = Reader::new(&data); 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_eq!(commands.len(), 2);
assert!(matches!( assert!(matches!(
commands[0], commands[0],
StitchCommand::Stitch { dx: 10, dy: -20 } StitchCommand::Stitch { dx: 10, dy: 20 }
)); ));
} }
#[test] #[test]
fn decode_large_jump() { fn decode_escape_trim() {
let mut commands = Vec::new(); let mut commands = Vec::new();
// Large move: high bit set, 2-byte BE dx and dy let data = [0x80, 0x03, 0x05, 0x03];
// dx = 0x8100 = -32512 as i16, dy = 0x0100 = 256
let data = [0x81, 0x00, 0x01, 0x00];
let mut reader = Reader::new(&data); 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_eq!(commands.len(), 1);
assert!(matches!(commands[0], StitchCommand::Jump { .. })); assert!(matches!(
commands[0],
StitchCommand::Stitch { dx: 256, dy: -256 }
));
} }
} }
+130
View File
@@ -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<StitchCommand>, 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<ResolvedDesign, Error> {
let (commands, colors) = parse(data)?;
crate::resolve::resolve(&commands, colors)
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "stitch-peek" name = "stitch-peek"
version = "0.1.3" version = "0.1.4"
edition = "2024" edition = "2024"
description = "Nautilus thumbnail generator for PES embroidery files" description = "Nautilus thumbnail generator for PES embroidery files"
license = "MIT" license = "MIT"
+1 -1
View File
@@ -5,7 +5,7 @@ use std::fs;
#[derive(Parser)] #[derive(Parser)]
#[command( #[command(
name = "stitch-peek", 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 { struct Args {
/// Input embroidery file path /// Input embroidery file path