From c4cb9ecb4ec6e7504b6c85beb521dac2a1f71bd5 Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:39:00 -0400 Subject: [PATCH 1/3] Add C-ABI iOS/Swift FFI wrapper crate (dithering only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `packages/rust/ios`, a thin C-ABI wrapper around `epaper-dithering-core` so the OpenDisplay iOS app can call the same OKLab dithering as the website/Python instead of its divergent sRGB-Euclidean Swift reimplementation. Scope is deliberately minimal — matching + error diffusion only. Tone/ gamut/exposure/saturation pre-processing is NOT surfaced: the app keeps its own tone-compression pass in Swift for now and hands us already-pre-processed pixels, so we run at DitherConfig defaults. Design (locked for iOS-only): - Plain C ABI (`ed_dither`, `ed_abi_version`), no UniFFI. - Caller-allocated output: output length is deterministically width*height, so Swift allocates; nothing crosses the boundary to free. - Indices returned in the caller's palette order, so the app's existing wire-format packing needs no remap. - Panics can't cross FFI (catch_unwind -> ED_ERR_PANIC); errors are negative status codes. Wiring: - staticlib+cdylib+rlib crate, excluded from the workspace (like `wasm`) so Linux `cargo build --workspace` and the Python sdist vendoring are unaffected. - build-xcframework.sh produces EpaperDithering.xcframework (device arm64 + simulator arm64/x86_64) — the only Xcode-gated step. - ios-test.yml runs the FFI unit/integration tests on the HOST target (Ubuntu, no Xcode) on every PR; includes a test asserting byte-for-byte parity with calling core::dither directly. Not built/verified locally: no Rust toolchain on the authoring machine. CI (ios-test.yml) builds and tests the FFI; the XCFramework must be built on a Mac with Rust + iOS targets. App-side ImageProcessor integration is a follow-up PR in the app repo. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01KCmHoU5FHgL4Sec6EFfLpy --- .github/workflows/ios-test.yml | 43 +++ packages/rust/Cargo.toml | 5 +- packages/rust/ios/.gitignore | 6 + packages/rust/ios/Cargo.toml | 16 + packages/rust/ios/README.md | 74 +++++ packages/rust/ios/build-xcframework.sh | 62 ++++ packages/rust/ios/include/epaper_dithering.h | 60 ++++ packages/rust/ios/include/module.modulemap | 6 + packages/rust/ios/src/lib.rs | 330 +++++++++++++++++++ 9 files changed, 601 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ios-test.yml create mode 100644 packages/rust/ios/.gitignore create mode 100644 packages/rust/ios/Cargo.toml create mode 100644 packages/rust/ios/README.md create mode 100755 packages/rust/ios/build-xcframework.sh create mode 100644 packages/rust/ios/include/epaper_dithering.h create mode 100644 packages/rust/ios/include/module.modulemap create mode 100644 packages/rust/ios/src/lib.rs diff --git a/.github/workflows/ios-test.yml b/.github/workflows/ios-test.yml new file mode 100644 index 0000000..e2b724e --- /dev/null +++ b/.github/workflows/ios-test.yml @@ -0,0 +1,43 @@ +name: iOS Wrapper Tests + +# The `ios` crate is workspace-excluded (so it never breaks the Linux `cargo test --workspace` +# in rust-test.yml), so it needs its own job. These tests build the C-ABI FFI for the HOST +# target and exercise it — no Xcode and no Apple iOS targets required. The actual XCFramework +# build (build-xcframework.sh) is macOS/Xcode-only and is not run in CI. + +on: + push: + branches: [main] + paths: + - 'packages/rust/ios/**' + - 'packages/rust/core/**' + - '.github/workflows/ios-test.yml' + pull_request: + paths: + - 'packages/rust/ios/**' + - 'packages/rust/core/**' + - '.github/workflows/ios-test.yml' + workflow_dispatch: + +defaults: + run: + working-directory: packages/rust/ios + +jobs: + test: + name: Test (host FFI) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: packages/rust/ios + + - name: Test + run: cargo test + + - name: Clippy + run: cargo clippy -- -D warnings diff --git a/packages/rust/Cargo.toml b/packages/rust/Cargo.toml index 64955dd..8fe83a6 100644 --- a/packages/rust/Cargo.toml +++ b/packages/rust/Cargo.toml @@ -1,4 +1,7 @@ [workspace] members = ["core"] -exclude = ["wasm"] +# `wasm` and `ios` are excluded so `cargo build` at the workspace root (and Linux CI) never +# tries to cross-compile them, and so the Python sdist's cargo vendoring stays intact +# (issue #40). Each is built independently by its own tooling. +exclude = ["wasm", "ios"] resolver = "2" \ No newline at end of file diff --git a/packages/rust/ios/.gitignore b/packages/rust/ios/.gitignore new file mode 100644 index 0000000..cccd848 --- /dev/null +++ b/packages/rust/ios/.gitignore @@ -0,0 +1,6 @@ +# Build artifacts — the XCFramework is produced by build-xcframework.sh and vendored into +# the consuming app repo, not committed here. +/target +/.build +/EpaperDithering.xcframework +Cargo.lock diff --git a/packages/rust/ios/Cargo.toml b/packages/rust/ios/Cargo.toml new file mode 100644 index 0000000..b15ae4d --- /dev/null +++ b/packages/rust/ios/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "epaper-dithering-ios" +version = "0.1.0" +edition = "2024" +description = "C-ABI FFI wrapper around epaper-dithering-core for iOS/Swift (dithering only; matching + error diffusion)" +license = "MIT" +repository = "https://github.com/OpenDisplay-org/epaper-dithering" +publish = false + +[lib] +# staticlib → a `.a` we bundle into an XCFramework and link into the iOS app. +# cdylib is also emitted so the crate can be smoke-tested as a shared object on the host. +crate-type = ["staticlib", "cdylib", "rlib"] + +[dependencies] +epaper-dithering-core = { path = "../core" } diff --git a/packages/rust/ios/README.md b/packages/rust/ios/README.md new file mode 100644 index 0000000..83f302b --- /dev/null +++ b/packages/rust/ios/README.md @@ -0,0 +1,74 @@ +# epaper-dithering-ios + +C-ABI FFI wrapper around [`epaper-dithering-core`](../core) for the OpenDisplay **iOS/Swift** +app. Sibling of the [`wasm`](../wasm) crate — same "thin wrapper over the core" role, different +ABI (plain C instead of `wasm-bindgen`). + +## Scope (deliberately minimal) + +Exposes **only dithering** — OKLab palette matching + error diffusion. It does **not** surface +the core's tone / gamut / exposure / saturation pre-processing: the iOS app keeps its own +tone-compression pass in Swift for now and hands us already-pre-processed pixels, so we run +pure matching + diffusion at `DitherConfig` defaults (all pre-processing off). + +Why a wrapper at all: the app currently reimplements dithering in Swift with **sRGB-Euclidean** +nearest-color matching, which diverges from the core / website / Python (**OKLab**). This makes +the iOS output match the reference implementation exactly. + +## API + +Two functions (see [`include/epaper_dithering.h`](include/epaper_dithering.h)): + +- `ed_dither(...) -> int32_t` — dither a flat sRGB image to palette indices. +- `ed_abi_version() -> uint32_t` — ABI version (currently `1`). + +Contract highlights: + +- **Output is caller-allocated.** Output length is deterministic — exactly `width * height` + indices — so Swift allocates the buffer; nothing is allocated across the boundary and there + is no free function. +- **Indices are in the palette you pass.** Feed the app's palette in the app's index order and + the returned indices line up with the app's wire-format packing with no remap. +- **Panics never cross the boundary** (`catch_unwind` → `ED_ERR_PANIC`); errors are negative + status codes, never exceptions. + +## Build & test + +Host-side unit/integration tests (no Xcode, no iOS targets needed): + +```sh +cargo test --manifest-path packages/rust/ios/Cargo.toml +``` + +Produce the XCFramework (macOS + Xcode + Rust; the **only** Xcode-gated step in the repo): + +```sh +rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios +packages/rust/ios/build-xcframework.sh +# → packages/rust/ios/EpaperDithering.xcframework +``` + +The resulting `EpaperDithering.xcframework` (device arm64 + simulator arm64/x86_64) is vendored +into the app repo, not committed here. + +## Swift usage sketch + +```swift +import EpaperDithering + +// pixels: [UInt8] flat RGB (w*h*3); palette: app palette bytes in app index order +var indices = [UInt8](repeating: 0, count: w * h) +let status = pixels.withUnsafeBufferPointer { px in + palette.withUnsafeBufferPointer { pal in + indices.withUnsafeMutableBufferPointer { out in + ed_dither(px.baseAddress, px.count, w, + pal.baseAddress, pal.count, accentIdx, + nil, 0, 0, // no canonical palette + modeId, true, + out.baseAddress, out.count) + } + } +} +precondition(status == ED_OK) +// `indices` are palette indices in app order → hand straight to the existing packer. +``` diff --git a/packages/rust/ios/build-xcframework.sh b/packages/rust/ios/build-xcframework.sh new file mode 100755 index 0000000..ee0ce49 --- /dev/null +++ b/packages/rust/ios/build-xcframework.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Build EpaperDithering.xcframework from the iOS FFI wrapper crate. +# +# Requires macOS with Xcode + Rust (rustup) and the Apple iOS targets: +# rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios +# +# Output: ./EpaperDithering.xcframework (device arm64 + simulator arm64/x86_64 fat) +# +# This is the ONLY step that needs Xcode. Everything else in the repo (core, python, +# javascript, and `cargo test` of this crate on the host) builds without it. +set -euo pipefail + +CRATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_NAME="libepaper_dithering_ios.a" +OUT_DIR="${CRATE_DIR}/EpaperDithering.xcframework" +HEADERS_SRC="${CRATE_DIR}/include" +BUILD="${CRATE_DIR}/.build" + +DEVICE_TARGET="aarch64-apple-ios" +SIM_TARGETS=("aarch64-apple-ios-sim" "x86_64-apple-ios") + +echo "==> Checking toolchain" +command -v cargo >/dev/null || { echo "error: cargo not found (install rustup)"; exit 1; } +command -v xcodebuild >/dev/null || { echo "error: xcodebuild not found (install Xcode)"; exit 1; } +for t in "${DEVICE_TARGET}" "${SIM_TARGETS[@]}"; do + rustup target list --installed | grep -qx "$t" || { + echo "error: missing rust target '$t' — run: rustup target add $t"; exit 1; } +done + +echo "==> Building release staticlib for each target" +for t in "${DEVICE_TARGET}" "${SIM_TARGETS[@]}"; do + cargo build --release --manifest-path "${CRATE_DIR}/Cargo.toml" --target "$t" +done + +# The ios crate is workspace-excluded, so it is its own workspace root and cargo writes +# its target dir local to the crate. +TARGET_ROOT="${CRATE_DIR}/target" + +echo "==> Assembling simulator fat archive (lipo)" +rm -rf "${BUILD}"; mkdir -p "${BUILD}/sim" "${BUILD}/device" "${BUILD}/headers" +SIM_INPUTS=() +for t in "${SIM_TARGETS[@]}"; do + SIM_INPUTS+=("${TARGET_ROOT}/${t}/release/${LIB_NAME}") +done +lipo -create "${SIM_INPUTS[@]}" -output "${BUILD}/sim/${LIB_NAME}" +cp "${TARGET_ROOT}/${DEVICE_TARGET}/release/${LIB_NAME}" "${BUILD}/device/${LIB_NAME}" + +echo "==> Staging headers (+ modulemap)" +cp "${HEADERS_SRC}/epaper_dithering.h" "${BUILD}/headers/" +cp "${HEADERS_SRC}/module.modulemap" "${BUILD}/headers/" + +echo "==> Creating XCFramework" +rm -rf "${OUT_DIR}" +xcodebuild -create-xcframework \ + -library "${BUILD}/device/${LIB_NAME}" -headers "${BUILD}/headers" \ + -library "${BUILD}/sim/${LIB_NAME}" -headers "${BUILD}/headers" \ + -output "${OUT_DIR}" + +echo "==> Done: ${OUT_DIR}" +echo " Device: arm64 (${DEVICE_TARGET})" +echo " Simulator: arm64 + x86_64 (fat)" diff --git a/packages/rust/ios/include/epaper_dithering.h b/packages/rust/ios/include/epaper_dithering.h new file mode 100644 index 0000000..e0fd5fd --- /dev/null +++ b/packages/rust/ios/include/epaper_dithering.h @@ -0,0 +1,60 @@ +/* + * epaper_dithering.h — C ABI for the epaper-dithering iOS wrapper. + * + * Hand-written (the surface is two functions). Keep in sync with `src/lib.rs`. + * Bump ED_ABI_VERSION / ed_abi_version() together when the signature changes. + */ +#ifndef EPAPER_DITHERING_H +#define EPAPER_DITHERING_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Status codes returned by ed_dither. */ +#define ED_OK 0 +#define ED_ERR_NULL_POINTER -1 +#define ED_ERR_BAD_WIDTH -2 +#define ED_ERR_BAD_PIXELS -3 +#define ED_ERR_BAD_PALETTE -4 +#define ED_ERR_BAD_OUTPUT_LEN -5 +#define ED_ERR_BAD_MODE -6 +#define ED_ERR_PANIC -7 + +/* + * Dither a flat sRGB image to palette indices (matching + error diffusion only). + * + * pixels/pixels_len flat RGB bytes, len = width*height*3 + * width image width in pixels (> 0) + * matching_palette/… palette matched against, caller index order; len multiple of 3, + * >= 2 colors; matching_accent < color count. Required. + * canonical_palette/… optional ideal-color palette for exact-pixel passthrough; + * pass canonical_len == 0 (and NULL) to disable. + * mode_id DitherMode discriminant: 0=None,1=Burkes,2=Ordered, + * 3=FloydSteinberg,4=Atkinson,5=Stucki,6=Sierra,7=SierraLite, + * 8=JarvisJudiceNinke + * serpentine serpentine scan for error-diffusion modes + * out/out_len caller-allocated output; out_len MUST equal width*height. + * One u8 palette index per pixel, in matching-palette order. + * + * Returns ED_OK (0) on success (out filled), or a negative ED_ERR_* code (out untouched). + * Never panics across the boundary; never allocates memory the caller must free. + */ +int32_t ed_dither(const uint8_t *pixels, size_t pixels_len, size_t width, + const uint8_t *matching_palette, size_t matching_len, size_t matching_accent, + const uint8_t *canonical_palette, size_t canonical_len, size_t canonical_accent, + uint8_t mode_id, bool serpentine, + uint8_t *out, size_t out_len); + +/* ABI version of this wrapper (currently 1). */ +uint32_t ed_abi_version(void); + +#ifdef __cplusplus +} +#endif + +#endif /* EPAPER_DITHERING_H */ diff --git a/packages/rust/ios/include/module.modulemap b/packages/rust/ios/include/module.modulemap new file mode 100644 index 0000000..b0bfc57 --- /dev/null +++ b/packages/rust/ios/include/module.modulemap @@ -0,0 +1,6 @@ +// Lets Swift `import EpaperDithering` and see the C declarations as a clean module, +// rather than requiring a bridging header. Bundled into the XCFramework's Headers dir. +module EpaperDithering { + header "epaper_dithering.h" + export * +} diff --git a/packages/rust/ios/src/lib.rs b/packages/rust/ios/src/lib.rs new file mode 100644 index 0000000..228b20c --- /dev/null +++ b/packages/rust/ios/src/lib.rs @@ -0,0 +1,330 @@ +//! C-ABI FFI wrapper around `epaper-dithering-core`, intended for the iOS/Swift app. +//! +//! Scope is deliberately minimal — this exposes **only** the dithering step (palette +//! matching in OKLab + error diffusion). The tone/gamut/exposure/saturation +//! pre-processing pipeline is intentionally *not* surfaced here: the OpenDisplay iOS app +//! keeps its own tone-compression pass in Swift for now, so it hands us pixels that are +//! already pre-processed and we run pure matching + diffusion at `DitherConfig` defaults +//! (all pre-processing off). +//! +//! ## Contract +//! +//! - **Output is caller-allocated.** The number of output indices is deterministic — +//! exactly `width * height`, one `u8` palette index per pixel — so Swift allocates the +//! buffer and we fill it. There is no allocation handed across the boundary and no free +//! function to call. +//! - **Indices are into the palette you pass.** Feed the app's palette in the app's own +//! index order and the returned indices line up with the app's wire-format packing with +//! no remap. When a `canonical` palette is supplied, matching uses `matching` (e.g. a +//! measured palette) while already-displayable exact colors pass through via `canonical`; +//! the returned indices are still in `matching` order (which the app keeps index-aligned +//! with `canonical`). +//! - **Panics never cross the boundary.** The body runs inside `catch_unwind`; a panic is +//! converted to [`ED_ERR_PANIC`]. +//! - **Errors are status codes**, never exceptions. `0` is success; negative values are the +//! `ED_ERR_*` constants below. + +use std::panic::{self, AssertUnwindSafe}; + +use epaper_dithering_core::{ + DitherConfig, dither, dither_with_canonical, + enums::DitherMode, + palettes::Palette, + types::ImageBuffer, +}; + +/// Success. +pub const ED_OK: i32 = 0; +/// A required pointer argument was null. +pub const ED_ERR_NULL_POINTER: i32 = -1; +/// `width` was zero. +pub const ED_ERR_BAD_WIDTH: i32 = -2; +/// `pixels_len` is not a multiple of 3, or not a whole number of `width`-pixel rows. +pub const ED_ERR_BAD_PIXELS: i32 = -3; +/// A palette byte length was not a multiple of 3, held fewer than 2 colors, or had an +/// out-of-range accent index. +pub const ED_ERR_BAD_PALETTE: i32 = -4; +/// `out_len` does not equal `width * height`. +pub const ED_ERR_BAD_OUTPUT_LEN: i32 = -5; +/// `mode_id` is not a known [`DitherMode`] discriminant. +pub const ED_ERR_BAD_MODE: i32 = -6; +/// A panic was caught inside the FFI body. +pub const ED_ERR_PANIC: i32 = -7; + +/// Interpret a `*const u8` + length as a palette of RGB triples. +/// +/// Returns `Ok(None)` when `len == 0` (meaning "no palette supplied"), `Ok(Some(palette))` +/// for a valid palette, or `Err(code)` for a malformed one. +fn build_palette(ptr: *const u8, len: usize, accent_idx: usize) -> Result, i32> { + if len == 0 { + return Ok(None); + } + if ptr.is_null() { + return Err(ED_ERR_NULL_POINTER); + } + if len % 3 != 0 { + return Err(ED_ERR_BAD_PALETTE); + } + // SAFETY: caller guarantees `ptr` is valid for `len` bytes; checked non-null above. + let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }; + let colors: Vec<[u8; 3]> = bytes.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect(); + if colors.len() < 2 || accent_idx >= colors.len() { + return Err(ED_ERR_BAD_PALETTE); + } + Ok(Some(Palette::new(colors, accent_idx))) +} + +/// Dither a flat RGB image to palette indices. +/// +/// # Parameters +/// - `pixels` / `pixels_len`: flat sRGB bytes, `len = width * height * 3`. +/// - `width`: image width in pixels (`> 0`). +/// - `matching_palette` / `matching_len` / `matching_accent`: the palette matched against, +/// in the caller's index order (`len` a multiple of 3, ≥ 2 colors). Required. +/// - `canonical_palette` / `canonical_len` / `canonical_accent`: optional ideal-color +/// palette for exact-pixel passthrough. Pass `canonical_len == 0` to disable (plain +/// matching against `matching_palette`). +/// - `mode_id`: [`DitherMode`] discriminant (0 = None … 8 = JarvisJudiceNinke). +/// - `serpentine`: serpentine scanning for error-diffusion modes. +/// - `out` / `out_len`: caller-allocated output, `out_len` must equal `width * height`. +/// +/// # Returns +/// [`ED_OK`] on success (and `out` is filled), or a negative `ED_ERR_*` code on failure +/// (and `out` is left untouched). +/// +/// # Safety +/// All pointers must either be null (where documented as optional) or valid for their +/// stated lengths for the duration of the call. `out` must be writable for `out_len` bytes. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn ed_dither( + pixels: *const u8, + pixels_len: usize, + width: usize, + matching_palette: *const u8, + matching_len: usize, + matching_accent: usize, + canonical_palette: *const u8, + canonical_len: usize, + canonical_accent: usize, + mode_id: u8, + serpentine: bool, + out: *mut u8, + out_len: usize, +) -> i32 { + let result = panic::catch_unwind(AssertUnwindSafe(|| { + if pixels.is_null() || out.is_null() { + return ED_ERR_NULL_POINTER; + } + if width == 0 { + return ED_ERR_BAD_WIDTH; + } + if pixels_len % 3 != 0 || (pixels_len / 3) % width != 0 { + return ED_ERR_BAD_PIXELS; + } + let pixel_count = pixels_len / 3; + if out_len != pixel_count { + return ED_ERR_BAD_OUTPUT_LEN; + } + + let mode = match DitherMode::try_from(mode_id) { + Ok(m) => m, + Err(_) => return ED_ERR_BAD_MODE, + }; + + let matching = match build_palette(matching_palette, matching_len, matching_accent) { + Ok(Some(p)) => p, + // A matching palette is required — treat "none supplied" as malformed. + Ok(None) => return ED_ERR_BAD_PALETTE, + Err(code) => return code, + }; + let canonical = match build_palette(canonical_palette, canonical_len, canonical_accent) { + Ok(c) => c, + Err(code) => return code, + }; + + // SAFETY: validated non-null and length above. + let pixel_bytes = unsafe { std::slice::from_raw_parts(pixels, pixels_len) }; + let img = ImageBuffer::new(pixel_bytes, width); + + // Pre-processing intentionally left at defaults (off) — tone/gamut stay in the app. + let config = DitherConfig { mode, serpentine, ..Default::default() }; + + let indices = match canonical { + Some(canon) => dither_with_canonical(&img, &matching, &canon, config), + None => dither(&img, &matching, config), + }; + + debug_assert_eq!(indices.len(), pixel_count); + // SAFETY: `out` validated non-null and `out_len == pixel_count == indices.len()`. + unsafe { + std::ptr::copy_nonoverlapping(indices.as_ptr(), out, indices.len()); + } + ED_OK + })); + + result.unwrap_or(ED_ERR_PANIC) +} + +/// ABI version of this wrapper. Bump when the exported signature changes so the Swift side +/// can assert compatibility at load time. +#[unsafe(no_mangle)] +pub extern "C" fn ed_abi_version() -> u32 { + 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Solid-red 2×2 against a black/white/red palette (app order) must map every pixel to + /// index 2 (red), exercising the exact-color bypass through the FFI. + #[test] + fn solid_red_maps_to_red_index() { + let pixels: Vec = std::iter::repeat_n([255u8, 0, 0], 4).flatten().collect(); + let palette: [u8; 9] = [0, 0, 0, 255, 255, 255, 255, 0, 0]; // black, white, red + let mut out = [0u8; 4]; + let code = unsafe { + ed_dither( + pixels.as_ptr(), + pixels.len(), + 2, + palette.as_ptr(), + palette.len(), + 2, + std::ptr::null(), + 0, + 0, + DitherMode::Burkes as u8, + true, + out.as_mut_ptr(), + out.len(), + ) + }; + assert_eq!(code, ED_OK); + assert_eq!(out, [2, 2, 2, 2]); + } + + /// The FFI must produce byte-identical output to calling `core::dither` directly — + /// this is the whole point of the wrapper (no logic of its own). + #[test] + fn ffi_matches_core_dither() { + // A small gradient so error diffusion actually does something. + let width = 4usize; + let height = 4usize; + let mut pixels = Vec::with_capacity(width * height * 3); + for i in 0..(width * height) { + let v = (i * 16) as u8; + pixels.extend_from_slice(&[v, v, v]); + } + let palette_bytes: [u8; 6] = [0, 0, 0, 255, 255, 255]; // black, white + let palette = Palette::new(vec![[0, 0, 0], [255, 255, 255]], 1); + + let img = ImageBuffer::new(&pixels, width); + let expected = dither( + &img, + &palette, + DitherConfig { mode: DitherMode::FloydSteinberg, serpentine: true, ..Default::default() }, + ); + + let mut out = vec![0u8; width * height]; + let code = unsafe { + ed_dither( + pixels.as_ptr(), + pixels.len(), + width, + palette_bytes.as_ptr(), + palette_bytes.len(), + 1, + std::ptr::null(), + 0, + 0, + DitherMode::FloydSteinberg as u8, + true, + out.as_mut_ptr(), + out.len(), + ) + }; + assert_eq!(code, ED_OK); + assert_eq!(out, expected); + } + + #[test] + fn rejects_bad_output_len() { + let pixels = [0u8; 12]; // 4 px + let palette: [u8; 6] = [0, 0, 0, 255, 255, 255]; + let mut out = [0u8; 3]; // wrong: should be 4 + let code = unsafe { + ed_dither( + pixels.as_ptr(), + pixels.len(), + 2, + palette.as_ptr(), + palette.len(), + 1, + std::ptr::null(), + 0, + 0, + DitherMode::Burkes as u8, + true, + out.as_mut_ptr(), + out.len(), + ) + }; + assert_eq!(code, ED_ERR_BAD_OUTPUT_LEN); + } + + #[test] + fn rejects_null_pixels() { + let mut out = [0u8; 4]; + let palette: [u8; 6] = [0, 0, 0, 255, 255, 255]; + let code = unsafe { + ed_dither( + std::ptr::null(), + 12, + 2, + palette.as_ptr(), + palette.len(), + 1, + std::ptr::null(), + 0, + 0, + DitherMode::Burkes as u8, + true, + out.as_mut_ptr(), + out.len(), + ) + }; + assert_eq!(code, ED_ERR_NULL_POINTER); + } + + #[test] + fn rejects_unknown_mode() { + let pixels = [0u8; 12]; + let palette: [u8; 6] = [0, 0, 0, 255, 255, 255]; + let mut out = [0u8; 4]; + let code = unsafe { + ed_dither( + pixels.as_ptr(), + pixels.len(), + 2, + palette.as_ptr(), + palette.len(), + 1, + std::ptr::null(), + 0, + 0, + 99, + true, + out.as_mut_ptr(), + out.len(), + ) + }; + assert_eq!(code, ED_ERR_BAD_MODE); + } + + #[test] + fn abi_version_is_one() { + assert_eq!(ed_abi_version(), 1); + } +} From 8c5b30566196cd6973ca6600b3b8b993e432627c Mon Sep 17 00:00:00 2001 From: David Lee <247393336+davelee98@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:49:24 -0400 Subject: [PATCH 2/3] Use is_multiple_of() to satisfy clippy -D warnings Matches the idiom already used in the wasm crate. Verified locally: cargo test (6 pass, incl. core::dither byte-parity), cargo clippy clean, and build-xcframework.sh produces a valid XCFramework (device arm64 + simulator arm64/x86_64) exporting ed_dither / ed_abi_version. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01KCmHoU5FHgL4Sec6EFfLpy --- packages/rust/ios/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rust/ios/src/lib.rs b/packages/rust/ios/src/lib.rs index 228b20c..ff0dd1a 100644 --- a/packages/rust/ios/src/lib.rs +++ b/packages/rust/ios/src/lib.rs @@ -62,7 +62,7 @@ fn build_palette(ptr: *const u8, len: usize, accent_idx: usize) -> Result