added DST, EXP, JEF, VP3 #7

Merged
nvrl merged 6 commits from release/0.2.0 into main 2026-03-31 12:42:44 +02:00
28 changed files with 1398 additions and 308 deletions

4
Cargo.lock generated
View File

@@ -235,7 +235,7 @@ dependencies = [
[[package]] [[package]]
name = "rustitch" name = "rustitch"
version = "0.1.2" version = "0.2.0"
dependencies = [ dependencies = [
"png", "png",
"thiserror", "thiserror",
@@ -250,7 +250,7 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]] [[package]]
name = "stitch-peek" name = "stitch-peek"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",

View File

@@ -6,13 +6,15 @@
[![docs.rs](https://img.shields.io/docsrs/rustitch)](https://docs.rs/rustitch) [![docs.rs](https://img.shields.io/docsrs/rustitch)](https://docs.rs/rustitch)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
A Nautilus/GNOME thumbnailer for **PES embroidery files**. Browse your embroidery designs in the file manager with automatic thumbnail previews. A Nautilus/GNOME thumbnailer for **embroidery files**. Browse your embroidery designs in the file manager with automatic thumbnail previews.
Supported formats: **PES**, **DST**, **EXP**, **JEF**, **VP3**
Built as two crates: Built as two crates:
| Crate | Description | | 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 | | [**stitch-peek**](stitch-peek/) | CLI thumbnailer that integrates with GNOME/Nautilus |
<!-- TODO: Add screenshot of Nautilus showing PES thumbnails --> <!-- TODO: Add screenshot of Nautilus showing PES thumbnails -->
@@ -68,7 +70,7 @@ nautilus -q
### As a thumbnailer ### 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 ### Standalone CLI
@@ -76,11 +78,12 @@ Generate a thumbnail manually:
```sh ```sh
stitch-peek -i design.pes -o preview.png -s 256 stitch-peek -i design.pes -o preview.png -s 256
stitch-peek -i pattern.dst -o preview.png -s 256
``` ```
| Flag | Description | Default | | Flag | Description | Default |
|------|-------------|---------| |------|-------------|---------|
| `-i` | Input PES file | required | | `-i` | Input embroidery file | required |
| `-o` | Output PNG path | required | | `-o` | Output PNG path | required |
| `-s` | Thumbnail size (pixels) | 128 | | `-s` | Thumbnail size (pixels) | 128 |
@@ -94,16 +97,26 @@ rustitch = "0.1"
``` ```
```rust ```rust
// PES (auto-detected)
let pes_data = std::fs::read("design.pes")?; let pes_data = std::fs::read("design.pes")?;
let png_bytes = rustitch::thumbnail(&pes_data, 256)?; 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. See the [rustitch README](rustitch/README.md) for more API examples.
## Supported formats ## 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 ## Project structure
@@ -112,16 +125,22 @@ stitch-peek-rs/
├── rustitch/ # Library crate ├── rustitch/ # Library crate
│ └── src/ │ └── src/
│ ├── lib.rs # Public API │ ├── lib.rs # Public API
│ ├── pes/ # PES format parser │ ├── types.rs # Shared types (StitchCommand, ResolvedDesign, ...)
│ ├── header.rs # File header (#PES magic, version, PEC offset) ├── error.rs # Error types
│ ├── pec.rs # PEC section (colors, stitch decoding) ├── format.rs # Format detection (magic bytes, extension)
│ └── palette.rs # Brother 65-color thread palette ── palette.rs # Thread color palettes (PEC, default)
── render.rs # tiny-skia renderer ── 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) ├── stitch-peek/ # Binary crate (CLI thumbnailer)
│ └── src/main.rs │ └── src/main.rs
└── data/ └── data/
├── stitch-peek.thumbnailer # Nautilus integration ├── stitch-peek.thumbnailer # Nautilus integration
└── pes.xml # MIME type definition └── pes.xml # MIME type definitions
``` ```
## Development ## Development

View File

@@ -7,4 +7,20 @@
<match type="string" offset="0" value="#PES"/> <match type="string" offset="0" value="#PES"/>
</magic> </magic>
</mime-type> </mime-type>
<mime-type type="application/x-dst">
<comment>DST Tajima embroidery file</comment>
<glob pattern="*.dst"/>
</mime-type>
<mime-type type="application/x-exp">
<comment>EXP Melco embroidery file</comment>
<glob pattern="*.exp"/>
</mime-type>
<mime-type type="application/x-jef">
<comment>JEF Janome embroidery file</comment>
<glob pattern="*.jef"/>
</mime-type>
<mime-type type="application/x-vp3">
<comment>VP3 Pfaff embroidery file</comment>
<glob pattern="*.vp3"/>
</mime-type>
</mime-info> </mime-info>

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 MimeType=application/x-pes;application/x-dst;application/x-exp;application/x-jef;application/x-vp3

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "rustitch" name = "rustitch"
version = "0.1.2" version = "0.2.1"
edition = "2024" edition = "2024"
description = "PES embroidery file parser and thumbnail renderer" description = "PES embroidery file parser and thumbnail renderer"
license = "MIT" license = "MIT"

View File

@@ -4,7 +4,9 @@
[![docs.rs](https://img.shields.io/docsrs/rustitch)](https://docs.rs/rustitch) [![docs.rs](https://img.shields.io/docsrs/rustitch)](https://docs.rs/rustitch)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE)
A Rust library for parsing **PES embroidery files** and rendering stitch data to images. A Rust library for parsing **embroidery files** and rendering stitch data to images.
Supported formats: **PES**, **DST**, **EXP**, **JEF**, **VP3**
Part of the [stitch-peek-rs](https://git.narl.io/nvrl/stitch-peek-rs) project. 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 ```toml
[dependencies] [dependencies]
rustitch = "0.1" rustitch = "0.2"
``` ```
### Generate a thumbnail ### Generate a thumbnail
```rust ```rust
// PES files (backward-compatible API)
let pes_data = std::fs::read("design.pes")?; let pes_data = std::fs::read("design.pes")?;
let png_bytes = rustitch::thumbnail(&pes_data, 256)?; let png_bytes = rustitch::thumbnail(&pes_data, 256)?;
std::fs::write("preview.png", &png_bytes)?; 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 ```rust
use rustitch::pes::{self, StitchCommand}; 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)?; 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 ## 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 ## How it works
1. **Parse** the PES binary header to locate the PEC section 1. **Detect** the file format from magic bytes or extension
2. **Decode** the PEC stitch byte stream (7-bit and 12-bit encoded relative movements, jumps, trims, color changes) 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, using the 65-color Brother PEC palette 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 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 5. **Encode** as PNG with proper alpha handling

220
rustitch/src/dst/mod.rs Normal file
View File

@@ -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<Vec<StitchCommand>, 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<ResolvedDesign, Error> {
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);
}
}

23
rustitch/src/error.rs Normal file
View File

@@ -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),
}

126
rustitch/src/exp.rs Normal file
View File

@@ -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<Vec<StitchCommand>, 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<ResolvedDesign, Error> {
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));
}
}

34
rustitch/src/format.rs Normal file
View File

@@ -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<Format> {
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<Format> {
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,
}
}

169
rustitch/src/jef/mod.rs Normal file
View File

@@ -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<StitchCommand>, 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<Vec<StitchCommand>, 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<ResolvedDesign, Error> {
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 }));
}
}

View File

@@ -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
];

View File

@@ -1,12 +1,44 @@
pub mod pes; pub mod error;
mod render; 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 render::render_thumbnail;
pub use types::{BoundingBox, ResolvedDesign, StitchCommand, StitchSegment};
/// Parse a PES file and render a thumbnail PNG of the given size. /// Parse a PES file and render a thumbnail PNG of the given size.
pub fn thumbnail(pes_data: &[u8], size: u32) -> Result<Vec<u8>, pes::Error> { pub fn thumbnail(pes_data: &[u8], size: u32) -> Result<Vec<u8>, Error> {
let design = pes::parse(pes_data)?; let design = pes::parse(pes_data)?;
let resolved = pes::resolve(&design)?; let resolved = pes::resolve(&design)?;
let png_bytes = render::render_thumbnail(&resolved, size)?; render::render_thumbnail(&resolved, size)
Ok(png_bytes) }
/// Parse any supported format and render a thumbnail PNG.
pub fn thumbnail_format(data: &[u8], size: u32, fmt: Format) -> Result<Vec<u8>, Error> {
let resolved = parse_and_resolve(data, fmt)?;
render::render_thumbnail(&resolved, size)
}
fn parse_and_resolve(data: &[u8], fmt: Format) -> Result<ResolvedDesign, Error> {
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),
}
} }

93
rustitch/src/palette.rs Normal file
View File

@@ -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()
}

View File

@@ -1,4 +1,4 @@
use super::Error; use crate::error::Error;
#[derive(Debug)] #[derive(Debug)]
pub struct PesHeader { pub struct PesHeader {
@@ -18,7 +18,7 @@ pub fn parse_header(data: &[u8]) -> Result<PesHeader, Error> {
if magic != b"#PES" { if magic != b"#PES" {
let mut m = [0u8; 4]; let mut m = [0u8; 4];
m.copy_from_slice(magic); m.copy_from_slice(magic);
return Err(Error::InvalidMagic(m)); return Err(Error::InvalidPesMagic(m));
} }
let mut version = [0u8; 4]; let mut version = [0u8; 4];
@@ -41,7 +41,6 @@ mod tests {
let mut data = vec![0u8; 20]; let mut data = vec![0u8; 20];
data[0..4].copy_from_slice(b"#PES"); data[0..4].copy_from_slice(b"#PES");
data[4..8].copy_from_slice(b"0001"); data[4..8].copy_from_slice(b"0001");
// PEC offset = 16 (little-endian)
data[8..12].copy_from_slice(&16u32.to_le_bytes()); data[8..12].copy_from_slice(&16u32.to_le_bytes());
let header = parse_header(&data).unwrap(); let header = parse_header(&data).unwrap();
@@ -53,7 +52,7 @@ mod tests {
fn reject_invalid_magic() { fn reject_invalid_magic() {
let data = b"NOTPES0001\x10\x00\x00\x00"; let data = b"NOTPES0001\x10\x00\x00\x00";
let err = parse_header(data).unwrap_err(); let err = parse_header(data).unwrap_err();
assert!(matches!(err, Error::InvalidMagic(_))); assert!(matches!(err, Error::InvalidPesMagic(_)));
} }
#[test] #[test]

View File

@@ -1,30 +1,13 @@
mod header; mod header;
mod palette;
mod pec; mod pec;
pub use header::PesHeader; pub use header::PesHeader;
pub use palette::PEC_PALETTE; pub use pec::PecHeader;
pub use pec::{PecHeader, StitchCommand};
use thiserror::Error; // Re-export shared types for backward compatibility
pub use crate::error::Error;
#[derive(Debug, Error)] pub use crate::palette::PEC_PALETTE;
pub enum Error { pub use crate::types::{BoundingBox, ResolvedDesign, StitchCommand, StitchSegment};
#[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),
}
pub struct PesDesign { pub struct PesDesign {
pub header: PesHeader, pub header: PesHeader,
@@ -32,27 +15,6 @@ pub struct PesDesign {
pub commands: Vec<StitchCommand>, pub commands: Vec<StitchCommand>,
} }
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<StitchSegment>,
pub colors: Vec<(u8, u8, u8)>,
pub bounds: BoundingBox,
}
/// Parse a PES file from raw bytes. /// Parse a PES file from raw bytes.
pub fn parse(data: &[u8]) -> Result<PesDesign, Error> { pub fn parse(data: &[u8]) -> Result<PesDesign, Error> {
let header = header::parse_header(data)?; let header = header::parse_header(data)?;
@@ -73,66 +35,8 @@ pub fn parse(data: &[u8]) -> Result<PesDesign, Error> {
}) })
} }
/// 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<ResolvedDesign, Error> { pub fn resolve(design: &PesDesign) -> Result<ResolvedDesign, Error> {
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 let colors: Vec<(u8, u8, u8)> = design
.pec_header .pec_header
.color_indices .color_indices
@@ -143,14 +47,5 @@ pub fn resolve(design: &PesDesign) -> Result<ResolvedDesign, Error> {
}) })
.collect(); .collect();
Ok(ResolvedDesign { crate::resolve::resolve(&design.commands, colors)
segments,
colors,
bounds: BoundingBox {
min_x,
max_x,
min_y,
max_y,
},
})
} }

View File

@@ -1,69 +1,2 @@
/// Brother PEC thread color palette (65 entries). // Re-export from crate root for backward compatibility
/// Index 0 is a fallback; indices 164 correspond to standard Brother thread colors. pub use crate::palette::PEC_PALETTE;
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
];

View File

@@ -1,4 +1,5 @@
use super::Error; use crate::error::Error;
use crate::types::StitchCommand;
pub struct PecHeader { pub struct PecHeader {
pub label: String, pub label: String,
@@ -6,19 +7,9 @@ pub struct PecHeader {
pub color_indices: Vec<u8>, pub color_indices: Vec<u8>,
} }
#[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. /// 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. /// 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> { 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 { if pec_data.len() < 532 {
return Err(Error::TooShort { return Err(Error::TooShort {
expected: 532, 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_raw = &pec_data[3..19];
let label = std::str::from_utf8(label_raw) let label = std::str::from_utf8(label_raw)
.unwrap_or("") .unwrap_or("")
.trim() .trim()
.to_string(); .to_string();
// Color count at offset 48 from PEC start
let color_count = pec_data[48] + 1; let color_count = pec_data[48] + 1;
// Color indices follow at offset 49
let color_indices: Vec<u8> = pec_data[49..49 + color_count as usize].to_vec(); let color_indices: Vec<u8> = 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; let stitch_data_offset = 532;
if pec_data.len() <= stitch_data_offset { if pec_data.len() <= stitch_data_offset {
@@ -101,31 +50,42 @@ pub fn decode_stitches(data: &[u8]) -> Result<Vec<StitchCommand>, Error> {
while i < data.len() { while i < data.len() {
let b1 = data[i]; let b1 = data[i];
// End marker
if b1 == 0xFF { if b1 == 0xFF {
commands.push(StitchCommand::End); commands.push(StitchCommand::End);
break; break;
} }
// Color change
if b1 == 0xFE { if b1 == 0xFE {
commands.push(StitchCommand::ColorChange); commands.push(StitchCommand::ColorChange);
i += 2; // skip the 0xFE and the following byte (typically 0xB0) i += 2;
continue; continue;
} }
// Parse dx // PEC encodes coordinates as (Y, X) — read first value as vertical,
let (dx, dx_flags, bytes_dx) = decode_coordinate(data, i)?; // second as horizontal, then swap to (dx, dy) for screen coordinates.
i += bytes_dx; let (val1, flags1, bytes1) = decode_coordinate(data, i)?;
i += bytes1;
// Parse dy // Check for special bytes at second coordinate position — color change
let (dy, dy_flags, bytes_dy) = decode_coordinate(data, i)?; // or end markers can appear between the two coordinates.
i += bytes_dy; 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 { if flags & 0x20 != 0 {
// Trim + jump
commands.push(StitchCommand::Trim); commands.push(StitchCommand::Trim);
commands.push(StitchCommand::Jump { dx, dy }); commands.push(StitchCommand::Jump { dx, dy });
} else if flags & 0x10 != 0 { } 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]; let b = data[pos];
if b & 0x80 != 0 { if b & 0x80 != 0 {
// Extended 12-bit encoding (2 bytes)
if pos + 1 >= data.len() { if pos + 1 >= data.len() {
return Err(Error::TooShort { return Err(Error::TooShort {
expected: pos + 2, 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 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 raw = (((b & 0x0F) as u16) << 8) | (b2 as u16);
let value = if raw > 0x7FF { let value = if raw > 0x7FF {
raw as i16 - 0x1000 raw as i16 - 0x1000
@@ -172,7 +131,6 @@ fn decode_coordinate(data: &[u8], pos: usize) -> Result<(i16, u8, usize), Error>
}; };
Ok((value, flags, 2)) Ok((value, flags, 2))
} else { } else {
// 7-bit encoding (1 byte)
let value = if b > 0x3F { b as i16 - 0x80 } else { b as i16 }; let value = if b > 0x3F { b as i16 - 0x80 } else { b as i16 };
Ok((value, 0, 1)) Ok((value, 0, 1))
} }
@@ -192,14 +150,14 @@ mod tests {
#[test] #[test]
fn decode_simple_stitch() { 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 data = [0x0A, 0x14, 0xFF];
let cmds = decode_stitches(&data).unwrap(); let cmds = decode_stitches(&data).unwrap();
assert_eq!(cmds.len(), 2); assert_eq!(cmds.len(), 2);
match &cmds[0] { match &cmds[0] {
StitchCommand::Stitch { dx, dy } => { StitchCommand::Stitch { dx, dy } => {
assert_eq!(*dx, 10); assert_eq!(*dx, 20);
assert_eq!(*dy, 20); assert_eq!(*dy, 10);
} }
_ => panic!("expected Stitch"), _ => panic!("expected Stitch"),
} }
@@ -207,13 +165,13 @@ mod tests {
#[test] #[test]
fn decode_negative_7bit() { 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 data = [0x50, 0x60, 0xFF];
let cmds = decode_stitches(&data).unwrap(); let cmds = decode_stitches(&data).unwrap();
match &cmds[0] { match &cmds[0] {
StitchCommand::Stitch { dx, dy } => { StitchCommand::Stitch { dx, dy } => {
assert_eq!(*dx, -48); assert_eq!(*dx, -32);
assert_eq!(*dy, -32); assert_eq!(*dy, -48);
} }
_ => panic!("expected Stitch"), _ => panic!("expected Stitch"),
} }
@@ -221,29 +179,27 @@ mod tests {
#[test] #[test]
fn decode_color_change() { fn decode_color_change() {
// PEC stores (Y, X): first=10 → dy, second=20 → dx
let data = [0xFE, 0xB0, 0x0A, 0x14, 0xFF]; let data = [0xFE, 0xB0, 0x0A, 0x14, 0xFF];
let cmds = decode_stitches(&data).unwrap(); let cmds = decode_stitches(&data).unwrap();
assert!(matches!(cmds[0], StitchCommand::ColorChange)); 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] #[test]
fn decode_extended_12bit() { fn decode_extended_12bit() {
// Extended dx: high bit set, flags=0x10 (jump), value = 0x100 = 256 // PEC stores (Y, X): first=0x91,0x00(256 with jump flag) → dy, second=0x05(5) → dx
// byte1 = 0x80 | 0x10 | 0x01 = 0x91, byte2 = 0x00 -> raw = 0x100 = 256
// dy: simple 0x05 = 5
let data = [0x91, 0x00, 0x05, 0xFF]; let data = [0x91, 0x00, 0x05, 0xFF];
let cmds = decode_stitches(&data).unwrap(); 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] #[test]
fn decode_trim_jump() { fn decode_trim_jump() {
// dx with trim flag (0x20): byte1 = 0x80 | 0x20 | 0x00 = 0xA0, byte2 = 0x0A -> raw=10 // PEC stores (Y, X): first=0xA0,0x0A(10 with trim flag) → dy, second=0x05(5) → dx
// dy: simple 0x05
let data = [0xA0, 0x0A, 0x05, 0xFF]; let data = [0xA0, 0x0A, 0x05, 0xFF];
let cmds = decode_stitches(&data).unwrap(); let cmds = decode_stitches(&data).unwrap();
assert!(matches!(cmds[0], StitchCommand::Trim)); 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 }));
} }
} }

View File

@@ -1,6 +1,7 @@
use tiny_skia::{LineCap, Paint, PathBuilder, Pixmap, Stroke, Transform}; 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. /// Render a resolved embroidery design to a PNG image of the given size.
pub fn render_thumbnail(design: &ResolvedDesign, size: u32) -> Result<Vec<u8>, Error> { pub fn render_thumbnail(design: &ResolvedDesign, size: u32) -> Result<Vec<u8>, Error> {
@@ -23,7 +24,6 @@ pub fn render_thumbnail(design: &ResolvedDesign, size: u32) -> Result<Vec<u8>, E
let line_width = (scale * 0.3).max(1.0); let line_width = (scale * 0.3).max(1.0);
// Group segments by color index and draw each group
let max_color = design let max_color = design
.segments .segments
.iter() .iter()
@@ -82,7 +82,6 @@ fn encode_png(pixmap: &Pixmap) -> Result<Vec<u8>, Error> {
let height = pixmap.height(); let height = pixmap.height();
let src = pixmap.data(); let src = pixmap.data();
// Unpremultiply alpha
let mut data = Vec::with_capacity(src.len()); let mut data = Vec::with_capacity(src.len());
for chunk in src.chunks_exact(4) { for chunk in src.chunks_exact(4) {
let (r, g, b, a) = (chunk[0], chunk[1], chunk[2], chunk[3]); let (r, g, b, a) = (chunk[0], chunk[1], chunk[2], chunk[3]);

75
rustitch/src/resolve.rs Normal file
View File

@@ -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<ResolvedDesign, Error> {
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,
},
})
}

29
rustitch/src/types.rs Normal file
View File

@@ -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<StitchSegment>,
pub colors: Vec<(u8, u8, u8)>,
pub bounds: BoundingBox,
}

346
rustitch/src/vp3.rs Normal file
View File

@@ -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<StitchCommand>, 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<ResolvedDesign, Error> {
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<StitchCommand>,
) -> 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<StitchCommand>, 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<u8, Error> {
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<u16, Error> {
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<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> {
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 { .. }));
}
}

BIN
rustitch/tests/fixtures/0.3x1 INCHES.EXP vendored Normal file

Binary file not shown.

BIN
rustitch/tests/fixtures/0.3x1 INCHES.JEF vendored Normal file

Binary file not shown.

BIN
rustitch/tests/fixtures/0.3x1 INCHES.PES vendored Normal file

Binary file not shown.

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "stitch-peek" name = "stitch-peek"
version = "0.1.2" version = "0.1.3"
edition = "2024" edition = "2024"
description = "Nautilus thumbnail generator for PES embroidery files" description = "Nautilus thumbnail generator for PES embroidery files"
license = "MIT" license = "MIT"
@@ -11,6 +11,6 @@ categories = ["graphics", "command-line-utilities"]
readme = "README.md" readme = "README.md"
[dependencies] [dependencies]
rustitch = { version = "0.1.1", path = "../rustitch" } rustitch = { version = "0.2", path = "../rustitch" }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
anyhow = "1" anyhow = "1"

View File

@@ -3,9 +3,11 @@
[![crates.io](https://img.shields.io/crates/v/stitch-peek)](https://crates.io/crates/stitch-peek) [![crates.io](https://img.shields.io/crates/v/stitch-peek)](https://crates.io/crates/stitch-peek)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE)
A CLI tool and **Nautilus/GNOME thumbnailer** for PES embroidery files. Generates PNG previews of embroidery designs directly in your file manager. A CLI tool and **Nautilus/GNOME thumbnailer** for embroidery files. Generates PNG previews of embroidery designs directly in your file manager.
Part of the [stitch-peek-rs](https://git.narl.io/nvrl/stitch-peek-rs) project. Uses [rustitch](https://crates.io/crates/rustitch) for PES parsing and rendering. Supported formats: **PES**, **DST**, **EXP**, **JEF**, **VP3**
Part of the [stitch-peek-rs](https://git.narl.io/nvrl/stitch-peek-rs) project. Uses [rustitch](https://crates.io/crates/rustitch) for parsing and rendering.
## Installation ## Installation
@@ -49,23 +51,27 @@ nautilus -q
### As a thumbnailer ### 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 ### Standalone CLI
```sh ```sh
stitch-peek -i design.pes -o preview.png -s 256 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 | | Flag | Description | Default |
|------|-------------|---------| |------|-------------|---------|
| `-i` | Input PES file | required | | `-i` | Input embroidery file | required |
| `-o` | Output PNG path | required | | `-o` | Output PNG path | required |
| `-s` | Thumbnail size (pixels) | 128 | | `-s` | Thumbnail size (pixels) | 128 |
The format is detected automatically from the file extension.
## How it works ## 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 ## License

View File

@@ -3,9 +3,12 @@ use clap::Parser;
use std::fs; use std::fs;
#[derive(Parser)] #[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 { struct Args {
/// Input PES file path /// Input embroidery file path
#[arg(short = 'i', long = "input")] #[arg(short = 'i', long = "input")]
input: std::path::PathBuf, input: std::path::PathBuf,
@@ -21,11 +24,14 @@ struct Args {
fn main() -> Result<()> { fn main() -> Result<()> {
let args = Args::parse(); 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) let data = fs::read(&args.input)
.with_context(|| format!("failed to read {}", args.input.display()))?; .with_context(|| format!("failed to read {}", args.input.display()))?;
let png = let png = rustitch::thumbnail_format(&data, args.size, format)
rustitch::thumbnail(&data, args.size).with_context(|| "failed to generate thumbnail")?; .with_context(|| "failed to generate thumbnail")?;
fs::write(&args.output, &png) fs::write(&args.output, &png)
.with_context(|| format!("failed to write {}", args.output.display()))?; .with_context(|| format!("failed to write {}", args.output.display()))?;