init
Some checks failed
Release / build-deb (push) Has been cancelled
CI / check (pull_request) Has been cancelled
CI / version-check (pull_request) Has been cancelled

This commit is contained in:
2026-03-30 00:01:49 +02:00
commit da71b56f2d
18 changed files with 1406 additions and 0 deletions

9
rustitch/Cargo.toml Normal file
View File

@@ -0,0 +1,9 @@
[package]
name = "rustitch"
version = "0.1.0"
edition = "2021"
[dependencies]
thiserror = "2"
tiny-skia = "0.11"
png = "0.17"

12
rustitch/src/lib.rs Normal file
View File

@@ -0,0 +1,12 @@
pub mod pes;
mod render;
pub use render::render_thumbnail;
/// 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> {
let design = pes::parse(pes_data)?;
let resolved = pes::resolve(&design)?;
let png_bytes = render::render_thumbnail(&resolved, size)?;
Ok(png_bytes)
}

View File

@@ -0,0 +1,65 @@
use super::Error;
#[derive(Debug)]
pub struct PesHeader {
pub version: [u8; 4],
pub pec_offset: u32,
}
pub fn parse_header(data: &[u8]) -> Result<PesHeader, Error> {
if data.len() < 12 {
return Err(Error::TooShort {
expected: 12,
actual: data.len(),
});
}
let magic = &data[0..4];
if magic != b"#PES" {
let mut m = [0u8; 4];
m.copy_from_slice(magic);
return Err(Error::InvalidMagic(m));
}
let mut version = [0u8; 4];
version.copy_from_slice(&data[4..8]);
let pec_offset = u32::from_le_bytes([data[8], data[9], data[10], data[11]]);
Ok(PesHeader {
version,
pec_offset,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_valid_header() {
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();
assert_eq!(&header.version, b"0001");
assert_eq!(header.pec_offset, 16);
}
#[test]
fn reject_invalid_magic() {
let data = b"NOTPES0001\x10\x00\x00\x00";
let err = parse_header(data).unwrap_err();
assert!(matches!(err, Error::InvalidMagic(_)));
}
#[test]
fn reject_too_short() {
let data = b"#PES00";
let err = parse_header(data).unwrap_err();
assert!(matches!(err, Error::TooShort { .. }));
}
}

156
rustitch/src/pes/mod.rs Normal file
View File

@@ -0,0 +1,156 @@
mod header;
mod palette;
mod pec;
pub use header::PesHeader;
pub use palette::PEC_PALETTE;
pub use pec::{PecHeader, StitchCommand};
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),
}
pub struct PesDesign {
pub header: PesHeader,
pub pec_header: PecHeader,
pub commands: Vec<StitchCommand>,
}
pub struct StitchSegment {
pub x0: f32,
pub y0: f32,
pub x1: f32,
pub y1: f32,
pub color_index: usize,
}
pub struct BoundingBox {
pub min_x: f32,
pub max_x: f32,
pub min_y: f32,
pub max_y: f32,
}
pub struct ResolvedDesign {
pub segments: Vec<StitchSegment>,
pub colors: Vec<(u8, u8, u8)>,
pub bounds: BoundingBox,
}
/// Parse a PES file from raw bytes.
pub fn parse(data: &[u8]) -> Result<PesDesign, Error> {
let header = header::parse_header(data)?;
let pec_offset = header.pec_offset as usize;
if pec_offset >= data.len() {
return Err(Error::InvalidPecOffset(header.pec_offset, data.len()));
}
let pec_data = &data[pec_offset..];
let (pec_header, stitch_data_offset) = pec::parse_pec_header(pec_data)?;
let commands = pec::decode_stitches(&pec_data[stitch_data_offset..])?;
Ok(PesDesign {
header,
pec_header,
commands,
})
}
/// Convert parsed commands into renderable segments with absolute coordinates.
pub fn resolve(design: &PesDesign) -> Result<ResolvedDesign, Error> {
let mut segments = Vec::new();
let mut x: f32 = 0.0;
let mut y: f32 = 0.0;
let mut color_idx: usize = 0;
let mut pen_down = true;
for cmd in &design.commands {
match cmd {
StitchCommand::Stitch { dx, dy } => {
let nx = x + *dx as f32;
let ny = y + *dy as f32;
if pen_down {
segments.push(StitchSegment {
x0: x,
y0: y,
x1: nx,
y1: ny,
color_index: color_idx,
});
}
x = nx;
y = ny;
pen_down = true;
}
StitchCommand::Jump { dx, dy } => {
x += *dx as f32;
y += *dy as f32;
pen_down = false;
}
StitchCommand::Trim => {
pen_down = false;
}
StitchCommand::ColorChange => {
color_idx += 1;
pen_down = false;
}
StitchCommand::End => break,
}
}
if segments.is_empty() {
return Err(Error::EmptyDesign);
}
// Compute bounding box
let mut min_x = f32::MAX;
let mut max_x = f32::MIN;
let mut min_y = f32::MAX;
let mut max_y = f32::MIN;
for seg in &segments {
min_x = min_x.min(seg.x0).min(seg.x1);
max_x = max_x.max(seg.x0).max(seg.x1);
min_y = min_y.min(seg.y0).min(seg.y1);
max_y = max_y.max(seg.y0).max(seg.y1);
}
// Resolve colors from palette indices
let colors: Vec<(u8, u8, u8)> = design
.pec_header
.color_indices
.iter()
.map(|&idx| {
let i = (idx as usize).min(PEC_PALETTE.len() - 1);
PEC_PALETTE[i]
})
.collect();
Ok(ResolvedDesign {
segments,
colors,
bounds: BoundingBox {
min_x,
max_x,
min_y,
max_y,
},
})
}

View File

@@ -0,0 +1,69 @@
/// Brother PEC thread color palette (65 entries).
/// Index 0 is a fallback; indices 164 correspond to standard Brother thread colors.
pub const PEC_PALETTE: [(u8, u8, u8); 65] = [
(0, 0, 0), // 0: Unknown
(14, 31, 124), // 1: Prussian Blue
(10, 85, 163), // 2: Blue
(0, 135, 119), // 3: Teal Green
(75, 107, 175), // 4: Cornflower Blue
(237, 23, 31), // 5: Red
(209, 92, 0), // 6: Reddish Brown
(145, 54, 151), // 7: Magenta
(228, 154, 203), // 8: Light Lilac
(145, 95, 172), // 9: Lilac
(158, 214, 125), // 10: Mint Green
(232, 169, 0), // 11: Deep Gold
(254, 186, 53), // 12: Orange
(255, 255, 0), // 13: Yellow
(112, 188, 31), // 14: Lime Green
(186, 152, 0), // 15: Brass
(168, 168, 168), // 16: Silver
(125, 111, 0), // 17: Russet Brown
(255, 255, 179), // 18: Cream Brown
(79, 85, 86), // 19: Pewter
(0, 0, 0), // 20: Black
(11, 61, 145), // 21: Ultramarine
(119, 1, 118), // 22: Royal Purple
(41, 49, 51), // 23: Dark Gray
(42, 19, 1), // 24: Dark Brown
(246, 74, 138), // 25: Deep Rose
(178, 118, 36), // 26: Light Brown
(252, 187, 197), // 27: Salmon Pink
(254, 55, 15), // 28: Vermilion
(240, 240, 240), // 29: White
(106, 28, 138), // 30: Violet
(168, 221, 196), // 31: Seacrest
(37, 132, 187), // 32: Sky Blue
(254, 179, 67), // 33: Pumpkin
(255, 243, 107), // 34: Cream Yellow
(208, 166, 96), // 35: Khaki
(209, 84, 0), // 36: Clay Brown
(102, 186, 73), // 37: Leaf Green
(19, 74, 70), // 38: Peacock Blue
(135, 135, 135), // 39: Gray
(216, 204, 198), // 40: Warm Gray
(67, 86, 7), // 41: Dark Olive
(253, 217, 222), // 42: Flesh Pink
(249, 147, 188), // 43: Pink
(0, 56, 34), // 44: Deep Green
(178, 175, 212), // 45: Lavender
(104, 106, 176), // 46: Wisteria Violet
(239, 227, 185), // 47: Beige
(247, 56, 102), // 48: Carmine
(181, 75, 100), // 49: Amber Red
(19, 43, 26), // 50: Olive Green
(199, 1, 86), // 51: Dark Fuchsia
(254, 158, 50), // 52: Tangerine
(168, 222, 235), // 53: Light Blue
(0, 103, 62), // 54: Emerald Green
(78, 41, 144), // 55: Purple
(47, 126, 32), // 56: Moss Green
(255, 204, 204), // 57: Flesh Pink
(255, 217, 17), // 58: Harvest Gold
(9, 91, 166), // 59: Electric Blue
(240, 249, 112), // 60: Lemon Yellow
(227, 243, 91), // 61: Fresh Green
(255, 153, 0), // 62: Orange
(255, 240, 141), // 63: Cream Yellow
(255, 200, 200), // 64: Applique
];

253
rustitch/src/pes/pec.rs Normal file
View File

@@ -0,0 +1,253 @@
use super::Error;
pub struct PecHeader {
pub label: String,
pub color_count: 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.
/// 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,
actual: pec_data.len(),
});
}
// Label: bytes 0..19, starts with "LA:"
let label_raw = &pec_data[3..19];
let label = std::str::from_utf8(label_raw)
.unwrap_or("")
.trim()
.to_string();
// Color count at offset 48 from PEC start
let color_count = pec_data[48] + 1;
// Color indices follow at offset 49
let color_indices: Vec<u8> = pec_data[49..49 + color_count as usize].to_vec();
// Stitch data starts at offset 532 from PEC section start
// (48 bytes header + 463 bytes padding/thumbnail = 512, plus 20 bytes of graphic data = 532)
// Actually the standard offset is 512 + the two thumbnail sections.
// The typical approach: skip to offset 48 + 1 + color_count + padding to 512, then skip thumbnails.
// Simplified: PEC stitch data offset = 512 + 20 (for the stitch data header that contains the graphic offsets)
// A more robust approach: read the stitch data offset from the header.
// At PEC offset + 20..24 there are two u16 LE values for the thumbnail image offsets.
// The stitch data typically starts after a fixed 532-byte header region.
// Let's use the more standard approach from libpes:
// Offset 514-515 (relative to PEC start): thumbnail1 image offset (u16 LE, relative)
// But the simplest reliable approach is to find the stitch data after the fixed header.
// The standard PEC header is 512 bytes, followed by two thumbnail images.
// Thumbnail 1: 6 bytes wide × 38 bytes high = 228 bytes (48×38 pixel, 1bpp padded)
// Actually, typical PEC has: after the 512-byte block, there are two graphics sections.
// The stitch data starts after those graphics.
//
// More robust: bytes 514..516 give the thumbnail offset (little-endian u16).
// We can derive stitch data from there, but let's use the standard fixed sizes.
// Thumbnail 1: at offset 512, size = ceil(width*2/8) * height, with default 48×38 = 6*38=228
// Thumbnail 2: at offset 512+228=740, size = ceil(width*2/8) * height, default 96×76=12*76=912
// Stitch data at: 512 + 228 + 912 = 1652? That doesn't seem right.
//
// Actually from libpes wiki: PEC header is 20 bytes, then color info, then padding to
// reach a 512-byte boundary. At byte 512 is the beginning of the PEC graphic section.
// After the graphics come the stitch data. But graphic sizes vary.
//
// The correct approach: at PEC_start + 514 (bytes 514-515), read a u16 LE which gives
// the absolute offset from PEC_start to the first thumbnail. Then after thumbnails come stitches.
// BUT actually, the standard approach used by most parsers is simpler:
//
// pyembroidery approach: seek to PEC_start + 532, that's where stitch data starts.
// The 532 = 512 + 20 (20 bytes for graphic header).
//
// Let's verify: pyembroidery's PecReader reads stitches starting 532 bytes after PEC start.
// Let's go with 532.
let stitch_data_offset = 532;
if pec_data.len() <= stitch_data_offset {
return Err(Error::NoStitchData);
}
Ok((
PecHeader {
label,
color_count,
color_indices,
},
stitch_data_offset,
))
}
/// Decode PEC stitch byte stream into a list of commands.
pub fn decode_stitches(data: &[u8]) -> Result<Vec<StitchCommand>, Error> {
let mut commands = Vec::new();
let mut i = 0;
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)
continue;
}
// Parse dx
let (dx, dx_flags, bytes_dx) = decode_coordinate(data, i)?;
i += bytes_dx;
// Parse dy
let (dy, dy_flags, bytes_dy) = decode_coordinate(data, i)?;
i += bytes_dy;
let flags = dx_flags | dy_flags;
if flags & 0x20 != 0 {
// Trim + jump
commands.push(StitchCommand::Trim);
commands.push(StitchCommand::Jump { dx, dy });
} else if flags & 0x10 != 0 {
commands.push(StitchCommand::Jump { dx, dy });
} else {
commands.push(StitchCommand::Stitch { dx, dy });
}
}
if commands.is_empty() {
return Err(Error::NoStitchData);
}
Ok(commands)
}
/// Decode a single coordinate (dx or dy) from the byte stream.
/// Returns (value, flags, bytes_consumed).
fn decode_coordinate(data: &[u8], pos: usize) -> Result<(i16, u8, usize), Error> {
if pos >= data.len() {
return Err(Error::TooShort {
expected: pos + 1,
actual: data.len(),
});
}
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,
actual: data.len(),
});
}
let b2 = data[pos + 1];
let flags = b & 0x70; // bits 6-4 for jump/trim flags
let raw = (((b & 0x0F) as u16) << 8) | (b2 as u16);
let value = if raw > 0x7FF {
raw as i16 - 0x1000
} else {
raw as i16
};
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))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_end_marker() {
let data = [0xFF];
let cmds = decode_stitches(&data).unwrap();
assert_eq!(cmds.len(), 1);
assert!(matches!(cmds[0], StitchCommand::End));
}
#[test]
fn decode_simple_stitch() {
// dx=10 (0x0A), dy=20 (0x14), then end
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);
}
_ => panic!("expected Stitch"),
}
}
#[test]
fn decode_negative_7bit() {
// dx=0x50 (80 decimal, > 0x3F so value = 80-128 = -48), dy=0x60 (96-128=-32), end
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);
}
_ => panic!("expected Stitch"),
}
}
#[test]
fn decode_color_change() {
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 }));
}
#[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
let data = [0x91, 0x00, 0x05, 0xFF];
let cmds = decode_stitches(&data).unwrap();
assert!(matches!(cmds[0], StitchCommand::Jump { dx: 256, dy: 5 }));
}
#[test]
fn decode_trim_jump() {
// dx with trim flag (0x20): byte1 = 0x80 | 0x20 | 0x00 = 0xA0, byte2 = 0x0A -> raw=10
// dy: simple 0x05
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 }));
}
}

111
rustitch/src/render.rs Normal file
View File

@@ -0,0 +1,111 @@
use tiny_skia::{LineCap, Paint, PathBuilder, Pixmap, Stroke, Transform};
use crate::pes::{Error, ResolvedDesign};
/// Render a resolved embroidery design to a PNG image of the given size.
pub fn render_thumbnail(design: &ResolvedDesign, size: u32) -> Result<Vec<u8>, Error> {
let mut pixmap =
Pixmap::new(size, size).ok_or_else(|| Error::Render("failed to create pixmap".into()))?;
let bounds = &design.bounds;
let design_w = bounds.max_x - bounds.min_x;
let design_h = bounds.max_y - bounds.min_y;
if design_w <= 0.0 || design_h <= 0.0 {
return Err(Error::EmptyDesign);
}
let padding = size as f32 * 0.05;
let available = size as f32 - 2.0 * padding;
let scale = (available / design_w).min(available / design_h);
let offset_x = (size as f32 - design_w * scale) / 2.0;
let offset_y = (size as f32 - design_h * scale) / 2.0;
let line_width = (scale * 0.3).max(1.0);
// Group segments by color index and draw each group
let max_color = design
.segments
.iter()
.map(|s| s.color_index)
.max()
.unwrap_or(0);
for ci in 0..=max_color {
let (r, g, b) = if ci < design.colors.len() {
design.colors[ci]
} else {
(0, 0, 0)
};
let mut paint = Paint::default();
paint.set_color_rgba8(r, g, b, 255);
paint.anti_alias = true;
let stroke = Stroke {
width: line_width,
line_cap: LineCap::Round,
..Stroke::default()
};
let mut pb = PathBuilder::new();
let mut has_segments = false;
for seg in &design.segments {
if seg.color_index != ci {
continue;
}
let sx = (seg.x0 - bounds.min_x) * scale + offset_x;
let sy = (seg.y0 - bounds.min_y) * scale + offset_y;
let ex = (seg.x1 - bounds.min_x) * scale + offset_x;
let ey = (seg.y1 - bounds.min_y) * scale + offset_y;
pb.move_to(sx, sy);
pb.line_to(ex, ey);
has_segments = true;
}
if !has_segments {
continue;
}
if let Some(path) = pb.finish() {
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
}
encode_png(&pixmap)
}
/// Encode a tiny-skia Pixmap as a PNG, converting from premultiplied to straight alpha.
fn encode_png(pixmap: &Pixmap) -> Result<Vec<u8>, Error> {
let width = pixmap.width();
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]);
if a == 0 {
data.extend_from_slice(&[0, 0, 0, 0]);
} else if a == 255 {
data.extend_from_slice(&[r, g, b, a]);
} else {
let af = a as f32;
data.push((r as f32 * 255.0 / af) as u8);
data.push((g as f32 * 255.0 / af) as u8);
data.push((b as f32 * 255.0 / af) as u8);
data.push(a);
}
}
let mut buf = Vec::new();
{
let mut encoder = png::Encoder::new(&mut buf, width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header()?;
writer.write_image_data(&data)?;
}
Ok(buf)
}