Compare commits
16 Commits
ea74aaa666
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e956e1939 | |||
| 9bbb7038aa | |||
| 800da1872b | |||
| f161a25002 | |||
| 9a367b4d10 | |||
| c9c7245dea | |||
| 473da90b01 | |||
| 08aafaa3c3 | |||
| 156365fa8f | |||
| 7c8ecda29a | |||
| 69d0269270 | |||
| 40ccf9ded4 | |||
| 512f49ab38 | |||
| fc2abb9524 | |||
| 7ae9abd338 | |||
| 0a6448c68a |
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
# stitch-peek-rs
|
||||
|
||||
A Nautilus/GNOME thumbnailer for **PES embroidery files**. Browse your embroidery designs in the file manager with automatic thumbnail previews.
|
||||
[](https://git.narl.io/nvrl/stitch-peek-rs/actions?workflow=ci.yml)
|
||||
[](https://crates.io/crates/rustitch)
|
||||
[](https://crates.io/crates/stitch-peek)
|
||||
[](https://docs.rs/rustitch)
|
||||
[](LICENSE)
|
||||
|
||||
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 -->
|
||||
@@ -62,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
|
||||
|
||||
@@ -70,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 |
|
||||
|
||||
@@ -88,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
|
||||
|
||||
@@ -106,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
|
||||
|
||||
@@ -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,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
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustitch"
|
||||
version = "0.1.1"
|
||||
version = "0.2.2"
|
||||
edition = "2024"
|
||||
description = "PES embroidery file parser and thumbnail renderer"
|
||||
license = "MIT"
|
||||
|
||||
+43
-7
@@ -1,6 +1,12 @@
|
||||
# rustitch
|
||||
|
||||
A Rust library for parsing **PES embroidery files** and rendering stitch data to images.
|
||||
[](https://crates.io/crates/rustitch)
|
||||
[](https://docs.rs/rustitch)
|
||||
[](../LICENSE)
|
||||
|
||||
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.
|
||||
|
||||
@@ -10,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};
|
||||
@@ -57,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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,69 +1,2 @@
|
||||
/// Brother PEC thread color palette (65 entries).
|
||||
/// Index 0 is a fallback; indices 1–64 correspond to standard Brother thread colors.
|
||||
pub const PEC_PALETTE: [(u8, u8, u8); 65] = [
|
||||
(0, 0, 0), // 0: Unknown
|
||||
(14, 31, 124), // 1: Prussian Blue
|
||||
(10, 85, 163), // 2: Blue
|
||||
(0, 135, 119), // 3: Teal Green
|
||||
(75, 107, 175), // 4: Cornflower Blue
|
||||
(237, 23, 31), // 5: Red
|
||||
(209, 92, 0), // 6: Reddish Brown
|
||||
(145, 54, 151), // 7: Magenta
|
||||
(228, 154, 203), // 8: Light Lilac
|
||||
(145, 95, 172), // 9: Lilac
|
||||
(158, 214, 125), // 10: Mint Green
|
||||
(232, 169, 0), // 11: Deep Gold
|
||||
(254, 186, 53), // 12: Orange
|
||||
(255, 255, 0), // 13: Yellow
|
||||
(112, 188, 31), // 14: Lime Green
|
||||
(186, 152, 0), // 15: Brass
|
||||
(168, 168, 168), // 16: Silver
|
||||
(125, 111, 0), // 17: Russet Brown
|
||||
(255, 255, 179), // 18: Cream Brown
|
||||
(79, 85, 86), // 19: Pewter
|
||||
(0, 0, 0), // 20: Black
|
||||
(11, 61, 145), // 21: Ultramarine
|
||||
(119, 1, 118), // 22: Royal Purple
|
||||
(41, 49, 51), // 23: Dark Gray
|
||||
(42, 19, 1), // 24: Dark Brown
|
||||
(246, 74, 138), // 25: Deep Rose
|
||||
(178, 118, 36), // 26: Light Brown
|
||||
(252, 187, 197), // 27: Salmon Pink
|
||||
(254, 55, 15), // 28: Vermilion
|
||||
(240, 240, 240), // 29: White
|
||||
(106, 28, 138), // 30: Violet
|
||||
(168, 221, 196), // 31: Seacrest
|
||||
(37, 132, 187), // 32: Sky Blue
|
||||
(254, 179, 67), // 33: Pumpkin
|
||||
(255, 243, 107), // 34: Cream Yellow
|
||||
(208, 166, 96), // 35: Khaki
|
||||
(209, 84, 0), // 36: Clay Brown
|
||||
(102, 186, 73), // 37: Leaf Green
|
||||
(19, 74, 70), // 38: Peacock Blue
|
||||
(135, 135, 135), // 39: Gray
|
||||
(216, 204, 198), // 40: Warm Gray
|
||||
(67, 86, 7), // 41: Dark Olive
|
||||
(253, 217, 222), // 42: Flesh Pink
|
||||
(249, 147, 188), // 43: Pink
|
||||
(0, 56, 34), // 44: Deep Green
|
||||
(178, 175, 212), // 45: Lavender
|
||||
(104, 106, 176), // 46: Wisteria Violet
|
||||
(239, 227, 185), // 47: Beige
|
||||
(247, 56, 102), // 48: Carmine
|
||||
(181, 75, 100), // 49: Amber Red
|
||||
(19, 43, 26), // 50: Olive Green
|
||||
(199, 1, 86), // 51: Dark Fuchsia
|
||||
(254, 158, 50), // 52: Tangerine
|
||||
(168, 222, 235), // 53: Light Blue
|
||||
(0, 103, 62), // 54: Emerald Green
|
||||
(78, 41, 144), // 55: Purple
|
||||
(47, 126, 32), // 56: Moss Green
|
||||
(255, 204, 204), // 57: Flesh Pink
|
||||
(255, 217, 17), // 58: Harvest Gold
|
||||
(9, 91, 166), // 59: Electric Blue
|
||||
(240, 249, 112), // 60: Lemon Yellow
|
||||
(227, 243, 91), // 61: Fresh Green
|
||||
(255, 153, 0), // 62: Orange
|
||||
(255, 240, 141), // 63: Cream Yellow
|
||||
(255, 200, 200), // 64: Applique
|
||||
];
|
||||
// Re-export from crate root for backward compatibility
|
||||
pub use crate::palette::PEC_PALETTE;
|
||||
|
||||
+37
-81
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "stitch-peek"
|
||||
version = "0.1.1"
|
||||
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"
|
||||
|
||||
+14
-5
@@ -1,8 +1,13 @@
|
||||
# stitch-peek
|
||||
|
||||
A CLI tool and **Nautilus/GNOME thumbnailer** for PES embroidery files. Generates PNG previews of embroidery designs directly in your file manager.
|
||||
[](https://crates.io/crates/stitch-peek)
|
||||
[](../LICENSE)
|
||||
|
||||
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.
|
||||
A CLI tool and **Nautilus/GNOME thumbnailer** for embroidery files. Generates PNG previews of embroidery designs directly in your file manager.
|
||||
|
||||
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
|
||||
|
||||
@@ -46,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
@@ -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()))?;
|
||||
|
||||
Reference in New Issue
Block a user