Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 473da90b01 | |||
| 08aafaa3c3 | |||
| 156365fa8f | |||
| 7c8ecda29a | |||
| 69d0269270 | |||
| 40ccf9ded4 | |||
| 512f49ab38 | |||
| fc2abb9524 | |||
| 7ae9abd338 | |||
| 0a6448c68a | |||
| ea74aaa666 | |||
| ad8738e6dd | |||
| 4e05428d83 | |||
| de363276b2 | |||
| 1f952b8e03 |
Generated
+10
-10
@@ -78,9 +78,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "2.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytemuck"
|
name = "bytemuck"
|
||||||
@@ -204,9 +204,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "png"
|
name = "png"
|
||||||
version = "0.17.16"
|
version = "0.18.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
|
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
@@ -235,7 +235,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustitch"
|
name = "rustitch"
|
||||||
version = "0.1.0"
|
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.0"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -302,9 +302,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tiny-skia"
|
name = "tiny-skia"
|
||||||
version = "0.11.4"
|
version = "0.12.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayref",
|
"arrayref",
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
@@ -317,9 +317,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tiny-skia-path"
|
name = "tiny-skia-path"
|
||||||
version = "0.11.4"
|
version = "0.12.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayref",
|
"arrayref",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
# stitch-peek
|
# 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:
|
Built as two crates:
|
||||||
|
|
||||||
- **rustitch** -- library for parsing PES files and rendering stitch data to images
|
| Crate | Description |
|
||||||
- **stitch-peek** -- CLI thumbnailer that integrates with GNOME/Nautilus via the freedesktop thumbnail spec
|
|-------|-------------|
|
||||||
|
| [**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 -->
|
<!-- TODO: Add screenshot of Nautilus showing PES thumbnails -->
|
||||||
|
|
||||||
@@ -13,7 +23,7 @@ Built as two crates:
|
|||||||
|
|
||||||
### From .deb (Debian/Ubuntu)
|
### From .deb (Debian/Ubuntu)
|
||||||
|
|
||||||
Download the latest `.deb` from the [Releases](../../releases) page:
|
Download the latest `.deb` from the [Releases](https://git.narl.io/nvrl/stitch-peek-rs/releases) page:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo dpkg -i stitch-peek_*_amd64.deb
|
sudo dpkg -i stitch-peek_*_amd64.deb
|
||||||
@@ -25,12 +35,27 @@ This installs the binary, thumbnailer entry, and MIME type definition. Restart N
|
|||||||
nautilus -q
|
nautilus -q
|
||||||
```
|
```
|
||||||
|
|
||||||
### From source
|
### From crates.io
|
||||||
|
|
||||||
Requires Rust 1.70+.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/YOUR_USER/stitch-peek-rs.git
|
cargo install stitch-peek
|
||||||
|
```
|
||||||
|
|
||||||
|
Then install the data files:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -Dm644 data/stitch-peek.thumbnailer /usr/share/thumbnailers/stitch-peek.thumbnailer
|
||||||
|
sudo install -Dm644 data/pes.xml /usr/share/mime/packages/pes.xml
|
||||||
|
sudo update-mime-database /usr/share/mime
|
||||||
|
nautilus -q
|
||||||
|
```
|
||||||
|
|
||||||
|
### From source
|
||||||
|
|
||||||
|
Requires Rust 1.85+.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://git.narl.io/nvrl/stitch-peek-rs.git
|
||||||
cd stitch-peek-rs
|
cd stitch-peek-rs
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
||||||
@@ -45,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
|
||||||
|
|
||||||
@@ -53,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 |
|
||||||
|
|
||||||
@@ -67,26 +93,30 @@ Add `rustitch` to your project:
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rustitch = { git = "https://github.com/YOUR_USER/stitch-peek-rs.git" }
|
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)?;
|
||||||
```
|
```
|
||||||
|
|
||||||
## How it works
|
See the [rustitch README](rustitch/README.md) for more API examples.
|
||||||
|
|
||||||
1. **Parse** the PES binary format -- extract the PEC section containing stitch commands and thread color indices
|
|
||||||
2. **Decode** the stitch byte stream into movement commands (stitches, jumps, trims, color changes)
|
|
||||||
3. **Resolve** relative movements into absolute coordinates grouped by thread color
|
|
||||||
4. **Render** anti-aliased line segments onto a transparent canvas using [tiny-skia](https://github.com/nickel-org/tiny-skia), scaled to fit the requested thumbnail size
|
|
||||||
5. **Encode** the result as a PNG image
|
|
||||||
|
|
||||||
## Supported formats
|
## Supported formats
|
||||||
|
|
||||||
Currently supports **PES** (Brother PE-Design) embroidery files, versions 1 through 6. The PEC section -- which contains the actual 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
|
||||||
|
|
||||||
@@ -95,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
|
||||||
@@ -128,4 +164,4 @@ Pull requests must bump the version in `stitch-peek/Cargo.toml` -- CI will rejec
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the MIT License. See [LICENSE](LICENSE) for details.
|
[MIT](LICENSE)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+14
-4
@@ -1,9 +1,19 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustitch"
|
name = "rustitch"
|
||||||
version = "0.1.0"
|
version = "0.2.1"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
description = "PES embroidery file parser and thumbnail renderer"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://git.narl.io/nvrl/stitch-peek-rs"
|
||||||
|
authors = ["Nils Pukropp <nils@narl.io>"]
|
||||||
|
keywords = ["embroidery", "pes", "thumbnail", "stitch"]
|
||||||
|
categories = ["graphics", "parser-implementations"]
|
||||||
|
readme = "README.md"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tiny-skia = "0.11"
|
tiny-skia = "0.12"
|
||||||
png = "0.17"
|
png = "0.18"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
png = "0.18"
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# rustitch
|
||||||
|
|
||||||
|
[](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.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Add `rustitch` to your `Cargo.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
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 PES design
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use rustitch::pes::{self, StitchCommand};
|
||||||
|
|
||||||
|
let data = std::fs::read("design.pes")?;
|
||||||
|
let design = pes::parse(&data)?;
|
||||||
|
|
||||||
|
println!("PES version: {}", std::str::from_utf8(&design.header.version).unwrap());
|
||||||
|
println!("Label: {}", design.pec_header.label);
|
||||||
|
println!("Colors: {}", design.pec_header.color_count);
|
||||||
|
|
||||||
|
let stitch_count = design.commands.iter()
|
||||||
|
.filter(|c| matches!(c, StitchCommand::Stitch { .. }))
|
||||||
|
.count();
|
||||||
|
println!("Stitches: {stitch_count}");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resolve and render manually
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use rustitch::pes;
|
||||||
|
|
||||||
|
let data = std::fs::read("design.pes")?;
|
||||||
|
let design = pes::parse(&data)?;
|
||||||
|
let resolved = pes::resolve(&design)?;
|
||||||
|
|
||||||
|
println!("Segments: {}", resolved.segments.len());
|
||||||
|
println!("Bounding box: ({}, {}) to ({}, {})",
|
||||||
|
resolved.bounds.min_x, resolved.bounds.min_y,
|
||||||
|
resolved.bounds.max_x, resolved.bounds.max_y);
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
| 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. **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
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
];
|
||||||
+37
-5
@@ -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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
+7
-112
@@ -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
|
|
||||||
];
|
|
||||||
|
|||||||
+37
-81
@@ -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]);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,201 @@
|
|||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
use rustitch::pes::{self, StitchCommand};
|
||||||
|
|
||||||
|
const GNOME_BARFS: &[u8] = include_bytes!("fixtures/JLS_Gnome Barfs.PES");
|
||||||
|
const URSULA_ONE: &[u8] = include_bytes!("fixtures/UrsulaOne.PES");
|
||||||
|
const UFRONT: &[u8] = include_bytes!("fixtures/UFront.PES");
|
||||||
|
|
||||||
|
// -- Header parsing ----------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_header_gnome_barfs() {
|
||||||
|
let design = pes::parse(GNOME_BARFS).unwrap();
|
||||||
|
assert_eq!(&design.header.version, b"0100");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_header_ursula_one() {
|
||||||
|
let design = pes::parse(URSULA_ONE).unwrap();
|
||||||
|
assert_eq!(&design.header.version, b"0060");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_header_ufront() {
|
||||||
|
let design = pes::parse(UFRONT).unwrap();
|
||||||
|
assert_eq!(&design.header.version, b"0060");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- PEC color table ---------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gnome_barfs_has_colors() {
|
||||||
|
let design = pes::parse(GNOME_BARFS).unwrap();
|
||||||
|
assert!(
|
||||||
|
design.pec_header.color_count > 0,
|
||||||
|
"expected at least one color"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
design.pec_header.color_indices.len(),
|
||||||
|
design.pec_header.color_count as usize
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ursula_one_has_colors() {
|
||||||
|
let design = pes::parse(URSULA_ONE).unwrap();
|
||||||
|
assert!(design.pec_header.color_count > 0);
|
||||||
|
// All color indices should be valid palette entries (0..65)
|
||||||
|
for &idx in &design.pec_header.color_indices {
|
||||||
|
assert!(
|
||||||
|
(idx as usize) < pes::PEC_PALETTE.len(),
|
||||||
|
"color index {idx} out of palette range"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Stitch commands ---------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gnome_barfs_commands_end_properly() {
|
||||||
|
let design = pes::parse(GNOME_BARFS).unwrap();
|
||||||
|
let last = design.commands.last().unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(last, StitchCommand::End),
|
||||||
|
"expected End command as last, got {last:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ursula_one_commands_end_properly() {
|
||||||
|
let design = pes::parse(URSULA_ONE).unwrap();
|
||||||
|
let last = design.commands.last().unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(last, StitchCommand::End),
|
||||||
|
"expected End command as last, got {last:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ufront_has_stitches() {
|
||||||
|
let design = pes::parse(UFRONT).unwrap();
|
||||||
|
let stitch_count = design
|
||||||
|
.commands
|
||||||
|
.iter()
|
||||||
|
.filter(|c| matches!(c, StitchCommand::Stitch { .. }))
|
||||||
|
.count();
|
||||||
|
assert!(
|
||||||
|
stitch_count > 100,
|
||||||
|
"expected many stitches, got {stitch_count}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gnome_barfs_has_color_changes() {
|
||||||
|
let design = pes::parse(GNOME_BARFS).unwrap();
|
||||||
|
let changes = design
|
||||||
|
.commands
|
||||||
|
.iter()
|
||||||
|
.filter(|c| matches!(c, StitchCommand::ColorChange))
|
||||||
|
.count();
|
||||||
|
// Multi-color design should have at least one color change
|
||||||
|
assert!(changes > 0, "expected color changes, got none");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Resolve to segments -----------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_gnome_barfs() {
|
||||||
|
let design = pes::parse(GNOME_BARFS).unwrap();
|
||||||
|
let resolved = pes::resolve(&design).unwrap();
|
||||||
|
|
||||||
|
assert!(!resolved.segments.is_empty());
|
||||||
|
assert!(!resolved.colors.is_empty());
|
||||||
|
|
||||||
|
// Bounding box should be non-degenerate
|
||||||
|
assert!(resolved.bounds.max_x > resolved.bounds.min_x);
|
||||||
|
assert!(resolved.bounds.max_y > resolved.bounds.min_y);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_ursula_one() {
|
||||||
|
let design = pes::parse(URSULA_ONE).unwrap();
|
||||||
|
let resolved = pes::resolve(&design).unwrap();
|
||||||
|
|
||||||
|
assert!(!resolved.segments.is_empty());
|
||||||
|
assert!(resolved.bounds.max_x > resolved.bounds.min_x);
|
||||||
|
assert!(resolved.bounds.max_y > resolved.bounds.min_y);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_ufront() {
|
||||||
|
let design = pes::parse(UFRONT).unwrap();
|
||||||
|
let resolved = pes::resolve(&design).unwrap();
|
||||||
|
|
||||||
|
assert!(!resolved.segments.is_empty());
|
||||||
|
|
||||||
|
// All segment color indices should be within the resolved color list
|
||||||
|
let max_ci = resolved
|
||||||
|
.segments
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.color_index)
|
||||||
|
.max()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
max_ci < resolved.colors.len(),
|
||||||
|
"segment references color {max_ci} but only {} colors resolved",
|
||||||
|
resolved.colors.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Full thumbnail pipeline -------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thumbnail_gnome_barfs_128() {
|
||||||
|
let png = rustitch::thumbnail(GNOME_BARFS, 128).unwrap();
|
||||||
|
assert_png_dimensions(&png, 128, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thumbnail_ursula_one_256() {
|
||||||
|
let png = rustitch::thumbnail(URSULA_ONE, 256).unwrap();
|
||||||
|
assert_png_dimensions(&png, 256, 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thumbnail_ufront_64() {
|
||||||
|
let png = rustitch::thumbnail(UFRONT, 64).unwrap();
|
||||||
|
assert_png_dimensions(&png, 64, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thumbnail_gnome_barfs_not_blank() {
|
||||||
|
let png = rustitch::thumbnail(GNOME_BARFS, 128).unwrap();
|
||||||
|
let pixels = decode_png_pixels(&png);
|
||||||
|
// At least some pixels should have non-zero alpha (not fully transparent)
|
||||||
|
let opaque_count = pixels.chunks_exact(4).filter(|px| px[3] > 0).count();
|
||||||
|
assert!(
|
||||||
|
opaque_count > 100,
|
||||||
|
"thumbnail looks blank, only {opaque_count} non-transparent pixels"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
fn assert_png_dimensions(png_data: &[u8], expected_w: u32, expected_h: u32) {
|
||||||
|
let decoder = png::Decoder::new(Cursor::new(png_data));
|
||||||
|
let reader = decoder.read_info().unwrap();
|
||||||
|
let info = reader.info();
|
||||||
|
assert_eq!(info.width, expected_w, "unexpected PNG width");
|
||||||
|
assert_eq!(info.height, expected_h, "unexpected PNG height");
|
||||||
|
assert_eq!(info.color_type, png::ColorType::Rgba);
|
||||||
|
assert_eq!(info.bit_depth, png::BitDepth::Eight);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_png_pixels(png_data: &[u8]) -> Vec<u8> {
|
||||||
|
let decoder = png::Decoder::new(Cursor::new(png_data));
|
||||||
|
let mut reader = decoder.read_info().unwrap();
|
||||||
|
let mut buf = vec![0u8; reader.output_buffer_size().unwrap()];
|
||||||
|
reader.next_frame(&mut buf).unwrap();
|
||||||
|
buf
|
||||||
|
}
|
||||||
+10
-3
@@ -1,9 +1,16 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "stitch-peek"
|
name = "stitch-peek"
|
||||||
version = "0.1.0"
|
version = "0.1.3"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
description = "Nautilus thumbnail generator for PES embroidery files"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://git.narl.io/nvrl/stitch-peek-rs"
|
||||||
|
authors = ["Nils Pukropp <nils@narl.io>"]
|
||||||
|
keywords = ["embroidery", "pes", "thumbnailer", "nautilus"]
|
||||||
|
categories = ["graphics", "command-line-utilities"]
|
||||||
|
readme = "README.md"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rustitch = { path = "../rustitch" }
|
rustitch = { version = "0.2", path = "../rustitch" }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# stitch-peek
|
||||||
|
|
||||||
|
[](https://crates.io/crates/stitch-peek)
|
||||||
|
[](../LICENSE)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
### From .deb (Debian/Ubuntu)
|
||||||
|
|
||||||
|
Download the latest `.deb` from the [Releases](https://git.narl.io/nvrl/stitch-peek-rs/releases) page:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo dpkg -i stitch-peek_*_amd64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
### From crates.io
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo install stitch-peek
|
||||||
|
```
|
||||||
|
|
||||||
|
Then install the thumbnailer and MIME type files manually:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -Dm644 data/stitch-peek.thumbnailer /usr/share/thumbnailers/stitch-peek.thumbnailer
|
||||||
|
sudo install -Dm644 data/pes.xml /usr/share/mime/packages/pes.xml
|
||||||
|
sudo update-mime-database /usr/share/mime
|
||||||
|
```
|
||||||
|
|
||||||
|
### From source
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://git.narl.io/nvrl/stitch-peek-rs.git
|
||||||
|
cd stitch-peek-rs
|
||||||
|
cargo install --path stitch-peek
|
||||||
|
```
|
||||||
|
|
||||||
|
After installing, restart Nautilus to pick up the thumbnailer:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nautilus -q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### As a thumbnailer
|
||||||
|
|
||||||
|
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 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 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
|
||||||
|
|
||||||
|
MIT
|
||||||
+10
-4
@@ -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