19 Commits

Author SHA1 Message Date
nvrl 4e956e1939 Merge pull request 'fixed clippy issues and cargo fmt' (#9) from release/0.2.4 into main
Release / Build and Release (push) Successful in 23s
Reviewed-on: #9
2026-04-03 23:48:33 +02:00
nvrl 9bbb7038aa fixed clippy issues and cargo fmt
CI / Lint and Test (pull_request) Successful in 1m0s
CI / Version Check (pull_request) Failing after 3s
2026-04-03 23:47:55 +02:00
nvrl 800da1872b Merge pull request 'release/0.2.4' (#8) from release/0.2.4 into main
Release / Build and Release (push) Successful in 37s
Reviewed-on: #8
2026-04-03 18:46:57 +02:00
nvrl f161a25002 version bump
CI / Lint and Test (pull_request) Failing after 21s
CI / Version Check (pull_request) Failing after 3s
2026-04-03 18:46:24 +02:00
nvrl 9a367b4d10 added all remaining formats 2026-04-03 18:45:37 +02:00
nvrl c9c7245dea Merge pull request 'added DST, EXP, JEF, VP3' (#7) from release/0.2.0 into main
Release / Build and Release (push) Successful in 38s
Reviewed-on: #7
2026-03-31 12:42:44 +02:00
nvrl 473da90b01 fixed rotation issues + updated readme
CI / Lint and Test (pull_request) Successful in 38s
CI / Version Check (pull_request) Successful in 3s
2026-03-31 12:41:36 +02:00
nvrl 08aafaa3c3 cargo lock
CI / Lint and Test (pull_request) Successful in 36s
CI / Version Check (pull_request) Successful in 3s
2026-03-31 12:20:36 +02:00
nvrl 156365fa8f version bump to 0.1.3
CI / Lint and Test (pull_request) Successful in 38s
CI / Version Check (pull_request) Successful in 4s
2026-03-31 12:20:15 +02:00
nvrl 7c8ecda29a fixed bug with color layering issue
CI / Lint and Test (pull_request) Successful in 44s
CI / Version Check (pull_request) Failing after 3s
2026-03-31 12:18:53 +02:00
nvrl 69d0269270 version bump
CI / Lint and Test (pull_request) Failing after 16s
CI / Version Check (pull_request) Failing after 3s
2026-03-31 08:12:12 +02:00
nvrl 40ccf9ded4 added DST, EXP, JEF, VP3
CI / Lint and Test (pull_request) Successful in 36s
CI / Version Check (pull_request) Failing after 3s
2026-03-31 08:08:36 +02:00
nvrl 512f49ab38 fixed ci badge
Release / Build and Release (push) Has been cancelled
2026-03-30 01:07:27 +02:00
nvrl fc2abb9524 Merge pull request 'version bump' (#6) from release/0.1.2 into main
Release / Build and Release (push) Successful in 36s
Reviewed-on: #6
2026-03-30 01:03:55 +02:00
nvrl ea74aaa666 Merge pull request 'added publishing metadata and seperated project readmes' (#5) from release/0.1.2 into main
Release / Build and Release (push) Successful in 19s
Reviewed-on: #5
2026-03-30 00:59:01 +02:00
nvrl 4e05428d83 Merge pull request 'added testfiles' (#4) from release/0.1.1 into main
Release / Build and Release (push) Successful in 35s
Reviewed-on: #4
2026-03-30 00:48:52 +02:00
nvrl b155830118 Merge pull request 'fixed tooling in ci' (#3) from release/0.1.0 into main
Release / Build and Release (push) Successful in 37s
Reviewed-on: #3
2026-03-30 00:32:41 +02:00
nvrl a74d504bca Merge pull request 'cargo fmt' (#2) from release/0.1.0 into main
Release / Build and Release (push) Failing after 15s
Reviewed-on: #2
2026-03-30 00:31:22 +02:00
nvrl ff6f279ff5 Merge pull request 'release/0.1.0' (#1) from release/0.1.0 into main
Release / Build and Release (push) Failing after 19s
Reviewed-on: #1
2026-03-30 00:29:56 +02:00
38 changed files with 1801 additions and 311 deletions
Generated
+2 -2
View File
@@ -235,7 +235,7 @@ dependencies = [
[[package]]
name = "rustitch"
version = "0.1.1"
version = "0.2.2"
dependencies = [
"png",
"thiserror",
@@ -250,7 +250,7 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "stitch-peek"
version = "0.1.1"
version = "0.1.4"
dependencies = [
"anyhow",
"clap",
+32 -13
View File
@@ -1,18 +1,20 @@
# stitch-peek-rs
[![CI](https://git.narl.io/nvrl/stitch-peek-rs/actions/workflows/ci.yml/badge.svg)](https://git.narl.io/nvrl/stitch-peek-rs/actions/workflows/ci.yml)
[![CI](https://git.narl.io/nvrl/stitch-peek-rs/actions/workflows/ci.yml/badge.svg)](https://git.narl.io/nvrl/stitch-peek-rs/actions?workflow=ci.yml)
[![rustitch on crates.io](https://img.shields.io/crates/v/rustitch)](https://crates.io/crates/rustitch)
[![stitch-peek on crates.io](https://img.shields.io/crates/v/stitch-peek)](https://crates.io/crates/stitch-peek)
[![docs.rs](https://img.shields.io/docsrs/rustitch)](https://docs.rs/rustitch)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
A Nautilus/GNOME thumbnailer for **PES embroidery files**. Browse your embroidery designs in the file manager with automatic thumbnail previews.
A Nautilus/GNOME thumbnailer for **embroidery files**. Browse your embroidery designs in the file manager with automatic thumbnail previews.
Supported formats: **PES**, **DST**, **EXP**, **JEF**, **VP3**
Built as two crates:
| Crate | Description |
|-------|-------------|
| [**rustitch**](rustitch/) | Library for parsing PES files and rendering stitch data to images |
| [**rustitch**](rustitch/) | Library for parsing embroidery files and rendering stitch data to images |
| [**stitch-peek**](stitch-peek/) | CLI thumbnailer that integrates with GNOME/Nautilus |
<!-- TODO: Add screenshot of Nautilus showing PES thumbnails -->
@@ -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
+31
View File
@@ -7,4 +7,35 @@
<match type="string" offset="0" value="#PES"/>
</magic>
</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-type type="application/x-pec">
<comment>PEC Brother embroidery file</comment>
<glob pattern="*.pec"/>
<magic priority="50">
<match type="string" offset="0" value="#PEC"/>
</magic>
</mime-type>
<mime-type type="application/x-xxx">
<comment>XXX Singer embroidery file</comment>
<glob pattern="*.xxx"/>
</mime-type>
<mime-type type="application/x-sew">
<comment>SEW Janome embroidery file</comment>
<glob pattern="*.sew"/>
</mime-type>
</mime-info>
+1 -1
View File
@@ -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;application/x-pec;application/x-xxx;application/x-sew
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rustitch"
version = "0.1.2"
version = "0.2.2"
edition = "2024"
description = "PES embroidery file parser and thumbnail renderer"
license = "MIT"
+39 -7
View File
@@ -4,7 +4,9 @@
[![docs.rs](https://img.shields.io/docsrs/rustitch)](https://docs.rs/rustitch)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE)
A Rust library for parsing **PES embroidery files** and rendering stitch data to images.
A Rust library for parsing **embroidery files** and rendering stitch data to images.
Supported formats: **PES**, **DST**, **EXP**, **JEF**, **VP3**
Part of the [stitch-peek-rs](https://git.narl.io/nvrl/stitch-peek-rs) project.
@@ -14,18 +16,24 @@ Add `rustitch` to your `Cargo.toml`:
```toml
[dependencies]
rustitch = "0.1"
rustitch = "0.2"
```
### Generate a thumbnail
```rust
// PES files (backward-compatible API)
let pes_data = std::fs::read("design.pes")?;
let png_bytes = rustitch::thumbnail(&pes_data, 256)?;
std::fs::write("preview.png", &png_bytes)?;
// Any supported format
let dst_data = std::fs::read("pattern.dst")?;
let png_bytes = rustitch::thumbnail_format(&dst_data, 256, rustitch::Format::Dst)?;
std::fs::write("preview.png", &png_bytes)?;
```
### Parse and inspect a design
### Parse and inspect a PES design
```rust
use rustitch::pes::{self, StitchCommand};
@@ -61,15 +69,39 @@ let png_bytes = rustitch::render_thumbnail(&resolved, 512)?;
std::fs::write("large_preview.png", &png_bytes)?;
```
### Format detection
```rust
use rustitch::format::{self, Format};
use std::path::Path;
// Detect from file extension
let fmt = format::detect_from_extension(Path::new("design.jef"));
assert_eq!(fmt, Some(Format::Jef));
// Detect from file content (magic bytes)
let data = std::fs::read("design.pes")?;
let fmt = format::detect_from_bytes(&data);
assert_eq!(fmt, Some(Format::Pes));
```
## Supported formats
**PES** (Brother PE-Design) embroidery files, versions 1 through 10. The PEC section containing stitch data is consistent across versions.
| Format | Manufacturer | Colors | Notes |
|--------|-------------|--------|-------|
| **PES** | Brother PE-Design | Embedded (PEC palette) | Versions 1-10 |
| **DST** | Tajima | Default palette | 3-byte bit-packed records |
| **EXP** | Melco/Bernina | Default palette | Simple 2-byte encoding |
| **JEF** | Janome | Embedded (Janome palette) | Structured header with color table |
| **VP3** | Pfaff/Viking | Embedded (RGB) | Hierarchical format with per-section colors |
Formats without embedded color info (DST, EXP) use a default palette of 12 high-contrast colors, cycling on each color change.
## How it works
1. **Parse** the PES binary header to locate the PEC section
2. **Decode** the PEC stitch byte stream (7-bit and 12-bit encoded relative movements, jumps, trims, color changes)
3. **Resolve** relative movements into absolute coordinate segments grouped by thread color, using the 65-color Brother PEC palette
1. **Detect** the file format from magic bytes or extension
2. **Parse** the format-specific binary encoding into a common `StitchCommand` stream (stitch, jump, trim, color change, end)
3. **Resolve** relative movements into absolute coordinate segments grouped by thread color
4. **Render** anti-aliased line segments with [tiny-skia](https://github.com/nickel-org/tiny-skia), scaled to fit the requested size
5. **Encode** as PNG with proper alpha handling
+225
View File
@@ -0,0 +1,225 @@
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: standard DST EOF pattern (0x00, 0x00, 0xF3)
// Bits 0 and 1 of byte 2 are always set in valid DST records,
// so we must check the full EOF pattern, not just those bits.
if b0 == 0x00 && b1 == 0x00 && b2 == 0xF3 {
commands.push(StitchCommand::End);
break;
}
let dx = decode_dx(b0, b1, b2);
let dy = decode_dy(b0, b1, b2);
// Mask off the always-set bits 0,1 to get control flags
let flags = b2 & 0xFC;
// Color change: byte 2 bit 7
if flags & 0x80 != 0 {
commands.push(StitchCommand::ColorChange);
continue;
}
// Jump: byte 2 bit 6
if flags & 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() {
// DST EOF = 0x00, 0x00, 0xF3
let data = [0x00, 0x00, 0xF3];
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=0x03 (always-set bits)
// Then end marker 0x00, 0x00, 0xF3
let data = [0x81, 0x00, 0x03, 0x00, 0x00, 0xF3];
let cmds = parse(&data).unwrap();
assert!(matches!(cmds[0], StitchCommand::Stitch { dx: 1, dy: -1 }));
assert!(matches!(cmds[1], StitchCommand::End));
}
#[test]
fn decode_jump() {
// b2 bit 6 = jump, with always-set bits 0,1
let data = [0x01, 0x00, 0x43, 0x00, 0x00, 0xF3];
let cmds = parse(&data).unwrap();
assert!(matches!(cmds[0], StitchCommand::Jump { dx: 1, dy: 0 }));
}
#[test]
fn decode_color_change() {
// b2 bit 7 = color change, with always-set bits 0,1
let data = [0x00, 0x00, 0x83, 0x01, 0x00, 0x03, 0x00, 0x00, 0xF3];
let cmds = parse(&data).unwrap();
assert!(matches!(cmds[0], StitchCommand::ColorChange));
}
#[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
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
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));
}
}
+43
View File
@@ -0,0 +1,43 @@
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Pes,
Dst,
Exp,
Jef,
Vp3,
Pec,
Xxx,
Sew,
}
/// 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);
}
if data.len() >= 8 && &data[0..8] == b"#PEC0001" {
return Some(Format::Pec);
}
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),
"pec" => Some(Format::Pec),
"xxx" => Some(Format::Xxx),
"sew" => Some(Format::Sew),
_ => None,
}
}
+169
View File
@@ -0,0 +1,169 @@
mod palette;
use crate::error::Error;
use crate::types::{Color, RawDesign, 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<RawDesign, 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<Color> = 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 }));
}
}
+84
View File
@@ -0,0 +1,84 @@
use crate::types::Color;
/// Janome thread color palette (78 entries).
/// Index 0 is a fallback; indices 1-77 correspond to standard Janome thread colors.
pub const JEF_PALETTE: [Color; 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
];
+43 -5
View File
@@ -1,12 +1,50 @@
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 pec;
pub mod pes;
pub mod sew;
pub mod vp3;
pub mod xxx;
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<Vec<u8>, pes::Error> {
pub fn thumbnail(pes_data: &[u8], size: u32) -> Result<Vec<u8>, 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<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),
Format::Pec => pec::parse_and_resolve(data),
Format::Xxx => xxx::parse_and_resolve(data),
Format::Sew => sew::parse_and_resolve(data),
}
}
+100
View File
@@ -0,0 +1,100 @@
use crate::types::Color;
/// 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: [Color; 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: [Color; 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
];
/// Look up a PEC palette color by index, clamping to valid range.
pub fn pec_color(idx: u8) -> Color {
PEC_PALETTE[(idx as usize).min(PEC_PALETTE.len() - 1)]
}
/// Build a color list for `n` thread slots by cycling through `DEFAULT_PALETTE`.
pub fn default_colors(n: usize) -> Vec<Color> {
(0..n)
.map(|i| DEFAULT_PALETTE[i % DEFAULT_PALETTE.len()])
.collect()
}
+29
View File
@@ -0,0 +1,29 @@
use crate::error::Error;
use crate::pes::pec::{decode_stitches, parse_pec_header};
use crate::types::{Color, RawDesign, ResolvedDesign};
/// Parse a standalone PEC file (`#PEC0001` prefix + PEC data).
pub fn parse(data: &[u8]) -> Result<RawDesign, Error> {
if data.len() < 8 || &data[0..8] != b"#PEC0001" {
return Err(Error::InvalidHeader("missing #PEC0001 magic".into()));
}
let pec_data = &data[8..];
let (header, stitch_offset) = parse_pec_header(pec_data)?;
let commands = decode_stitches(&pec_data[stitch_offset..])?;
// Map PEC palette indices to RGB colors
let colors: Vec<Color> = header
.color_indices
.iter()
.map(|&idx| crate::palette::pec_color(idx))
.collect();
Ok((commands, colors))
}
/// Parse a standalone PEC file and resolve to a renderable design.
pub fn parse_and_resolve(data: &[u8]) -> Result<ResolvedDesign, Error> {
let (commands, colors) = parse(data)?;
crate::resolve::resolve(&commands, colors)
}
+3 -4
View File
@@ -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<PesHeader, Error> {
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]
+9 -114
View File
@@ -1,30 +1,13 @@
mod header;
mod palette;
mod pec;
pub 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, Color, ResolvedDesign, StitchCommand, StitchSegment};
pub struct PesDesign {
pub header: PesHeader,
@@ -32,27 +15,6 @@ pub struct PesDesign {
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.
pub fn parse(data: &[u8]) -> Result<PesDesign, Error> {
let header = header::parse_header(data)?;
@@ -73,67 +35,9 @@ 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> {
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<Color> = design
.pec_header
.color_indices
.iter()
@@ -143,14 +47,5 @@ pub fn resolve(design: &PesDesign) -> Result<ResolvedDesign, Error> {
})
.collect();
Ok(ResolvedDesign {
segments,
colors,
bounds: BoundingBox {
min_x,
max_x,
min_y,
max_y,
},
})
crate::resolve::resolve(&design.commands, colors)
}
+2 -69
View File
@@ -1,69 +1,2 @@
/// Brother PEC thread color palette (65 entries).
/// Index 0 is a fallback; indices 164 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;
+37 -81
View File
@@ -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<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.
/// 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<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;
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() {
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 }));
}
}
+2 -3
View File
@@ -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<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);
// 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<Vec<u8>, 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]);
+72
View File
@@ -0,0 +1,72 @@
use crate::error::Error;
use crate::types::{BoundingBox, Color, ResolvedDesign, StitchCommand, StitchSegment};
/// Convert parsed stitch commands into renderable segments with absolute coordinates.
pub fn resolve(commands: &[StitchCommand], colors: Vec<Color>) -> 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,
},
})
}
+186
View File
@@ -0,0 +1,186 @@
use crate::error::Error;
use crate::types::{Color, RawDesign, ResolvedDesign, StitchCommand};
const STITCH_DATA_OFFSET: usize = 0x1D78;
/// Janome SEW thread color palette (first 80 entries).
const SEW_PALETTE: [Color; 80] = [
(0, 0, 0), // 0: Unknown
(0, 0, 0), // 1: Black
(255, 255, 255), // 2: White
(255, 255, 23), // 3: Sunflower
(250, 160, 96), // 4: Hazel
(92, 118, 73), // 5: Green Dust
(64, 192, 48), // 6: Green
(101, 194, 200), // 7: Sky
(172, 128, 190), // 8: Purple
(245, 188, 203), // 9: Pink
(255, 0, 0), // 10: Red
(192, 128, 0), // 11: Brown
(0, 0, 240), // 12: Blue
(228, 195, 93), // 13: Gold
(165, 42, 42), // 14: Dark Brown
(213, 176, 212), // 15: Pale Violet
(252, 242, 148), // 16: Pale Yellow
(240, 208, 192), // 17: Pale Pink
(255, 192, 0), // 18: Peach
(201, 164, 128), // 19: Beige
(155, 61, 75), // 20: Wine Red
(160, 184, 204), // 21: Pale Sky
(127, 194, 28), // 22: Yellow Green
(185, 185, 185), // 23: Silver Grey
(160, 160, 160), // 24: Grey
(152, 214, 189), // 25: Pale Aqua
(184, 240, 240), // 26: Baby Blue
(54, 139, 160), // 27: Powder Blue
(79, 131, 171), // 28: Bright Blue
(56, 106, 145), // 29: Slate Blue
(0, 32, 107), // 30: Navy Blue
(229, 197, 202), // 31: Salmon Pink
(249, 103, 107), // 32: Coral
(227, 49, 31), // 33: Burnt Orange
(226, 161, 136), // 34: Cinnamon
(181, 148, 116), // 35: Umber
(228, 207, 153), // 36: Blonde
(225, 203, 0), // 37: Sunflower
(225, 173, 212), // 38: Orchid Pink
(195, 0, 126), // 39: Peony Purple
(128, 0, 75), // 40: Burgundy
(160, 96, 176), // 41: Royal Purple
(192, 64, 32), // 42: Cardinal Red
(202, 224, 192), // 43: Opal Green
(137, 152, 86), // 44: Moss Green
(0, 170, 0), // 45: Meadow Green
(33, 138, 33), // 46: Dark Green
(93, 174, 148), // 47: Aquamarine
(76, 191, 143), // 48: Emerald Green
(0, 119, 114), // 49: Peacock Green
(112, 112, 112), // 50: Dark Grey
(242, 255, 255), // 51: Ivory White
(177, 88, 24), // 52: Hazel
(203, 138, 7), // 53: Toast
(247, 146, 123), // 54: Salmon
(152, 105, 45), // 55: Cocoa Brown
(162, 113, 72), // 56: Sienna
(123, 85, 74), // 57: Sepia
(79, 57, 70), // 58: Dark Sepia
(82, 58, 151), // 59: Violet Blue
(0, 0, 160), // 60: Blue Ink
(0, 150, 222), // 61: Solar Blue
(178, 221, 83), // 62: Green Dust
(250, 143, 187), // 63: Crimson
(222, 100, 158), // 64: Floral Pink
(181, 80, 102), // 65: Wine
(94, 87, 71), // 66: Olive Drab
(76, 136, 31), // 67: Meadow
(228, 220, 121), // 68: Canary Yellow
(203, 138, 26), // 69: Toast
(198, 170, 66), // 70: Beige
(236, 176, 44), // 71: Honey Dew
(248, 128, 64), // 72: Tangerine
(255, 229, 5), // 73: Ocean Blue
(250, 122, 122), // 74: Sepia
(209, 164, 255), // 75: Sepia (alt)
(140, 90, 48), // 76: Unknown
(48, 80, 140), // 77: Unknown
(100, 160, 100), // 78: Unknown
(200, 100, 50), // 79: Unknown
];
/// Parse a SEW (Janome) embroidery file.
///
/// Format:
/// - u16 LE color count at offset 0x00
/// - color_count × u16 LE thread palette indices at offset 0x02
/// - Graphical preview bitmap
/// - Stitch data at fixed offset 0x1D78
/// - Escape byte 0x80, control in next byte:
/// - control & 1: color change (skip 2 bytes)
/// - 0x02/0x04: jump/move (read 2 signed bytes)
/// - 0x10: normal stitch (read 2 signed bytes)
/// - other: end
/// - Y is negated
pub fn parse(data: &[u8]) -> Result<RawDesign, Error> {
if data.len() < STITCH_DATA_OFFSET + 4 {
return Err(Error::TooShort {
expected: STITCH_DATA_OFFSET + 4,
actual: data.len(),
});
}
let color_count = u16::from_le_bytes([data[0], data[1]]) as usize;
if color_count == 0 {
return Err(Error::InvalidHeader("zero color count".into()));
}
// Read thread palette indices
let colors: Vec<Color> = (0..color_count)
.map(|i| {
let off = 2 + i * 2;
if off + 1 < data.len() {
let idx = u16::from_le_bytes([data[off], data[off + 1]]) as usize;
SEW_PALETTE[idx % SEW_PALETTE.len()]
} else {
(0, 0, 0)
}
})
.collect();
let mut commands = Vec::new();
let mut i = STITCH_DATA_OFFSET;
while i + 1 < data.len() {
let b0 = data[i];
let b1 = data[i + 1];
i += 2;
if b0 != 0x80 {
let dx = b0 as i8 as i16;
let dy = -(b1 as i8 as i16);
commands.push(StitchCommand::Stitch { dx, dy });
continue;
}
// Escape: b0 == 0x80, b1 is the control byte
if i + 1 >= data.len() {
break;
}
let c0 = data[i];
let c1 = data[i + 1];
i += 2;
if b1 & 1 != 0 {
// Color change
commands.push(StitchCommand::ColorChange);
} else if b1 == 0x04 || b1 == 0x02 {
// Move/jump
let dx = c0 as i8 as i16;
let dy = -(c1 as i8 as i16);
commands.push(StitchCommand::Jump { dx, dy });
} else if b1 == 0x10 {
// Stitch with preceding escape
let dx = c0 as i8 as i16;
let dy = -(c1 as i8 as i16);
commands.push(StitchCommand::Stitch { dx, dy });
} else {
// Unknown control or end
break;
}
}
if commands.is_empty() {
return Err(Error::NoStitchData);
}
if !matches!(commands.last(), Some(StitchCommand::End)) {
commands.push(StitchCommand::End);
}
Ok((commands, colors))
}
/// Parse a SEW file and resolve to a renderable design.
pub fn parse_and_resolve(data: &[u8]) -> Result<ResolvedDesign, Error> {
let (commands, colors) = parse(data)?;
crate::resolve::resolve(&commands, colors)
}
+32
View File
@@ -0,0 +1,32 @@
#[derive(Debug, Clone)]
pub enum StitchCommand {
Stitch { dx: i16, dy: i16 },
Jump { dx: i16, dy: i16 },
Trim,
ColorChange,
End,
}
pub type Color = (u8, u8, u8);
pub type RawDesign = (Vec<StitchCommand>, Vec<Color>);
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<Color>,
pub bounds: BoundingBox,
}
+357
View File
@@ -0,0 +1,357 @@
use crate::error::Error;
use crate::types::{ResolvedDesign, StitchCommand};
/// Parse a VP3 (Pfaff/Viking) file from raw bytes.
///
/// VP3 is a hierarchical format:
/// - `%vsm%` magic + null byte
/// - UTF-16 BE producer string
/// - Design metadata (including center coordinates)
/// - `xxPP` section marker
/// - Producer string (again)
/// - Color count
/// - Color blocks, each containing:
/// - 3-byte marker `\x00\x05\x00`
/// - 4-byte block size (u32 BE)
/// - Start position (2 × i32 BE, units ÷ 100, Y negated)
/// - Thread info (RGB, catalog, name, brand)
/// - 15 bytes metadata + 3 bytes preamble (`\x0A\xF6\x00`)
/// - Stitch data
///
/// Stitch encoding: 2-byte signed pairs (i8 dx, i8 dy).
/// Escape byte 0x80: next byte is sub-command:
/// - 0x01: extended move (2 × i16 BE dx, dy), followed by 2 bytes to skip
/// - 0x03: trim
type ParseResult = Result<(Vec<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(),
});
}
if &data[0..5] != b"%vsm%" {
return Err(Error::InvalidHeader("missing %vsm% magic".into()));
}
let xxpp_pos = find_marker(data, b"xxPP")
.ok_or_else(|| Error::InvalidHeader("missing xxPP section marker".into()))?;
let mut reader = Reader::new(data);
reader.pos = xxpp_pos + 4;
// Skip 2 bytes + producer string after xxPP
reader.skip(2)?;
skip_string(&mut reader)?;
let color_count = reader.read_u16_be()? as usize;
let mut colors = Vec::new();
let mut commands = Vec::new();
let mut cursor = (0i32, 0i32);
for ci in 0..color_count {
let color = read_color_block(&mut reader, &mut commands, &mut cursor, ci > 0)?;
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 find_marker(data: &[u8], marker: &[u8]) -> Option<usize> {
data.windows(marker.len()).position(|w| w == marker)
}
fn read_color_block(
reader: &mut Reader,
commands: &mut Vec<StitchCommand>,
cursor: &mut (i32, i32),
add_color_change: bool,
) -> Result<(u8, u8, u8), Error> {
// 3-byte marker: \x00\x05\x00
reader.skip(3)?;
// 4-byte block size (distance to next block from current position)
let block_size = reader.read_u32_be()? as usize;
let block_end = reader.pos + block_size;
// Start position: i32 BE x, i32 BE y (units ÷ 100, Y negated)
let start_x_raw = reader.read_i32_be()?;
let start_y_raw = reader.read_i32_be()?;
let start_x = start_x_raw / 100;
let start_y = -(start_y_raw / 100);
// Jump to section start position if cursor is not already there
let jump_dx = start_x - cursor.0;
let jump_dy = start_y - cursor.1;
if jump_dx != 0 || jump_dy != 0 {
commands.push(StitchCommand::Trim);
commands.push(StitchCommand::Jump {
dx: jump_dx.clamp(-32768, 32767) as i16,
dy: jump_dy.clamp(-32768, 32767) as i16,
});
cursor.0 = start_x;
cursor.1 = start_y;
}
if add_color_change {
commands.push(StitchCommand::ColorChange);
}
// Read thread info
let (r, g, b) = read_thread_info(reader)?;
// Skip 15 bytes post-thread metadata + 3 bytes preamble (\x0A\xF6\x00)
reader.skip(18)?;
// Decode stitches until block end
decode_vp3_stitches(reader, commands, block_end, cursor);
reader.pos = block_end;
Ok((r, g, b))
}
fn read_thread_info(reader: &mut Reader) -> Result<(u8, u8, u8), Error> {
// Color table: count of sub-colors, transition byte
let colors_count = reader.read_u8()?;
let _transition = reader.read_u8()?;
let mut r = 0u8;
let mut g = 0u8;
let mut b = 0u8;
for _ in 0..colors_count {
r = reader.read_u8()?;
g = reader.read_u8()?;
b = reader.read_u8()?;
let _parts = reader.read_u8()?;
let _color_length = reader.read_u16_be()?;
}
// Thread type + weight
reader.skip(2)?;
// 3 strings: catalog number, color name, brand name
skip_string(reader)?;
skip_string(reader)?;
skip_string(reader)?;
Ok((r, g, b))
}
fn decode_vp3_stitches(
reader: &mut Reader,
commands: &mut Vec<StitchCommand>,
end: usize,
cursor: &mut (i32, i32),
) {
while reader.pos + 1 < end && reader.pos + 1 < reader.data.len() {
let bx = reader.data[reader.pos] as i8;
let by = reader.data[reader.pos + 1] as i8;
reader.pos += 2;
if (bx as u8) != 0x80 {
// Normal stitch
let dx = bx as i16;
let dy = by as i16;
cursor.0 += dx as i32;
cursor.1 += dy as i32;
commands.push(StitchCommand::Stitch { dx, dy });
continue;
}
// Escape byte 0x80 — check sub-command
match by as u8 {
0x01 => {
// Extended move: 2 × i16 BE
if reader.pos + 4 <= end {
let dx = read_i16_be(reader.data, reader.pos);
reader.pos += 2;
let dy = read_i16_be(reader.data, reader.pos);
reader.pos += 2;
cursor.0 += dx as i32;
cursor.1 += dy as i32;
commands.push(StitchCommand::Stitch { dx, dy });
// Skip trailing 0x80 0x02
if reader.pos + 2 <= end {
reader.pos += 2;
}
}
}
0x03 => {
// Trim
commands.push(StitchCommand::Trim);
}
_ => {
// Unknown or no-op (0x00, 0x02, etc.)
}
}
}
}
fn skip_string(reader: &mut Reader) -> Result<(), Error> {
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 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 read_i32_be(&mut self) -> Result<i32, Error> {
if self.pos + 4 > self.data.len() {
return Err(Error::TooShort {
expected: self.pos + 4,
actual: self.data.len(),
});
}
let v = i32::from_be_bytes([
self.data[self.pos],
self.data[self.pos + 1],
self.data[self.pos + 2],
self.data[self.pos + 3],
]);
self.pos += 4;
Ok(v)
}
fn skip(&mut self, n: usize) -> Result<(), Error> {
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();
let data = [0x0A, 0x14, 0x05, 0x03];
let mut reader = Reader::new(&data);
let mut cursor = (0i32, 0i32);
decode_vp3_stitches(&mut reader, &mut commands, data.len(), &mut cursor);
assert_eq!(commands.len(), 2);
assert!(matches!(
commands[0],
StitchCommand::Stitch { dx: 10, dy: 20 }
));
}
#[test]
fn decode_escape_trim() {
let mut commands = Vec::new();
let data = [0x80, 0x03, 0x05, 0x03];
let mut reader = Reader::new(&data);
let mut cursor = (0i32, 0i32);
decode_vp3_stitches(&mut reader, &mut commands, data.len(), &mut cursor);
assert_eq!(commands.len(), 2);
assert!(matches!(commands[0], StitchCommand::Trim));
assert!(matches!(
commands[1],
StitchCommand::Stitch { dx: 5, dy: 3 }
));
}
#[test]
fn decode_extended_move() {
// 0x80 0x01 + i16 BE dx(0x0100=256) + i16 BE dy(0xFF00=-256) + 0x80 0x02
let data = [0x80, 0x01, 0x01, 0x00, 0xFF, 0x00, 0x80, 0x02];
let mut commands = Vec::new();
let mut reader = Reader::new(&data);
let mut cursor = (0i32, 0i32);
decode_vp3_stitches(&mut reader, &mut commands, data.len(), &mut cursor);
assert_eq!(commands.len(), 1);
assert!(matches!(
commands[0],
StitchCommand::Stitch { dx: 256, dy: -256 }
));
}
}
+130
View File
@@ -0,0 +1,130 @@
use crate::error::Error;
use crate::types::{Color, RawDesign, ResolvedDesign, StitchCommand};
const HEADER_SIZE: usize = 256;
/// Parse an XXX (Singer) embroidery file.
///
/// Format:
/// - 256-byte header ("XXX" at offset 0xB6)
/// - Color count at offset 0x27 (LE u16)
/// - Stitch data at offset 0x100: 2-byte signed pairs (i8 dx, i8 dy), Y negated
/// - Escape byte 0x7F, followed by sub-command + 2 data bytes:
/// - 0x01: jump/move
/// - 0x03: trim (with optional move)
/// - 0x08 or 0x0A..0x17: color change
/// - 0x7F: end of data
/// - Color table after stitch data: skip 2 bytes, then color_count × i32 BE (0x00RRGGBB)
pub fn parse(data: &[u8]) -> Result<RawDesign, Error> {
if data.len() < HEADER_SIZE + 2 {
return Err(Error::TooShort {
expected: HEADER_SIZE + 2,
actual: data.len(),
});
}
let color_count = u16::from_le_bytes([data[0x27], data[0x28]]) as usize;
if color_count == 0 {
return Err(Error::InvalidHeader("zero color count".into()));
}
let mut commands = Vec::new();
let mut i = HEADER_SIZE;
let mut color_table_start = data.len();
while i < data.len() {
let b1 = data[i];
i += 1;
// Big jump codes (0x7D, 0x7E)
if b1 == 0x7D || b1 == 0x7E {
if i + 4 > data.len() {
break;
}
let x = i16::from_le_bytes([data[i], data[i + 1]]);
let y = -i16::from_le_bytes([data[i + 2], data[i + 3]]);
i += 4;
commands.push(StitchCommand::Jump { dx: x, dy: y });
continue;
}
if i >= data.len() {
break;
}
let b2 = data[i];
i += 1;
if b1 != 0x7F {
let dx = b1 as i8 as i16;
let dy = -(b2 as i8 as i16);
commands.push(StitchCommand::Stitch { dx, dy });
continue;
}
// Escape: b1 == 0x7F
if i + 2 > data.len() {
break;
}
let b3 = data[i];
let b4 = data[i + 1];
i += 2;
if b2 == 0x01 {
// Move/jump
let dx = b3 as i8 as i16;
let dy = -(b4 as i8 as i16);
commands.push(StitchCommand::Jump { dx, dy });
} else if b2 == 0x03 {
// Trim with optional move
commands.push(StitchCommand::Trim);
let dx = b3 as i8 as i16;
let dy = -(b4 as i8 as i16);
if dx != 0 || dy != 0 {
commands.push(StitchCommand::Jump { dx, dy });
}
} else if b2 == 0x08 || (0x0A..=0x17).contains(&b2) {
// Color change
commands.push(StitchCommand::ColorChange);
} else if b2 == 0x7F || b2 == 0x18 {
// End — color table follows after 2 bytes
color_table_start = i + 2;
break;
}
}
if commands.is_empty() {
return Err(Error::NoStitchData);
}
commands.push(StitchCommand::End);
// Read color table: color_count × i32 BE (0x00RRGGBB)
let colors: Vec<Color> = if color_table_start + color_count * 4 <= data.len() {
(0..color_count)
.map(|c| {
let base = color_table_start + c * 4;
let rgb = u32::from_be_bytes([
data[base],
data[base + 1],
data[base + 2],
data[base + 3],
]);
(
((rgb >> 16) & 0xFF) as u8,
((rgb >> 8) & 0xFF) as u8,
(rgb & 0xFF) as u8,
)
})
.collect()
} else {
crate::palette::default_colors(color_count)
};
Ok((commands, colors))
}
/// Parse an XXX file and resolve to a renderable design.
pub fn parse_and_resolve(data: &[u8]) -> Result<ResolvedDesign, Error> {
let (commands, colors) = parse(data)?;
crate::resolve::resolve(&commands, colors)
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "stitch-peek"
version = "0.1.2"
version = "0.1.4"
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"
+11 -5
View File
@@ -3,9 +3,11 @@
[![crates.io](https://img.shields.io/crates/v/stitch-peek)](https://crates.io/crates/stitch-peek)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE)
A CLI tool and **Nautilus/GNOME thumbnailer** for PES embroidery files. Generates PNG previews of embroidery designs directly in your file manager.
A CLI tool and **Nautilus/GNOME thumbnailer** for embroidery files. Generates PNG previews of embroidery designs directly in your file manager.
Part of the [stitch-peek-rs](https://git.narl.io/nvrl/stitch-peek-rs) project. Uses [rustitch](https://crates.io/crates/rustitch) for PES parsing and rendering.
Supported formats: **PES**, **DST**, **EXP**, **JEF**, **VP3**
Part of the [stitch-peek-rs](https://git.narl.io/nvrl/stitch-peek-rs) project. Uses [rustitch](https://crates.io/crates/rustitch) for parsing and rendering.
## Installation
@@ -49,23 +51,27 @@ nautilus -q
### As a thumbnailer
Once installed with the `.thumbnailer` file in place, Nautilus automatically generates thumbnails for `.pes` files. No manual action needed.
Once installed with the `.thumbnailer` file in place, Nautilus automatically generates thumbnails for `.pes`, `.dst`, `.exp`, `.jef`, and `.vp3` files. No manual action needed.
### Standalone CLI
```sh
stitch-peek -i design.pes -o preview.png -s 256
stitch-peek -i pattern.dst -o preview.png
stitch-peek -i motif.jef -o preview.png -s 512
```
| Flag | Description | Default |
|------|-------------|---------|
| `-i` | Input PES file | required |
| `-i` | Input embroidery file | required |
| `-o` | Output PNG path | required |
| `-s` | Thumbnail size (pixels) | 128 |
The format is detected automatically from the file extension.
## How it works
The tool follows the [freedesktop thumbnail specification](https://specifications.freedesktop.org/thumbnail/latest/). Nautilus calls it with an input file, output path, and requested size. It parses the PES file, renders the stitch pattern as anti-aliased colored lines on a transparent background, and writes a PNG.
The tool follows the [freedesktop thumbnail specification](https://specifications.freedesktop.org/thumbnail/latest/). Nautilus calls it with an input file, output path, and requested size. It detects the embroidery format, parses the stitch data, renders the pattern as anti-aliased colored lines on a transparent background, and writes a PNG.
## License
+10 -4
View File
@@ -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, PEC, XXX, SEW)"
)]
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()))?;