Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/ios-test.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion packages/rust/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 6 additions & 0 deletions packages/rust/ios/.gitignore
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions packages/rust/ios/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[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" }

# The XCFramework is vendored into the app repo (git), so keep the static archives lean:
# strip DWARF debug info (globals like ed_dither are retained for linking) and LTO the core in.
# panic = "unwind" is REQUIRED — the FFI relies on catch_unwind, so do not set panic = "abort".
[profile.release]
lto = true
codegen-units = 1
strip = "debuginfo"
74 changes: 74 additions & 0 deletions packages/rust/ios/README.md
Original file line number Diff line number Diff line change
@@ -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.
```
69 changes: 69 additions & 0 deletions packages/rust/ios/build-xcframework.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/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

# Strip debug (-S) and local (-x) symbols from each static archive. The exported globals
# (ed_dither / ed_abi_version) are retained for linking; this ~cuts the vendored size by a third.
echo "==> Stripping static archives"
for t in "${DEVICE_TARGET}" "${SIM_TARGETS[@]}"; do
strip -S -x "${CRATE_DIR}/target/${t}/release/${LIB_NAME}"
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)"
60 changes: 60 additions & 0 deletions packages/rust/ios/include/epaper_dithering.h
Original file line number Diff line number Diff line change
@@ -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 <stddef.h>
#include <stdint.h>
#include <stdbool.h>

#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 */
6 changes: 6 additions & 0 deletions packages/rust/ios/include/module.modulemap
Original file line number Diff line number Diff line change
@@ -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 *
}
Loading
Loading