init
This commit is contained in:
69
.gitea/workflows/ci.yml
Normal file
69
.gitea/workflows/ci.yml
Normal 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"
|
||||
95
.gitea/workflows/release.yml
Normal file
95
.gitea/workflows/release.yml
Normal 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
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
354
Cargo.lock
generated
Normal file
354
Cargo.lock
generated
Normal 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
3
Cargo.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
members = ["rustitch", "stitch-peek"]
|
||||
resolver = "2"
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
131
README.md
Normal 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
10
data/pes.xml
Normal 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>
|
||||
4
data/stitch-peek.thumbnailer
Normal file
4
data/stitch-peek.thumbnailer
Normal 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
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)
|
||||
}
|
||||
9
stitch-peek/Cargo.toml
Normal file
9
stitch-peek/Cargo.toml
Normal 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
34
stitch-peek/src/main.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user