init
This commit is contained in:
9
rustitch/Cargo.toml
Normal file
9
rustitch/Cargo.toml
Normal 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
12
rustitch/src/lib.rs
Normal 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)
|
||||
}
|
||||
65
rustitch/src/pes/header.rs
Normal file
65
rustitch/src/pes/header.rs
Normal 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
156
rustitch/src/pes/mod.rs
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
69
rustitch/src/pes/palette.rs
Normal file
69
rustitch/src/pes/palette.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
/// 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
|
||||
];
|
||||
253
rustitch/src/pes/pec.rs
Normal file
253
rustitch/src/pes/pec.rs
Normal 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
111
rustitch/src/render.rs
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user