Compare commits
13 Commits
release/0.
...
release/0.
| Author | SHA1 | Date | |
|---|---|---|---|
| 473da90b01 | |||
| 08aafaa3c3 | |||
| 156365fa8f | |||
| 7c8ecda29a | |||
| 69d0269270 | |||
| 40ccf9ded4 | |||
| 512f49ab38 | |||
| fc2abb9524 | |||
| ea74aaa666 | |||
| 4e05428d83 | |||
| b155830118 | |||
| a74d504bca | |||
| ff6f279ff5 |
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -235,7 +235,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustitch"
|
name = "rustitch"
|
||||||
version = "0.1.1"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"png",
|
"png",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
@@ -250,7 +250,7 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stitch-peek"
|
name = "stitch-peek"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
|||||||
45
README.md
45
README.md
@@ -1,18 +1,20 @@
|
|||||||
# stitch-peek-rs
|
# stitch-peek-rs
|
||||||
|
|
||||||
[](https://git.narl.io/nvrl/stitch-peek-rs/actions/workflows/ci.yml)
|
[](https://git.narl.io/nvrl/stitch-peek-rs/actions?workflow=ci.yml)
|
||||||
[](https://crates.io/crates/rustitch)
|
[](https://crates.io/crates/rustitch)
|
||||||
[](https://crates.io/crates/stitch-peek)
|
[](https://crates.io/crates/stitch-peek)
|
||||||
[](https://docs.rs/rustitch)
|
[](https://docs.rs/rustitch)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
A Nautilus/GNOME thumbnailer for **PES embroidery files**. Browse your embroidery designs in the file manager with automatic thumbnail previews.
|
A Nautilus/GNOME thumbnailer for **embroidery files**. Browse your embroidery designs in the file manager with automatic thumbnail previews.
|
||||||
|
|
||||||
|
Supported formats: **PES**, **DST**, **EXP**, **JEF**, **VP3**
|
||||||
|
|
||||||
Built as two crates:
|
Built as two crates:
|
||||||
|
|
||||||
| Crate | Description |
|
| Crate | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| [**rustitch**](rustitch/) | Library for parsing PES files and rendering stitch data to images |
|
| [**rustitch**](rustitch/) | Library for parsing embroidery files and rendering stitch data to images |
|
||||||
| [**stitch-peek**](stitch-peek/) | CLI thumbnailer that integrates with GNOME/Nautilus |
|
| [**stitch-peek**](stitch-peek/) | CLI thumbnailer that integrates with GNOME/Nautilus |
|
||||||
|
|
||||||
<!-- TODO: Add screenshot of Nautilus showing PES thumbnails -->
|
<!-- TODO: Add screenshot of Nautilus showing PES thumbnails -->
|
||||||
@@ -68,7 +70,7 @@ nautilus -q
|
|||||||
|
|
||||||
### As a thumbnailer
|
### As a thumbnailer
|
||||||
|
|
||||||
Once installed, Nautilus will automatically generate thumbnails for `.pes` files. No manual action needed -- just open a folder containing PES files.
|
Once installed, Nautilus will automatically generate thumbnails for embroidery files. No manual action needed -- just open a folder containing `.pes`, `.dst`, `.exp`, `.jef`, or `.vp3` files.
|
||||||
|
|
||||||
### Standalone CLI
|
### Standalone CLI
|
||||||
|
|
||||||
@@ -76,11 +78,12 @@ Generate a thumbnail manually:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
stitch-peek -i design.pes -o preview.png -s 256
|
stitch-peek -i design.pes -o preview.png -s 256
|
||||||
|
stitch-peek -i pattern.dst -o preview.png -s 256
|
||||||
```
|
```
|
||||||
|
|
||||||
| Flag | Description | Default |
|
| Flag | Description | Default |
|
||||||
|------|-------------|---------|
|
|------|-------------|---------|
|
||||||
| `-i` | Input PES file | required |
|
| `-i` | Input embroidery file | required |
|
||||||
| `-o` | Output PNG path | required |
|
| `-o` | Output PNG path | required |
|
||||||
| `-s` | Thumbnail size (pixels) | 128 |
|
| `-s` | Thumbnail size (pixels) | 128 |
|
||||||
|
|
||||||
@@ -94,16 +97,26 @@ rustitch = "0.1"
|
|||||||
```
|
```
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
|
// PES (auto-detected)
|
||||||
let pes_data = std::fs::read("design.pes")?;
|
let pes_data = std::fs::read("design.pes")?;
|
||||||
let png_bytes = rustitch::thumbnail(&pes_data, 256)?;
|
let png_bytes = rustitch::thumbnail(&pes_data, 256)?;
|
||||||
std::fs::write("preview.png", &png_bytes)?;
|
|
||||||
|
// Any supported format (explicit)
|
||||||
|
let dst_data = std::fs::read("pattern.dst")?;
|
||||||
|
let png_bytes = rustitch::thumbnail_format(&dst_data, 256, rustitch::Format::Dst)?;
|
||||||
```
|
```
|
||||||
|
|
||||||
See the [rustitch README](rustitch/README.md) for more API examples.
|
See the [rustitch README](rustitch/README.md) for more API examples.
|
||||||
|
|
||||||
## Supported formats
|
## Supported formats
|
||||||
|
|
||||||
**PES** (Brother PE-Design) embroidery files, versions 1 through 10. The PEC section containing stitch data is consistent across versions.
|
| Format | Manufacturer | Colors | Notes |
|
||||||
|
|--------|-------------|--------|-------|
|
||||||
|
| **PES** | Brother PE-Design | Embedded (PEC palette) | Versions 1-10 |
|
||||||
|
| **DST** | Tajima | Default palette | 3-byte bit-packed records |
|
||||||
|
| **EXP** | Melco/Bernina | Default palette | Simple 2-byte encoding |
|
||||||
|
| **JEF** | Janome | Embedded (Janome palette) | Structured header with color table |
|
||||||
|
| **VP3** | Pfaff/Viking | Embedded (RGB) | Hierarchical format with per-section colors |
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
@@ -112,16 +125,22 @@ stitch-peek-rs/
|
|||||||
├── rustitch/ # Library crate
|
├── rustitch/ # Library crate
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── lib.rs # Public API
|
│ ├── lib.rs # Public API
|
||||||
│ ├── pes/ # PES format parser
|
│ ├── types.rs # Shared types (StitchCommand, ResolvedDesign, ...)
|
||||||
│ │ ├── header.rs # File header (#PES magic, version, PEC offset)
|
│ ├── error.rs # Error types
|
||||||
│ │ ├── pec.rs # PEC section (colors, stitch decoding)
|
│ ├── format.rs # Format detection (magic bytes, extension)
|
||||||
│ │ └── palette.rs # Brother 65-color thread palette
|
│ ├── palette.rs # Thread color palettes (PEC, default)
|
||||||
│ └── render.rs # tiny-skia renderer
|
│ ├── resolve.rs # Stitch command to segment resolver
|
||||||
|
│ ├── render.rs # tiny-skia renderer
|
||||||
|
│ ├── pes/ # PES (Brother) parser
|
||||||
|
│ ├── dst/ # DST (Tajima) parser
|
||||||
|
│ ├── exp.rs # EXP (Melco) parser
|
||||||
|
│ ├── jef/ # JEF (Janome) parser
|
||||||
|
│ └── vp3.rs # VP3 (Pfaff/Viking) parser
|
||||||
├── stitch-peek/ # Binary crate (CLI thumbnailer)
|
├── stitch-peek/ # Binary crate (CLI thumbnailer)
|
||||||
│ └── src/main.rs
|
│ └── src/main.rs
|
||||||
└── data/
|
└── data/
|
||||||
├── stitch-peek.thumbnailer # Nautilus integration
|
├── stitch-peek.thumbnailer # Nautilus integration
|
||||||
└── pes.xml # MIME type definition
|
└── pes.xml # MIME type definitions
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
16
data/pes.xml
16
data/pes.xml
@@ -7,4 +7,20 @@
|
|||||||
<match type="string" offset="0" value="#PES"/>
|
<match type="string" offset="0" value="#PES"/>
|
||||||
</magic>
|
</magic>
|
||||||
</mime-type>
|
</mime-type>
|
||||||
|
<mime-type type="application/x-dst">
|
||||||
|
<comment>DST Tajima embroidery file</comment>
|
||||||
|
<glob pattern="*.dst"/>
|
||||||
|
</mime-type>
|
||||||
|
<mime-type type="application/x-exp">
|
||||||
|
<comment>EXP Melco embroidery file</comment>
|
||||||
|
<glob pattern="*.exp"/>
|
||||||
|
</mime-type>
|
||||||
|
<mime-type type="application/x-jef">
|
||||||
|
<comment>JEF Janome embroidery file</comment>
|
||||||
|
<glob pattern="*.jef"/>
|
||||||
|
</mime-type>
|
||||||
|
<mime-type type="application/x-vp3">
|
||||||
|
<comment>VP3 Pfaff embroidery file</comment>
|
||||||
|
<glob pattern="*.vp3"/>
|
||||||
|
</mime-type>
|
||||||
</mime-info>
|
</mime-info>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
[Thumbnailer Entry]
|
[Thumbnailer Entry]
|
||||||
TryExec=stitch-peek
|
TryExec=stitch-peek
|
||||||
Exec=stitch-peek -i %i -o %o -s %s
|
Exec=stitch-peek -i %i -o %o -s %s
|
||||||
MimeType=application/x-pes
|
MimeType=application/x-pes;application/x-dst;application/x-exp;application/x-jef;application/x-vp3
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustitch"
|
name = "rustitch"
|
||||||
version = "0.1.2"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "PES embroidery file parser and thumbnail renderer"
|
description = "PES embroidery file parser and thumbnail renderer"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
[](https://docs.rs/rustitch)
|
[](https://docs.rs/rustitch)
|
||||||
[](../LICENSE)
|
[](../LICENSE)
|
||||||
|
|
||||||
A Rust library for parsing **PES embroidery files** and rendering stitch data to images.
|
A Rust library for parsing **embroidery files** and rendering stitch data to images.
|
||||||
|
|
||||||
|
Supported formats: **PES**, **DST**, **EXP**, **JEF**, **VP3**
|
||||||
|
|
||||||
Part of the [stitch-peek-rs](https://git.narl.io/nvrl/stitch-peek-rs) project.
|
Part of the [stitch-peek-rs](https://git.narl.io/nvrl/stitch-peek-rs) project.
|
||||||
|
|
||||||
@@ -14,18 +16,24 @@ Add `rustitch` to your `Cargo.toml`:
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rustitch = "0.1"
|
rustitch = "0.2"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Generate a thumbnail
|
### Generate a thumbnail
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
|
// PES files (backward-compatible API)
|
||||||
let pes_data = std::fs::read("design.pes")?;
|
let pes_data = std::fs::read("design.pes")?;
|
||||||
let png_bytes = rustitch::thumbnail(&pes_data, 256)?;
|
let png_bytes = rustitch::thumbnail(&pes_data, 256)?;
|
||||||
std::fs::write("preview.png", &png_bytes)?;
|
std::fs::write("preview.png", &png_bytes)?;
|
||||||
|
|
||||||
|
// Any supported format
|
||||||
|
let dst_data = std::fs::read("pattern.dst")?;
|
||||||
|
let png_bytes = rustitch::thumbnail_format(&dst_data, 256, rustitch::Format::Dst)?;
|
||||||
|
std::fs::write("preview.png", &png_bytes)?;
|
||||||
```
|
```
|
||||||
|
|
||||||
### Parse and inspect a design
|
### Parse and inspect a PES design
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use rustitch::pes::{self, StitchCommand};
|
use rustitch::pes::{self, StitchCommand};
|
||||||
@@ -61,15 +69,39 @@ let png_bytes = rustitch::render_thumbnail(&resolved, 512)?;
|
|||||||
std::fs::write("large_preview.png", &png_bytes)?;
|
std::fs::write("large_preview.png", &png_bytes)?;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Format detection
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use rustitch::format::{self, Format};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
// Detect from file extension
|
||||||
|
let fmt = format::detect_from_extension(Path::new("design.jef"));
|
||||||
|
assert_eq!(fmt, Some(Format::Jef));
|
||||||
|
|
||||||
|
// Detect from file content (magic bytes)
|
||||||
|
let data = std::fs::read("design.pes")?;
|
||||||
|
let fmt = format::detect_from_bytes(&data);
|
||||||
|
assert_eq!(fmt, Some(Format::Pes));
|
||||||
|
```
|
||||||
|
|
||||||
## Supported formats
|
## Supported formats
|
||||||
|
|
||||||
**PES** (Brother PE-Design) embroidery files, versions 1 through 10. The PEC section containing stitch data is consistent across versions.
|
| Format | Manufacturer | Colors | Notes |
|
||||||
|
|--------|-------------|--------|-------|
|
||||||
|
| **PES** | Brother PE-Design | Embedded (PEC palette) | Versions 1-10 |
|
||||||
|
| **DST** | Tajima | Default palette | 3-byte bit-packed records |
|
||||||
|
| **EXP** | Melco/Bernina | Default palette | Simple 2-byte encoding |
|
||||||
|
| **JEF** | Janome | Embedded (Janome palette) | Structured header with color table |
|
||||||
|
| **VP3** | Pfaff/Viking | Embedded (RGB) | Hierarchical format with per-section colors |
|
||||||
|
|
||||||
|
Formats without embedded color info (DST, EXP) use a default palette of 12 high-contrast colors, cycling on each color change.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
1. **Parse** the PES binary header to locate the PEC section
|
1. **Detect** the file format from magic bytes or extension
|
||||||
2. **Decode** the PEC stitch byte stream (7-bit and 12-bit encoded relative movements, jumps, trims, color changes)
|
2. **Parse** the format-specific binary encoding into a common `StitchCommand` stream (stitch, jump, trim, color change, end)
|
||||||
3. **Resolve** relative movements into absolute coordinate segments grouped by thread color, using the 65-color Brother PEC palette
|
3. **Resolve** relative movements into absolute coordinate segments grouped by thread color
|
||||||
4. **Render** anti-aliased line segments with [tiny-skia](https://github.com/nickel-org/tiny-skia), scaled to fit the requested size
|
4. **Render** anti-aliased line segments with [tiny-skia](https://github.com/nickel-org/tiny-skia), scaled to fit the requested size
|
||||||
5. **Encode** as PNG with proper alpha handling
|
5. **Encode** as PNG with proper alpha handling
|
||||||
|
|
||||||
|
|||||||
220
rustitch/src/dst/mod.rs
Normal file
220
rustitch/src/dst/mod.rs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
use crate::error::Error;
|
||||||
|
use crate::palette::default_colors;
|
||||||
|
use crate::types::{ResolvedDesign, StitchCommand};
|
||||||
|
|
||||||
|
/// Parse a DST (Tajima) file from raw bytes into stitch commands.
|
||||||
|
///
|
||||||
|
/// DST format: 3 bytes per stitch record with bit-packed dx, dy, and control flags.
|
||||||
|
/// The standard Tajima bit layout maps specific bits across all 3 bytes to coordinate values.
|
||||||
|
pub fn parse(data: &[u8]) -> Result<Vec<StitchCommand>, Error> {
|
||||||
|
if data.len() < 3 {
|
||||||
|
return Err(Error::TooShort {
|
||||||
|
expected: 3,
|
||||||
|
actual: data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DST files may have a 512-byte header; stitch data can start at byte 512
|
||||||
|
// if the file is large enough, or at byte 0 for raw stitch streams.
|
||||||
|
// The header contains "LA:" at offset 0 if present.
|
||||||
|
let offset = if data.len() > 512 && &data[0..3] == b"LA:" {
|
||||||
|
512
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let stitch_data = &data[offset..];
|
||||||
|
if stitch_data.len() < 3 {
|
||||||
|
return Err(Error::NoStitchData);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut commands = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i + 2 < stitch_data.len() {
|
||||||
|
let b0 = stitch_data[i];
|
||||||
|
let b1 = stitch_data[i + 1];
|
||||||
|
let b2 = stitch_data[i + 2];
|
||||||
|
i += 3;
|
||||||
|
|
||||||
|
// End of file: byte 2 bits 0 and 1 both set, and specific pattern
|
||||||
|
if b2 & 0x03 == 0x03 {
|
||||||
|
commands.push(StitchCommand::End);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dx = decode_dx(b0, b1, b2);
|
||||||
|
let dy = decode_dy(b0, b1, b2);
|
||||||
|
|
||||||
|
// Color change: byte 2 bit 7
|
||||||
|
if b2 & 0x80 != 0 {
|
||||||
|
commands.push(StitchCommand::ColorChange);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jump: byte 2 bit 6
|
||||||
|
if b2 & 0x40 != 0 {
|
||||||
|
commands.push(StitchCommand::Jump { dx, dy });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
commands.push(StitchCommand::Stitch { dx, dy });
|
||||||
|
}
|
||||||
|
|
||||||
|
if commands.is_empty() || (commands.len() == 1 && matches!(commands[0], StitchCommand::End)) {
|
||||||
|
return Err(Error::NoStitchData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we have an End marker
|
||||||
|
if !matches!(commands.last(), Some(StitchCommand::End)) {
|
||||||
|
commands.push(StitchCommand::End);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(commands)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode X displacement from the 3-byte Tajima record.
|
||||||
|
/// Standard bit layout for dx across bytes b0, b1, b2.
|
||||||
|
fn decode_dx(b0: u8, b1: u8, b2: u8) -> i16 {
|
||||||
|
let mut x: i16 = 0;
|
||||||
|
if b0 & 0x01 != 0 {
|
||||||
|
x += 1;
|
||||||
|
}
|
||||||
|
if b0 & 0x02 != 0 {
|
||||||
|
x -= 1;
|
||||||
|
}
|
||||||
|
if b0 & 0x04 != 0 {
|
||||||
|
x += 9;
|
||||||
|
}
|
||||||
|
if b0 & 0x08 != 0 {
|
||||||
|
x -= 9;
|
||||||
|
}
|
||||||
|
if b1 & 0x01 != 0 {
|
||||||
|
x += 3;
|
||||||
|
}
|
||||||
|
if b1 & 0x02 != 0 {
|
||||||
|
x -= 3;
|
||||||
|
}
|
||||||
|
if b1 & 0x04 != 0 {
|
||||||
|
x += 27;
|
||||||
|
}
|
||||||
|
if b1 & 0x08 != 0 {
|
||||||
|
x -= 27;
|
||||||
|
}
|
||||||
|
if b2 & 0x04 != 0 {
|
||||||
|
x += 81;
|
||||||
|
}
|
||||||
|
if b2 & 0x08 != 0 {
|
||||||
|
x -= 81;
|
||||||
|
}
|
||||||
|
x
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode Y displacement from the 3-byte Tajima record.
|
||||||
|
/// Standard bit layout for dy across bytes b0, b1, b2.
|
||||||
|
fn decode_dy(b0: u8, b1: u8, b2: u8) -> i16 {
|
||||||
|
let mut y: i16 = 0;
|
||||||
|
if b0 & 0x80 != 0 {
|
||||||
|
y += 1;
|
||||||
|
}
|
||||||
|
if b0 & 0x40 != 0 {
|
||||||
|
y -= 1;
|
||||||
|
}
|
||||||
|
if b0 & 0x20 != 0 {
|
||||||
|
y += 9;
|
||||||
|
}
|
||||||
|
if b0 & 0x10 != 0 {
|
||||||
|
y -= 9;
|
||||||
|
}
|
||||||
|
if b1 & 0x80 != 0 {
|
||||||
|
y += 3;
|
||||||
|
}
|
||||||
|
if b1 & 0x40 != 0 {
|
||||||
|
y -= 3;
|
||||||
|
}
|
||||||
|
if b1 & 0x20 != 0 {
|
||||||
|
y += 27;
|
||||||
|
}
|
||||||
|
if b1 & 0x10 != 0 {
|
||||||
|
y -= 27;
|
||||||
|
}
|
||||||
|
if b2 & 0x20 != 0 {
|
||||||
|
y += 81;
|
||||||
|
}
|
||||||
|
if b2 & 0x10 != 0 {
|
||||||
|
y -= 81;
|
||||||
|
}
|
||||||
|
// DST Y axis is inverted (positive = up in machine coords, down in screen coords)
|
||||||
|
-y
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a DST file and resolve to a renderable design.
|
||||||
|
pub fn parse_and_resolve(data: &[u8]) -> Result<ResolvedDesign, Error> {
|
||||||
|
let commands = parse(data)?;
|
||||||
|
let color_count = commands
|
||||||
|
.iter()
|
||||||
|
.filter(|c| matches!(c, StitchCommand::ColorChange))
|
||||||
|
.count()
|
||||||
|
+ 1;
|
||||||
|
let colors = default_colors(color_count);
|
||||||
|
crate::resolve::resolve(&commands, colors)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_end_marker() {
|
||||||
|
// b2 = 0x03 means end
|
||||||
|
let data = [0x00, 0x00, 0x03];
|
||||||
|
let cmds = parse(&data).unwrap_err();
|
||||||
|
assert!(matches!(cmds, Error::NoStitchData));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_simple_stitch() {
|
||||||
|
// A normal stitch followed by end
|
||||||
|
// dx=+1: b0 bit 0. dy=+1: b0 bit 7. b2=0x00 (normal stitch)
|
||||||
|
// Then end marker
|
||||||
|
let data = [0x81, 0x00, 0x00, 0x00, 0x00, 0x03];
|
||||||
|
let cmds = parse(&data).unwrap();
|
||||||
|
assert!(matches!(cmds[0], StitchCommand::Stitch { dx: 1, dy: -1 }));
|
||||||
|
assert!(matches!(cmds[1], StitchCommand::End));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_jump() {
|
||||||
|
// b2 bit 6 = jump
|
||||||
|
let data = [0x01, 0x00, 0x40, 0x00, 0x00, 0x03];
|
||||||
|
let cmds = parse(&data).unwrap();
|
||||||
|
assert!(matches!(cmds[0], StitchCommand::Jump { dx: 1, dy: 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_color_change() {
|
||||||
|
// b2 bit 7 = color change
|
||||||
|
let data = [0x00, 0x00, 0x80, 0x01, 0x00, 0x00, 0x00, 0x00, 0x03];
|
||||||
|
let cmds = parse(&data).unwrap();
|
||||||
|
assert!(matches!(cmds[0], StitchCommand::ColorChange));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_dx_values() {
|
||||||
|
assert_eq!(decode_dx(0x01, 0x00, 0x00), 1);
|
||||||
|
assert_eq!(decode_dx(0x02, 0x00, 0x00), -1);
|
||||||
|
assert_eq!(decode_dx(0x04, 0x00, 0x00), 9);
|
||||||
|
assert_eq!(decode_dx(0x00, 0x04, 0x00), 27);
|
||||||
|
assert_eq!(decode_dx(0x00, 0x00, 0x04), 81);
|
||||||
|
assert_eq!(decode_dx(0x05, 0x05, 0x04), 1 + 9 + 3 + 27 + 81); // 121
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_dy_values() {
|
||||||
|
assert_eq!(decode_dy(0x80, 0x00, 0x00), -1);
|
||||||
|
assert_eq!(decode_dy(0x40, 0x00, 0x00), 1);
|
||||||
|
assert_eq!(decode_dy(0x20, 0x00, 0x00), -9);
|
||||||
|
assert_eq!(decode_dy(0x00, 0x20, 0x00), -27);
|
||||||
|
assert_eq!(decode_dy(0x00, 0x00, 0x20), -81);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
rustitch/src/error.rs
Normal file
23
rustitch/src/error.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("invalid PES magic: expected #PES, got {0:?}")]
|
||||||
|
InvalidPesMagic([u8; 4]),
|
||||||
|
#[error("file too short: need {expected} bytes, got {actual}")]
|
||||||
|
TooShort { expected: usize, actual: usize },
|
||||||
|
#[error("invalid PEC offset: {0} exceeds file length {1}")]
|
||||||
|
InvalidPecOffset(u32, usize),
|
||||||
|
#[error("invalid header: {0}")]
|
||||||
|
InvalidHeader(String),
|
||||||
|
#[error("no stitch data found")]
|
||||||
|
NoStitchData,
|
||||||
|
#[error("empty design: no stitch segments produced")]
|
||||||
|
EmptyDesign,
|
||||||
|
#[error("unsupported format")]
|
||||||
|
UnsupportedFormat,
|
||||||
|
#[error("render error: {0}")]
|
||||||
|
Render(String),
|
||||||
|
#[error("PNG encoding error: {0}")]
|
||||||
|
PngEncode(#[from] png::EncodingError),
|
||||||
|
}
|
||||||
126
rustitch/src/exp.rs
Normal file
126
rustitch/src/exp.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
use crate::error::Error;
|
||||||
|
use crate::palette::default_colors;
|
||||||
|
use crate::types::{ResolvedDesign, StitchCommand};
|
||||||
|
|
||||||
|
/// Parse an EXP (Melco) file from raw bytes into stitch commands.
|
||||||
|
///
|
||||||
|
/// EXP format: 2 bytes per stitch (signed i8 dx, dy).
|
||||||
|
/// Escape byte 0x80 followed by a control byte:
|
||||||
|
/// 0x01 = color change
|
||||||
|
/// 0x02 = color change (variant)
|
||||||
|
/// 0x04 = jump (next 2 bytes are jump dx, dy)
|
||||||
|
/// 0x80 = trim
|
||||||
|
pub fn parse(data: &[u8]) -> Result<Vec<StitchCommand>, Error> {
|
||||||
|
if data.len() < 2 {
|
||||||
|
return Err(Error::TooShort {
|
||||||
|
expected: 2,
|
||||||
|
actual: data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut commands = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i + 1 < data.len() {
|
||||||
|
let b1 = data[i];
|
||||||
|
let b2 = data[i + 1];
|
||||||
|
|
||||||
|
if b1 == 0x80 {
|
||||||
|
match b2 {
|
||||||
|
0x01 | 0x02 => {
|
||||||
|
commands.push(StitchCommand::ColorChange);
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
0x80 => {
|
||||||
|
commands.push(StitchCommand::Trim);
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
0x04 => {
|
||||||
|
// Jump: next 2 bytes are the movement
|
||||||
|
i += 2;
|
||||||
|
if i + 1 >= data.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let dx = data[i] as i8 as i16;
|
||||||
|
let dy = -(data[i + 1] as i8 as i16);
|
||||||
|
commands.push(StitchCommand::Jump { dx, dy });
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Unknown escape, skip
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let dx = b1 as i8 as i16;
|
||||||
|
let dy = -(b2 as i8 as i16);
|
||||||
|
commands.push(StitchCommand::Stitch { dx, dy });
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
commands.push(StitchCommand::End);
|
||||||
|
|
||||||
|
if commands.len() <= 1 {
|
||||||
|
return Err(Error::NoStitchData);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(commands)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an EXP file and resolve to a renderable design.
|
||||||
|
pub fn parse_and_resolve(data: &[u8]) -> Result<ResolvedDesign, Error> {
|
||||||
|
let commands = parse(data)?;
|
||||||
|
let color_count = commands
|
||||||
|
.iter()
|
||||||
|
.filter(|c| matches!(c, StitchCommand::ColorChange))
|
||||||
|
.count()
|
||||||
|
+ 1;
|
||||||
|
let colors = default_colors(color_count);
|
||||||
|
crate::resolve::resolve(&commands, colors)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_simple_stitches() {
|
||||||
|
let data = [0x0A, 0x14, 0x05, 0x03];
|
||||||
|
let cmds = parse(&data).unwrap();
|
||||||
|
assert!(matches!(cmds[0], StitchCommand::Stitch { dx: 10, dy: -20 }));
|
||||||
|
assert!(matches!(cmds[1], StitchCommand::Stitch { dx: 5, dy: -3 }));
|
||||||
|
assert!(matches!(cmds[2], StitchCommand::End));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_negative_coords() {
|
||||||
|
// -10 as i8 = 0xF6, -20 as i8 = 0xEC
|
||||||
|
let data = [0xF6, 0xEC];
|
||||||
|
let cmds = parse(&data).unwrap();
|
||||||
|
assert!(matches!(cmds[0], StitchCommand::Stitch { dx: -10, dy: 20 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_color_change() {
|
||||||
|
let data = [0x0A, 0x14, 0x80, 0x01, 0x05, 0x03];
|
||||||
|
let cmds = parse(&data).unwrap();
|
||||||
|
assert!(matches!(cmds[0], StitchCommand::Stitch { .. }));
|
||||||
|
assert!(matches!(cmds[1], StitchCommand::ColorChange));
|
||||||
|
assert!(matches!(cmds[2], StitchCommand::Stitch { dx: 5, dy: -3 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_jump() {
|
||||||
|
let data = [0x80, 0x04, 0x0A, 0x14];
|
||||||
|
let cmds = parse(&data).unwrap();
|
||||||
|
assert!(matches!(cmds[0], StitchCommand::Jump { dx: 10, dy: -20 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_trim() {
|
||||||
|
let data = [0x0A, 0x14, 0x80, 0x80, 0x05, 0x03];
|
||||||
|
let cmds = parse(&data).unwrap();
|
||||||
|
assert!(matches!(cmds[1], StitchCommand::Trim));
|
||||||
|
}
|
||||||
|
}
|
||||||
34
rustitch/src/format.rs
Normal file
34
rustitch/src/format.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Format {
|
||||||
|
Pes,
|
||||||
|
Dst,
|
||||||
|
Exp,
|
||||||
|
Jef,
|
||||||
|
Vp3,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect format from file content (magic bytes).
|
||||||
|
pub fn detect_from_bytes(data: &[u8]) -> Option<Format> {
|
||||||
|
if data.len() >= 4 && &data[0..4] == b"#PES" {
|
||||||
|
return Some(Format::Pes);
|
||||||
|
}
|
||||||
|
if data.len() >= 5 && &data[0..5] == b"%vsm%" {
|
||||||
|
return Some(Format::Vp3);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect format from file extension.
|
||||||
|
pub fn detect_from_extension(path: &Path) -> Option<Format> {
|
||||||
|
let ext = path.extension()?.to_str()?;
|
||||||
|
match ext.to_ascii_lowercase().as_str() {
|
||||||
|
"pes" => Some(Format::Pes),
|
||||||
|
"dst" => Some(Format::Dst),
|
||||||
|
"exp" => Some(Format::Exp),
|
||||||
|
"jef" => Some(Format::Jef),
|
||||||
|
"vp3" => Some(Format::Vp3),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
169
rustitch/src/jef/mod.rs
Normal file
169
rustitch/src/jef/mod.rs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
mod palette;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::types::{ResolvedDesign, StitchCommand};
|
||||||
|
use palette::JEF_PALETTE;
|
||||||
|
|
||||||
|
/// Parse a JEF (Janome) file from raw bytes into stitch commands and color info.
|
||||||
|
///
|
||||||
|
/// JEF header layout (little-endian):
|
||||||
|
/// 0..4: stitch data offset (u32)
|
||||||
|
/// 4..8: flags/format indicator
|
||||||
|
/// 24..28: color count (u32)
|
||||||
|
/// 28..32: stitch count (u32)
|
||||||
|
/// 116+: color table (each entry: i32 palette index)
|
||||||
|
///
|
||||||
|
/// Stitch data: 2 bytes per stitch (signed i8 dx, dy).
|
||||||
|
/// Control codes: 0x80 0x01 = color change, 0x80 0x02 = jump, 0x80 0x10 = end.
|
||||||
|
type ParseResult = Result<(Vec<StitchCommand>, Vec<(u8, u8, u8)>), Error>;
|
||||||
|
|
||||||
|
pub fn parse(data: &[u8]) -> ParseResult {
|
||||||
|
if data.len() < 116 {
|
||||||
|
return Err(Error::TooShort {
|
||||||
|
expected: 116,
|
||||||
|
actual: data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let stitch_offset = read_u32_le(data, 0) as usize;
|
||||||
|
let color_count = read_u32_le(data, 24) as usize;
|
||||||
|
|
||||||
|
if stitch_offset > data.len() {
|
||||||
|
return Err(Error::InvalidHeader(format!(
|
||||||
|
"stitch data offset {} exceeds file length {}",
|
||||||
|
stitch_offset,
|
||||||
|
data.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read color table starting at offset 116
|
||||||
|
let color_table_start = 116;
|
||||||
|
let mut colors = Vec::with_capacity(color_count);
|
||||||
|
for i in 0..color_count {
|
||||||
|
let entry_offset = color_table_start + i * 4;
|
||||||
|
if entry_offset + 4 > data.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let idx = read_i32_le(data, entry_offset);
|
||||||
|
let palette_idx = if idx >= 0 && (idx as usize) < JEF_PALETTE.len() {
|
||||||
|
idx as usize
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
colors.push(JEF_PALETTE[palette_idx]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if colors.is_empty() {
|
||||||
|
colors.push((0, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse stitch data
|
||||||
|
let stitch_data = &data[stitch_offset..];
|
||||||
|
let commands = decode_stitches(stitch_data)?;
|
||||||
|
|
||||||
|
Ok((commands, colors))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_stitches(data: &[u8]) -> Result<Vec<StitchCommand>, Error> {
|
||||||
|
let mut commands = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i + 1 < data.len() {
|
||||||
|
let b1 = data[i];
|
||||||
|
let b2 = data[i + 1];
|
||||||
|
|
||||||
|
if b1 == 0x80 {
|
||||||
|
match b2 {
|
||||||
|
0x01 => {
|
||||||
|
commands.push(StitchCommand::ColorChange);
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
0x02 => {
|
||||||
|
// Jump: next 2 bytes are movement
|
||||||
|
i += 2;
|
||||||
|
if i + 1 >= data.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let dx = data[i] as i8 as i16;
|
||||||
|
let dy = -(data[i + 1] as i8 as i16);
|
||||||
|
commands.push(StitchCommand::Jump { dx, dy });
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
0x10 => {
|
||||||
|
commands.push(StitchCommand::End);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let dx = b1 as i8 as i16;
|
||||||
|
let dy = -(b2 as i8 as i16);
|
||||||
|
commands.push(StitchCommand::Stitch { dx, dy });
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if commands.is_empty() {
|
||||||
|
return Err(Error::NoStitchData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matches!(commands.last(), Some(StitchCommand::End)) {
|
||||||
|
commands.push(StitchCommand::End);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(commands)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a JEF file and resolve to a renderable design.
|
||||||
|
pub fn parse_and_resolve(data: &[u8]) -> Result<ResolvedDesign, Error> {
|
||||||
|
let (commands, colors) = parse(data)?;
|
||||||
|
crate::resolve::resolve(&commands, colors)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32_le(data: &[u8], offset: usize) -> u32 {
|
||||||
|
u32::from_le_bytes([
|
||||||
|
data[offset],
|
||||||
|
data[offset + 1],
|
||||||
|
data[offset + 2],
|
||||||
|
data[offset + 3],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_i32_le(data: &[u8], offset: usize) -> i32 {
|
||||||
|
i32::from_le_bytes([
|
||||||
|
data[offset],
|
||||||
|
data[offset + 1],
|
||||||
|
data[offset + 2],
|
||||||
|
data[offset + 3],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_simple_stitches() {
|
||||||
|
let data = [0x0A, 0x14, 0x05, 0x03, 0x80, 0x10];
|
||||||
|
let cmds = decode_stitches(&data).unwrap();
|
||||||
|
assert!(matches!(cmds[0], StitchCommand::Stitch { dx: 10, dy: -20 }));
|
||||||
|
assert!(matches!(cmds[1], StitchCommand::Stitch { dx: 5, dy: -3 }));
|
||||||
|
assert!(matches!(cmds[2], StitchCommand::End));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_color_change() {
|
||||||
|
let data = [0x0A, 0x14, 0x80, 0x01, 0x05, 0x03, 0x80, 0x10];
|
||||||
|
let cmds = decode_stitches(&data).unwrap();
|
||||||
|
assert!(matches!(cmds[1], StitchCommand::ColorChange));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_jump() {
|
||||||
|
let data = [0x80, 0x02, 0x0A, 0x14, 0x80, 0x10];
|
||||||
|
let cmds = decode_stitches(&data).unwrap();
|
||||||
|
assert!(matches!(cmds[0], StitchCommand::Jump { dx: 10, dy: -20 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
82
rustitch/src/jef/palette.rs
Normal file
82
rustitch/src/jef/palette.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/// Janome thread color palette (78 entries).
|
||||||
|
/// Index 0 is a fallback; indices 1-77 correspond to standard Janome thread colors.
|
||||||
|
pub const JEF_PALETTE: [(u8, u8, u8); 78] = [
|
||||||
|
(0, 0, 0), // 0: Unknown / Black
|
||||||
|
(0, 0, 0), // 1: Black
|
||||||
|
(255, 255, 255), // 2: White
|
||||||
|
(255, 255, 23), // 3: Yellow
|
||||||
|
(250, 160, 96), // 4: Orange
|
||||||
|
(92, 118, 73), // 5: Olive Green
|
||||||
|
(64, 192, 48), // 6: Green
|
||||||
|
(101, 194, 200), // 7: Sky Blue
|
||||||
|
(172, 128, 190), // 8: Purple
|
||||||
|
(245, 188, 203), // 9: Pink
|
||||||
|
(255, 0, 0), // 10: Red
|
||||||
|
(192, 128, 0), // 11: Brown
|
||||||
|
(0, 0, 240), // 12: Blue
|
||||||
|
(228, 195, 93), // 13: Gold
|
||||||
|
(165, 42, 42), // 14: Dark Brown
|
||||||
|
(213, 176, 212), // 15: Pale Violet
|
||||||
|
(252, 242, 148), // 16: Pale Yellow
|
||||||
|
(240, 208, 192), // 17: Pale Pink
|
||||||
|
(255, 192, 0), // 18: Peach
|
||||||
|
(201, 164, 128), // 19: Beige
|
||||||
|
(155, 61, 75), // 20: Wine Red
|
||||||
|
(160, 184, 204), // 21: Pale Sky Blue
|
||||||
|
(127, 194, 28), // 22: Yellow Green
|
||||||
|
(185, 185, 185), // 23: Silver Gray
|
||||||
|
(160, 160, 160), // 24: Gray
|
||||||
|
(152, 214, 189), // 25: Pale Aqua
|
||||||
|
(184, 240, 240), // 26: Baby Blue
|
||||||
|
(54, 139, 160), // 27: Powder Blue
|
||||||
|
(79, 131, 171), // 28: Bright Blue
|
||||||
|
(56, 106, 145), // 29: Slate Blue
|
||||||
|
(0, 32, 107), // 30: Navy Blue
|
||||||
|
(229, 197, 202), // 31: Salmon Pink
|
||||||
|
(249, 103, 107), // 32: Coral
|
||||||
|
(227, 49, 31), // 33: Burnt Orange
|
||||||
|
(226, 161, 136), // 34: Cinnamon
|
||||||
|
(181, 148, 116), // 35: Umber
|
||||||
|
(228, 207, 153), // 36: Blond
|
||||||
|
(225, 203, 0), // 37: Sunflower
|
||||||
|
(225, 173, 212), // 38: Orchid Pink
|
||||||
|
(195, 0, 126), // 39: Peony
|
||||||
|
(128, 0, 75), // 40: Burgundy
|
||||||
|
(160, 96, 176), // 41: Royal Purple
|
||||||
|
(192, 64, 32), // 42: Cardinal Red
|
||||||
|
(202, 224, 192), // 43: Opal Green
|
||||||
|
(137, 152, 86), // 44: Moss Green
|
||||||
|
(0, 170, 0), // 45: Meadow Green
|
||||||
|
(33, 138, 33), // 46: Dark Green
|
||||||
|
(93, 174, 148), // 47: Aquamarine
|
||||||
|
(76, 191, 143), // 48: Emerald Green
|
||||||
|
(0, 119, 114), // 49: Peacock Green
|
||||||
|
(112, 112, 112), // 50: Dark Gray
|
||||||
|
(242, 255, 255), // 51: Ivory White
|
||||||
|
(177, 88, 24), // 52: Hazel
|
||||||
|
(203, 138, 7), // 53: Toast
|
||||||
|
(247, 146, 123), // 54: Salmon
|
||||||
|
(152, 105, 45), // 55: Cocoa Brown
|
||||||
|
(162, 113, 72), // 56: Sienna
|
||||||
|
(123, 85, 74), // 57: Sepia
|
||||||
|
(79, 57, 70), // 58: Dark Sepia
|
||||||
|
(82, 58, 151), // 59: Violet Blue
|
||||||
|
(0, 0, 160), // 60: Blue Ink
|
||||||
|
(0, 150, 222), // 61: Solar Blue
|
||||||
|
(178, 221, 83), // 62: Green Dust
|
||||||
|
(250, 143, 187), // 63: Crimson
|
||||||
|
(222, 100, 158), // 64: Floral Pink
|
||||||
|
(181, 80, 102), // 65: Wine
|
||||||
|
(94, 87, 71), // 66: Olive Drab
|
||||||
|
(76, 136, 31), // 67: Meadow
|
||||||
|
(228, 220, 121), // 68: Mustard
|
||||||
|
(203, 138, 26), // 69: Yellow Ochre
|
||||||
|
(198, 170, 66), // 70: Old Gold
|
||||||
|
(236, 176, 44), // 71: Honeydew
|
||||||
|
(248, 128, 64), // 72: Tangerine
|
||||||
|
(255, 229, 5), // 73: Canary Yellow
|
||||||
|
(250, 122, 122), // 74: Vermilion
|
||||||
|
(107, 224, 0), // 75: Bright Green
|
||||||
|
(56, 108, 174), // 76: Ocean Blue
|
||||||
|
(227, 196, 180), // 77: Beige Gray
|
||||||
|
];
|
||||||
@@ -1,12 +1,44 @@
|
|||||||
pub mod pes;
|
pub mod error;
|
||||||
mod render;
|
pub mod format;
|
||||||
|
pub mod palette;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
pub mod dst;
|
||||||
|
pub mod exp;
|
||||||
|
pub mod jef;
|
||||||
|
pub mod pes;
|
||||||
|
pub mod vp3;
|
||||||
|
|
||||||
|
mod render;
|
||||||
|
mod resolve;
|
||||||
|
|
||||||
|
pub use error::Error;
|
||||||
|
pub use format::Format;
|
||||||
pub use render::render_thumbnail;
|
pub use render::render_thumbnail;
|
||||||
|
pub use types::{BoundingBox, ResolvedDesign, StitchCommand, StitchSegment};
|
||||||
|
|
||||||
/// Parse a PES file and render a thumbnail PNG of the given size.
|
/// Parse a PES file and render a thumbnail PNG of the given size.
|
||||||
pub fn thumbnail(pes_data: &[u8], size: u32) -> Result<Vec<u8>, pes::Error> {
|
pub fn thumbnail(pes_data: &[u8], size: u32) -> Result<Vec<u8>, Error> {
|
||||||
let design = pes::parse(pes_data)?;
|
let design = pes::parse(pes_data)?;
|
||||||
let resolved = pes::resolve(&design)?;
|
let resolved = pes::resolve(&design)?;
|
||||||
let png_bytes = render::render_thumbnail(&resolved, size)?;
|
render::render_thumbnail(&resolved, size)
|
||||||
Ok(png_bytes)
|
}
|
||||||
|
|
||||||
|
/// Parse any supported format and render a thumbnail PNG.
|
||||||
|
pub fn thumbnail_format(data: &[u8], size: u32, fmt: Format) -> Result<Vec<u8>, Error> {
|
||||||
|
let resolved = parse_and_resolve(data, fmt)?;
|
||||||
|
render::render_thumbnail(&resolved, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_and_resolve(data: &[u8], fmt: Format) -> Result<ResolvedDesign, Error> {
|
||||||
|
match fmt {
|
||||||
|
Format::Pes => {
|
||||||
|
let design = pes::parse(data)?;
|
||||||
|
pes::resolve(&design)
|
||||||
|
}
|
||||||
|
Format::Dst => dst::parse_and_resolve(data),
|
||||||
|
Format::Exp => exp::parse_and_resolve(data),
|
||||||
|
Format::Jef => jef::parse_and_resolve(data),
|
||||||
|
Format::Vp3 => vp3::parse_and_resolve(data),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
93
rustitch/src/palette.rs
Normal file
93
rustitch/src/palette.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/// Brother PEC thread color palette (65 entries).
|
||||||
|
/// Index 0 is a fallback; indices 1-64 correspond to standard Brother thread colors.
|
||||||
|
pub const PEC_PALETTE: [(u8, u8, u8); 65] = [
|
||||||
|
(0, 0, 0), // 0: Unknown
|
||||||
|
(14, 31, 124), // 1: Prussian Blue
|
||||||
|
(10, 85, 163), // 2: Blue
|
||||||
|
(0, 135, 119), // 3: Teal Green
|
||||||
|
(75, 107, 175), // 4: Cornflower Blue
|
||||||
|
(237, 23, 31), // 5: Red
|
||||||
|
(209, 92, 0), // 6: Reddish Brown
|
||||||
|
(145, 54, 151), // 7: Magenta
|
||||||
|
(228, 154, 203), // 8: Light Lilac
|
||||||
|
(145, 95, 172), // 9: Lilac
|
||||||
|
(158, 214, 125), // 10: Mint Green
|
||||||
|
(232, 169, 0), // 11: Deep Gold
|
||||||
|
(254, 186, 53), // 12: Orange
|
||||||
|
(255, 255, 0), // 13: Yellow
|
||||||
|
(112, 188, 31), // 14: Lime Green
|
||||||
|
(186, 152, 0), // 15: Brass
|
||||||
|
(168, 168, 168), // 16: Silver
|
||||||
|
(125, 111, 0), // 17: Russet Brown
|
||||||
|
(255, 255, 179), // 18: Cream Brown
|
||||||
|
(79, 85, 86), // 19: Pewter
|
||||||
|
(0, 0, 0), // 20: Black
|
||||||
|
(11, 61, 145), // 21: Ultramarine
|
||||||
|
(119, 1, 118), // 22: Royal Purple
|
||||||
|
(41, 49, 51), // 23: Dark Gray
|
||||||
|
(42, 19, 1), // 24: Dark Brown
|
||||||
|
(246, 74, 138), // 25: Deep Rose
|
||||||
|
(178, 118, 36), // 26: Light Brown
|
||||||
|
(252, 187, 197), // 27: Salmon Pink
|
||||||
|
(254, 55, 15), // 28: Vermilion
|
||||||
|
(240, 240, 240), // 29: White
|
||||||
|
(106, 28, 138), // 30: Violet
|
||||||
|
(168, 221, 196), // 31: Seacrest
|
||||||
|
(37, 132, 187), // 32: Sky Blue
|
||||||
|
(254, 179, 67), // 33: Pumpkin
|
||||||
|
(255, 243, 107), // 34: Cream Yellow
|
||||||
|
(208, 166, 96), // 35: Khaki
|
||||||
|
(209, 84, 0), // 36: Clay Brown
|
||||||
|
(102, 186, 73), // 37: Leaf Green
|
||||||
|
(19, 74, 70), // 38: Peacock Blue
|
||||||
|
(135, 135, 135), // 39: Gray
|
||||||
|
(216, 204, 198), // 40: Warm Gray
|
||||||
|
(67, 86, 7), // 41: Dark Olive
|
||||||
|
(253, 217, 222), // 42: Flesh Pink
|
||||||
|
(249, 147, 188), // 43: Pink
|
||||||
|
(0, 56, 34), // 44: Deep Green
|
||||||
|
(178, 175, 212), // 45: Lavender
|
||||||
|
(104, 106, 176), // 46: Wisteria Violet
|
||||||
|
(239, 227, 185), // 47: Beige
|
||||||
|
(247, 56, 102), // 48: Carmine
|
||||||
|
(181, 75, 100), // 49: Amber Red
|
||||||
|
(19, 43, 26), // 50: Olive Green
|
||||||
|
(199, 1, 86), // 51: Dark Fuchsia
|
||||||
|
(254, 158, 50), // 52: Tangerine
|
||||||
|
(168, 222, 235), // 53: Light Blue
|
||||||
|
(0, 103, 62), // 54: Emerald Green
|
||||||
|
(78, 41, 144), // 55: Purple
|
||||||
|
(47, 126, 32), // 56: Moss Green
|
||||||
|
(255, 204, 204), // 57: Flesh Pink
|
||||||
|
(255, 217, 17), // 58: Harvest Gold
|
||||||
|
(9, 91, 166), // 59: Electric Blue
|
||||||
|
(240, 249, 112), // 60: Lemon Yellow
|
||||||
|
(227, 243, 91), // 61: Fresh Green
|
||||||
|
(255, 153, 0), // 62: Orange
|
||||||
|
(255, 240, 141), // 63: Cream Yellow
|
||||||
|
(255, 200, 200), // 64: Applique
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Default high-contrast palette for formats without embedded color info (DST, EXP).
|
||||||
|
/// Colors cycle on each color change.
|
||||||
|
pub const DEFAULT_PALETTE: [(u8, u8, u8); 12] = [
|
||||||
|
(0, 0, 0), // Black
|
||||||
|
(237, 23, 31), // Red
|
||||||
|
(10, 85, 163), // Blue
|
||||||
|
(0, 135, 119), // Teal Green
|
||||||
|
(254, 186, 53), // Orange
|
||||||
|
(145, 54, 151), // Magenta
|
||||||
|
(112, 188, 31), // Lime Green
|
||||||
|
(42, 19, 1), // Dark Brown
|
||||||
|
(37, 132, 187), // Sky Blue
|
||||||
|
(246, 74, 138), // Deep Rose
|
||||||
|
(186, 152, 0), // Brass
|
||||||
|
(106, 28, 138), // Violet
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Build a color list for `n` thread slots by cycling through `DEFAULT_PALETTE`.
|
||||||
|
pub fn default_colors(n: usize) -> Vec<(u8, u8, u8)> {
|
||||||
|
(0..n)
|
||||||
|
.map(|i| DEFAULT_PALETTE[i % DEFAULT_PALETTE.len()])
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use super::Error;
|
use crate::error::Error;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct PesHeader {
|
pub struct PesHeader {
|
||||||
@@ -18,7 +18,7 @@ pub fn parse_header(data: &[u8]) -> Result<PesHeader, Error> {
|
|||||||
if magic != b"#PES" {
|
if magic != b"#PES" {
|
||||||
let mut m = [0u8; 4];
|
let mut m = [0u8; 4];
|
||||||
m.copy_from_slice(magic);
|
m.copy_from_slice(magic);
|
||||||
return Err(Error::InvalidMagic(m));
|
return Err(Error::InvalidPesMagic(m));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut version = [0u8; 4];
|
let mut version = [0u8; 4];
|
||||||
@@ -41,7 +41,6 @@ mod tests {
|
|||||||
let mut data = vec![0u8; 20];
|
let mut data = vec![0u8; 20];
|
||||||
data[0..4].copy_from_slice(b"#PES");
|
data[0..4].copy_from_slice(b"#PES");
|
||||||
data[4..8].copy_from_slice(b"0001");
|
data[4..8].copy_from_slice(b"0001");
|
||||||
// PEC offset = 16 (little-endian)
|
|
||||||
data[8..12].copy_from_slice(&16u32.to_le_bytes());
|
data[8..12].copy_from_slice(&16u32.to_le_bytes());
|
||||||
|
|
||||||
let header = parse_header(&data).unwrap();
|
let header = parse_header(&data).unwrap();
|
||||||
@@ -53,7 +52,7 @@ mod tests {
|
|||||||
fn reject_invalid_magic() {
|
fn reject_invalid_magic() {
|
||||||
let data = b"NOTPES0001\x10\x00\x00\x00";
|
let data = b"NOTPES0001\x10\x00\x00\x00";
|
||||||
let err = parse_header(data).unwrap_err();
|
let err = parse_header(data).unwrap_err();
|
||||||
assert!(matches!(err, Error::InvalidMagic(_)));
|
assert!(matches!(err, Error::InvalidPesMagic(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,30 +1,13 @@
|
|||||||
mod header;
|
mod header;
|
||||||
mod palette;
|
|
||||||
mod pec;
|
mod pec;
|
||||||
|
|
||||||
pub use header::PesHeader;
|
pub use header::PesHeader;
|
||||||
pub use palette::PEC_PALETTE;
|
pub use pec::PecHeader;
|
||||||
pub use pec::{PecHeader, StitchCommand};
|
|
||||||
|
|
||||||
use thiserror::Error;
|
// Re-export shared types for backward compatibility
|
||||||
|
pub use crate::error::Error;
|
||||||
#[derive(Debug, Error)]
|
pub use crate::palette::PEC_PALETTE;
|
||||||
pub enum Error {
|
pub use crate::types::{BoundingBox, ResolvedDesign, StitchCommand, StitchSegment};
|
||||||
#[error("invalid PES magic: expected #PES, got {0:?}")]
|
|
||||||
InvalidMagic([u8; 4]),
|
|
||||||
#[error("file too short: need {expected} bytes, got {actual}")]
|
|
||||||
TooShort { expected: usize, actual: usize },
|
|
||||||
#[error("invalid PEC offset: {0} exceeds file length {1}")]
|
|
||||||
InvalidPecOffset(u32, usize),
|
|
||||||
#[error("no stitch data found")]
|
|
||||||
NoStitchData,
|
|
||||||
#[error("empty design: no stitch segments produced")]
|
|
||||||
EmptyDesign,
|
|
||||||
#[error("render error: {0}")]
|
|
||||||
Render(String),
|
|
||||||
#[error("PNG encoding error: {0}")]
|
|
||||||
PngEncode(#[from] png::EncodingError),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PesDesign {
|
pub struct PesDesign {
|
||||||
pub header: PesHeader,
|
pub header: PesHeader,
|
||||||
@@ -32,27 +15,6 @@ pub struct PesDesign {
|
|||||||
pub commands: Vec<StitchCommand>,
|
pub commands: Vec<StitchCommand>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct StitchSegment {
|
|
||||||
pub x0: f32,
|
|
||||||
pub y0: f32,
|
|
||||||
pub x1: f32,
|
|
||||||
pub y1: f32,
|
|
||||||
pub color_index: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct BoundingBox {
|
|
||||||
pub min_x: f32,
|
|
||||||
pub max_x: f32,
|
|
||||||
pub min_y: f32,
|
|
||||||
pub max_y: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ResolvedDesign {
|
|
||||||
pub segments: Vec<StitchSegment>,
|
|
||||||
pub colors: Vec<(u8, u8, u8)>,
|
|
||||||
pub bounds: BoundingBox,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a PES file from raw bytes.
|
/// Parse a PES file from raw bytes.
|
||||||
pub fn parse(data: &[u8]) -> Result<PesDesign, Error> {
|
pub fn parse(data: &[u8]) -> Result<PesDesign, Error> {
|
||||||
let header = header::parse_header(data)?;
|
let header = header::parse_header(data)?;
|
||||||
@@ -73,66 +35,8 @@ pub fn parse(data: &[u8]) -> Result<PesDesign, Error> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert parsed commands into renderable segments with absolute coordinates.
|
/// Convert parsed PES design into renderable segments with absolute coordinates.
|
||||||
pub fn resolve(design: &PesDesign) -> Result<ResolvedDesign, Error> {
|
pub fn resolve(design: &PesDesign) -> Result<ResolvedDesign, Error> {
|
||||||
let mut segments = Vec::new();
|
|
||||||
let mut x: f32 = 0.0;
|
|
||||||
let mut y: f32 = 0.0;
|
|
||||||
let mut color_idx: usize = 0;
|
|
||||||
let mut pen_down = true;
|
|
||||||
|
|
||||||
for cmd in &design.commands {
|
|
||||||
match cmd {
|
|
||||||
StitchCommand::Stitch { dx, dy } => {
|
|
||||||
let nx = x + *dx as f32;
|
|
||||||
let ny = y + *dy as f32;
|
|
||||||
if pen_down {
|
|
||||||
segments.push(StitchSegment {
|
|
||||||
x0: x,
|
|
||||||
y0: y,
|
|
||||||
x1: nx,
|
|
||||||
y1: ny,
|
|
||||||
color_index: color_idx,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
x = nx;
|
|
||||||
y = ny;
|
|
||||||
pen_down = true;
|
|
||||||
}
|
|
||||||
StitchCommand::Jump { dx, dy } => {
|
|
||||||
x += *dx as f32;
|
|
||||||
y += *dy as f32;
|
|
||||||
pen_down = false;
|
|
||||||
}
|
|
||||||
StitchCommand::Trim => {
|
|
||||||
pen_down = false;
|
|
||||||
}
|
|
||||||
StitchCommand::ColorChange => {
|
|
||||||
color_idx += 1;
|
|
||||||
pen_down = false;
|
|
||||||
}
|
|
||||||
StitchCommand::End => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if segments.is_empty() {
|
|
||||||
return Err(Error::EmptyDesign);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute bounding box
|
|
||||||
let mut min_x = f32::MAX;
|
|
||||||
let mut max_x = f32::MIN;
|
|
||||||
let mut min_y = f32::MAX;
|
|
||||||
let mut max_y = f32::MIN;
|
|
||||||
|
|
||||||
for seg in &segments {
|
|
||||||
min_x = min_x.min(seg.x0).min(seg.x1);
|
|
||||||
max_x = max_x.max(seg.x0).max(seg.x1);
|
|
||||||
min_y = min_y.min(seg.y0).min(seg.y1);
|
|
||||||
max_y = max_y.max(seg.y0).max(seg.y1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve colors from palette indices
|
|
||||||
let colors: Vec<(u8, u8, u8)> = design
|
let colors: Vec<(u8, u8, u8)> = design
|
||||||
.pec_header
|
.pec_header
|
||||||
.color_indices
|
.color_indices
|
||||||
@@ -143,14 +47,5 @@ pub fn resolve(design: &PesDesign) -> Result<ResolvedDesign, Error> {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(ResolvedDesign {
|
crate::resolve::resolve(&design.commands, colors)
|
||||||
segments,
|
|
||||||
colors,
|
|
||||||
bounds: BoundingBox {
|
|
||||||
min_x,
|
|
||||||
max_x,
|
|
||||||
min_y,
|
|
||||||
max_y,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,2 @@
|
|||||||
/// Brother PEC thread color palette (65 entries).
|
// Re-export from crate root for backward compatibility
|
||||||
/// Index 0 is a fallback; indices 1–64 correspond to standard Brother thread colors.
|
pub use crate::palette::PEC_PALETTE;
|
||||||
pub const PEC_PALETTE: [(u8, u8, u8); 65] = [
|
|
||||||
(0, 0, 0), // 0: Unknown
|
|
||||||
(14, 31, 124), // 1: Prussian Blue
|
|
||||||
(10, 85, 163), // 2: Blue
|
|
||||||
(0, 135, 119), // 3: Teal Green
|
|
||||||
(75, 107, 175), // 4: Cornflower Blue
|
|
||||||
(237, 23, 31), // 5: Red
|
|
||||||
(209, 92, 0), // 6: Reddish Brown
|
|
||||||
(145, 54, 151), // 7: Magenta
|
|
||||||
(228, 154, 203), // 8: Light Lilac
|
|
||||||
(145, 95, 172), // 9: Lilac
|
|
||||||
(158, 214, 125), // 10: Mint Green
|
|
||||||
(232, 169, 0), // 11: Deep Gold
|
|
||||||
(254, 186, 53), // 12: Orange
|
|
||||||
(255, 255, 0), // 13: Yellow
|
|
||||||
(112, 188, 31), // 14: Lime Green
|
|
||||||
(186, 152, 0), // 15: Brass
|
|
||||||
(168, 168, 168), // 16: Silver
|
|
||||||
(125, 111, 0), // 17: Russet Brown
|
|
||||||
(255, 255, 179), // 18: Cream Brown
|
|
||||||
(79, 85, 86), // 19: Pewter
|
|
||||||
(0, 0, 0), // 20: Black
|
|
||||||
(11, 61, 145), // 21: Ultramarine
|
|
||||||
(119, 1, 118), // 22: Royal Purple
|
|
||||||
(41, 49, 51), // 23: Dark Gray
|
|
||||||
(42, 19, 1), // 24: Dark Brown
|
|
||||||
(246, 74, 138), // 25: Deep Rose
|
|
||||||
(178, 118, 36), // 26: Light Brown
|
|
||||||
(252, 187, 197), // 27: Salmon Pink
|
|
||||||
(254, 55, 15), // 28: Vermilion
|
|
||||||
(240, 240, 240), // 29: White
|
|
||||||
(106, 28, 138), // 30: Violet
|
|
||||||
(168, 221, 196), // 31: Seacrest
|
|
||||||
(37, 132, 187), // 32: Sky Blue
|
|
||||||
(254, 179, 67), // 33: Pumpkin
|
|
||||||
(255, 243, 107), // 34: Cream Yellow
|
|
||||||
(208, 166, 96), // 35: Khaki
|
|
||||||
(209, 84, 0), // 36: Clay Brown
|
|
||||||
(102, 186, 73), // 37: Leaf Green
|
|
||||||
(19, 74, 70), // 38: Peacock Blue
|
|
||||||
(135, 135, 135), // 39: Gray
|
|
||||||
(216, 204, 198), // 40: Warm Gray
|
|
||||||
(67, 86, 7), // 41: Dark Olive
|
|
||||||
(253, 217, 222), // 42: Flesh Pink
|
|
||||||
(249, 147, 188), // 43: Pink
|
|
||||||
(0, 56, 34), // 44: Deep Green
|
|
||||||
(178, 175, 212), // 45: Lavender
|
|
||||||
(104, 106, 176), // 46: Wisteria Violet
|
|
||||||
(239, 227, 185), // 47: Beige
|
|
||||||
(247, 56, 102), // 48: Carmine
|
|
||||||
(181, 75, 100), // 49: Amber Red
|
|
||||||
(19, 43, 26), // 50: Olive Green
|
|
||||||
(199, 1, 86), // 51: Dark Fuchsia
|
|
||||||
(254, 158, 50), // 52: Tangerine
|
|
||||||
(168, 222, 235), // 53: Light Blue
|
|
||||||
(0, 103, 62), // 54: Emerald Green
|
|
||||||
(78, 41, 144), // 55: Purple
|
|
||||||
(47, 126, 32), // 56: Moss Green
|
|
||||||
(255, 204, 204), // 57: Flesh Pink
|
|
||||||
(255, 217, 17), // 58: Harvest Gold
|
|
||||||
(9, 91, 166), // 59: Electric Blue
|
|
||||||
(240, 249, 112), // 60: Lemon Yellow
|
|
||||||
(227, 243, 91), // 61: Fresh Green
|
|
||||||
(255, 153, 0), // 62: Orange
|
|
||||||
(255, 240, 141), // 63: Cream Yellow
|
|
||||||
(255, 200, 200), // 64: Applique
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::Error;
|
use crate::error::Error;
|
||||||
|
use crate::types::StitchCommand;
|
||||||
|
|
||||||
pub struct PecHeader {
|
pub struct PecHeader {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
@@ -6,19 +7,9 @@ pub struct PecHeader {
|
|||||||
pub color_indices: Vec<u8>,
|
pub color_indices: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum StitchCommand {
|
|
||||||
Stitch { dx: i16, dy: i16 },
|
|
||||||
Jump { dx: i16, dy: i16 },
|
|
||||||
Trim,
|
|
||||||
ColorChange,
|
|
||||||
End,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse the PEC header starting at the PEC section offset.
|
/// Parse the PEC header starting at the PEC section offset.
|
||||||
/// Returns the header and the byte offset (relative to pec_data start) where stitch data begins.
|
/// Returns the header and the byte offset (relative to pec_data start) where stitch data begins.
|
||||||
pub fn parse_pec_header(pec_data: &[u8]) -> Result<(PecHeader, usize), Error> {
|
pub fn parse_pec_header(pec_data: &[u8]) -> Result<(PecHeader, usize), Error> {
|
||||||
// PEC section starts with "LA:" label field (19 bytes total)
|
|
||||||
if pec_data.len() < 532 {
|
if pec_data.len() < 532 {
|
||||||
return Err(Error::TooShort {
|
return Err(Error::TooShort {
|
||||||
expected: 532,
|
expected: 532,
|
||||||
@@ -26,57 +17,15 @@ pub fn parse_pec_header(pec_data: &[u8]) -> Result<(PecHeader, usize), Error> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Label: bytes 0..19, starts with "LA:"
|
|
||||||
let label_raw = &pec_data[3..19];
|
let label_raw = &pec_data[3..19];
|
||||||
let label = std::str::from_utf8(label_raw)
|
let label = std::str::from_utf8(label_raw)
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.trim()
|
.trim()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Color count at offset 48 from PEC start
|
|
||||||
let color_count = pec_data[48] + 1;
|
let color_count = pec_data[48] + 1;
|
||||||
|
|
||||||
// Color indices follow at offset 49
|
|
||||||
let color_indices: Vec<u8> = pec_data[49..49 + color_count as usize].to_vec();
|
let color_indices: Vec<u8> = pec_data[49..49 + color_count as usize].to_vec();
|
||||||
|
|
||||||
// Stitch data starts at offset 532 from PEC section start
|
|
||||||
// (48 bytes header + 463 bytes padding/thumbnail = 512, plus 20 bytes of graphic data = 532)
|
|
||||||
// Actually the standard offset is 512 + the two thumbnail sections.
|
|
||||||
// The typical approach: skip to offset 48 + 1 + color_count + padding to 512, then skip thumbnails.
|
|
||||||
// Simplified: PEC stitch data offset = 512 + 20 (for the stitch data header that contains the graphic offsets)
|
|
||||||
// A more robust approach: read the stitch data offset from the header.
|
|
||||||
|
|
||||||
// At PEC offset + 20..24 there are two u16 LE values for the thumbnail image offsets.
|
|
||||||
// The stitch data typically starts after a fixed 532-byte header region.
|
|
||||||
// Let's use the more standard approach from libpes:
|
|
||||||
// Offset 514-515 (relative to PEC start): thumbnail1 image offset (u16 LE, relative)
|
|
||||||
// But the simplest reliable approach is to find the stitch data after the fixed header.
|
|
||||||
|
|
||||||
// The standard PEC header is 512 bytes, followed by two thumbnail images.
|
|
||||||
// Thumbnail 1: 6 bytes wide × 38 bytes high = 228 bytes (48×38 pixel, 1bpp padded)
|
|
||||||
// Actually, typical PEC has: after the 512-byte block, there are two graphics sections.
|
|
||||||
// The stitch data starts after those graphics.
|
|
||||||
//
|
|
||||||
// More robust: bytes 514..516 give the thumbnail offset (little-endian u16).
|
|
||||||
// We can derive stitch data from there, but let's use the standard fixed sizes.
|
|
||||||
// Thumbnail 1: at offset 512, size = ceil(width*2/8) * height, with default 48×38 = 6*38=228
|
|
||||||
// Thumbnail 2: at offset 512+228=740, size = ceil(width*2/8) * height, default 96×76=12*76=912
|
|
||||||
// Stitch data at: 512 + 228 + 912 = 1652? That doesn't seem right.
|
|
||||||
//
|
|
||||||
// Actually from libpes wiki: PEC header is 20 bytes, then color info, then padding to
|
|
||||||
// reach a 512-byte boundary. At byte 512 is the beginning of the PEC graphic section.
|
|
||||||
// After the graphics come the stitch data. But graphic sizes vary.
|
|
||||||
//
|
|
||||||
// The correct approach: at PEC_start + 514 (bytes 514-515), read a u16 LE which gives
|
|
||||||
// the absolute offset from PEC_start to the first thumbnail. Then after thumbnails come stitches.
|
|
||||||
// BUT actually, the standard approach used by most parsers is simpler:
|
|
||||||
//
|
|
||||||
// pyembroidery approach: seek to PEC_start + 532, that's where stitch data starts.
|
|
||||||
// The 532 = 512 + 20 (20 bytes for graphic header).
|
|
||||||
//
|
|
||||||
// Let's verify: pyembroidery's PecReader reads stitches starting 532 bytes after PEC start.
|
|
||||||
// Let's go with 532.
|
|
||||||
|
|
||||||
let stitch_data_offset = 532;
|
let stitch_data_offset = 532;
|
||||||
|
|
||||||
if pec_data.len() <= stitch_data_offset {
|
if pec_data.len() <= stitch_data_offset {
|
||||||
@@ -101,31 +50,42 @@ pub fn decode_stitches(data: &[u8]) -> Result<Vec<StitchCommand>, Error> {
|
|||||||
while i < data.len() {
|
while i < data.len() {
|
||||||
let b1 = data[i];
|
let b1 = data[i];
|
||||||
|
|
||||||
// End marker
|
|
||||||
if b1 == 0xFF {
|
if b1 == 0xFF {
|
||||||
commands.push(StitchCommand::End);
|
commands.push(StitchCommand::End);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color change
|
|
||||||
if b1 == 0xFE {
|
if b1 == 0xFE {
|
||||||
commands.push(StitchCommand::ColorChange);
|
commands.push(StitchCommand::ColorChange);
|
||||||
i += 2; // skip the 0xFE and the following byte (typically 0xB0)
|
i += 2;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse dx
|
// PEC encodes coordinates as (Y, X) — read first value as vertical,
|
||||||
let (dx, dx_flags, bytes_dx) = decode_coordinate(data, i)?;
|
// second as horizontal, then swap to (dx, dy) for screen coordinates.
|
||||||
i += bytes_dx;
|
let (val1, flags1, bytes1) = decode_coordinate(data, i)?;
|
||||||
|
i += bytes1;
|
||||||
|
|
||||||
// Parse dy
|
// Check for special bytes at second coordinate position — color change
|
||||||
let (dy, dy_flags, bytes_dy) = decode_coordinate(data, i)?;
|
// or end markers can appear between the two coordinates.
|
||||||
i += bytes_dy;
|
if i < data.len() && data[i] == 0xFF {
|
||||||
|
commands.push(StitchCommand::End);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if i < data.len() && data[i] == 0xFE {
|
||||||
|
commands.push(StitchCommand::ColorChange);
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let flags = dx_flags | dy_flags;
|
let (val2, flags2, bytes2) = decode_coordinate(data, i)?;
|
||||||
|
i += bytes2;
|
||||||
|
|
||||||
|
let flags = flags1 | flags2;
|
||||||
|
let dx = val2;
|
||||||
|
let dy = val1;
|
||||||
|
|
||||||
if flags & 0x20 != 0 {
|
if flags & 0x20 != 0 {
|
||||||
// Trim + jump
|
|
||||||
commands.push(StitchCommand::Trim);
|
commands.push(StitchCommand::Trim);
|
||||||
commands.push(StitchCommand::Jump { dx, dy });
|
commands.push(StitchCommand::Jump { dx, dy });
|
||||||
} else if flags & 0x10 != 0 {
|
} else if flags & 0x10 != 0 {
|
||||||
@@ -155,7 +115,6 @@ fn decode_coordinate(data: &[u8], pos: usize) -> Result<(i16, u8, usize), Error>
|
|||||||
let b = data[pos];
|
let b = data[pos];
|
||||||
|
|
||||||
if b & 0x80 != 0 {
|
if b & 0x80 != 0 {
|
||||||
// Extended 12-bit encoding (2 bytes)
|
|
||||||
if pos + 1 >= data.len() {
|
if pos + 1 >= data.len() {
|
||||||
return Err(Error::TooShort {
|
return Err(Error::TooShort {
|
||||||
expected: pos + 2,
|
expected: pos + 2,
|
||||||
@@ -163,7 +122,7 @@ fn decode_coordinate(data: &[u8], pos: usize) -> Result<(i16, u8, usize), Error>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let b2 = data[pos + 1];
|
let b2 = data[pos + 1];
|
||||||
let flags = b & 0x70; // bits 6-4 for jump/trim flags
|
let flags = b & 0x70;
|
||||||
let raw = (((b & 0x0F) as u16) << 8) | (b2 as u16);
|
let raw = (((b & 0x0F) as u16) << 8) | (b2 as u16);
|
||||||
let value = if raw > 0x7FF {
|
let value = if raw > 0x7FF {
|
||||||
raw as i16 - 0x1000
|
raw as i16 - 0x1000
|
||||||
@@ -172,7 +131,6 @@ fn decode_coordinate(data: &[u8], pos: usize) -> Result<(i16, u8, usize), Error>
|
|||||||
};
|
};
|
||||||
Ok((value, flags, 2))
|
Ok((value, flags, 2))
|
||||||
} else {
|
} else {
|
||||||
// 7-bit encoding (1 byte)
|
|
||||||
let value = if b > 0x3F { b as i16 - 0x80 } else { b as i16 };
|
let value = if b > 0x3F { b as i16 - 0x80 } else { b as i16 };
|
||||||
Ok((value, 0, 1))
|
Ok((value, 0, 1))
|
||||||
}
|
}
|
||||||
@@ -192,14 +150,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decode_simple_stitch() {
|
fn decode_simple_stitch() {
|
||||||
// dx=10 (0x0A), dy=20 (0x14), then end
|
// PEC stores (Y, X): first=10 → dy, second=20 → dx
|
||||||
let data = [0x0A, 0x14, 0xFF];
|
let data = [0x0A, 0x14, 0xFF];
|
||||||
let cmds = decode_stitches(&data).unwrap();
|
let cmds = decode_stitches(&data).unwrap();
|
||||||
assert_eq!(cmds.len(), 2);
|
assert_eq!(cmds.len(), 2);
|
||||||
match &cmds[0] {
|
match &cmds[0] {
|
||||||
StitchCommand::Stitch { dx, dy } => {
|
StitchCommand::Stitch { dx, dy } => {
|
||||||
assert_eq!(*dx, 10);
|
assert_eq!(*dx, 20);
|
||||||
assert_eq!(*dy, 20);
|
assert_eq!(*dy, 10);
|
||||||
}
|
}
|
||||||
_ => panic!("expected Stitch"),
|
_ => panic!("expected Stitch"),
|
||||||
}
|
}
|
||||||
@@ -207,13 +165,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decode_negative_7bit() {
|
fn decode_negative_7bit() {
|
||||||
// dx=0x50 (80 decimal, > 0x3F so value = 80-128 = -48), dy=0x60 (96-128=-32), end
|
// PEC stores (Y, X): first=0x50(-48) → dy, second=0x60(-32) → dx
|
||||||
let data = [0x50, 0x60, 0xFF];
|
let data = [0x50, 0x60, 0xFF];
|
||||||
let cmds = decode_stitches(&data).unwrap();
|
let cmds = decode_stitches(&data).unwrap();
|
||||||
match &cmds[0] {
|
match &cmds[0] {
|
||||||
StitchCommand::Stitch { dx, dy } => {
|
StitchCommand::Stitch { dx, dy } => {
|
||||||
assert_eq!(*dx, -48);
|
assert_eq!(*dx, -32);
|
||||||
assert_eq!(*dy, -32);
|
assert_eq!(*dy, -48);
|
||||||
}
|
}
|
||||||
_ => panic!("expected Stitch"),
|
_ => panic!("expected Stitch"),
|
||||||
}
|
}
|
||||||
@@ -221,29 +179,27 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decode_color_change() {
|
fn decode_color_change() {
|
||||||
|
// PEC stores (Y, X): first=10 → dy, second=20 → dx
|
||||||
let data = [0xFE, 0xB0, 0x0A, 0x14, 0xFF];
|
let data = [0xFE, 0xB0, 0x0A, 0x14, 0xFF];
|
||||||
let cmds = decode_stitches(&data).unwrap();
|
let cmds = decode_stitches(&data).unwrap();
|
||||||
assert!(matches!(cmds[0], StitchCommand::ColorChange));
|
assert!(matches!(cmds[0], StitchCommand::ColorChange));
|
||||||
assert!(matches!(cmds[1], StitchCommand::Stitch { dx: 10, dy: 20 }));
|
assert!(matches!(cmds[1], StitchCommand::Stitch { dx: 20, dy: 10 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decode_extended_12bit() {
|
fn decode_extended_12bit() {
|
||||||
// Extended dx: high bit set, flags=0x10 (jump), value = 0x100 = 256
|
// PEC stores (Y, X): first=0x91,0x00(256 with jump flag) → dy, second=0x05(5) → dx
|
||||||
// byte1 = 0x80 | 0x10 | 0x01 = 0x91, byte2 = 0x00 -> raw = 0x100 = 256
|
|
||||||
// dy: simple 0x05 = 5
|
|
||||||
let data = [0x91, 0x00, 0x05, 0xFF];
|
let data = [0x91, 0x00, 0x05, 0xFF];
|
||||||
let cmds = decode_stitches(&data).unwrap();
|
let cmds = decode_stitches(&data).unwrap();
|
||||||
assert!(matches!(cmds[0], StitchCommand::Jump { dx: 256, dy: 5 }));
|
assert!(matches!(cmds[0], StitchCommand::Jump { dx: 5, dy: 256 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decode_trim_jump() {
|
fn decode_trim_jump() {
|
||||||
// dx with trim flag (0x20): byte1 = 0x80 | 0x20 | 0x00 = 0xA0, byte2 = 0x0A -> raw=10
|
// PEC stores (Y, X): first=0xA0,0x0A(10 with trim flag) → dy, second=0x05(5) → dx
|
||||||
// dy: simple 0x05
|
|
||||||
let data = [0xA0, 0x0A, 0x05, 0xFF];
|
let data = [0xA0, 0x0A, 0x05, 0xFF];
|
||||||
let cmds = decode_stitches(&data).unwrap();
|
let cmds = decode_stitches(&data).unwrap();
|
||||||
assert!(matches!(cmds[0], StitchCommand::Trim));
|
assert!(matches!(cmds[0], StitchCommand::Trim));
|
||||||
assert!(matches!(cmds[1], StitchCommand::Jump { dx: 10, dy: 5 }));
|
assert!(matches!(cmds[1], StitchCommand::Jump { dx: 5, dy: 10 }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use tiny_skia::{LineCap, Paint, PathBuilder, Pixmap, Stroke, Transform};
|
use tiny_skia::{LineCap, Paint, PathBuilder, Pixmap, Stroke, Transform};
|
||||||
|
|
||||||
use crate::pes::{Error, ResolvedDesign};
|
use crate::error::Error;
|
||||||
|
use crate::types::ResolvedDesign;
|
||||||
|
|
||||||
/// Render a resolved embroidery design to a PNG image of the given size.
|
/// Render a resolved embroidery design to a PNG image of the given size.
|
||||||
pub fn render_thumbnail(design: &ResolvedDesign, size: u32) -> Result<Vec<u8>, Error> {
|
pub fn render_thumbnail(design: &ResolvedDesign, size: u32) -> Result<Vec<u8>, Error> {
|
||||||
@@ -23,7 +24,6 @@ pub fn render_thumbnail(design: &ResolvedDesign, size: u32) -> Result<Vec<u8>, E
|
|||||||
|
|
||||||
let line_width = (scale * 0.3).max(1.0);
|
let line_width = (scale * 0.3).max(1.0);
|
||||||
|
|
||||||
// Group segments by color index and draw each group
|
|
||||||
let max_color = design
|
let max_color = design
|
||||||
.segments
|
.segments
|
||||||
.iter()
|
.iter()
|
||||||
@@ -82,7 +82,6 @@ fn encode_png(pixmap: &Pixmap) -> Result<Vec<u8>, Error> {
|
|||||||
let height = pixmap.height();
|
let height = pixmap.height();
|
||||||
let src = pixmap.data();
|
let src = pixmap.data();
|
||||||
|
|
||||||
// Unpremultiply alpha
|
|
||||||
let mut data = Vec::with_capacity(src.len());
|
let mut data = Vec::with_capacity(src.len());
|
||||||
for chunk in src.chunks_exact(4) {
|
for chunk in src.chunks_exact(4) {
|
||||||
let (r, g, b, a) = (chunk[0], chunk[1], chunk[2], chunk[3]);
|
let (r, g, b, a) = (chunk[0], chunk[1], chunk[2], chunk[3]);
|
||||||
|
|||||||
75
rustitch/src/resolve.rs
Normal file
75
rustitch/src/resolve.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
use crate::error::Error;
|
||||||
|
use crate::types::{BoundingBox, ResolvedDesign, StitchCommand, StitchSegment};
|
||||||
|
|
||||||
|
/// Convert parsed stitch commands into renderable segments with absolute coordinates.
|
||||||
|
pub fn resolve(
|
||||||
|
commands: &[StitchCommand],
|
||||||
|
colors: Vec<(u8, u8, u8)>,
|
||||||
|
) -> Result<ResolvedDesign, Error> {
|
||||||
|
let mut segments = Vec::new();
|
||||||
|
let mut x: f32 = 0.0;
|
||||||
|
let mut y: f32 = 0.0;
|
||||||
|
let mut color_idx: usize = 0;
|
||||||
|
let mut pen_down = true;
|
||||||
|
|
||||||
|
for cmd in commands {
|
||||||
|
match cmd {
|
||||||
|
StitchCommand::Stitch { dx, dy } => {
|
||||||
|
let nx = x + *dx as f32;
|
||||||
|
let ny = y + *dy as f32;
|
||||||
|
if pen_down {
|
||||||
|
segments.push(StitchSegment {
|
||||||
|
x0: x,
|
||||||
|
y0: y,
|
||||||
|
x1: nx,
|
||||||
|
y1: ny,
|
||||||
|
color_index: color_idx,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
x = nx;
|
||||||
|
y = ny;
|
||||||
|
pen_down = true;
|
||||||
|
}
|
||||||
|
StitchCommand::Jump { dx, dy } => {
|
||||||
|
x += *dx as f32;
|
||||||
|
y += *dy as f32;
|
||||||
|
pen_down = false;
|
||||||
|
}
|
||||||
|
StitchCommand::Trim => {
|
||||||
|
pen_down = false;
|
||||||
|
}
|
||||||
|
StitchCommand::ColorChange => {
|
||||||
|
color_idx += 1;
|
||||||
|
pen_down = false;
|
||||||
|
}
|
||||||
|
StitchCommand::End => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if segments.is_empty() {
|
||||||
|
return Err(Error::EmptyDesign);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut min_x = f32::MAX;
|
||||||
|
let mut max_x = f32::MIN;
|
||||||
|
let mut min_y = f32::MAX;
|
||||||
|
let mut max_y = f32::MIN;
|
||||||
|
|
||||||
|
for seg in &segments {
|
||||||
|
min_x = min_x.min(seg.x0).min(seg.x1);
|
||||||
|
max_x = max_x.max(seg.x0).max(seg.x1);
|
||||||
|
min_y = min_y.min(seg.y0).min(seg.y1);
|
||||||
|
max_y = max_y.max(seg.y0).max(seg.y1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ResolvedDesign {
|
||||||
|
segments,
|
||||||
|
colors,
|
||||||
|
bounds: BoundingBox {
|
||||||
|
min_x,
|
||||||
|
max_x,
|
||||||
|
min_y,
|
||||||
|
max_y,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
29
rustitch/src/types.rs
Normal file
29
rustitch/src/types.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum StitchCommand {
|
||||||
|
Stitch { dx: i16, dy: i16 },
|
||||||
|
Jump { dx: i16, dy: i16 },
|
||||||
|
Trim,
|
||||||
|
ColorChange,
|
||||||
|
End,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StitchSegment {
|
||||||
|
pub x0: f32,
|
||||||
|
pub y0: f32,
|
||||||
|
pub x1: f32,
|
||||||
|
pub y1: f32,
|
||||||
|
pub color_index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BoundingBox {
|
||||||
|
pub min_x: f32,
|
||||||
|
pub max_x: f32,
|
||||||
|
pub min_y: f32,
|
||||||
|
pub max_y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ResolvedDesign {
|
||||||
|
pub segments: Vec<StitchSegment>,
|
||||||
|
pub colors: Vec<(u8, u8, u8)>,
|
||||||
|
pub bounds: BoundingBox,
|
||||||
|
}
|
||||||
346
rustitch/src/vp3.rs
Normal file
346
rustitch/src/vp3.rs
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
use crate::error::Error;
|
||||||
|
use crate::types::{ResolvedDesign, StitchCommand};
|
||||||
|
|
||||||
|
/// Parse a VP3 (Pfaff/Viking) file from raw bytes.
|
||||||
|
///
|
||||||
|
/// VP3 is a hierarchical format:
|
||||||
|
/// - File header with "%vsm%" magic (or similar signature)
|
||||||
|
/// - Design metadata section
|
||||||
|
/// - One or more color sections, each containing:
|
||||||
|
/// - Thread color (RGB)
|
||||||
|
/// - Stitch data block
|
||||||
|
///
|
||||||
|
/// Byte order: mixed, but length-prefixed strings and section sizes use big-endian.
|
||||||
|
/// Stitch encoding: variable-length (1 byte for small moves, 3 bytes for large).
|
||||||
|
type ParseResult = Result<(Vec<StitchCommand>, Vec<(u8, u8, u8)>), Error>;
|
||||||
|
|
||||||
|
pub fn parse(data: &[u8]) -> ParseResult {
|
||||||
|
if data.len() < 20 {
|
||||||
|
return Err(Error::TooShort {
|
||||||
|
expected: 20,
|
||||||
|
actual: data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut reader = Reader::new(data);
|
||||||
|
|
||||||
|
// VP3 files start with a magic/signature section
|
||||||
|
// Skip the initial header to find the design data
|
||||||
|
// The format starts with a variable-length producer string, then design sections
|
||||||
|
skip_vp3_header(&mut reader)?;
|
||||||
|
|
||||||
|
let mut colors = Vec::new();
|
||||||
|
let mut commands = Vec::new();
|
||||||
|
|
||||||
|
// Read color sections
|
||||||
|
let color_section_count = reader.read_u16_be()?;
|
||||||
|
|
||||||
|
for _ in 0..color_section_count {
|
||||||
|
if reader.remaining() < 4 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let color = read_color_section(&mut reader, &mut commands)?;
|
||||||
|
colors.push(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
if commands.is_empty() {
|
||||||
|
return Err(Error::NoStitchData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matches!(commands.last(), Some(StitchCommand::End)) {
|
||||||
|
commands.push(StitchCommand::End);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((commands, colors))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a VP3 file and resolve to a renderable design.
|
||||||
|
pub fn parse_and_resolve(data: &[u8]) -> Result<ResolvedDesign, Error> {
|
||||||
|
let (commands, colors) = parse(data)?;
|
||||||
|
crate::resolve::resolve(&commands, colors)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip_vp3_header(reader: &mut Reader) -> Result<(), Error> {
|
||||||
|
// Skip magic/producer string at start
|
||||||
|
// VP3 starts with a string like "%vsm%" or similar, followed by metadata
|
||||||
|
// Find the start of actual design data by looking for patterns
|
||||||
|
|
||||||
|
// Read and skip the initial producer/signature string
|
||||||
|
skip_string(reader)?;
|
||||||
|
|
||||||
|
// Skip design metadata: dimensions and other header fields
|
||||||
|
// After the producer string there are typically coordinate fields (i32 BE)
|
||||||
|
// and additional metadata strings
|
||||||
|
if reader.remaining() < 38 {
|
||||||
|
return Err(Error::TooShort {
|
||||||
|
expected: 38,
|
||||||
|
actual: reader.remaining(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip: design size fields (4x i32 = 16 bytes) + unknown bytes (4) + unknown (4)
|
||||||
|
reader.skip(24)?;
|
||||||
|
|
||||||
|
// Skip design notes/comments strings
|
||||||
|
skip_string(reader)?; // x-offset or notes
|
||||||
|
skip_string(reader)?; // y-offset or notes
|
||||||
|
|
||||||
|
// Skip remaining header fields before color sections
|
||||||
|
// There are typically 6 more bytes of header data
|
||||||
|
if reader.remaining() >= 6 {
|
||||||
|
reader.skip(6)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip another potential string
|
||||||
|
if reader.remaining() >= 2 {
|
||||||
|
let peek = reader.peek_u16_be();
|
||||||
|
if let Ok(len) = peek
|
||||||
|
&& len < 1000
|
||||||
|
&& (len as usize) + 2 <= reader.remaining()
|
||||||
|
{
|
||||||
|
skip_string(reader)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_color_section(
|
||||||
|
reader: &mut Reader,
|
||||||
|
commands: &mut Vec<StitchCommand>,
|
||||||
|
) -> Result<(u8, u8, u8), Error> {
|
||||||
|
// Color change between sections (except first)
|
||||||
|
if !commands.is_empty() {
|
||||||
|
commands.push(StitchCommand::ColorChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip section start marker/offset bytes
|
||||||
|
// Color sections start with coordinate offset data
|
||||||
|
if reader.remaining() < 12 {
|
||||||
|
return Err(Error::TooShort {
|
||||||
|
expected: 12,
|
||||||
|
actual: reader.remaining(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip section offset/position data (2x i32 = 8 bytes)
|
||||||
|
reader.skip(8)?;
|
||||||
|
|
||||||
|
// Skip thread info string
|
||||||
|
skip_string(reader)?;
|
||||||
|
|
||||||
|
// Read thread color: RGB (3 bytes)
|
||||||
|
if reader.remaining() < 3 {
|
||||||
|
return Err(Error::TooShort {
|
||||||
|
expected: 3,
|
||||||
|
actual: reader.remaining(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let r = reader.read_u8()?;
|
||||||
|
let g = reader.read_u8()?;
|
||||||
|
let b = reader.read_u8()?;
|
||||||
|
|
||||||
|
// Skip remaining thread metadata (thread type, weight, catalog info)
|
||||||
|
// Skip to stitch data: look for the stitch count field
|
||||||
|
skip_string(reader)?; // thread catalog number
|
||||||
|
skip_string(reader)?; // thread description
|
||||||
|
|
||||||
|
// Skip thread brand and additional metadata
|
||||||
|
// There's typically some padding/unknown bytes here
|
||||||
|
if reader.remaining() >= 18 {
|
||||||
|
reader.skip(18)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read stitch data
|
||||||
|
let stitch_byte_count = if reader.remaining() >= 4 {
|
||||||
|
reader.read_u32_be()? as usize
|
||||||
|
} else {
|
||||||
|
return Ok((r, g, b));
|
||||||
|
};
|
||||||
|
|
||||||
|
if stitch_byte_count == 0 || stitch_byte_count > reader.remaining() {
|
||||||
|
// Skip what we can
|
||||||
|
return Ok((r, g, b));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stitch_end = reader.pos + stitch_byte_count;
|
||||||
|
decode_vp3_stitches(reader, commands, stitch_end);
|
||||||
|
|
||||||
|
// Ensure we're at the right position after stitch data
|
||||||
|
if reader.pos < stitch_end {
|
||||||
|
reader.pos = stitch_end;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((r, g, b))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_vp3_stitches(reader: &mut Reader, commands: &mut Vec<StitchCommand>, end: usize) {
|
||||||
|
while reader.pos < end && reader.remaining() >= 2 {
|
||||||
|
let b1 = reader.data[reader.pos];
|
||||||
|
|
||||||
|
// Check for 3-byte extended coordinates (high bit set on first byte)
|
||||||
|
if b1 & 0x80 != 0 {
|
||||||
|
if reader.remaining() < 4 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let dx = read_i16_be(reader.data, reader.pos);
|
||||||
|
reader.pos += 2;
|
||||||
|
let dy = read_i16_be(reader.data, reader.pos);
|
||||||
|
reader.pos += 2;
|
||||||
|
|
||||||
|
// Large moves are jumps
|
||||||
|
commands.push(StitchCommand::Jump { dx, dy: -dy });
|
||||||
|
} else {
|
||||||
|
// 1-byte per coordinate
|
||||||
|
let dx = reader.data[reader.pos] as i8 as i16;
|
||||||
|
reader.pos += 1;
|
||||||
|
if reader.pos >= end {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let dy = -(reader.data[reader.pos] as i8 as i16);
|
||||||
|
reader.pos += 1;
|
||||||
|
|
||||||
|
if dx == 0 && dy == 0 {
|
||||||
|
// Zero-length stitch can be a trim marker
|
||||||
|
commands.push(StitchCommand::Trim);
|
||||||
|
} else {
|
||||||
|
commands.push(StitchCommand::Stitch { dx, dy });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip_string(reader: &mut Reader) -> Result<(), Error> {
|
||||||
|
if reader.remaining() < 2 {
|
||||||
|
return Err(Error::TooShort {
|
||||||
|
expected: reader.pos + 2,
|
||||||
|
actual: reader.data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let len = reader.read_u16_be()? as usize;
|
||||||
|
if len > reader.remaining() {
|
||||||
|
return Err(Error::InvalidHeader(format!(
|
||||||
|
"string length {} exceeds remaining data {}",
|
||||||
|
len,
|
||||||
|
reader.remaining()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
reader.skip(len)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_i16_be(data: &[u8], pos: usize) -> i16 {
|
||||||
|
i16::from_be_bytes([data[pos], data[pos + 1]])
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Reader<'a> {
|
||||||
|
data: &'a [u8],
|
||||||
|
pos: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Reader<'a> {
|
||||||
|
fn new(data: &'a [u8]) -> Self {
|
||||||
|
Self { data, pos: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remaining(&self) -> usize {
|
||||||
|
self.data.len().saturating_sub(self.pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u8(&mut self) -> Result<u8, Error> {
|
||||||
|
if self.pos >= self.data.len() {
|
||||||
|
return Err(Error::TooShort {
|
||||||
|
expected: self.pos + 1,
|
||||||
|
actual: self.data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let v = self.data[self.pos];
|
||||||
|
self.pos += 1;
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u16_be(&mut self) -> Result<u16, Error> {
|
||||||
|
if self.pos + 2 > self.data.len() {
|
||||||
|
return Err(Error::TooShort {
|
||||||
|
expected: self.pos + 2,
|
||||||
|
actual: self.data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let v = u16::from_be_bytes([self.data[self.pos], self.data[self.pos + 1]]);
|
||||||
|
self.pos += 2;
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peek_u16_be(&self) -> Result<u16, Error> {
|
||||||
|
if self.pos + 2 > self.data.len() {
|
||||||
|
return Err(Error::TooShort {
|
||||||
|
expected: self.pos + 2,
|
||||||
|
actual: self.data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(u16::from_be_bytes([
|
||||||
|
self.data[self.pos],
|
||||||
|
self.data[self.pos + 1],
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32_be(&mut self) -> Result<u32, Error> {
|
||||||
|
if self.pos + 4 > self.data.len() {
|
||||||
|
return Err(Error::TooShort {
|
||||||
|
expected: self.pos + 4,
|
||||||
|
actual: self.data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let v = u32::from_be_bytes([
|
||||||
|
self.data[self.pos],
|
||||||
|
self.data[self.pos + 1],
|
||||||
|
self.data[self.pos + 2],
|
||||||
|
self.data[self.pos + 3],
|
||||||
|
]);
|
||||||
|
self.pos += 4;
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip(&mut self, n: usize) -> Result<(), Error> {
|
||||||
|
if self.pos + n > self.data.len() {
|
||||||
|
return Err(Error::TooShort {
|
||||||
|
expected: self.pos + n,
|
||||||
|
actual: self.data.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.pos += n;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_small_stitch() {
|
||||||
|
let mut commands = Vec::new();
|
||||||
|
// Two small stitches: (10, -20) and (5, -3)
|
||||||
|
let data = [0x0A, 0x14, 0x05, 0x03];
|
||||||
|
let mut reader = Reader::new(&data);
|
||||||
|
decode_vp3_stitches(&mut reader, &mut commands, data.len());
|
||||||
|
assert_eq!(commands.len(), 2);
|
||||||
|
assert!(matches!(
|
||||||
|
commands[0],
|
||||||
|
StitchCommand::Stitch { dx: 10, dy: -20 }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_large_jump() {
|
||||||
|
let mut commands = Vec::new();
|
||||||
|
// Large move: high bit set, 2-byte BE dx and dy
|
||||||
|
// dx = 0x8100 = -32512 as i16, dy = 0x0100 = 256
|
||||||
|
let data = [0x81, 0x00, 0x01, 0x00];
|
||||||
|
let mut reader = Reader::new(&data);
|
||||||
|
decode_vp3_stitches(&mut reader, &mut commands, data.len());
|
||||||
|
assert_eq!(commands.len(), 1);
|
||||||
|
assert!(matches!(commands[0], StitchCommand::Jump { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
rustitch/tests/fixtures/0.3x1 INCHES.EXP
vendored
Normal file
BIN
rustitch/tests/fixtures/0.3x1 INCHES.EXP
vendored
Normal file
Binary file not shown.
BIN
rustitch/tests/fixtures/0.3x1 INCHES.JEF
vendored
Normal file
BIN
rustitch/tests/fixtures/0.3x1 INCHES.JEF
vendored
Normal file
Binary file not shown.
BIN
rustitch/tests/fixtures/0.3x1 INCHES.PES
vendored
Normal file
BIN
rustitch/tests/fixtures/0.3x1 INCHES.PES
vendored
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "stitch-peek"
|
name = "stitch-peek"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Nautilus thumbnail generator for PES embroidery files"
|
description = "Nautilus thumbnail generator for PES embroidery files"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -11,6 +11,6 @@ categories = ["graphics", "command-line-utilities"]
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rustitch = { version = "0.1.1", path = "../rustitch" }
|
rustitch = { version = "0.2", path = "../rustitch" }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
[](https://crates.io/crates/stitch-peek)
|
[](https://crates.io/crates/stitch-peek)
|
||||||
[](../LICENSE)
|
[](../LICENSE)
|
||||||
|
|
||||||
A CLI tool and **Nautilus/GNOME thumbnailer** for PES embroidery files. Generates PNG previews of embroidery designs directly in your file manager.
|
A CLI tool and **Nautilus/GNOME thumbnailer** for embroidery files. Generates PNG previews of embroidery designs directly in your file manager.
|
||||||
|
|
||||||
Part of the [stitch-peek-rs](https://git.narl.io/nvrl/stitch-peek-rs) project. Uses [rustitch](https://crates.io/crates/rustitch) for PES parsing and rendering.
|
Supported formats: **PES**, **DST**, **EXP**, **JEF**, **VP3**
|
||||||
|
|
||||||
|
Part of the [stitch-peek-rs](https://git.narl.io/nvrl/stitch-peek-rs) project. Uses [rustitch](https://crates.io/crates/rustitch) for parsing and rendering.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -49,23 +51,27 @@ nautilus -q
|
|||||||
|
|
||||||
### As a thumbnailer
|
### As a thumbnailer
|
||||||
|
|
||||||
Once installed with the `.thumbnailer` file in place, Nautilus automatically generates thumbnails for `.pes` files. No manual action needed.
|
Once installed with the `.thumbnailer` file in place, Nautilus automatically generates thumbnails for `.pes`, `.dst`, `.exp`, `.jef`, and `.vp3` files. No manual action needed.
|
||||||
|
|
||||||
### Standalone CLI
|
### Standalone CLI
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
stitch-peek -i design.pes -o preview.png -s 256
|
stitch-peek -i design.pes -o preview.png -s 256
|
||||||
|
stitch-peek -i pattern.dst -o preview.png
|
||||||
|
stitch-peek -i motif.jef -o preview.png -s 512
|
||||||
```
|
```
|
||||||
|
|
||||||
| Flag | Description | Default |
|
| Flag | Description | Default |
|
||||||
|------|-------------|---------|
|
|------|-------------|---------|
|
||||||
| `-i` | Input PES file | required |
|
| `-i` | Input embroidery file | required |
|
||||||
| `-o` | Output PNG path | required |
|
| `-o` | Output PNG path | required |
|
||||||
| `-s` | Thumbnail size (pixels) | 128 |
|
| `-s` | Thumbnail size (pixels) | 128 |
|
||||||
|
|
||||||
|
The format is detected automatically from the file extension.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
The tool follows the [freedesktop thumbnail specification](https://specifications.freedesktop.org/thumbnail/latest/). Nautilus calls it with an input file, output path, and requested size. It parses the PES file, renders the stitch pattern as anti-aliased colored lines on a transparent background, and writes a PNG.
|
The tool follows the [freedesktop thumbnail specification](https://specifications.freedesktop.org/thumbnail/latest/). Nautilus calls it with an input file, output path, and requested size. It detects the embroidery format, parses the stitch data, renders the pattern as anti-aliased colored lines on a transparent background, and writes a PNG.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ use clap::Parser;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "stitch-peek", about = "PES embroidery file thumbnailer")]
|
#[command(
|
||||||
|
name = "stitch-peek",
|
||||||
|
about = "Embroidery file thumbnailer (PES, DST, EXP, JEF, VP3)"
|
||||||
|
)]
|
||||||
struct Args {
|
struct Args {
|
||||||
/// Input PES file path
|
/// Input embroidery file path
|
||||||
#[arg(short = 'i', long = "input")]
|
#[arg(short = 'i', long = "input")]
|
||||||
input: std::path::PathBuf,
|
input: std::path::PathBuf,
|
||||||
|
|
||||||
@@ -21,11 +24,14 @@ struct Args {
|
|||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
|
let format = rustitch::format::detect_from_extension(&args.input)
|
||||||
|
.with_context(|| format!("unsupported file extension: {}", args.input.display()))?;
|
||||||
|
|
||||||
let data = fs::read(&args.input)
|
let data = fs::read(&args.input)
|
||||||
.with_context(|| format!("failed to read {}", args.input.display()))?;
|
.with_context(|| format!("failed to read {}", args.input.display()))?;
|
||||||
|
|
||||||
let png =
|
let png = rustitch::thumbnail_format(&data, args.size, format)
|
||||||
rustitch::thumbnail(&data, args.size).with_context(|| "failed to generate thumbnail")?;
|
.with_context(|| "failed to generate thumbnail")?;
|
||||||
|
|
||||||
fs::write(&args.output, &png)
|
fs::write(&args.output, &png)
|
||||||
.with_context(|| format!("failed to write {}", args.output.display()))?;
|
.with_context(|| format!("failed to write {}", args.output.display()))?;
|
||||||
|
|||||||
Reference in New Issue
Block a user