diff --git a/Cargo.lock b/Cargo.lock index ff33dad..4d53b54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,7 +235,7 @@ dependencies = [ [[package]] name = "rustitch" -version = "0.1.2" +version = "0.2.0" dependencies = [ "png", "thiserror", @@ -250,7 +250,7 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "stitch-peek" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "clap", diff --git a/README.md b/README.md index 6fa27d2..739bf62 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,15 @@ [![docs.rs](https://img.shields.io/docsrs/rustitch)](https://docs.rs/rustitch) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -A Nautilus/GNOME thumbnailer for **PES embroidery files**. Browse your embroidery designs in the file manager with automatic thumbnail previews. +A Nautilus/GNOME thumbnailer for **embroidery files**. Browse your embroidery designs in the file manager with automatic thumbnail previews. + +Supported formats: **PES**, **DST**, **EXP**, **JEF**, **VP3** Built as two crates: | Crate | Description | |-------|-------------| -| [**rustitch**](rustitch/) | Library for parsing PES files and rendering stitch data to images | +| [**rustitch**](rustitch/) | Library for parsing embroidery files and rendering stitch data to images | | [**stitch-peek**](stitch-peek/) | CLI thumbnailer that integrates with GNOME/Nautilus | @@ -68,7 +70,7 @@ nautilus -q ### As a thumbnailer -Once installed, Nautilus will automatically generate thumbnails for `.pes` files. No manual action needed -- just open a folder containing PES files. +Once installed, Nautilus will automatically generate thumbnails for embroidery files. No manual action needed -- just open a folder containing `.pes`, `.dst`, `.exp`, `.jef`, or `.vp3` files. ### Standalone CLI @@ -76,11 +78,12 @@ Generate a thumbnail manually: ```sh stitch-peek -i design.pes -o preview.png -s 256 +stitch-peek -i pattern.dst -o preview.png -s 256 ``` | Flag | Description | Default | |------|-------------|---------| -| `-i` | Input PES file | required | +| `-i` | Input embroidery file | required | | `-o` | Output PNG path | required | | `-s` | Thumbnail size (pixels) | 128 | @@ -94,16 +97,26 @@ rustitch = "0.1" ``` ```rust +// PES (auto-detected) let pes_data = std::fs::read("design.pes")?; let png_bytes = rustitch::thumbnail(&pes_data, 256)?; -std::fs::write("preview.png", &png_bytes)?; + +// Any supported format (explicit) +let dst_data = std::fs::read("pattern.dst")?; +let png_bytes = rustitch::thumbnail_format(&dst_data, 256, rustitch::Format::Dst)?; ``` See the [rustitch README](rustitch/README.md) for more API examples. ## Supported formats -**PES** (Brother PE-Design) embroidery files, versions 1 through 10. The PEC section containing stitch data is consistent across versions. +| Format | Manufacturer | Colors | Notes | +|--------|-------------|--------|-------| +| **PES** | Brother PE-Design | Embedded (PEC palette) | Versions 1-10 | +| **DST** | Tajima | Default palette | 3-byte bit-packed records | +| **EXP** | Melco/Bernina | Default palette | Simple 2-byte encoding | +| **JEF** | Janome | Embedded (Janome palette) | Structured header with color table | +| **VP3** | Pfaff/Viking | Embedded (RGB) | Hierarchical format with per-section colors | ## Project structure @@ -112,16 +125,22 @@ stitch-peek-rs/ ├── rustitch/ # Library crate │ └── src/ │ ├── lib.rs # Public API -│ ├── pes/ # PES format parser -│ │ ├── header.rs # File header (#PES magic, version, PEC offset) -│ │ ├── pec.rs # PEC section (colors, stitch decoding) -│ │ └── palette.rs # Brother 65-color thread palette -│ └── render.rs # tiny-skia renderer +│ ├── types.rs # Shared types (StitchCommand, ResolvedDesign, ...) +│ ├── error.rs # Error types +│ ├── format.rs # Format detection (magic bytes, extension) +│ ├── palette.rs # Thread color palettes (PEC, default) +│ ├── resolve.rs # Stitch command to segment resolver +│ ├── render.rs # tiny-skia renderer +│ ├── pes/ # PES (Brother) parser +│ ├── dst/ # DST (Tajima) parser +│ ├── exp.rs # EXP (Melco) parser +│ ├── jef/ # JEF (Janome) parser +│ └── vp3.rs # VP3 (Pfaff/Viking) parser ├── stitch-peek/ # Binary crate (CLI thumbnailer) │ └── src/main.rs └── data/ ├── stitch-peek.thumbnailer # Nautilus integration - └── pes.xml # MIME type definition + └── pes.xml # MIME type definitions ``` ## Development diff --git a/data/pes.xml b/data/pes.xml index b188082..a9f3dae 100644 --- a/data/pes.xml +++ b/data/pes.xml @@ -7,4 +7,20 @@ + + DST Tajima embroidery file + + + + EXP Melco embroidery file + + + + JEF Janome embroidery file + + + + VP3 Pfaff embroidery file + + diff --git a/data/stitch-peek.thumbnailer b/data/stitch-peek.thumbnailer index 7b0c6c7..30548e0 100644 --- a/data/stitch-peek.thumbnailer +++ b/data/stitch-peek.thumbnailer @@ -1,4 +1,4 @@ [Thumbnailer Entry] TryExec=stitch-peek Exec=stitch-peek -i %i -o %o -s %s -MimeType=application/x-pes +MimeType=application/x-pes;application/x-dst;application/x-exp;application/x-jef;application/x-vp3 diff --git a/rustitch/Cargo.toml b/rustitch/Cargo.toml index ba80fc4..3eff2d1 100644 --- a/rustitch/Cargo.toml +++ b/rustitch/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustitch" -version = "0.1.2" +version = "0.2.1" edition = "2024" description = "PES embroidery file parser and thumbnail renderer" license = "MIT" diff --git a/rustitch/README.md b/rustitch/README.md index a549440..8158179 100644 --- a/rustitch/README.md +++ b/rustitch/README.md @@ -4,7 +4,9 @@ [![docs.rs](https://img.shields.io/docsrs/rustitch)](https://docs.rs/rustitch) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE) -A Rust library for parsing **PES embroidery files** and rendering stitch data to images. +A Rust library for parsing **embroidery files** and rendering stitch data to images. + +Supported formats: **PES**, **DST**, **EXP**, **JEF**, **VP3** Part of the [stitch-peek-rs](https://git.narl.io/nvrl/stitch-peek-rs) project. @@ -14,18 +16,24 @@ Add `rustitch` to your `Cargo.toml`: ```toml [dependencies] -rustitch = "0.1" +rustitch = "0.2" ``` ### Generate a thumbnail ```rust +// PES files (backward-compatible API) let pes_data = std::fs::read("design.pes")?; let png_bytes = rustitch::thumbnail(&pes_data, 256)?; std::fs::write("preview.png", &png_bytes)?; + +// Any supported format +let dst_data = std::fs::read("pattern.dst")?; +let png_bytes = rustitch::thumbnail_format(&dst_data, 256, rustitch::Format::Dst)?; +std::fs::write("preview.png", &png_bytes)?; ``` -### Parse and inspect a design +### Parse and inspect a PES design ```rust use rustitch::pes::{self, StitchCommand}; @@ -61,15 +69,39 @@ let png_bytes = rustitch::render_thumbnail(&resolved, 512)?; std::fs::write("large_preview.png", &png_bytes)?; ``` +### Format detection + +```rust +use rustitch::format::{self, Format}; +use std::path::Path; + +// Detect from file extension +let fmt = format::detect_from_extension(Path::new("design.jef")); +assert_eq!(fmt, Some(Format::Jef)); + +// Detect from file content (magic bytes) +let data = std::fs::read("design.pes")?; +let fmt = format::detect_from_bytes(&data); +assert_eq!(fmt, Some(Format::Pes)); +``` + ## Supported formats -**PES** (Brother PE-Design) embroidery files, versions 1 through 10. The PEC section containing stitch data is consistent across versions. +| Format | Manufacturer | Colors | Notes | +|--------|-------------|--------|-------| +| **PES** | Brother PE-Design | Embedded (PEC palette) | Versions 1-10 | +| **DST** | Tajima | Default palette | 3-byte bit-packed records | +| **EXP** | Melco/Bernina | Default palette | Simple 2-byte encoding | +| **JEF** | Janome | Embedded (Janome palette) | Structured header with color table | +| **VP3** | Pfaff/Viking | Embedded (RGB) | Hierarchical format with per-section colors | + +Formats without embedded color info (DST, EXP) use a default palette of 12 high-contrast colors, cycling on each color change. ## How it works -1. **Parse** the PES binary header to locate the PEC section -2. **Decode** the PEC stitch byte stream (7-bit and 12-bit encoded relative movements, jumps, trims, color changes) -3. **Resolve** relative movements into absolute coordinate segments grouped by thread color, using the 65-color Brother PEC palette +1. **Detect** the file format from magic bytes or extension +2. **Parse** the format-specific binary encoding into a common `StitchCommand` stream (stitch, jump, trim, color change, end) +3. **Resolve** relative movements into absolute coordinate segments grouped by thread color 4. **Render** anti-aliased line segments with [tiny-skia](https://github.com/nickel-org/tiny-skia), scaled to fit the requested size 5. **Encode** as PNG with proper alpha handling diff --git a/rustitch/src/dst/mod.rs b/rustitch/src/dst/mod.rs new file mode 100644 index 0000000..ca2cc8c --- /dev/null +++ b/rustitch/src/dst/mod.rs @@ -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, 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 { + 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); + } +} diff --git a/rustitch/src/error.rs b/rustitch/src/error.rs new file mode 100644 index 0000000..f325741 --- /dev/null +++ b/rustitch/src/error.rs @@ -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), +} diff --git a/rustitch/src/exp.rs b/rustitch/src/exp.rs new file mode 100644 index 0000000..0c654e8 --- /dev/null +++ b/rustitch/src/exp.rs @@ -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, 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 { + 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)); + } +} diff --git a/rustitch/src/format.rs b/rustitch/src/format.rs new file mode 100644 index 0000000..a07e429 --- /dev/null +++ b/rustitch/src/format.rs @@ -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 { + 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 { + 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, + } +} diff --git a/rustitch/src/jef/mod.rs b/rustitch/src/jef/mod.rs new file mode 100644 index 0000000..22ce9d4 --- /dev/null +++ b/rustitch/src/jef/mod.rs @@ -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, 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, 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 { + 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 })); + } +} diff --git a/rustitch/src/jef/palette.rs b/rustitch/src/jef/palette.rs new file mode 100644 index 0000000..0b91fbc --- /dev/null +++ b/rustitch/src/jef/palette.rs @@ -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 +]; diff --git a/rustitch/src/lib.rs b/rustitch/src/lib.rs index aa9ad3b..31587ee 100644 --- a/rustitch/src/lib.rs +++ b/rustitch/src/lib.rs @@ -1,12 +1,44 @@ -pub mod pes; -mod render; +pub mod error; +pub mod format; +pub mod palette; +pub mod types; +pub mod dst; +pub mod exp; +pub mod jef; +pub mod pes; +pub mod vp3; + +mod render; +mod resolve; + +pub use error::Error; +pub use format::Format; pub use render::render_thumbnail; +pub use types::{BoundingBox, ResolvedDesign, StitchCommand, StitchSegment}; /// Parse a PES file and render a thumbnail PNG of the given size. -pub fn thumbnail(pes_data: &[u8], size: u32) -> Result, pes::Error> { +pub fn thumbnail(pes_data: &[u8], size: u32) -> Result, Error> { let design = pes::parse(pes_data)?; let resolved = pes::resolve(&design)?; - let png_bytes = render::render_thumbnail(&resolved, size)?; - Ok(png_bytes) + render::render_thumbnail(&resolved, size) +} + +/// Parse any supported format and render a thumbnail PNG. +pub fn thumbnail_format(data: &[u8], size: u32, fmt: Format) -> Result, Error> { + let resolved = parse_and_resolve(data, fmt)?; + render::render_thumbnail(&resolved, size) +} + +fn parse_and_resolve(data: &[u8], fmt: Format) -> Result { + 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), + } } diff --git a/rustitch/src/palette.rs b/rustitch/src/palette.rs new file mode 100644 index 0000000..0c3fd2f --- /dev/null +++ b/rustitch/src/palette.rs @@ -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() +} diff --git a/rustitch/src/pes/header.rs b/rustitch/src/pes/header.rs index 60a215e..b9b84dc 100644 --- a/rustitch/src/pes/header.rs +++ b/rustitch/src/pes/header.rs @@ -1,4 +1,4 @@ -use super::Error; +use crate::error::Error; #[derive(Debug)] pub struct PesHeader { @@ -18,7 +18,7 @@ pub fn parse_header(data: &[u8]) -> Result { if magic != b"#PES" { let mut m = [0u8; 4]; m.copy_from_slice(magic); - return Err(Error::InvalidMagic(m)); + return Err(Error::InvalidPesMagic(m)); } let mut version = [0u8; 4]; @@ -41,7 +41,6 @@ mod tests { let mut data = vec![0u8; 20]; data[0..4].copy_from_slice(b"#PES"); data[4..8].copy_from_slice(b"0001"); - // PEC offset = 16 (little-endian) data[8..12].copy_from_slice(&16u32.to_le_bytes()); let header = parse_header(&data).unwrap(); @@ -53,7 +52,7 @@ mod tests { fn reject_invalid_magic() { let data = b"NOTPES0001\x10\x00\x00\x00"; let err = parse_header(data).unwrap_err(); - assert!(matches!(err, Error::InvalidMagic(_))); + assert!(matches!(err, Error::InvalidPesMagic(_))); } #[test] diff --git a/rustitch/src/pes/mod.rs b/rustitch/src/pes/mod.rs index a305109..44e6c37 100644 --- a/rustitch/src/pes/mod.rs +++ b/rustitch/src/pes/mod.rs @@ -1,30 +1,13 @@ mod header; -mod palette; mod pec; pub use header::PesHeader; -pub use palette::PEC_PALETTE; -pub use pec::{PecHeader, StitchCommand}; +pub use pec::PecHeader; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum Error { - #[error("invalid PES magic: expected #PES, got {0:?}")] - InvalidMagic([u8; 4]), - #[error("file too short: need {expected} bytes, got {actual}")] - TooShort { expected: usize, actual: usize }, - #[error("invalid PEC offset: {0} exceeds file length {1}")] - InvalidPecOffset(u32, usize), - #[error("no stitch data found")] - NoStitchData, - #[error("empty design: no stitch segments produced")] - EmptyDesign, - #[error("render error: {0}")] - Render(String), - #[error("PNG encoding error: {0}")] - PngEncode(#[from] png::EncodingError), -} +// Re-export shared types for backward compatibility +pub use crate::error::Error; +pub use crate::palette::PEC_PALETTE; +pub use crate::types::{BoundingBox, ResolvedDesign, StitchCommand, StitchSegment}; pub struct PesDesign { pub header: PesHeader, @@ -32,27 +15,6 @@ pub struct PesDesign { pub commands: Vec, } -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, - pub colors: Vec<(u8, u8, u8)>, - pub bounds: BoundingBox, -} - /// Parse a PES file from raw bytes. pub fn parse(data: &[u8]) -> Result { let header = header::parse_header(data)?; @@ -73,66 +35,8 @@ pub fn parse(data: &[u8]) -> Result { }) } -/// 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 { - 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 .pec_header .color_indices @@ -143,14 +47,5 @@ pub fn resolve(design: &PesDesign) -> Result { }) .collect(); - Ok(ResolvedDesign { - segments, - colors, - bounds: BoundingBox { - min_x, - max_x, - min_y, - max_y, - }, - }) + crate::resolve::resolve(&design.commands, colors) } diff --git a/rustitch/src/pes/palette.rs b/rustitch/src/pes/palette.rs index ea6002b..423c1bc 100644 --- a/rustitch/src/pes/palette.rs +++ b/rustitch/src/pes/palette.rs @@ -1,69 +1,2 @@ -/// Brother PEC thread color palette (65 entries). -/// Index 0 is a fallback; indices 1–64 correspond to standard Brother thread colors. -pub const PEC_PALETTE: [(u8, u8, u8); 65] = [ - (0, 0, 0), // 0: Unknown - (14, 31, 124), // 1: Prussian Blue - (10, 85, 163), // 2: Blue - (0, 135, 119), // 3: Teal Green - (75, 107, 175), // 4: Cornflower Blue - (237, 23, 31), // 5: Red - (209, 92, 0), // 6: Reddish Brown - (145, 54, 151), // 7: Magenta - (228, 154, 203), // 8: Light Lilac - (145, 95, 172), // 9: Lilac - (158, 214, 125), // 10: Mint Green - (232, 169, 0), // 11: Deep Gold - (254, 186, 53), // 12: Orange - (255, 255, 0), // 13: Yellow - (112, 188, 31), // 14: Lime Green - (186, 152, 0), // 15: Brass - (168, 168, 168), // 16: Silver - (125, 111, 0), // 17: Russet Brown - (255, 255, 179), // 18: Cream Brown - (79, 85, 86), // 19: Pewter - (0, 0, 0), // 20: Black - (11, 61, 145), // 21: Ultramarine - (119, 1, 118), // 22: Royal Purple - (41, 49, 51), // 23: Dark Gray - (42, 19, 1), // 24: Dark Brown - (246, 74, 138), // 25: Deep Rose - (178, 118, 36), // 26: Light Brown - (252, 187, 197), // 27: Salmon Pink - (254, 55, 15), // 28: Vermilion - (240, 240, 240), // 29: White - (106, 28, 138), // 30: Violet - (168, 221, 196), // 31: Seacrest - (37, 132, 187), // 32: Sky Blue - (254, 179, 67), // 33: Pumpkin - (255, 243, 107), // 34: Cream Yellow - (208, 166, 96), // 35: Khaki - (209, 84, 0), // 36: Clay Brown - (102, 186, 73), // 37: Leaf Green - (19, 74, 70), // 38: Peacock Blue - (135, 135, 135), // 39: Gray - (216, 204, 198), // 40: Warm Gray - (67, 86, 7), // 41: Dark Olive - (253, 217, 222), // 42: Flesh Pink - (249, 147, 188), // 43: Pink - (0, 56, 34), // 44: Deep Green - (178, 175, 212), // 45: Lavender - (104, 106, 176), // 46: Wisteria Violet - (239, 227, 185), // 47: Beige - (247, 56, 102), // 48: Carmine - (181, 75, 100), // 49: Amber Red - (19, 43, 26), // 50: Olive Green - (199, 1, 86), // 51: Dark Fuchsia - (254, 158, 50), // 52: Tangerine - (168, 222, 235), // 53: Light Blue - (0, 103, 62), // 54: Emerald Green - (78, 41, 144), // 55: Purple - (47, 126, 32), // 56: Moss Green - (255, 204, 204), // 57: Flesh Pink - (255, 217, 17), // 58: Harvest Gold - (9, 91, 166), // 59: Electric Blue - (240, 249, 112), // 60: Lemon Yellow - (227, 243, 91), // 61: Fresh Green - (255, 153, 0), // 62: Orange - (255, 240, 141), // 63: Cream Yellow - (255, 200, 200), // 64: Applique -]; +// Re-export from crate root for backward compatibility +pub use crate::palette::PEC_PALETTE; diff --git a/rustitch/src/pes/pec.rs b/rustitch/src/pes/pec.rs index b3c95d1..c41a29a 100644 --- a/rustitch/src/pes/pec.rs +++ b/rustitch/src/pes/pec.rs @@ -1,4 +1,5 @@ -use super::Error; +use crate::error::Error; +use crate::types::StitchCommand; pub struct PecHeader { pub label: String, @@ -6,19 +7,9 @@ pub struct PecHeader { pub color_indices: Vec, } -#[derive(Debug, Clone)] -pub enum StitchCommand { - Stitch { dx: i16, dy: i16 }, - Jump { dx: i16, dy: i16 }, - Trim, - ColorChange, - End, -} - /// Parse the PEC header starting at the PEC section offset. /// Returns the header and the byte offset (relative to pec_data start) where stitch data begins. pub fn parse_pec_header(pec_data: &[u8]) -> Result<(PecHeader, usize), Error> { - // PEC section starts with "LA:" label field (19 bytes total) if pec_data.len() < 532 { return Err(Error::TooShort { expected: 532, @@ -26,57 +17,15 @@ pub fn parse_pec_header(pec_data: &[u8]) -> Result<(PecHeader, usize), Error> { }); } - // Label: bytes 0..19, starts with "LA:" let label_raw = &pec_data[3..19]; let label = std::str::from_utf8(label_raw) .unwrap_or("") .trim() .to_string(); - // Color count at offset 48 from PEC start let color_count = pec_data[48] + 1; - - // Color indices follow at offset 49 let color_indices: Vec = pec_data[49..49 + color_count as usize].to_vec(); - // Stitch data starts at offset 532 from PEC section start - // (48 bytes header + 463 bytes padding/thumbnail = 512, plus 20 bytes of graphic data = 532) - // Actually the standard offset is 512 + the two thumbnail sections. - // The typical approach: skip to offset 48 + 1 + color_count + padding to 512, then skip thumbnails. - // Simplified: PEC stitch data offset = 512 + 20 (for the stitch data header that contains the graphic offsets) - // A more robust approach: read the stitch data offset from the header. - - // At PEC offset + 20..24 there are two u16 LE values for the thumbnail image offsets. - // The stitch data typically starts after a fixed 532-byte header region. - // Let's use the more standard approach from libpes: - // Offset 514-515 (relative to PEC start): thumbnail1 image offset (u16 LE, relative) - // But the simplest reliable approach is to find the stitch data after the fixed header. - - // The standard PEC header is 512 bytes, followed by two thumbnail images. - // Thumbnail 1: 6 bytes wide × 38 bytes high = 228 bytes (48×38 pixel, 1bpp padded) - // Actually, typical PEC has: after the 512-byte block, there are two graphics sections. - // The stitch data starts after those graphics. - // - // More robust: bytes 514..516 give the thumbnail offset (little-endian u16). - // We can derive stitch data from there, but let's use the standard fixed sizes. - // Thumbnail 1: at offset 512, size = ceil(width*2/8) * height, with default 48×38 = 6*38=228 - // Thumbnail 2: at offset 512+228=740, size = ceil(width*2/8) * height, default 96×76=12*76=912 - // Stitch data at: 512 + 228 + 912 = 1652? That doesn't seem right. - // - // Actually from libpes wiki: PEC header is 20 bytes, then color info, then padding to - // reach a 512-byte boundary. At byte 512 is the beginning of the PEC graphic section. - // After the graphics come the stitch data. But graphic sizes vary. - // - // The correct approach: at PEC_start + 514 (bytes 514-515), read a u16 LE which gives - // the absolute offset from PEC_start to the first thumbnail. Then after thumbnails come stitches. - // BUT actually, the standard approach used by most parsers is simpler: - // - // pyembroidery approach: seek to PEC_start + 532, that's where stitch data starts. - // The 532 = 512 + 20 (20 bytes for graphic header). - // - // Let's verify: pyembroidery's PecReader reads stitches starting 532 bytes after PEC start. - // Let's go with 532. - let stitch_data_offset = 532; if pec_data.len() <= stitch_data_offset { @@ -101,31 +50,42 @@ pub fn decode_stitches(data: &[u8]) -> Result, Error> { while i < data.len() { let b1 = data[i]; - // End marker if b1 == 0xFF { commands.push(StitchCommand::End); break; } - // Color change if b1 == 0xFE { commands.push(StitchCommand::ColorChange); - i += 2; // skip the 0xFE and the following byte (typically 0xB0) + i += 2; continue; } - // Parse dx - let (dx, dx_flags, bytes_dx) = decode_coordinate(data, i)?; - i += bytes_dx; + // PEC encodes coordinates as (Y, X) — read first value as vertical, + // second as horizontal, then swap to (dx, dy) for screen coordinates. + let (val1, flags1, bytes1) = decode_coordinate(data, i)?; + i += bytes1; - // Parse dy - let (dy, dy_flags, bytes_dy) = decode_coordinate(data, i)?; - i += bytes_dy; + // Check for special bytes at second coordinate position — color change + // or end markers can appear between the two coordinates. + if i < data.len() && data[i] == 0xFF { + commands.push(StitchCommand::End); + break; + } + if i < data.len() && data[i] == 0xFE { + commands.push(StitchCommand::ColorChange); + i += 2; + continue; + } - let flags = dx_flags | dy_flags; + let (val2, flags2, bytes2) = decode_coordinate(data, i)?; + i += bytes2; + + let flags = flags1 | flags2; + let dx = val2; + let dy = val1; if flags & 0x20 != 0 { - // Trim + jump commands.push(StitchCommand::Trim); commands.push(StitchCommand::Jump { dx, dy }); } else if flags & 0x10 != 0 { @@ -155,7 +115,6 @@ fn decode_coordinate(data: &[u8], pos: usize) -> Result<(i16, u8, usize), Error> let b = data[pos]; if b & 0x80 != 0 { - // Extended 12-bit encoding (2 bytes) if pos + 1 >= data.len() { return Err(Error::TooShort { expected: pos + 2, @@ -163,7 +122,7 @@ fn decode_coordinate(data: &[u8], pos: usize) -> Result<(i16, u8, usize), Error> }); } let b2 = data[pos + 1]; - let flags = b & 0x70; // bits 6-4 for jump/trim flags + let flags = b & 0x70; let raw = (((b & 0x0F) as u16) << 8) | (b2 as u16); let value = if raw > 0x7FF { raw as i16 - 0x1000 @@ -172,7 +131,6 @@ fn decode_coordinate(data: &[u8], pos: usize) -> Result<(i16, u8, usize), Error> }; Ok((value, flags, 2)) } else { - // 7-bit encoding (1 byte) let value = if b > 0x3F { b as i16 - 0x80 } else { b as i16 }; Ok((value, 0, 1)) } @@ -192,14 +150,14 @@ mod tests { #[test] fn decode_simple_stitch() { - // dx=10 (0x0A), dy=20 (0x14), then end + // PEC stores (Y, X): first=10 → dy, second=20 → dx let data = [0x0A, 0x14, 0xFF]; let cmds = decode_stitches(&data).unwrap(); assert_eq!(cmds.len(), 2); match &cmds[0] { StitchCommand::Stitch { dx, dy } => { - assert_eq!(*dx, 10); - assert_eq!(*dy, 20); + assert_eq!(*dx, 20); + assert_eq!(*dy, 10); } _ => panic!("expected Stitch"), } @@ -207,13 +165,13 @@ mod tests { #[test] fn decode_negative_7bit() { - // dx=0x50 (80 decimal, > 0x3F so value = 80-128 = -48), dy=0x60 (96-128=-32), end + // PEC stores (Y, X): first=0x50(-48) → dy, second=0x60(-32) → dx let data = [0x50, 0x60, 0xFF]; let cmds = decode_stitches(&data).unwrap(); match &cmds[0] { StitchCommand::Stitch { dx, dy } => { - assert_eq!(*dx, -48); - assert_eq!(*dy, -32); + assert_eq!(*dx, -32); + assert_eq!(*dy, -48); } _ => panic!("expected Stitch"), } @@ -221,29 +179,27 @@ mod tests { #[test] fn decode_color_change() { + // PEC stores (Y, X): first=10 → dy, second=20 → dx let data = [0xFE, 0xB0, 0x0A, 0x14, 0xFF]; let cmds = decode_stitches(&data).unwrap(); assert!(matches!(cmds[0], StitchCommand::ColorChange)); - assert!(matches!(cmds[1], StitchCommand::Stitch { dx: 10, dy: 20 })); + assert!(matches!(cmds[1], StitchCommand::Stitch { dx: 20, dy: 10 })); } #[test] fn decode_extended_12bit() { - // Extended dx: high bit set, flags=0x10 (jump), value = 0x100 = 256 - // byte1 = 0x80 | 0x10 | 0x01 = 0x91, byte2 = 0x00 -> raw = 0x100 = 256 - // dy: simple 0x05 = 5 + // PEC stores (Y, X): first=0x91,0x00(256 with jump flag) → dy, second=0x05(5) → dx let data = [0x91, 0x00, 0x05, 0xFF]; let cmds = decode_stitches(&data).unwrap(); - assert!(matches!(cmds[0], StitchCommand::Jump { dx: 256, dy: 5 })); + assert!(matches!(cmds[0], StitchCommand::Jump { dx: 5, dy: 256 })); } #[test] fn decode_trim_jump() { - // dx with trim flag (0x20): byte1 = 0x80 | 0x20 | 0x00 = 0xA0, byte2 = 0x0A -> raw=10 - // dy: simple 0x05 + // PEC stores (Y, X): first=0xA0,0x0A(10 with trim flag) → dy, second=0x05(5) → dx let data = [0xA0, 0x0A, 0x05, 0xFF]; let cmds = decode_stitches(&data).unwrap(); assert!(matches!(cmds[0], StitchCommand::Trim)); - assert!(matches!(cmds[1], StitchCommand::Jump { dx: 10, dy: 5 })); + assert!(matches!(cmds[1], StitchCommand::Jump { dx: 5, dy: 10 })); } } diff --git a/rustitch/src/render.rs b/rustitch/src/render.rs index a6621a0..f2e59f8 100644 --- a/rustitch/src/render.rs +++ b/rustitch/src/render.rs @@ -1,6 +1,7 @@ use tiny_skia::{LineCap, Paint, PathBuilder, Pixmap, Stroke, Transform}; -use crate::pes::{Error, ResolvedDesign}; +use crate::error::Error; +use crate::types::ResolvedDesign; /// Render a resolved embroidery design to a PNG image of the given size. pub fn render_thumbnail(design: &ResolvedDesign, size: u32) -> Result, Error> { @@ -23,7 +24,6 @@ pub fn render_thumbnail(design: &ResolvedDesign, size: u32) -> Result, E let line_width = (scale * 0.3).max(1.0); - // Group segments by color index and draw each group let max_color = design .segments .iter() @@ -82,7 +82,6 @@ fn encode_png(pixmap: &Pixmap) -> Result, Error> { let height = pixmap.height(); let src = pixmap.data(); - // Unpremultiply alpha let mut data = Vec::with_capacity(src.len()); for chunk in src.chunks_exact(4) { let (r, g, b, a) = (chunk[0], chunk[1], chunk[2], chunk[3]); diff --git a/rustitch/src/resolve.rs b/rustitch/src/resolve.rs new file mode 100644 index 0000000..bd4371f --- /dev/null +++ b/rustitch/src/resolve.rs @@ -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 { + 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, + }, + }) +} diff --git a/rustitch/src/types.rs b/rustitch/src/types.rs new file mode 100644 index 0000000..bb8face --- /dev/null +++ b/rustitch/src/types.rs @@ -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, + pub colors: Vec<(u8, u8, u8)>, + pub bounds: BoundingBox, +} diff --git a/rustitch/src/vp3.rs b/rustitch/src/vp3.rs new file mode 100644 index 0000000..ce88b36 --- /dev/null +++ b/rustitch/src/vp3.rs @@ -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, 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 { + 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, +) -> 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, 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 { + 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 { + 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 { + 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 { + 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 { .. })); + } +} diff --git a/rustitch/tests/fixtures/0.3x1 INCHES.EXP b/rustitch/tests/fixtures/0.3x1 INCHES.EXP new file mode 100644 index 0000000..7b95a5a Binary files /dev/null and b/rustitch/tests/fixtures/0.3x1 INCHES.EXP differ diff --git a/rustitch/tests/fixtures/0.3x1 INCHES.JEF b/rustitch/tests/fixtures/0.3x1 INCHES.JEF new file mode 100644 index 0000000..d836da4 Binary files /dev/null and b/rustitch/tests/fixtures/0.3x1 INCHES.JEF differ diff --git a/rustitch/tests/fixtures/0.3x1 INCHES.PES b/rustitch/tests/fixtures/0.3x1 INCHES.PES new file mode 100644 index 0000000..90e2bfd Binary files /dev/null and b/rustitch/tests/fixtures/0.3x1 INCHES.PES differ diff --git a/stitch-peek/Cargo.toml b/stitch-peek/Cargo.toml index 8c016a2..4c02c01 100644 --- a/stitch-peek/Cargo.toml +++ b/stitch-peek/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stitch-peek" -version = "0.1.2" +version = "0.1.3" edition = "2024" description = "Nautilus thumbnail generator for PES embroidery files" license = "MIT" @@ -11,6 +11,6 @@ categories = ["graphics", "command-line-utilities"] readme = "README.md" [dependencies] -rustitch = { version = "0.1.1", path = "../rustitch" } +rustitch = { version = "0.2", path = "../rustitch" } clap = { version = "4", features = ["derive"] } anyhow = "1" diff --git a/stitch-peek/README.md b/stitch-peek/README.md index 2395650..7e60fbc 100644 --- a/stitch-peek/README.md +++ b/stitch-peek/README.md @@ -3,9 +3,11 @@ [![crates.io](https://img.shields.io/crates/v/stitch-peek)](https://crates.io/crates/stitch-peek) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE) -A CLI tool and **Nautilus/GNOME thumbnailer** for PES embroidery files. Generates PNG previews of embroidery designs directly in your file manager. +A CLI tool and **Nautilus/GNOME thumbnailer** for embroidery files. Generates PNG previews of embroidery designs directly in your file manager. -Part of the [stitch-peek-rs](https://git.narl.io/nvrl/stitch-peek-rs) project. Uses [rustitch](https://crates.io/crates/rustitch) for PES parsing and rendering. +Supported formats: **PES**, **DST**, **EXP**, **JEF**, **VP3** + +Part of the [stitch-peek-rs](https://git.narl.io/nvrl/stitch-peek-rs) project. Uses [rustitch](https://crates.io/crates/rustitch) for parsing and rendering. ## Installation @@ -49,23 +51,27 @@ nautilus -q ### As a thumbnailer -Once installed with the `.thumbnailer` file in place, Nautilus automatically generates thumbnails for `.pes` files. No manual action needed. +Once installed with the `.thumbnailer` file in place, Nautilus automatically generates thumbnails for `.pes`, `.dst`, `.exp`, `.jef`, and `.vp3` files. No manual action needed. ### Standalone CLI ```sh stitch-peek -i design.pes -o preview.png -s 256 +stitch-peek -i pattern.dst -o preview.png +stitch-peek -i motif.jef -o preview.png -s 512 ``` | Flag | Description | Default | |------|-------------|---------| -| `-i` | Input PES file | required | +| `-i` | Input embroidery file | required | | `-o` | Output PNG path | required | | `-s` | Thumbnail size (pixels) | 128 | +The format is detected automatically from the file extension. + ## How it works -The tool follows the [freedesktop thumbnail specification](https://specifications.freedesktop.org/thumbnail/latest/). Nautilus calls it with an input file, output path, and requested size. It parses the PES file, renders the stitch pattern as anti-aliased colored lines on a transparent background, and writes a PNG. +The tool follows the [freedesktop thumbnail specification](https://specifications.freedesktop.org/thumbnail/latest/). Nautilus calls it with an input file, output path, and requested size. It detects the embroidery format, parses the stitch data, renders the pattern as anti-aliased colored lines on a transparent background, and writes a PNG. ## License diff --git a/stitch-peek/src/main.rs b/stitch-peek/src/main.rs index a2dba8f..ac31467 100644 --- a/stitch-peek/src/main.rs +++ b/stitch-peek/src/main.rs @@ -3,9 +3,12 @@ use clap::Parser; use std::fs; #[derive(Parser)] -#[command(name = "stitch-peek", about = "PES embroidery file thumbnailer")] +#[command( + name = "stitch-peek", + about = "Embroidery file thumbnailer (PES, DST, EXP, JEF, VP3)" +)] struct Args { - /// Input PES file path + /// Input embroidery file path #[arg(short = 'i', long = "input")] input: std::path::PathBuf, @@ -21,11 +24,14 @@ struct Args { fn main() -> Result<()> { let args = Args::parse(); + let format = rustitch::format::detect_from_extension(&args.input) + .with_context(|| format!("unsupported file extension: {}", args.input.display()))?; + let data = fs::read(&args.input) .with_context(|| format!("failed to read {}", args.input.display()))?; - let png = - rustitch::thumbnail(&data, args.size).with_context(|| "failed to generate thumbnail")?; + let png = rustitch::thumbnail_format(&data, args.size, format) + .with_context(|| "failed to generate thumbnail")?; fs::write(&args.output, &png) .with_context(|| format!("failed to write {}", args.output.display()))?;