diff --git a/Cargo.lock b/Cargo.lock
index ff33dad..4d53b54 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",
@@ -250,7 +250,7 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "stitch-peek"
-version = "0.1.2"
+version = "0.1.3"
dependencies = [
"anyhow",
"clap",
diff --git a/README.md b/README.md
index 6fa27d2..739bf62 100644
--- a/README.md
+++ b/README.md
@@ -6,13 +6,15 @@
[](https://docs.rs/rustitch)
[](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/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/Cargo.toml b/rustitch/Cargo.toml
index ba80fc4..3eff2d1 100644
--- a/rustitch/Cargo.toml
+++ b/rustitch/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "rustitch"
-version = "0.1.2"
+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 @@
[](https://docs.rs/rustitch)
[](../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/dst/mod.rs b/rustitch/src/dst/mod.rs
new file mode 100644
index 0000000..ca2cc8c
--- /dev/null
+++ b/rustitch/src/dst/mod.rs
@@ -0,0 +1,220 @@
+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 + 3 + 27 + 81); // 121
+ }
+
+ #[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..0c654e8
--- /dev/null
+++ b/rustitch/src/exp.rs
@@ -0,0 +1,126 @@
+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..22ce9d4
--- /dev/null
+++ b/rustitch/src/jef/mod.rs
@@ -0,0 +1,169 @@
+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.
+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,
+ 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..31587ee 100644
--- a/rustitch/src/lib.rs
+++ b/rustitch/src/lib.rs
@@ -1,12 +1,44 @@
-pub mod pes;
-mod render;
+pub mod error;
+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;
+
+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)
+ }
+ 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/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..c41a29a 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,42 @@ 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;
+ // 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;
- // Parse dy
- let (dy, dy_flags, bytes_dy) = decode_coordinate(data, i)?;
- i += bytes_dy;
+ // 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;
+ }
+ if i < data.len() && data[i] == 0xFE {
+ commands.push(StitchCommand::ColorChange);
+ i += 2;
+ continue;
+ }
- let flags = dx_flags | dy_flags;
+ let (val2, flags2, bytes2) = decode_coordinate(data, i)?;
+ i += bytes2;
+
+ let flags = flags1 | flags2;
+ let dx = val2;
+ let dy = val1;
if flags & 0x20 != 0 {
- // Trim + jump
commands.push(StitchCommand::Trim);
commands.push(StitchCommand::Jump { dx, dy });
} else if flags & 0x10 != 0 {
@@ -155,7 +115,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 +122,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 +131,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,14 +150,14 @@ mod tests {
#[test]
fn decode_simple_stitch() {
- // dx=10 (0x0A), dy=20 (0x14), then end
+ // 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"),
}
@@ -207,13 +165,13 @@ mod tests {
#[test]
fn decode_negative_7bit() {
- // dx=0x50 (80 decimal, > 0x3F so value = 80-128 = -48), dy=0x60 (96-128=-32), end
+ // 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"),
}
@@ -221,29 +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() {
- // 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
+ // 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() {
- // dx with trim flag (0x20): byte1 = 0x80 | 0x20 | 0x00 = 0xA0, byte2 = 0x0A -> raw=10
- // dy: simple 0x05
+ // 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/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..ce88b36
--- /dev/null
+++ b/rustitch/src/vp3.rs
@@ -0,0 +1,346 @@
+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).
+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,
+ 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
+ && 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 { .. }));
+ }
+}
diff --git a/rustitch/tests/fixtures/0.3x1 INCHES.EXP b/rustitch/tests/fixtures/0.3x1 INCHES.EXP
new file mode 100644
index 0000000..7b95a5a
Binary files /dev/null and b/rustitch/tests/fixtures/0.3x1 INCHES.EXP differ
diff --git a/rustitch/tests/fixtures/0.3x1 INCHES.JEF b/rustitch/tests/fixtures/0.3x1 INCHES.JEF
new file mode 100644
index 0000000..d836da4
Binary files /dev/null and b/rustitch/tests/fixtures/0.3x1 INCHES.JEF differ
diff --git a/rustitch/tests/fixtures/0.3x1 INCHES.PES b/rustitch/tests/fixtures/0.3x1 INCHES.PES
new file mode 100644
index 0000000..90e2bfd
Binary files /dev/null and b/rustitch/tests/fixtures/0.3x1 INCHES.PES differ
diff --git a/stitch-peek/Cargo.toml b/stitch-peek/Cargo.toml
index 8c016a2..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"
@@ -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/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 @@
[](https://crates.io/crates/stitch-peek)
[](../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
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()))?;