commit da71b56f2d818d4753b0aef9d028a8d88e05538b Author: Nils Pukropp Date: Mon Mar 30 00:01:49 2026 +0200 init diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..cbb1954 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..3684921 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..fc0482a --- /dev/null +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..37d22a9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["rustitch", "stitch-peek"] +resolver = "2" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..166eba7 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3c4787 --- /dev/null +++ b/README.md @@ -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 + + + +## 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. diff --git a/data/pes.xml b/data/pes.xml new file mode 100644 index 0000000..b188082 --- /dev/null +++ b/data/pes.xml @@ -0,0 +1,10 @@ + + + + PES embroidery file + + + + + + diff --git a/data/stitch-peek.thumbnailer b/data/stitch-peek.thumbnailer new file mode 100644 index 0000000..7b0c6c7 --- /dev/null +++ b/data/stitch-peek.thumbnailer @@ -0,0 +1,4 @@ +[Thumbnailer Entry] +TryExec=stitch-peek +Exec=stitch-peek -i %i -o %o -s %s +MimeType=application/x-pes diff --git a/rustitch/Cargo.toml b/rustitch/Cargo.toml new file mode 100644 index 0000000..dac3b48 --- /dev/null +++ b/rustitch/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "rustitch" +version = "0.1.0" +edition = "2021" + +[dependencies] +thiserror = "2" +tiny-skia = "0.11" +png = "0.17" diff --git a/rustitch/src/lib.rs b/rustitch/src/lib.rs new file mode 100644 index 0000000..aa9ad3b --- /dev/null +++ b/rustitch/src/lib.rs @@ -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, pes::Error> { + let design = pes::parse(pes_data)?; + let resolved = pes::resolve(&design)?; + let png_bytes = render::render_thumbnail(&resolved, size)?; + Ok(png_bytes) +} diff --git a/rustitch/src/pes/header.rs b/rustitch/src/pes/header.rs new file mode 100644 index 0000000..60a215e --- /dev/null +++ b/rustitch/src/pes/header.rs @@ -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 { + 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 { .. })); + } +} diff --git a/rustitch/src/pes/mod.rs b/rustitch/src/pes/mod.rs new file mode 100644 index 0000000..a305109 --- /dev/null +++ b/rustitch/src/pes/mod.rs @@ -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, +} + +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)?; + 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 { + 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, + }, + }) +} diff --git a/rustitch/src/pes/palette.rs b/rustitch/src/pes/palette.rs new file mode 100644 index 0000000..23b17ca --- /dev/null +++ b/rustitch/src/pes/palette.rs @@ -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 +]; diff --git a/rustitch/src/pes/pec.rs b/rustitch/src/pes/pec.rs new file mode 100644 index 0000000..7d3be98 --- /dev/null +++ b/rustitch/src/pes/pec.rs @@ -0,0 +1,253 @@ +use super::Error; + +pub struct PecHeader { + pub label: String, + pub color_count: u8, + 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, + 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 = 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, 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 })); + } +} diff --git a/rustitch/src/render.rs b/rustitch/src/render.rs new file mode 100644 index 0000000..a6621a0 --- /dev/null +++ b/rustitch/src/render.rs @@ -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, 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, 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) +} diff --git a/stitch-peek/Cargo.toml b/stitch-peek/Cargo.toml new file mode 100644 index 0000000..cf6d9cc --- /dev/null +++ b/stitch-peek/Cargo.toml @@ -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" diff --git a/stitch-peek/src/main.rs b/stitch-peek/src/main.rs new file mode 100644 index 0000000..490c7ae --- /dev/null +++ b/stitch-peek/src/main.rs @@ -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(()) +}