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

69
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,69 @@
name: CI
on:
pull_request:
branches: [main]
jobs:
check:
runs-on: linux-amd64
container:
image: rust:1-bookworm
steps:
- uses: actions/checkout@v4
- name: Cache cargo registry and build
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-ci-${{ hashFiles('**/Cargo.lock') }}
- name: Check formatting
run: rustup component add rustfmt && cargo fmt --check
- name: Clippy
run: rustup component add clippy && cargo clippy -- -D warnings
- name: Test rustitch
run: cargo test -p rustitch
- name: Test stitch-peek
run: cargo test -p stitch-peek
- name: Build
run: cargo build --release
version-check:
runs-on: linux-amd64
container:
image: rust:1-bookworm
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Verify version was bumped
run: |
PR_VERSION=$(grep -m1 '^version' stitch-peek/Cargo.toml | sed 's/.*"\(.*\)"/\1/')
git fetch origin main
MAIN_VERSION=$(git show origin/main:stitch-peek/Cargo.toml | grep -m1 '^version' | sed 's/.*"\(.*\)"/\1/')
echo "PR version: $PR_VERSION"
echo "Main version: $MAIN_VERSION"
if [ "$PR_VERSION" = "$MAIN_VERSION" ]; then
echo "::error::Version in stitch-peek/Cargo.toml ($PR_VERSION) was not bumped. Please update the version before merging."
exit 1
fi
# Ensure the new version is actually newer (basic semver compare)
printf '%s\n%s' "$MAIN_VERSION" "$PR_VERSION" | sort -V | tail -1 | grep -qx "$PR_VERSION"
if [ $? -ne 0 ]; then
echo "::error::PR version ($PR_VERSION) is not newer than main ($MAIN_VERSION)."
exit 1
fi
echo "Version bump verified: $MAIN_VERSION -> $PR_VERSION"

View File

@@ -0,0 +1,95 @@
name: Release
on:
push:
branches: [main]
jobs:
build-deb:
runs-on: linux-amd64
container:
image: rust:1-bookworm
steps:
- uses: actions/checkout@v4
- name: Cache cargo registry and build
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-release-${{ hashFiles('**/Cargo.lock') }}
- name: Install packaging tools
run: apt-get update && apt-get install -y dpkg-dev
- name: Extract version
id: version
run: |
VERSION=$(grep -m1 '^version' stitch-peek/Cargo.toml | sed 's/.*"\(.*\)"/\1/')
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Building version $VERSION"
- name: Build release binary
run: cargo build --release -p stitch-peek
- name: Run tests
run: cargo test --release
- name: Package .deb
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
PKG="stitch-peek_${VERSION}_amd64"
mkdir -p "${PKG}/DEBIAN"
mkdir -p "${PKG}/usr/bin"
mkdir -p "${PKG}/usr/share/thumbnailers"
mkdir -p "${PKG}/usr/share/mime/packages"
cp target/release/stitch-peek "${PKG}/usr/bin/"
strip "${PKG}/usr/bin/stitch-peek"
cp data/stitch-peek.thumbnailer "${PKG}/usr/share/thumbnailers/"
cp data/pes.xml "${PKG}/usr/share/mime/packages/"
# Control file -- fields must start at column 0, continuation lines start with a space
printf '%s\n' \
"Package: stitch-peek" \
"Version: ${VERSION}" \
"Section: graphics" \
"Priority: optional" \
"Architecture: amd64" \
"Depends: shared-mime-info" \
"Maintainer: stitch-peek contributors" \
"Description: Nautilus thumbnail generator for PES embroidery files" \
" stitch-peek generates thumbnails for Brother PES embroidery files," \
" allowing GNOME/Nautilus to display preview images in the file manager." \
> "${PKG}/DEBIAN/control"
printf '#!/bin/sh\nset -e\nif command -v update-mime-database >/dev/null 2>&1; then\n update-mime-database /usr/share/mime\nfi\n' \
> "${PKG}/DEBIAN/postinst"
chmod 755 "${PKG}/DEBIAN/postinst"
cp "${PKG}/DEBIAN/postinst" "${PKG}/DEBIAN/postrm"
chmod 755 "${PKG}/DEBIAN/postrm"
dpkg-deb --build "${PKG}"
echo "Built: ${PKG}.deb"
- name: Create git tag
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
git config user.name "Gitea CI"
git config user.email "ci@noreply.localhost"
git tag -a "v${VERSION}" -m "Release v${VERSION}"
git push origin "v${VERSION}"
- name: Create release
uses: actions/gitea-release@v1
with:
token: ${{ secrets.RELEASE_TOKEN }}
tag_name: v${{ steps.version.outputs.version }}
title: v${{ steps.version.outputs.version }}
files: stitch-peek_${{ steps.version.outputs.version }}_amd64.deb

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

354
Cargo.lock generated Normal file
View File

@@ -0,0 +1,354 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "png"
version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustitch"
version = "0.1.0"
dependencies = [
"png",
"thiserror",
"tiny-skia",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "stitch-peek"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"rustitch",
]
[[package]]
name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"png",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]

3
Cargo.toml Normal file
View File

@@ -0,0 +1,3 @@
[workspace]
members = ["rustitch", "stitch-peek"]
resolver = "2"

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 stitch-peek contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

131
README.md Normal file
View File

@@ -0,0 +1,131 @@
# stitch-peek
A Nautilus/GNOME thumbnailer for **PES embroidery files**. Browse your embroidery designs in the file manager with automatic thumbnail previews.
Built as two crates:
- **rustitch** -- library for parsing PES files and rendering stitch data to images
- **stitch-peek** -- CLI thumbnailer that integrates with GNOME/Nautilus via the freedesktop thumbnail spec
<!-- TODO: Add screenshot of Nautilus showing PES thumbnails -->
## Installation
### From .deb (Debian/Ubuntu)
Download the latest `.deb` from the [Releases](../../releases) page:
```sh
sudo dpkg -i stitch-peek_*_amd64.deb
```
This installs the binary, thumbnailer entry, and MIME type definition. Restart Nautilus to pick up the changes:
```sh
nautilus -q
```
### From source
Requires Rust 1.70+.
```sh
git clone https://github.com/YOUR_USER/stitch-peek-rs.git
cd stitch-peek-rs
cargo build --release
sudo install -Dm755 target/release/stitch-peek /usr/local/bin/stitch-peek
sudo install -Dm644 data/stitch-peek.thumbnailer /usr/share/thumbnailers/stitch-peek.thumbnailer
sudo install -Dm644 data/pes.xml /usr/share/mime/packages/pes.xml
sudo update-mime-database /usr/share/mime
nautilus -q
```
## Usage
### As a thumbnailer
Once installed, Nautilus will automatically generate thumbnails for `.pes` files. No manual action needed -- just open a folder containing PES files.
### Standalone CLI
Generate a thumbnail manually:
```sh
stitch-peek -i design.pes -o preview.png -s 256
```
| Flag | Description | Default |
|------|-------------|---------|
| `-i` | Input PES file | required |
| `-o` | Output PNG path | required |
| `-s` | Thumbnail size (pixels) | 128 |
### As a library
Add `rustitch` to your project:
```toml
[dependencies]
rustitch = { git = "https://github.com/YOUR_USER/stitch-peek-rs.git" }
```
```rust
let pes_data = std::fs::read("design.pes")?;
let png_bytes = rustitch::thumbnail(&pes_data, 256)?;
std::fs::write("preview.png", &png_bytes)?;
```
## How it works
1. **Parse** the PES binary format -- extract the PEC section containing stitch commands and thread color indices
2. **Decode** the stitch byte stream into movement commands (stitches, jumps, trims, color changes)
3. **Resolve** relative movements into absolute coordinates grouped by thread color
4. **Render** anti-aliased line segments onto a transparent canvas using [tiny-skia](https://github.com/nickel-org/tiny-skia), scaled to fit the requested thumbnail size
5. **Encode** the result as a PNG image
## Supported formats
Currently supports **PES** (Brother PE-Design) embroidery files, versions 1 through 6. The PEC section -- which contains the actual stitch data -- is consistent across versions.
## Project structure
```
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
├── stitch-peek/ # Binary crate (CLI thumbnailer)
│ └── src/main.rs
└── data/
├── stitch-peek.thumbnailer # Nautilus integration
└── pes.xml # MIME type definition
```
## Development
```sh
cargo test # run all tests
cargo clippy # lint
cargo fmt --check # check formatting
```
Pull requests must bump the version in `stitch-peek/Cargo.toml` -- CI will reject merges without a version bump.
## Contributing
1. Fork the repo
2. Create a feature branch (`git checkout -b feature/my-change`)
3. Make your changes and add tests where appropriate
4. Ensure `cargo test && cargo clippy && cargo fmt --check` pass
5. Bump the version in `stitch-peek/Cargo.toml`
6. Open a pull request
## License
This project is licensed under the MIT License. See [LICENSE](LICENSE) for details.

10
data/pes.xml Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
<mime-type type="application/x-pes">
<comment>PES embroidery file</comment>
<glob pattern="*.pes"/>
<magic priority="50">
<match type="string" offset="0" value="#PES"/>
</magic>
</mime-type>
</mime-info>

View File

@@ -0,0 +1,4 @@
[Thumbnailer Entry]
TryExec=stitch-peek
Exec=stitch-peek -i %i -o %o -s %s
MimeType=application/x-pes

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)
}

9
stitch-peek/Cargo.toml Normal file
View File

@@ -0,0 +1,9 @@
[package]
name = "stitch-peek"
version = "0.1.0"
edition = "2021"
[dependencies]
rustitch = { path = "../rustitch" }
clap = { version = "4", features = ["derive"] }
anyhow = "1"

34
stitch-peek/src/main.rs Normal file
View File

@@ -0,0 +1,34 @@
use anyhow::{Context, Result};
use clap::Parser;
use std::fs;
#[derive(Parser)]
#[command(name = "stitch-peek", about = "PES embroidery file thumbnailer")]
struct Args {
/// Input PES file path
#[arg(short = 'i', long = "input")]
input: std::path::PathBuf,
/// Output PNG file path
#[arg(short = 'o', long = "output")]
output: std::path::PathBuf,
/// Thumbnail size in pixels
#[arg(short = 's', long = "size", default_value = "128")]
size: u32,
}
fn main() -> Result<()> {
let args = Args::parse();
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")?;
fs::write(&args.output, &png)
.with_context(|| format!("failed to write {}", args.output.display()))?;
Ok(())
}