From e34637a3b85d38549a73533f14e01ec338e363dc Mon Sep 17 00:00:00 2001 From: Gerard Harbers Date: Fri, 24 Apr 2026 12:22:33 -0700 Subject: [PATCH 1/7] feat: add cmx-icc WASM bindings crate for npm publishing - Add `cmx-icc` workspace crate with `wasm-bindgen` bindings: - `WasmDisplayProfile` builder with preset constructors (sRGB, Display P3, Adobe RGB) and flat setters for matrix columns, TRC (gamma and parametric), white point, chromatic adaptation, and profile metadata - `WasmProfile` for parsing existing ICC profiles from bytes and re-serializing - `WasmRenderingIntent` enum bridging Rust and JS representations - Add 12 `wasm-bindgen-test` integration tests (headless Node.js) - Expose `Profile::rendering_intent()` publicly in `src/profile.rs` - Add `[workspace.package]` for lock-step versioning between `cmx` and `cmx-icc` - Add `cmx-icc/README.md` generated by `cargo rdme` for npm package page - Add `xtask publish-npm` and `xtask publish-crate` commands replacing manual publish steps - Update CLAUDE.md release process to document xtask-based publish workflow Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + CLAUDE.md | 53 +++-- Cargo.lock | 95 ++++++++ Cargo.toml | 22 +- cmx-icc/Cargo.toml | 22 ++ cmx-icc/README.md | 108 +++++++++ cmx-icc/src/lib.rs | 470 ++++++++++++++++++++++++++++++++++++++ cmx-icc/tests/bindings.rs | 170 ++++++++++++++ src/profile.rs | 5 + xtask/src/main.rs | 126 ++++++++++ 10 files changed, 1051 insertions(+), 21 deletions(-) create mode 100644 cmx-icc/Cargo.toml create mode 100644 cmx-icc/README.md create mode 100644 cmx-icc/src/lib.rs create mode 100644 cmx-icc/tests/bindings.rs diff --git a/.gitignore b/.gitignore index 50bc453..12de679 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +/cmx-icc/pkg **/.DS_Store /.vscode/ /.cargo/ diff --git a/CLAUDE.md b/CLAUDE.md index 03eefea..cfdf08f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,8 @@ src/ tag/tagdata/ # Individual tag data types tests/ # Integration tests + test .icc files examples/ # Runnable example programs -xtask/ # Code generation / maintenance utilities +cmx-icc/ # WebAssembly / npm bindings (published as cmx-icc) +xtask/ # Custom build and publish tasks (cargo xtask) ``` --- @@ -224,8 +225,12 @@ Ensure the `main` branch is up to date and all intended changes are merged. ### 4. Bump the version in `Cargo.toml` +The version is defined once in `[workspace.package]` in the root `Cargo.toml`. +Both `cmx` (crates.io) and `cmx-icc` (npm) inherit it via `version.workspace = true`, +so a single edit keeps everything in lock-step. + ```bash -# Edit the `version = "..."` field in [package] +# Edit the `version = "..."` field in [workspace.package] ``` Search the codebase for any hard-coded version strings in documentation and update them too: @@ -236,26 +241,24 @@ grep -rn "0\.0\.X" --include="*.rs" --include="*.toml" --include="*.md" ### 5. Run the full test suite and quality checks +Use the xtask dry-run, which covers tests, doc-tests, clippy, cargo doc, and README in one step: + ```bash -cargo test # unit + integration tests -cargo test --doc # doc-tests -cargo clippy -- -D warnings # must produce zero warnings -cargo doc --no-deps # must produce zero warnings +cargo xtask publish-crate --dry-run ``` -All tests must pass and both `clippy` and `cargo doc` must be warning-free before tagging. +All checks must be green before tagging. Fix any failures before proceeding. ### 6. Regenerate README.md -The `README.md` is generated from the crate-level doc comment in `src/lib.rs` using -[`cargo-rdme`](https://github.com/orium/cargo-rdme). Run it after any change to `src/lib.rs`, -and always as part of a release: +`cargo xtask publish-crate --dry-run` (step 5) regenerates `README.md` automatically via +[`cargo-rdme`](https://github.com/orium/cargo-rdme) if it is out of date. Review the diff +before staging: ```bash -cargo rdme +git diff README.md ``` -Review the diff (`git diff README.md`) to confirm the output looks correct, then stage the file. If `cargo-rdme` is not installed: `cargo install cargo-rdme`. ### 7. Commit the release @@ -279,7 +282,29 @@ git push origin main --tags ### 9. Publish to crates.io ```bash -cargo publish +cargo xtask publish-crate +``` + +This re-runs all checks (tests, clippy, doc, README) and then calls `cargo publish -p cmx`. +If anything fails, fix it before re-running — do not push the tag until this step succeeds. + +### 10. Publish to npm (cmx-icc) + +```bash +cargo xtask publish-npm ``` -`cargo publish` performs a dry-run check internally; if it fails, fix the issue before pushing the tag. +This does three things in order: + +1. Regenerates `cmx-icc/README.md` from the doc comment in `cmx-icc/src/lib.rs` + via `cargo rdme -w cmx-icc` (the README is the npm package page). +2. Runs `wasm-pack build cmx-icc --target bundler --release`, which compiles the + `.wasm` binary and copies `cmx-icc/README.md` into `cmx-icc/pkg/`. +3. Runs `npm publish cmx-icc/pkg --access public`. + +Requires `wasm-pack` and an active `npm login` session. The published package +name is `cmx-icc` at the same version as the Rust crate. + +To update the npm README, edit the module doc comment (`//!`) at the top of +`cmx-icc/src/lib.rs` — do not edit `cmx-icc/README.md` directly, as it is +generated and will be overwritten. diff --git a/Cargo.lock b/Cargo.lock index f1ed746..1885732 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -249,6 +249,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "cmx-icc" +version = "0.1.0" +dependencies = [ + "cmx", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-test", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -792,6 +802,16 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1152,6 +1172,15 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "serde" version = "1.0.219" @@ -1416,6 +1445,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasm-bindgen" version = "0.2.101" @@ -1443,6 +1482,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca85039a9b469b38336411d6d6ced91f3fc87109a2a27b0c197663f5144dffe" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.101" @@ -1475,6 +1527,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80cc7f8a4114fdaa0c58383caf973fc126cf004eba25c9dc639bccd3880d55ad" +dependencies = [ + "js-sys", + "minicov", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5ada2ab788d46d4bda04c9d567702a79c8ced14f51f221646a16ed39d0e6a5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "web-sys" +version = "0.3.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e4b637749ff0d92b8fad63aa1f7cff3cbe125fd49c175cd6345e7272638b12" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wide" version = "0.7.33" @@ -1485,6 +1571,15 @@ dependencies = [ "safe_arch", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-core" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index 3626c3a..2d453a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,26 +1,34 @@ [package] name = "cmx" -version = "0.1.0" +version.workspace = true description = "Rust Spectral Color Management Library" -authors = ["Gerard Harbers", "Harbers Bik LLC"] -repository = "https://github.com/harbik/cmx" +authors.workspace = true +repository.workspace = true keywords = ["color_management", "icc_profiles", "iccmax"] -edition = "2021" -license = "MIT OR Apache-2.0" +edition.workspace = true +license.workspace = true [workspace] members = [ "xtask", - "." # main crate + ".", # main crate + "cmx-icc", # WebAssembly bindings ] +[workspace.package] +version = "0.1.0" +authors = ["Gerard Harbers", "Harbers Bik LLC"] +license = "MIT OR Apache-2.0" +repository = "https://github.com/harbik/cmx" +edition = "2021" + [features] v5 = [] [dependencies] anyhow = "1.0.99" approx = "0.5.1" -chrono = { version = "0.4.19", features = ["serde"] } +chrono = { version = "0.4.19", features = ["serde", "wasmbind"] } clap = { version = "4.3", features = ["derive"] } colorimetry = "0.0.9" delegate = "0.13.4" diff --git a/cmx-icc/Cargo.toml b/cmx-icc/Cargo.toml new file mode 100644 index 0000000..9e78317 --- /dev/null +++ b/cmx-icc/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "cmx-icc" +version.workspace = true +edition.workspace = true +description = "WebAssembly bindings for the cmx ICC color profile library" +authors.workspace = true +license.workspace = true +repository.workspace = true +homepage = "https://github.com/harbik/cmx" +keywords = ["icc", "color-profile", "wasm", "color-management"] +readme = "README.md" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +cmx = { path = ".." } +wasm-bindgen = "0.2" +js-sys = "0.3" + +[dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/cmx-icc/README.md b/cmx-icc/README.md new file mode 100644 index 0000000..e70b30a --- /dev/null +++ b/cmx-icc/README.md @@ -0,0 +1,108 @@ + + +WebAssembly bindings for the [cmx](https://crates.io/crates/cmx) ICC color +profile library, published to npm as +[`cmx-icc`](https://www.npmjs.com/package/cmx-icc). + +Parse, inspect, and build ICC color profiles entirely in the browser or +Node.js — no native dependencies required. + +## Installation + +```sh +npm install cmx-icc +``` + +## Quick start + +```js +import init, { WasmProfile, WasmDisplayProfile, WasmRenderingIntent } from 'cmx-icc'; + +await init(); // load the .wasm binary once + +// ── Parse an existing profile ───────────────────────────────────────────── +// iccBytes is a Uint8Array, e.g. from fetch() or FileReader +const profile = WasmProfile.fromBytes(iccBytes); +const intent = profile.renderingIntent(); // WasmRenderingIntent enum value +const bytes = profile.toBytes(); // Uint8Array — byte-identical round-trip + +// ── Use a built-in preset ───────────────────────────────────────────────── +const srgb = WasmDisplayProfile.srgb(WasmRenderingIntent.RelativeColorimetric); +const p3 = WasmDisplayProfile.displayP3(WasmRenderingIntent.RelativeColorimetric); +const adobe = WasmDisplayProfile.adobeRgb(WasmRenderingIntent.RelativeColorimetric); + +// ── Build a custom matrix display profile ──────────────────────────────── +const custom = new WasmDisplayProfile(); +custom.setRenderingIntent(WasmRenderingIntent.RelativeColorimetric); +custom.setProfileDescription("My Custom Profile"); +custom.setCopyright("CC0 1.0"); +custom.setWhitePoint(0.950455, 1.0, 1.08905); // D50 XYZ +custom.setRedMatrixColumn(0.436066, 0.222488, 0.013916); +custom.setGreenMatrixColumn(0.385147, 0.716873, 0.097076); +custom.setBlueMatrixColumn(0.143066, 0.060608, 0.714096); +custom.setRedTrcGamma(2.2); +custom.setGreenTrcGamma(2.2); +custom.setBlueTrcGamma(2.2); +custom.finalize(); // embeds MD5 profile ID +const customBytes = custom.toBytes(); // Uint8Array +``` + +## TypeScript + +Full `.d.ts` declarations are included in the package. Every class and +method is documented inline, so your IDE will show JSDoc on hover. + +## Bundler support + +The package is built with `--target bundler` (Webpack, Vite, Rollup). +The `.wasm` binary is a separate file that your bundler will handle via the +standard WebAssembly asset pipeline. + +## API summary + +| Class | Purpose | +|---|---| +| `WasmProfile` | Parse an existing ICC profile from a `Uint8Array` | +| `WasmDisplayProfile` | Build a display-class ICC profile from scratch | +| `WasmRenderingIntent` | Enum: `Perceptual`, `RelativeColorimetric`, `Saturation`, `AbsoluteColorimetric` | + +### `WasmProfile` + +| Method | Description | +|---|---| +| `WasmProfile.fromBytes(data)` | Parse a `Uint8Array`; throws on invalid data | +| `profile.toBytes()` | Serialize back to `Uint8Array` (byte-identical round-trip) | +| `profile.renderingIntent()` | Read the rendering intent from the header | + +### `WasmDisplayProfile` + +| Method | Description | +|---|---| +| `new WasmDisplayProfile()` | Empty profile — set all tags manually | +| `WasmDisplayProfile.srgb(intent)` | sRGB preset | +| `WasmDisplayProfile.displayP3(intent)` | Display P3 preset | +| `WasmDisplayProfile.adobeRgb(intent)` | Adobe RGB preset | +| `setRenderingIntent(intent)` | Header rendering intent | +| `setProfileDescription(text)` | ASCII description tag | +| `setProfileDescriptionMluc(lang, country, text)` | v4 multi-language description | +| `setCopyright(text)` | Copyright tag | +| `setWhitePoint(x, y, z)` | Media white point (XYZ) | +| `setRedMatrixColumn(x, y, z)` | Red primary (XYZ) | +| `setGreenMatrixColumn(x, y, z)` | Green primary (XYZ) | +| `setBlueMatrixColumn(x, y, z)` | Blue primary (XYZ) | +| `setChromaticAdaptation(Float64Array[9])` | Bradford matrix, row-major | +| `setRedTrcGamma(gamma)` | Red TRC — simple power curve | +| `setGreenTrcGamma(gamma)` | Green TRC — simple power curve | +| `setBlueTrcGamma(gamma)` | Blue TRC — simple power curve | +| `setRedTrcParametric(Float64Array)` | Red TRC — ICC parametric curve (1/3/4/5/7 params) | +| `setGreenTrcParametric(Float64Array)` | Green TRC — ICC parametric curve | +| `setBlueTrcParametric(Float64Array)` | Blue TRC — ICC parametric curve | +| `finalize()` | Compute and embed the MD5 profile ID checksum | +| `toBytes()` | Serialize to `Uint8Array`; consumes the object | + +## Related + +- [`cmx`](https://crates.io/crates/cmx) — the underlying Rust crate +- [ICC specification](https://www.color.org/specification/ICC.1-2022-05.pdf) + + diff --git a/cmx-icc/src/lib.rs b/cmx-icc/src/lib.rs new file mode 100644 index 0000000..238e48e --- /dev/null +++ b/cmx-icc/src/lib.rs @@ -0,0 +1,470 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +// Copyright (c) 2021-2025, Harbers Bik LLC + +//! WebAssembly bindings for the [cmx](https://crates.io/crates/cmx) ICC color +//! profile library, published to npm as +//! [`cmx-icc`](https://www.npmjs.com/package/cmx-icc). +//! +//! Parse, inspect, and build ICC color profiles entirely in the browser or +//! Node.js — no native dependencies required. +//! +//! ## Installation +//! +//! ```sh +//! npm install cmx-icc +//! ``` +//! +//! ## Quick start +//! +//! ```js +//! import init, { WasmProfile, WasmDisplayProfile, WasmRenderingIntent } from 'cmx-icc'; +//! +//! await init(); // load the .wasm binary once +//! +//! // ── Parse an existing profile ───────────────────────────────────────────── +//! // iccBytes is a Uint8Array, e.g. from fetch() or FileReader +//! const profile = WasmProfile.fromBytes(iccBytes); +//! const intent = profile.renderingIntent(); // WasmRenderingIntent enum value +//! const bytes = profile.toBytes(); // Uint8Array — byte-identical round-trip +//! +//! // ── Use a built-in preset ───────────────────────────────────────────────── +//! const srgb = WasmDisplayProfile.srgb(WasmRenderingIntent.RelativeColorimetric); +//! const p3 = WasmDisplayProfile.displayP3(WasmRenderingIntent.RelativeColorimetric); +//! const adobe = WasmDisplayProfile.adobeRgb(WasmRenderingIntent.RelativeColorimetric); +//! +//! // ── Build a custom matrix display profile ──────────────────────────────── +//! const custom = new WasmDisplayProfile(); +//! custom.setRenderingIntent(WasmRenderingIntent.RelativeColorimetric); +//! custom.setProfileDescription("My Custom Profile"); +//! custom.setCopyright("CC0 1.0"); +//! custom.setWhitePoint(0.950455, 1.0, 1.08905); // D50 XYZ +//! custom.setRedMatrixColumn(0.436066, 0.222488, 0.013916); +//! custom.setGreenMatrixColumn(0.385147, 0.716873, 0.097076); +//! custom.setBlueMatrixColumn(0.143066, 0.060608, 0.714096); +//! custom.setRedTrcGamma(2.2); +//! custom.setGreenTrcGamma(2.2); +//! custom.setBlueTrcGamma(2.2); +//! custom.finalize(); // embeds MD5 profile ID +//! const customBytes = custom.toBytes(); // Uint8Array +//! ``` +//! +//! ## TypeScript +//! +//! Full `.d.ts` declarations are included in the package. Every class and +//! method is documented inline, so your IDE will show JSDoc on hover. +//! +//! ## Bundler support +//! +//! The package is built with `--target bundler` (Webpack, Vite, Rollup). +//! The `.wasm` binary is a separate file that your bundler will handle via the +//! standard WebAssembly asset pipeline. +//! +//! ## API summary +//! +//! | Class | Purpose | +//! |---|---| +//! | `WasmProfile` | Parse an existing ICC profile from a `Uint8Array` | +//! | `WasmDisplayProfile` | Build a display-class ICC profile from scratch | +//! | `WasmRenderingIntent` | Enum: `Perceptual`, `RelativeColorimetric`, `Saturation`, `AbsoluteColorimetric` | +//! +//! ### `WasmProfile` +//! +//! | Method | Description | +//! |---|---| +//! | `WasmProfile.fromBytes(data)` | Parse a `Uint8Array`; throws on invalid data | +//! | `profile.toBytes()` | Serialize back to `Uint8Array` (byte-identical round-trip) | +//! | `profile.renderingIntent()` | Read the rendering intent from the header | +//! +//! ### `WasmDisplayProfile` +//! +//! | Method | Description | +//! |---|---| +//! | `new WasmDisplayProfile()` | Empty profile — set all tags manually | +//! | `WasmDisplayProfile.srgb(intent)` | sRGB preset | +//! | `WasmDisplayProfile.displayP3(intent)` | Display P3 preset | +//! | `WasmDisplayProfile.adobeRgb(intent)` | Adobe RGB preset | +//! | `setRenderingIntent(intent)` | Header rendering intent | +//! | `setProfileDescription(text)` | ASCII description tag | +//! | `setProfileDescriptionMluc(lang, country, text)` | v4 multi-language description | +//! | `setCopyright(text)` | Copyright tag | +//! | `setWhitePoint(x, y, z)` | Media white point (XYZ) | +//! | `setRedMatrixColumn(x, y, z)` | Red primary (XYZ) | +//! | `setGreenMatrixColumn(x, y, z)` | Green primary (XYZ) | +//! | `setBlueMatrixColumn(x, y, z)` | Blue primary (XYZ) | +//! | `setChromaticAdaptation(Float64Array[9])` | Bradford matrix, row-major | +//! | `setRedTrcGamma(gamma)` | Red TRC — simple power curve | +//! | `setGreenTrcGamma(gamma)` | Green TRC — simple power curve | +//! | `setBlueTrcGamma(gamma)` | Blue TRC — simple power curve | +//! | `setRedTrcParametric(Float64Array)` | Red TRC — ICC parametric curve (1/3/4/5/7 params) | +//! | `setGreenTrcParametric(Float64Array)` | Green TRC — ICC parametric curve | +//! | `setBlueTrcParametric(Float64Array)` | Blue TRC — ICC parametric curve | +//! | `finalize()` | Compute and embed the MD5 profile ID checksum | +//! | `toBytes()` | Serialize to `Uint8Array`; consumes the object | +//! +//! ## Related +//! +//! - [`cmx`](https://crates.io/crates/cmx) — the underlying Rust crate +//! - [ICC specification](https://www.color.org/specification/ICC.1-2022-05.pdf) + +use wasm_bindgen::prelude::*; + +use cmx::{ + profile::DisplayProfile, + tag::{ + tags::{ + BlueMatrixColumnTag, BlueTRCTag, ChromaticAdaptationTag, CopyrightTag, + GreenMatrixColumnTag, GreenTRCTag, MediaWhitePointTag, ProfileDescriptionTag, + RedMatrixColumnTag, RedTRCTag, + }, + RenderingIntent, + }, +}; + +// --------------------------------------------------------------------------- +// RenderingIntent +// --------------------------------------------------------------------------- + +/// ICC rendering intent, controlling how out-of-gamut colors are handled +/// when converting between color spaces. +#[wasm_bindgen] +#[derive(Clone, Copy)] +pub enum WasmRenderingIntent { + Perceptual = 0, + RelativeColorimetric = 1, + Saturation = 2, + AbsoluteColorimetric = 3, +} + +impl From for RenderingIntent { + fn from(i: WasmRenderingIntent) -> Self { + match i { + WasmRenderingIntent::Perceptual => RenderingIntent::Perceptual, + WasmRenderingIntent::RelativeColorimetric => RenderingIntent::RelativeColorimetric, + WasmRenderingIntent::Saturation => RenderingIntent::Saturation, + WasmRenderingIntent::AbsoluteColorimetric => RenderingIntent::AbsoluteColorimetric, + } + } +} + +impl From for WasmRenderingIntent { + fn from(i: RenderingIntent) -> Self { + match i { + RenderingIntent::Perceptual => WasmRenderingIntent::Perceptual, + RenderingIntent::RelativeColorimetric => WasmRenderingIntent::RelativeColorimetric, + RenderingIntent::Saturation => WasmRenderingIntent::Saturation, + RenderingIntent::AbsoluteColorimetric => WasmRenderingIntent::AbsoluteColorimetric, + } + } +} + +// --------------------------------------------------------------------------- +// WasmProfile — parse / round-trip existing profiles +// --------------------------------------------------------------------------- + +/// A parsed ICC color profile. +/// +/// Use [`WasmProfile.fromBytes`] to parse an existing ICC file that you have +/// loaded as a `Uint8Array`, and [`WasmProfile.toBytes`] to serialize it back. +#[wasm_bindgen] +pub struct WasmProfile { + inner: cmx::profile::Profile, +} + +#[wasm_bindgen] +impl WasmProfile { + /// Parse an ICC profile from a `Uint8Array`. + /// + /// Throws a `TypeError` if the bytes do not contain a valid ICC profile. + #[wasm_bindgen(js_name = fromBytes)] + pub fn from_bytes(data: &[u8]) -> Result { + let inner = cmx::profile::Profile::from_bytes(data) + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(WasmProfile { inner }) + } + + /// Serialize the profile back to a `Uint8Array`. + /// + /// Throws if serialization fails (should not happen for a successfully + /// parsed profile). + #[wasm_bindgen(js_name = toBytes)] + pub fn to_bytes(self) -> Result, JsError> { + self.inner + .into_bytes() + .map_err(|e| JsError::new(&e.to_string())) + } + + /// Returns the rendering intent stored in the profile header. + #[wasm_bindgen(js_name = renderingIntent)] + pub fn rendering_intent(&self) -> WasmRenderingIntent { + self.inner.rendering_intent().into() + } +} + +// --------------------------------------------------------------------------- +// WasmDisplayProfile — build display profiles from scratch +// --------------------------------------------------------------------------- + +/// A display-class ICC color profile builder. +/// +/// Create a blank profile with `new WasmDisplayProfile()`, set tags via the +/// setter methods, then call `finalize()` before serializing with `toBytes()`. +/// +/// Alternatively use one of the built-in presets: +/// - [`WasmDisplayProfile.srgb`] +/// - [`WasmDisplayProfile.displayP3`] +/// - [`WasmDisplayProfile.adobeRgb`] +#[wasm_bindgen] +pub struct WasmDisplayProfile { + // Option lets us temporarily take ownership to drive the consuming builder. + inner: Option, +} + +impl WasmDisplayProfile { + /// Apply a consuming transform to the inner `DisplayProfile`. + fn mutate DisplayProfile>(&mut self, f: F) { + if let Some(p) = self.inner.take() { + self.inner = Some(f(p)); + } + } +} + +#[wasm_bindgen] +impl WasmDisplayProfile { + /// Create a new, empty display profile. + /// + /// You must set all required tags before the profile is usable by a CMS: + /// at minimum `profileDescriptionTag`, `copyrightTag`, `mediaWhitePointTag`, + /// the three matrix-column tags, and the three TRC tags (for matrix-based + /// display profiles). + #[wasm_bindgen(constructor)] + pub fn new() -> WasmDisplayProfile { + WasmDisplayProfile { + inner: Some(DisplayProfile::new()), + } + } + + // -- Presets ------------------------------------------------------------- + + /// Returns a ready-to-use sRGB display profile. + pub fn srgb(intent: WasmRenderingIntent) -> WasmDisplayProfile { + WasmDisplayProfile { + inner: Some(DisplayProfile::cmx_srgb(intent.into())), + } + } + + /// Returns a ready-to-use Display P3 display profile. + #[wasm_bindgen(js_name = displayP3)] + pub fn display_p3(intent: WasmRenderingIntent) -> WasmDisplayProfile { + WasmDisplayProfile { + inner: Some(DisplayProfile::cmx_display_p3(intent.into())), + } + } + + /// Returns a ready-to-use Adobe RGB display profile. + #[wasm_bindgen(js_name = adobeRgb)] + pub fn adobe_rgb(intent: WasmRenderingIntent) -> WasmDisplayProfile { + WasmDisplayProfile { + inner: Some(DisplayProfile::cmx_adobe_rgb(intent.into())), + } + } + + // -- Header fields ------------------------------------------------------- + + /// Set the rendering intent in the profile header. + #[wasm_bindgen(js_name = setRenderingIntent)] + pub fn set_rendering_intent(&mut self, intent: WasmRenderingIntent) { + self.mutate(|p| p.with_rendering_intent(intent.into())); + } + + // -- Descriptive tags ---------------------------------------------------- + + /// Set the profile description (ASCII). + /// + /// Writes a legacy `desc` (v2 `TextDescriptionData`) tag, which is the + /// most broadly compatible format. For v4 multi-language descriptions use + /// [`setProfileDescriptionMluc`]. + #[wasm_bindgen(js_name = setProfileDescription)] + pub fn set_profile_description(&mut self, description: &str) { + self.mutate(|p| { + p.with_tag(ProfileDescriptionTag) + .as_text_description(|t| t.set_ascii(description)) + }); + } + + /// Set the profile description using the v4 `mluc` (MultiLocalizedUnicode) tag. + /// + /// `language` and `country` must each be a 2-character ISO code, + /// e.g. `"en"` / `"US"`. + #[wasm_bindgen(js_name = setProfileDescriptionMluc)] + pub fn set_profile_description_mluc(&mut self, language: &str, country: &str, text: &str) { + self.mutate(|p| { + p.with_tag(ProfileDescriptionTag) + .as_multi_localized_unicode(|m| { + m.insert(language, Some(country), text); + }) + }); + } + + /// Set the copyright string (plain ASCII `text` tag). + #[wasm_bindgen(js_name = setCopyright)] + pub fn set_copyright(&mut self, text: &str) { + self.mutate(|p| { + p.with_tag(CopyrightTag) + .as_text(|t| t.set_text(text)) + }); + } + + // -- Colorimetric tags --------------------------------------------------- + + /// Set the media white point (XYZ, PCS-adapted to D50). + #[wasm_bindgen(js_name = setWhitePoint)] + pub fn set_white_point(&mut self, x: f64, y: f64, z: f64) { + self.mutate(|p| { + p.with_tag(MediaWhitePointTag) + .as_xyz_array(|xyz| xyz.set([x, y, z])) + }); + } + + /// Set the red matrix column (XYZ). + #[wasm_bindgen(js_name = setRedMatrixColumn)] + pub fn set_red_matrix_column(&mut self, x: f64, y: f64, z: f64) { + self.mutate(|p| { + p.with_tag(RedMatrixColumnTag) + .as_xyz_array(|xyz| xyz.set([x, y, z])) + }); + } + + /// Set the green matrix column (XYZ). + #[wasm_bindgen(js_name = setGreenMatrixColumn)] + pub fn set_green_matrix_column(&mut self, x: f64, y: f64, z: f64) { + self.mutate(|p| { + p.with_tag(GreenMatrixColumnTag) + .as_xyz_array(|xyz| xyz.set([x, y, z])) + }); + } + + /// Set the blue matrix column (XYZ). + #[wasm_bindgen(js_name = setBlueMatrixColumn)] + pub fn set_blue_matrix_column(&mut self, x: f64, y: f64, z: f64) { + self.mutate(|p| { + p.with_tag(BlueMatrixColumnTag) + .as_xyz_array(|xyz| xyz.set([x, y, z])) + }); + } + + /// Set the chromatic adaptation matrix (Bradford, 9 values in row-major order). + /// + /// `matrix` must be a `Float64Array` of exactly 9 values. + /// Throws if the slice length is not 9. + #[wasm_bindgen(js_name = setChromaticAdaptation)] + pub fn set_chromatic_adaptation(&mut self, matrix: &[f64]) -> Result<(), JsError> { + let arr: [f64; 9] = matrix + .try_into() + .map_err(|_| JsError::new("setChromaticAdaptation requires exactly 9 values"))?; + self.mutate(|p| { + p.with_tag(ChromaticAdaptationTag) + .as_sf15_fixed_16_array(|a| a.set(arr)) + }); + Ok(()) + } + + // -- TRC tags (simple gamma) --------------------------------------------- + + /// Set the red TRC as a simple power-law gamma curve. + #[wasm_bindgen(js_name = setRedTrcGamma)] + pub fn set_red_trc_gamma(&mut self, gamma: f64) { + self.mutate(|p| { + p.with_tag(RedTRCTag) + .as_curve(|c| c.set_gamma(gamma)) + }); + } + + /// Set the green TRC as a simple power-law gamma curve. + #[wasm_bindgen(js_name = setGreenTrcGamma)] + pub fn set_green_trc_gamma(&mut self, gamma: f64) { + self.mutate(|p| { + p.with_tag(GreenTRCTag) + .as_curve(|c| c.set_gamma(gamma)) + }); + } + + /// Set the blue TRC as a simple power-law gamma curve. + #[wasm_bindgen(js_name = setBlueTrcGamma)] + pub fn set_blue_trc_gamma(&mut self, gamma: f64) { + self.mutate(|p| { + p.with_tag(BlueTRCTag) + .as_curve(|c| c.set_gamma(gamma)) + }); + } + + // -- TRC tags (parametric curve) ----------------------------------------- + + /// Set the red TRC as a parametric curve (`para` tag). + /// + /// `params` is a `Float64Array` of 1, 3, 4, 5, or 7 values corresponding + /// to ICC parametric curve types 0–4. Throws if the count is invalid. + #[wasm_bindgen(js_name = setRedTrcParametric)] + pub fn set_red_trc_parametric(&mut self, params: &[f64]) -> Result<(), JsError> { + let params = params.to_vec(); + self.mutate(|p| { + p.with_tag(RedTRCTag).as_parametric_curve(|c| { + c.set_parameters_slice(¶ms) + .expect("invalid parametric curve parameter count"); + }) + }); + Ok(()) + } + + /// Set the green TRC as a parametric curve (`para` tag). + /// + /// See [`setRedTrcParametric`] for parameter details. + #[wasm_bindgen(js_name = setGreenTrcParametric)] + pub fn set_green_trc_parametric(&mut self, params: &[f64]) -> Result<(), JsError> { + let params = params.to_vec(); + self.mutate(|p| { + p.with_tag(GreenTRCTag).as_parametric_curve(|c| { + c.set_parameters_slice(¶ms) + .expect("invalid parametric curve parameter count"); + }) + }); + Ok(()) + } + + /// Set the blue TRC as a parametric curve (`para` tag). + /// + /// See [`setRedTrcParametric`] for parameter details. + #[wasm_bindgen(js_name = setBlueTrcParametric)] + pub fn set_blue_trc_parametric(&mut self, params: &[f64]) -> Result<(), JsError> { + let params = params.to_vec(); + self.mutate(|p| { + p.with_tag(BlueTRCTag).as_parametric_curve(|c| { + c.set_parameters_slice(¶ms) + .expect("invalid parametric curve parameter count"); + }) + }); + Ok(()) + } + + // -- Finalization & serialization ---------------------------------------- + + /// Compute and embed the MD5 profile ID checksum (fields 84–99 of the + /// header). Call this once, after setting all tags, before `toBytes()`. + pub fn finalize(&mut self) { + self.mutate(|p| p.with_profile_id()); + } + + /// Serialize the profile to a `Uint8Array`. + /// + /// Consumes the object — the `WasmDisplayProfile` instance is no longer + /// usable after this call. Throws if serialization fails. + #[wasm_bindgen(js_name = toBytes)] + pub fn to_bytes(mut self) -> Result, JsError> { + let profile = self + .inner + .take() + .ok_or_else(|| JsError::new("profile already consumed"))?; + profile + .to_bytes() + .map_err(|e| JsError::new(&e.to_string())) + } +} diff --git a/cmx-icc/tests/bindings.rs b/cmx-icc/tests/bindings.rs new file mode 100644 index 0000000..52ef89c --- /dev/null +++ b/cmx-icc/tests/bindings.rs @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +// Copyright (c) 2021-2025, Harbers Bik LLC + +use wasm_bindgen_test::*; + +// Run tests in Node.js (no browser driver needed). +wasm_bindgen_test_configure!(run_in_node_experimental); + +use cmx_icc::{WasmDisplayProfile, WasmProfile, WasmRenderingIntent}; + +// --------------------------------------------------------------------------- +// WasmDisplayProfile — preset constructors +// --------------------------------------------------------------------------- + +#[wasm_bindgen_test] +fn preset_srgb_serializes() { + let p = WasmDisplayProfile::srgb(WasmRenderingIntent::RelativeColorimetric); + let bytes = p.to_bytes().expect("sRGB preset should serialize"); + // ICC profiles always start with the 4-byte profile size followed by 'acsp' + assert!(bytes.len() > 128, "serialized profile must be larger than the 128-byte header"); + assert_eq!(&bytes[36..40], b"acsp", "ICC file signature must be 'acsp' at offset 36"); +} + +#[wasm_bindgen_test] +fn preset_display_p3_serializes() { + let p = WasmDisplayProfile::display_p3(WasmRenderingIntent::Perceptual); + let bytes = p.to_bytes().expect("Display P3 preset should serialize"); + assert!(bytes.len() > 128); + assert_eq!(&bytes[36..40], b"acsp", "ICC file signature must be 'acsp' at offset 36"); +} + +#[wasm_bindgen_test] +fn preset_adobe_rgb_serializes() { + let p = WasmDisplayProfile::adobe_rgb(WasmRenderingIntent::Saturation); + let bytes = p.to_bytes().expect("Adobe RGB preset should serialize"); + assert!(bytes.len() > 128); + assert_eq!(&bytes[36..40], b"acsp", "ICC file signature must be 'acsp' at offset 36"); +} + +// --------------------------------------------------------------------------- +// WasmProfile — round-trip parsing +// --------------------------------------------------------------------------- + +#[wasm_bindgen_test] +fn round_trip_srgb() { + // Build → serialize + let original_bytes = WasmDisplayProfile::srgb(WasmRenderingIntent::RelativeColorimetric) + .to_bytes() + .expect("serialize"); + + // Parse → re-serialize + let parsed = WasmProfile::from_bytes(&original_bytes).expect("parse"); + let round_tripped = parsed.to_bytes().expect("re-serialize"); + + assert_eq!( + original_bytes, round_tripped, + "round-trip must be byte-identical" + ); +} + +#[wasm_bindgen_test] +fn round_trip_display_p3() { + let original_bytes = WasmDisplayProfile::display_p3(WasmRenderingIntent::RelativeColorimetric) + .to_bytes() + .expect("serialize"); + let parsed = WasmProfile::from_bytes(&original_bytes).expect("parse"); + let round_tripped = parsed.to_bytes().expect("re-serialize"); + assert_eq!(original_bytes, round_tripped); +} + +#[wasm_bindgen_test] +fn from_bytes_invalid_data_errors() { + let result = WasmProfile::from_bytes(&[0u8; 32]); + assert!(result.is_err(), "32 garbage bytes should fail to parse"); +} + +// --------------------------------------------------------------------------- +// WasmProfile — rendering intent accessor +// --------------------------------------------------------------------------- + +#[wasm_bindgen_test] +fn rendering_intent_relative_colorimetric() { + let bytes = WasmDisplayProfile::srgb(WasmRenderingIntent::RelativeColorimetric) + .to_bytes() + .expect("serialize"); + let p = WasmProfile::from_bytes(&bytes).expect("parse"); + assert!( + matches!(p.rendering_intent(), WasmRenderingIntent::RelativeColorimetric), + "rendering intent should round-trip through serialization" + ); +} + +#[wasm_bindgen_test] +fn rendering_intent_perceptual() { + let bytes = WasmDisplayProfile::display_p3(WasmRenderingIntent::Perceptual) + .to_bytes() + .expect("serialize"); + let p = WasmProfile::from_bytes(&bytes).expect("parse"); + assert!(matches!(p.rendering_intent(), WasmRenderingIntent::Perceptual)); +} + +// --------------------------------------------------------------------------- +// WasmDisplayProfile — custom builder +// --------------------------------------------------------------------------- + +#[wasm_bindgen_test] +fn custom_profile_builds_and_parses() { + let mut p = WasmDisplayProfile::new(); + p.set_rendering_intent(WasmRenderingIntent::RelativeColorimetric); + p.set_profile_description("Test Profile"); + p.set_copyright("CC0 1.0"); + p.set_white_point(0.950455, 1.0, 1.08905); + p.set_red_matrix_column(0.436066, 0.222488, 0.013916); + p.set_green_matrix_column(0.385147, 0.716873, 0.097076); + p.set_blue_matrix_column(0.143066, 0.060608, 0.714096); + p.set_red_trc_gamma(2.2); + p.set_green_trc_gamma(2.2); + p.set_blue_trc_gamma(2.2); + p.finalize(); + + let bytes = p.to_bytes().expect("custom profile should serialize"); + assert!(bytes.len() > 128); + assert_eq!(&bytes[36..40], b"acsp", "ICC file signature must be 'acsp' at offset 36"); + + // Must also parse back cleanly + WasmProfile::from_bytes(&bytes).expect("custom profile bytes must parse back"); +} + +#[wasm_bindgen_test] +fn parametric_trc_builds_and_parses() { + let mut p = WasmDisplayProfile::new(); + p.set_rendering_intent(WasmRenderingIntent::RelativeColorimetric); + p.set_profile_description("Parametric TRC Test"); + p.set_copyright("CC0 1.0"); + p.set_white_point(0.950455, 1.0, 1.08905); + p.set_red_matrix_column(0.436066, 0.222488, 0.013916); + p.set_green_matrix_column(0.385147, 0.716873, 0.097076); + p.set_blue_matrix_column(0.143066, 0.060608, 0.714096); + // sRGB parametric curve (type 4, 5 params) + p.set_red_trc_parametric(&[2.39999, 0.94786, 0.05214, 0.07739, 0.04045]) + .expect("valid sRGB parametric params"); + p.set_green_trc_parametric(&[2.39999, 0.94786, 0.05214, 0.07739, 0.04045]) + .expect("valid sRGB parametric params"); + p.set_blue_trc_parametric(&[2.39999, 0.94786, 0.05214, 0.07739, 0.04045]) + .expect("valid sRGB parametric params"); + p.finalize(); + + let bytes = p.to_bytes().expect("parametric TRC profile should serialize"); + WasmProfile::from_bytes(&bytes).expect("parametric TRC profile bytes must parse back"); +} + +#[wasm_bindgen_test] +fn chromatic_adaptation_wrong_size_errors() { + let mut p = WasmDisplayProfile::new(); + // 8 values instead of 9 — must return Err + let result = p.set_chromatic_adaptation(&[1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]); + assert!(result.is_err(), "8-element matrix should be rejected"); +} + +#[wasm_bindgen_test] +fn chromatic_adaptation_correct_size_succeeds() { + let mut p = WasmDisplayProfile::new(); + #[rustfmt::skip] + let result = p.set_chromatic_adaptation(&[ + 1.047882, 0.022919, -0.050201, + 0.029587, 0.990479, -0.017059, + -0.009232, 0.015076, 0.751678, + ]); + assert!(result.is_ok(), "9-element Bradford matrix should be accepted"); +} diff --git a/src/profile.rs b/src/profile.rs index 2a46859..d873cc0 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -128,6 +128,11 @@ impl Profile { // Read-only accessors — all delegate to the underlying RawProfile. // ----------------------------------------------------------------------- + /// Returns the rendering intent stored in the profile header. + pub fn rendering_intent(&self) -> crate::tag::RenderingIntent { + self.as_raw_profile().rendering_intent() + } + /// Returns the ICC profile version as `(major, minor)`. /// Errors if the version stored in the header is not one of the values /// recognised by this crate (2.x, 4.x, 5.0). diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 9350d4e..f6456c1 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,4 +1,5 @@ use clap::{Parser, Subcommand}; +use std::path::{Path, PathBuf}; use std::process::Command; /// Represents the command-line arguments for the xtask utility. @@ -18,6 +19,18 @@ enum Commands { Test, /// Builds the documentation, and opens it Doc, + /// Build cmx-icc and publish it to npm as "cmx-icc" + PublishNpm { + /// Print what would be published without actually uploading + #[arg(long)] + dry_run: bool, + }, + /// Run pre-publish checks and publish the cmx crate to crates.io + PublishCrate { + /// Run all checks and a cargo publish --dry-run without uploading + #[arg(long)] + dry_run: bool, + }, } impl Commands { @@ -53,6 +66,68 @@ impl Commands { .expect("failed to run cargo doc"); println!("✅ Documentation build complete"); } + Commands::PublishNpm { dry_run } => { + let root = workspace_root(); + let icc_dir = root.join("cmx-icc"); + let pkg_dir = icc_dir.join("pkg"); + + // Regenerate cmx-icc/README.md from src/lib.rs doc comment. + // wasm-pack copies it into pkg/ so it lands on the npm page. + check_or_force_rdme_workspace("cmx-icc"); + + // Build + run_in( + &root, + "wasm-pack", + &["build", "cmx-icc", "--target", "bundler", "--release"], + ); + println!("✅ wasm-pack build complete"); + + if *dry_run { + println!("Dry run — skipping npm publish."); + println!("Package would be published from: {}", pkg_dir.display()); + let pkg_json = std::fs::read_to_string(pkg_dir.join("package.json")) + .expect("pkg/package.json not found after build"); + println!("{pkg_json}"); + } else { + // Publish + run_in( + &root, + "npm", + &["publish", "cmx-icc/pkg", "--access", "public"], + ); + println!("✅ Published cmx-icc to npm"); + } + } + Commands::PublishCrate { dry_run } => { + // ── Pre-publish checks (mirrors CLAUDE.md release checklist) ── + println!("Running tests..."); + run("cargo", &["test"]); + run("cargo", &["test", "--doc"]); + + println!("Running clippy..."); + run("cargo", &["clippy", "--", "-D", "warnings"]); + + println!("Checking docs..."); + run_env( + "cargo", + &["doc", "--no-deps"], + &[("RUSTDOCFLAGS", "--deny warnings")], + ) + .expect("cargo doc failed"); + + println!("Checking README..."); + check_or_force_rdme(); + + // ── Publish ─────────────────────────────────────────────────── + if *dry_run { + run("cargo", &["publish", "-p", "cmx", "--dry-run"]); + println!("✅ Dry run complete — no files uploaded"); + } else { + run("cargo", &["publish", "-p", "cmx"]); + println!("✅ Published cmx to crates.io"); + } + } } } } @@ -62,6 +137,32 @@ fn main() { args.command.handle(); } +/// Same as [`check_or_force_rdme`] but targets a specific workspace member +/// using `cargo rdme -w `. +fn check_or_force_rdme_workspace(project: &str) { + let status = Command::new("cargo") + .args(["rdme", "-w", project, "--check"]) + .status() + .unwrap_or_else(|e| panic!("failed to run cargo rdme: {e}")); + + if status.success() { + println!("✅ {project}/README.md is up to date"); + } else { + println!("⚠️ {project}/README.md is out of date, regenerating..."); + let force = Command::new("cargo") + .args(["rdme", "-w", project, "--force"]) + .status() + .unwrap_or_else(|e| panic!("failed to run cargo rdme --force: {e}")); + + if force.success() { + println!("✅ {project}/README.md regenerated successfully"); + } else { + eprintln!("❌ Failed to regenerate {project}/README.md"); + std::process::exit(force.code().unwrap_or(1)); + } + } +} + fn check_or_force_rdme() { let status = Command::new("cargo") .args(["rdme", "--check"]) @@ -97,6 +198,31 @@ fn run(cmd: &str, args: &[&str]) { } } +/// Returns the workspace root (the directory that contains the root Cargo.toml). +/// When invoked via `cargo xtask`, CARGO_MANIFEST_DIR is the xtask crate directory, +/// so the workspace root is one level up. +fn workspace_root() -> PathBuf { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") + .expect("CARGO_MANIFEST_DIR not set — run this via `cargo xtask`"); + Path::new(&manifest_dir) + .parent() + .expect("xtask manifest has no parent directory") + .to_path_buf() +} + +/// Run `cmd args` from `dir`, exiting on failure. +fn run_in(dir: &Path, cmd: &str, args: &[&str]) { + println!("$ {} {}", cmd, args.join(" ")); + let status = Command::new(cmd) + .args(args) + .current_dir(dir) + .status() + .unwrap_or_else(|e| panic!("failed to start `{cmd}`: {e}")); + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } +} + fn run_env(cmd: &str, args: &[&str], env: &[(&str, &str)]) -> Result<(), std::io::Error> { let mut c = Command::new(cmd); c.args(args); From 42101619b8eaf9b82279ee13dca793b8084465b2 Mon Sep 17 00:00:00 2001 From: Gerard Harbers Date: Fri, 24 Apr 2026 13:13:03 -0700 Subject: [PATCH 2/7] refactor(cmx-icc): expose JS classes without Wasm prefix via js_name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use `#[wasm_bindgen(js_name = "...")]` so JS/TS consumers see clean names: - `WasmRenderingIntent` → `RenderingIntent` - `WasmProfile` → `Profile` - `WasmDisplayProfile` → `DisplayProfile` Rust type names are unchanged, so the test suite and internal code are unaffected. Update doc comment examples and API tables in lib.rs to reflect the JS-facing names, and regenerate README.md. Co-Authored-By: Claude Sonnet 4.6 --- cmx-icc/README.md | 36 ++++++++++++++++++------------------ cmx-icc/src/lib.rs | 42 +++++++++++++++++++++--------------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/cmx-icc/README.md b/cmx-icc/README.md index e70b30a..2775670 100644 --- a/cmx-icc/README.md +++ b/cmx-icc/README.md @@ -16,24 +16,24 @@ npm install cmx-icc ## Quick start ```js -import init, { WasmProfile, WasmDisplayProfile, WasmRenderingIntent } from 'cmx-icc'; +import init, { Profile, DisplayProfile, RenderingIntent } from 'cmx-icc'; await init(); // load the .wasm binary once // ── Parse an existing profile ───────────────────────────────────────────── // iccBytes is a Uint8Array, e.g. from fetch() or FileReader -const profile = WasmProfile.fromBytes(iccBytes); -const intent = profile.renderingIntent(); // WasmRenderingIntent enum value +const profile = Profile.fromBytes(iccBytes); +const intent = profile.renderingIntent(); // RenderingIntent enum value const bytes = profile.toBytes(); // Uint8Array — byte-identical round-trip // ── Use a built-in preset ───────────────────────────────────────────────── -const srgb = WasmDisplayProfile.srgb(WasmRenderingIntent.RelativeColorimetric); -const p3 = WasmDisplayProfile.displayP3(WasmRenderingIntent.RelativeColorimetric); -const adobe = WasmDisplayProfile.adobeRgb(WasmRenderingIntent.RelativeColorimetric); +const srgb = DisplayProfile.srgb(RenderingIntent.RelativeColorimetric); +const p3 = DisplayProfile.displayP3(RenderingIntent.RelativeColorimetric); +const adobe = DisplayProfile.adobeRgb(RenderingIntent.RelativeColorimetric); // ── Build a custom matrix display profile ──────────────────────────────── -const custom = new WasmDisplayProfile(); -custom.setRenderingIntent(WasmRenderingIntent.RelativeColorimetric); +const custom = new DisplayProfile(); +custom.setRenderingIntent(RenderingIntent.RelativeColorimetric); custom.setProfileDescription("My Custom Profile"); custom.setCopyright("CC0 1.0"); custom.setWhitePoint(0.950455, 1.0, 1.08905); // D50 XYZ @@ -62,26 +62,26 @@ standard WebAssembly asset pipeline. | Class | Purpose | |---|---| -| `WasmProfile` | Parse an existing ICC profile from a `Uint8Array` | -| `WasmDisplayProfile` | Build a display-class ICC profile from scratch | -| `WasmRenderingIntent` | Enum: `Perceptual`, `RelativeColorimetric`, `Saturation`, `AbsoluteColorimetric` | +| `Profile` | Parse an existing ICC profile from a `Uint8Array` | +| `DisplayProfile` | Build a display-class ICC profile from scratch | +| `RenderingIntent` | Enum: `Perceptual`, `RelativeColorimetric`, `Saturation`, `AbsoluteColorimetric` | -### `WasmProfile` +### `Profile` | Method | Description | |---|---| -| `WasmProfile.fromBytes(data)` | Parse a `Uint8Array`; throws on invalid data | +| `Profile.fromBytes(data)` | Parse a `Uint8Array`; throws on invalid data | | `profile.toBytes()` | Serialize back to `Uint8Array` (byte-identical round-trip) | | `profile.renderingIntent()` | Read the rendering intent from the header | -### `WasmDisplayProfile` +### `DisplayProfile` | Method | Description | |---|---| -| `new WasmDisplayProfile()` | Empty profile — set all tags manually | -| `WasmDisplayProfile.srgb(intent)` | sRGB preset | -| `WasmDisplayProfile.displayP3(intent)` | Display P3 preset | -| `WasmDisplayProfile.adobeRgb(intent)` | Adobe RGB preset | +| `new DisplayProfile()` | Empty profile — set all tags manually | +| `DisplayProfile.srgb(intent)` | sRGB preset | +| `DisplayProfile.displayP3(intent)` | Display P3 preset | +| `DisplayProfile.adobeRgb(intent)` | Adobe RGB preset | | `setRenderingIntent(intent)` | Header rendering intent | | `setProfileDescription(text)` | ASCII description tag | | `setProfileDescriptionMluc(lang, country, text)` | v4 multi-language description | diff --git a/cmx-icc/src/lib.rs b/cmx-icc/src/lib.rs index 238e48e..35df7bf 100644 --- a/cmx-icc/src/lib.rs +++ b/cmx-icc/src/lib.rs @@ -17,24 +17,24 @@ //! ## Quick start //! //! ```js -//! import init, { WasmProfile, WasmDisplayProfile, WasmRenderingIntent } from 'cmx-icc'; +//! import init, { Profile, DisplayProfile, RenderingIntent } from 'cmx-icc'; //! //! await init(); // load the .wasm binary once //! //! // ── Parse an existing profile ───────────────────────────────────────────── //! // iccBytes is a Uint8Array, e.g. from fetch() or FileReader -//! const profile = WasmProfile.fromBytes(iccBytes); -//! const intent = profile.renderingIntent(); // WasmRenderingIntent enum value +//! const profile = Profile.fromBytes(iccBytes); +//! const intent = profile.renderingIntent(); // RenderingIntent enum value //! const bytes = profile.toBytes(); // Uint8Array — byte-identical round-trip //! //! // ── Use a built-in preset ───────────────────────────────────────────────── -//! const srgb = WasmDisplayProfile.srgb(WasmRenderingIntent.RelativeColorimetric); -//! const p3 = WasmDisplayProfile.displayP3(WasmRenderingIntent.RelativeColorimetric); -//! const adobe = WasmDisplayProfile.adobeRgb(WasmRenderingIntent.RelativeColorimetric); +//! const srgb = DisplayProfile.srgb(RenderingIntent.RelativeColorimetric); +//! const p3 = DisplayProfile.displayP3(RenderingIntent.RelativeColorimetric); +//! const adobe = DisplayProfile.adobeRgb(RenderingIntent.RelativeColorimetric); //! //! // ── Build a custom matrix display profile ──────────────────────────────── -//! const custom = new WasmDisplayProfile(); -//! custom.setRenderingIntent(WasmRenderingIntent.RelativeColorimetric); +//! const custom = new DisplayProfile(); +//! custom.setRenderingIntent(RenderingIntent.RelativeColorimetric); //! custom.setProfileDescription("My Custom Profile"); //! custom.setCopyright("CC0 1.0"); //! custom.setWhitePoint(0.950455, 1.0, 1.08905); // D50 XYZ @@ -63,26 +63,26 @@ //! //! | Class | Purpose | //! |---|---| -//! | `WasmProfile` | Parse an existing ICC profile from a `Uint8Array` | -//! | `WasmDisplayProfile` | Build a display-class ICC profile from scratch | -//! | `WasmRenderingIntent` | Enum: `Perceptual`, `RelativeColorimetric`, `Saturation`, `AbsoluteColorimetric` | +//! | `Profile` | Parse an existing ICC profile from a `Uint8Array` | +//! | `DisplayProfile` | Build a display-class ICC profile from scratch | +//! | `RenderingIntent` | Enum: `Perceptual`, `RelativeColorimetric`, `Saturation`, `AbsoluteColorimetric` | //! -//! ### `WasmProfile` +//! ### `Profile` //! //! | Method | Description | //! |---|---| -//! | `WasmProfile.fromBytes(data)` | Parse a `Uint8Array`; throws on invalid data | +//! | `Profile.fromBytes(data)` | Parse a `Uint8Array`; throws on invalid data | //! | `profile.toBytes()` | Serialize back to `Uint8Array` (byte-identical round-trip) | //! | `profile.renderingIntent()` | Read the rendering intent from the header | //! -//! ### `WasmDisplayProfile` +//! ### `DisplayProfile` //! //! | Method | Description | //! |---|---| -//! | `new WasmDisplayProfile()` | Empty profile — set all tags manually | -//! | `WasmDisplayProfile.srgb(intent)` | sRGB preset | -//! | `WasmDisplayProfile.displayP3(intent)` | Display P3 preset | -//! | `WasmDisplayProfile.adobeRgb(intent)` | Adobe RGB preset | +//! | `new DisplayProfile()` | Empty profile — set all tags manually | +//! | `DisplayProfile.srgb(intent)` | sRGB preset | +//! | `DisplayProfile.displayP3(intent)` | Display P3 preset | +//! | `DisplayProfile.adobeRgb(intent)` | Adobe RGB preset | //! | `setRenderingIntent(intent)` | Header rendering intent | //! | `setProfileDescription(text)` | ASCII description tag | //! | `setProfileDescriptionMluc(lang, country, text)` | v4 multi-language description | @@ -126,7 +126,7 @@ use cmx::{ /// ICC rendering intent, controlling how out-of-gamut colors are handled /// when converting between color spaces. -#[wasm_bindgen] +#[wasm_bindgen(js_name = "RenderingIntent")] #[derive(Clone, Copy)] pub enum WasmRenderingIntent { Perceptual = 0, @@ -165,7 +165,7 @@ impl From for WasmRenderingIntent { /// /// Use [`WasmProfile.fromBytes`] to parse an existing ICC file that you have /// loaded as a `Uint8Array`, and [`WasmProfile.toBytes`] to serialize it back. -#[wasm_bindgen] +#[wasm_bindgen(js_name = "Profile")] pub struct WasmProfile { inner: cmx::profile::Profile, } @@ -213,7 +213,7 @@ impl WasmProfile { /// - [`WasmDisplayProfile.srgb`] /// - [`WasmDisplayProfile.displayP3`] /// - [`WasmDisplayProfile.adobeRgb`] -#[wasm_bindgen] +#[wasm_bindgen(js_name = "DisplayProfile")] pub struct WasmDisplayProfile { // Option lets us temporarily take ownership to drive the consuming builder. inner: Option, From 0181a7acfd019804f4d9644093b51628755ac5e3 Mon Sep 17 00:00:00 2001 From: Gerard Harbers Date: Fri, 24 Apr 2026 14:38:34 -0700 Subject: [PATCH 3/7] docs(cmx-icc): describe CMX as a spectral color management system Add introductory context to the crate doc comment explaining that CMX is built on the Rust Colorimetry library and uses spectral representations of light for physically accurate color modelling. Regenerate README.md. Co-Authored-By: Claude Sonnet 4.6 --- cmx-icc/README.md | 9 +++++++-- cmx-icc/src/lib.rs | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/cmx-icc/README.md b/cmx-icc/README.md index 2775670..0bd8cad 100644 --- a/cmx-icc/README.md +++ b/cmx-icc/README.md @@ -4,8 +4,13 @@ WebAssembly bindings for the [cmx](https://crates.io/crates/cmx) ICC color profile library, published to npm as [`cmx-icc`](https://www.npmjs.com/package/cmx-icc). -Parse, inspect, and build ICC color profiles entirely in the browser or -Node.js — no native dependencies required. +CMX is an advanced spectral color management system built on the +[Rust Colorimetry library](https://crates.io/crates/colorimetry), which +uses spectral representations of light to model color with physical +accuracy — going beyond the tristimulus approximations used in traditional +ICC workflows. This package exposes the ICC profile layer of CMX: parse, +inspect, and build ICC color profiles entirely in the browser or Node.js, +with no native dependencies required. ## Installation diff --git a/cmx-icc/src/lib.rs b/cmx-icc/src/lib.rs index 35df7bf..f015704 100644 --- a/cmx-icc/src/lib.rs +++ b/cmx-icc/src/lib.rs @@ -5,8 +5,13 @@ //! profile library, published to npm as //! [`cmx-icc`](https://www.npmjs.com/package/cmx-icc). //! -//! Parse, inspect, and build ICC color profiles entirely in the browser or -//! Node.js — no native dependencies required. +//! CMX is an advanced spectral color management system built on the +//! [Rust Colorimetry library](https://crates.io/crates/colorimetry), which +//! uses spectral representations of light to model color with physical +//! accuracy — going beyond the tristimulus approximations used in traditional +//! ICC workflows. This package exposes the ICC profile layer of CMX: parse, +//! inspect, and build ICC color profiles entirely in the browser or Node.js, +//! with no native dependencies required. //! //! ## Installation //! From ae536c3055a689dcdfb62fb0bb0c73bd4074724a Mon Sep 17 00:00:00 2001 From: Gerard Harbers Date: Fri, 24 Apr 2026 14:44:23 -0700 Subject: [PATCH 4/7] style: apply rustfmt to all files (cargo xtask check) Co-Authored-By: Claude Sonnet 4.6 --- cmx-icc/src/lib.rs | 28 +++++--------------- cmx-icc/tests/bindings.rs | 48 ++++++++++++++++++++++++++++------- src/profile/tag_setter.rs | 5 +--- src/tag/tagdata.rs | 2 +- src/tag/tagdata/dict.rs | 26 +++++++++++++++---- src/tag/tagdata/make_model.rs | 5 +++- tests/validation_tests.rs | 41 ++++++++++++++++++------------ 7 files changed, 98 insertions(+), 57 deletions(-) diff --git a/cmx-icc/src/lib.rs b/cmx-icc/src/lib.rs index f015704..b85225e 100644 --- a/cmx-icc/src/lib.rs +++ b/cmx-icc/src/lib.rs @@ -182,8 +182,8 @@ impl WasmProfile { /// Throws a `TypeError` if the bytes do not contain a valid ICC profile. #[wasm_bindgen(js_name = fromBytes)] pub fn from_bytes(data: &[u8]) -> Result { - let inner = cmx::profile::Profile::from_bytes(data) - .map_err(|e| JsError::new(&e.to_string()))?; + let inner = + cmx::profile::Profile::from_bytes(data).map_err(|e| JsError::new(&e.to_string()))?; Ok(WasmProfile { inner }) } @@ -313,10 +313,7 @@ impl WasmDisplayProfile { /// Set the copyright string (plain ASCII `text` tag). #[wasm_bindgen(js_name = setCopyright)] pub fn set_copyright(&mut self, text: &str) { - self.mutate(|p| { - p.with_tag(CopyrightTag) - .as_text(|t| t.set_text(text)) - }); + self.mutate(|p| p.with_tag(CopyrightTag).as_text(|t| t.set_text(text))); } // -- Colorimetric tags --------------------------------------------------- @@ -378,28 +375,19 @@ impl WasmDisplayProfile { /// Set the red TRC as a simple power-law gamma curve. #[wasm_bindgen(js_name = setRedTrcGamma)] pub fn set_red_trc_gamma(&mut self, gamma: f64) { - self.mutate(|p| { - p.with_tag(RedTRCTag) - .as_curve(|c| c.set_gamma(gamma)) - }); + self.mutate(|p| p.with_tag(RedTRCTag).as_curve(|c| c.set_gamma(gamma))); } /// Set the green TRC as a simple power-law gamma curve. #[wasm_bindgen(js_name = setGreenTrcGamma)] pub fn set_green_trc_gamma(&mut self, gamma: f64) { - self.mutate(|p| { - p.with_tag(GreenTRCTag) - .as_curve(|c| c.set_gamma(gamma)) - }); + self.mutate(|p| p.with_tag(GreenTRCTag).as_curve(|c| c.set_gamma(gamma))); } /// Set the blue TRC as a simple power-law gamma curve. #[wasm_bindgen(js_name = setBlueTrcGamma)] pub fn set_blue_trc_gamma(&mut self, gamma: f64) { - self.mutate(|p| { - p.with_tag(BlueTRCTag) - .as_curve(|c| c.set_gamma(gamma)) - }); + self.mutate(|p| p.with_tag(BlueTRCTag).as_curve(|c| c.set_gamma(gamma))); } // -- TRC tags (parametric curve) ----------------------------------------- @@ -468,8 +456,6 @@ impl WasmDisplayProfile { .inner .take() .ok_or_else(|| JsError::new("profile already consumed"))?; - profile - .to_bytes() - .map_err(|e| JsError::new(&e.to_string())) + profile.to_bytes().map_err(|e| JsError::new(&e.to_string())) } } diff --git a/cmx-icc/tests/bindings.rs b/cmx-icc/tests/bindings.rs index 52ef89c..af659ae 100644 --- a/cmx-icc/tests/bindings.rs +++ b/cmx-icc/tests/bindings.rs @@ -17,8 +17,15 @@ fn preset_srgb_serializes() { let p = WasmDisplayProfile::srgb(WasmRenderingIntent::RelativeColorimetric); let bytes = p.to_bytes().expect("sRGB preset should serialize"); // ICC profiles always start with the 4-byte profile size followed by 'acsp' - assert!(bytes.len() > 128, "serialized profile must be larger than the 128-byte header"); - assert_eq!(&bytes[36..40], b"acsp", "ICC file signature must be 'acsp' at offset 36"); + assert!( + bytes.len() > 128, + "serialized profile must be larger than the 128-byte header" + ); + assert_eq!( + &bytes[36..40], + b"acsp", + "ICC file signature must be 'acsp' at offset 36" + ); } #[wasm_bindgen_test] @@ -26,7 +33,11 @@ fn preset_display_p3_serializes() { let p = WasmDisplayProfile::display_p3(WasmRenderingIntent::Perceptual); let bytes = p.to_bytes().expect("Display P3 preset should serialize"); assert!(bytes.len() > 128); - assert_eq!(&bytes[36..40], b"acsp", "ICC file signature must be 'acsp' at offset 36"); + assert_eq!( + &bytes[36..40], + b"acsp", + "ICC file signature must be 'acsp' at offset 36" + ); } #[wasm_bindgen_test] @@ -34,7 +45,11 @@ fn preset_adobe_rgb_serializes() { let p = WasmDisplayProfile::adobe_rgb(WasmRenderingIntent::Saturation); let bytes = p.to_bytes().expect("Adobe RGB preset should serialize"); assert!(bytes.len() > 128); - assert_eq!(&bytes[36..40], b"acsp", "ICC file signature must be 'acsp' at offset 36"); + assert_eq!( + &bytes[36..40], + b"acsp", + "ICC file signature must be 'acsp' at offset 36" + ); } // --------------------------------------------------------------------------- @@ -85,7 +100,10 @@ fn rendering_intent_relative_colorimetric() { .expect("serialize"); let p = WasmProfile::from_bytes(&bytes).expect("parse"); assert!( - matches!(p.rendering_intent(), WasmRenderingIntent::RelativeColorimetric), + matches!( + p.rendering_intent(), + WasmRenderingIntent::RelativeColorimetric + ), "rendering intent should round-trip through serialization" ); } @@ -96,7 +114,10 @@ fn rendering_intent_perceptual() { .to_bytes() .expect("serialize"); let p = WasmProfile::from_bytes(&bytes).expect("parse"); - assert!(matches!(p.rendering_intent(), WasmRenderingIntent::Perceptual)); + assert!(matches!( + p.rendering_intent(), + WasmRenderingIntent::Perceptual + )); } // --------------------------------------------------------------------------- @@ -120,7 +141,11 @@ fn custom_profile_builds_and_parses() { let bytes = p.to_bytes().expect("custom profile should serialize"); assert!(bytes.len() > 128); - assert_eq!(&bytes[36..40], b"acsp", "ICC file signature must be 'acsp' at offset 36"); + assert_eq!( + &bytes[36..40], + b"acsp", + "ICC file signature must be 'acsp' at offset 36" + ); // Must also parse back cleanly WasmProfile::from_bytes(&bytes).expect("custom profile bytes must parse back"); @@ -145,7 +170,9 @@ fn parametric_trc_builds_and_parses() { .expect("valid sRGB parametric params"); p.finalize(); - let bytes = p.to_bytes().expect("parametric TRC profile should serialize"); + let bytes = p + .to_bytes() + .expect("parametric TRC profile should serialize"); WasmProfile::from_bytes(&bytes).expect("parametric TRC profile bytes must parse back"); } @@ -166,5 +193,8 @@ fn chromatic_adaptation_correct_size_succeeds() { 0.029587, 0.990479, -0.017059, -0.009232, 0.015076, 0.751678, ]); - assert!(result.is_ok(), "9-element Bradford matrix should be accepted"); + assert!( + result.is_ok(), + "9-element Bradford matrix should be accepted" + ); } diff --git a/src/profile/tag_setter.rs b/src/profile/tag_setter.rs index 3bddce7..cb09138 100644 --- a/src/profile/tag_setter.rs +++ b/src/profile/tag_setter.rs @@ -416,10 +416,7 @@ where S: IsDictTag, F: FnOnce(&mut crate::tag::tagdata::DictData), { - let dict = self - .profile - .raw_mut() - .ensure_dict_mut(self.tag.into()); + let dict = self.profile.raw_mut().ensure_dict_mut(self.tag.into()); configure(dict); self.profile } diff --git a/src/tag/tagdata.rs b/src/tag/tagdata.rs index 4996c98..5ad923c 100644 --- a/src/tag/tagdata.rs +++ b/src/tag/tagdata.rs @@ -2,8 +2,8 @@ // Copyright (c) 2021-2025, Harbers Bik LLC pub mod chromaticity; -pub mod dict; pub mod curve; +pub mod dict; pub mod lut16; pub mod lut8; pub mod make_model; diff --git a/src/tag/tagdata/dict.rs b/src/tag/tagdata/dict.rs index d40be2f..9c71515 100644 --- a/src/tag/tagdata/dict.rs +++ b/src/tag/tagdata/dict.rs @@ -174,10 +174,22 @@ impl From<&DictType> for DictData { let mut cursor = string_data_start; for (key_bytes, val_bytes) in &encoded { let val_offset = cursor + key_bytes.len(); - debug_assert!(cursor <= u32::MAX as usize, "dictType key offset overflows u32"); - debug_assert!(val_offset <= u32::MAX as usize, "dictType value offset overflows u32"); - debug_assert!(key_bytes.len() <= u32::MAX as usize, "dictType key length overflows u32"); - debug_assert!(val_bytes.len() <= u32::MAX as usize, "dictType value length overflows u32"); + debug_assert!( + cursor <= u32::MAX as usize, + "dictType key offset overflows u32" + ); + debug_assert!( + val_offset <= u32::MAX as usize, + "dictType value offset overflows u32" + ); + debug_assert!( + key_bytes.len() <= u32::MAX as usize, + "dictType key length overflows u32" + ); + debug_assert!( + val_bytes.len() <= u32::MAX as usize, + "dictType value length overflows u32" + ); records.push(Record { key_offset: U32::new(cursor as u32), key_length: U32::new(key_bytes.len() as u32), @@ -207,7 +219,11 @@ impl From<&DictType> for DictData { buf.extend_from_slice(val_bytes); } - debug_assert_eq!(buf.len(), total, "dictType serialisation: buffer size mismatch"); + debug_assert_eq!( + buf.len(), + total, + "dictType serialisation: buffer size mismatch" + ); DictData(buf) } } diff --git a/src/tag/tagdata/make_model.rs b/src/tag/tagdata/make_model.rs index 09e88c0..52c66d8 100644 --- a/src/tag/tagdata/make_model.rs +++ b/src/tag/tagdata/make_model.rs @@ -70,7 +70,10 @@ pub struct MakeAndModelType { impl From<&MakeAndModelData> for MakeAndModelType { fn from(data: &MakeAndModelData) -> Self { // We need at least the 24-byte prefix (header + 4 identifier fields). - let Some(layout) = data.0.get(..24).and_then(|b| Layout::try_ref_from_bytes(b).ok()) + let Some(layout) = data + .0 + .get(..24) + .and_then(|b| Layout::try_ref_from_bytes(b).ok()) else { return MakeAndModelType { manufacturer: "0x00000000".to_string(), diff --git a/tests/validation_tests.rs b/tests/validation_tests.rs index c3c486e..4aa7d44 100644 --- a/tests/validation_tests.rs +++ b/tests/validation_tests.rs @@ -73,10 +73,12 @@ fn parametric_curve_valid_counts_succeed() { assert!(para.set_parameters_slice(&[2.2]).is_ok()); // 1 — simple gamma assert!(para.set_parameters_slice(&[2.2, 0.9, 0.1]).is_ok()); // 3 assert!(para.set_parameters_slice(&[2.2, 0.9, 0.1, 0.03]).is_ok()); // 4 - assert!(para.set_parameters_slice(&[2.4, 0.948, 0.052, 0.077, 0.040]).is_ok()); // 5 (sRGB) - assert!( - para.set_parameters_slice(&[2.4, 0.948, 0.052, 0.077, 0.040, 0.0, 0.0]).is_ok() - ); // 7 + assert!(para + .set_parameters_slice(&[2.4, 0.948, 0.052, 0.077, 0.040]) + .is_ok()); // 5 (sRGB) + assert!(para + .set_parameters_slice(&[2.4, 0.948, 0.052, 0.077, 0.040, 0.0, 0.0]) + .is_ok()); // 7 } // --------------------------------------------------------------------------- @@ -139,7 +141,9 @@ fn creation_date_valid_date_succeeds() { bytes[128..132].copy_from_slice(&0u32.to_be_bytes()); let profile = RawProfile::from_bytes(&bytes).expect("profile should parse"); - let date = profile.creation_date().expect("valid date should not error"); + let date = profile + .creation_date() + .expect("valid date should not error"); use chrono::Datelike; assert_eq!(date.year(), 2024); assert_eq!(date.month(), 6); @@ -193,7 +197,10 @@ fn share_tags_same_offset_different_size_is_rejected() { (*b"gXYZ", offset, 16), // ← mismatch: same offset, different size ]); let result = RawProfile::from_bytes(&buf); - assert!(result.is_err(), "mismatched sizes at same offset should be rejected"); + assert!( + result.is_err(), + "mismatched sizes at same offset should be rejected" + ); let msg = result.unwrap_err().to_string(); assert!( msg.contains("corrupt") || msg.contains("offset") || msg.contains("size"), @@ -231,7 +238,7 @@ fn build_mluc(records: &[([u8; 2], [u8; 2], u32, u32)], string_bytes: &[u8]) -> buf.extend_from_slice(&0u32.to_be_bytes()); buf.extend_from_slice(&n.to_be_bytes()); buf.extend_from_slice(&12u32.to_be_bytes()); // record size - // Records: lang(2) + country(2) + length(4) + offset(4) + // Records: lang(2) + country(2) + length(4) + offset(4) for (lang, ctry, length, offset) in records { buf.extend_from_slice(lang); buf.extend_from_slice(ctry); @@ -252,8 +259,12 @@ fn mluc_oob_offset_skips_record_gracefully() { let mluc = MultiLocalizedUnicodeData(data); // Trigger the From conversion (used internally during TOML serialisation). // The bad record should be silently skipped; the result is an empty MLUC. - let mluc_type = cmx::tag::tagdata::multi_localized_unicode::MultiLocalizedUnicodeType::from(&mluc); - assert!(mluc_type.is_empty(), "bad record should be skipped, leaving an empty MLUC"); + let mluc_type = + cmx::tag::tagdata::multi_localized_unicode::MultiLocalizedUnicodeType::from(&mluc); + assert!( + mluc_type.is_empty(), + "bad record should be skipped, leaving an empty MLUC" + ); } // --------------------------------------------------------------------------- @@ -265,13 +276,11 @@ fn mluc_invalid_utf16_uses_lossy_fallback() { // String data: 0xD800 (lone high surrogate) followed by 0x000A (newline) — invalid UTF-16. // Record claims offset = 28 (right after the 16-byte header + 12-byte record). let string_bytes: &[u8] = &[0xD8, 0x00, 0x00, 0x0A]; - let data = build_mluc( - &[(*b"en", [0, 0], 4, 28)], - string_bytes, - ); + let data = build_mluc(&[(*b"en", [0, 0], 4, 28)], string_bytes); let mluc = MultiLocalizedUnicodeData(data); // Should not panic; the lossy fallback replaces the invalid code unit. - let mluc_type = cmx::tag::tagdata::multi_localized_unicode::MultiLocalizedUnicodeType::from(&mluc); + let mluc_type = + cmx::tag::tagdata::multi_localized_unicode::MultiLocalizedUnicodeType::from(&mluc); // One entry should be present (lossy decoding, not skipped). assert!( !mluc_type.is_empty(), @@ -288,7 +297,7 @@ fn mluc_invalid_utf16_uses_lossy_fallback() { fn lut8_header_bytes(n: u8, m: u8, g: u8) -> Vec { let mut h = vec![0u8; 48]; h[0..4].copy_from_slice(b"mft1"); // signature - // _reserved bytes 4-7 = 0 + // _reserved bytes 4-7 = 0 h[8] = n; h[9] = m; h[10] = g; @@ -322,7 +331,7 @@ fn lut8_truncated_data_panics_with_message() { /// plus an optional trailing odd byte to break alignment. fn curve_data_bytes(points: u32, extra_byte: bool) -> Vec { let mut buf = Vec::new(); - buf.extend_from_slice(b"curv"); // signature + buf.extend_from_slice(b"curv"); // signature buf.extend_from_slice(&0u32.to_be_bytes()); // reserved buf.extend_from_slice(&points.to_be_bytes()); // count for i in 0..points { From 28dc0f0db45a32bb0e94de603a1e54e089bcb87b Mon Sep 17 00:00:00 2001 From: Gerard Harbers Date: Fri, 24 Apr 2026 15:45:59 -0700 Subject: [PATCH 5/7] docs: fix markdownlint errors in CLAUDE.md and README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add language specifier to bare fenced code block (MD040) - Add spaces around table separator pipes (MD060) - Wrap long lines to stay within 120 characters (MD013) - Remove trailing spaces from list items (MD009) - Fix space inside code span `sig ` → `sig` (MD038) README.md fixes applied via src/lib.rs + cargo rdme regeneration. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 39 ++++++++++++++++++++++++++------------- README.md | 12 ++++++------ src/lib.rs | 12 ++++++------ 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cfdf08f..29dad0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,13 +3,15 @@ ## What This Is CMX is a Rust library for working with ICC color profiles (versions 2.0–5.0). It supports: + - **Parsing** binary ICC profiles from files or byte slices - **Constructing** profiles from scratch via a builder-style API - **Modifying** existing profiles programmatically - **Converting** profiles to human-readable TOML format - A **CLI tool** (`cmx`) to convert ICC profiles to TOML -It integrates with the [`colorimetry`](https://crates.io/crates/colorimetry) crate for color space operations and uses `zerocopy` for memory-safe binary data handling. +It integrates with the [`colorimetry`](https://crates.io/crates/colorimetry) crate for color space +operations and uses `zerocopy` for memory-safe binary data handling. --- @@ -42,7 +44,7 @@ cargo doc --open ## Project Layout -``` +```text src/ lib.rs # Library root, utility functions error.rs # Error types @@ -67,7 +69,7 @@ xtask/ # Custom build and publish tasks (cargo xtask) The `Profile` enum wraps eight device-class-specific structs, all backed by `RawProfile`: | Struct | Device class | -|---|---| +| --- | --- | | `DisplayProfile` | Monitors, projectors | | `InputProfile` | Cameras, scanners | | `OutputProfile` | Printers | @@ -77,7 +79,8 @@ The `Profile` enum wraps eight device-class-specific structs, all backed by `Raw | `NamedColorProfile` | Named color palettes | | `SpectralProfile` | Spectral data (future) | -Each wrapper enforces ICC spec constraints for that class. `RawProfile` holds the actual binary data (bytes + `IndexMap` of tag records). `HasRawProfile` is the delegation trait. +Each wrapper enforces ICC spec constraints for that class. `RawProfile` holds the actual binary +data (bytes + `IndexMap` of tag records). `HasRawProfile` is the delegation trait. ### Builder / Tag Setter Pattern @@ -92,7 +95,8 @@ let profile = DisplayProfile::new() .with_profile_id(); // computes MD5 checksum in place ``` -`TagSetter` is the intermediate builder type. It uses generic marker types and capability traits to enforce at compile time which tag data types are valid for a given tag signature. +`TagSetter` is the intermediate builder type. It uses generic marker types and capability +traits to enforce at compile time which tag data types are valid for a given tag signature. ### Tag Data Types @@ -114,14 +118,16 @@ Unknown/vendor tags are preserved via `RawData` — round-trips are lossless. ### Binary Safety -Uses `zerocopy` for zero-copy overlay of the 128-byte ICC header — no unsafe code required. `IndexMap` preserves tag insertion order from the source profile, which is critical for deterministic round-trips and offset recalculation. +Uses `zerocopy` for zero-copy overlay of the 128-byte ICC header — no unsafe code required. +`IndexMap` preserves tag insertion order from the source profile, which is critical for +deterministic round-trips and offset recalculation. --- ## Dependencies (Notable) | Crate | Purpose | -|---|---| +| --- | --- | | `colorimetry = "0.0.9"` | Color space definitions (RgbSpace, etc.) | | `zerocopy = "0.8"` | Safe binary overlay for ICC header | | `nalgebra = "0.33"` | Matrix math | @@ -138,10 +144,14 @@ Uses `zerocopy` for zero-copy overlay of the 128-byte ICC header — no unsafe c ## Key Conventions -- **Lossless round-trips**: Unknown tags are stored as `RawData` and written back verbatim. Any test that reads a real ICC file and re-serializes it must produce byte-identical output. -- **Profile ID**: Call `.with_profile_id()` at the end of profile construction to compute and embed the MD5 checksum (fields 84–99 of the header, zeroed during computation per the ICC spec). -- **Tag data sharing**: Multiple tag signatures can reference the same offset in the file. The library detects and preserves this optimization. -- **Consuming builder**: `with_tag(...)` and `as_*` methods consume `self` and return a new value. Do not attempt to reuse intermediate `TagSetter` values. +- **Lossless round-trips**: Unknown tags are stored as `RawData` and written back verbatim. + Any test that reads a real ICC file and re-serializes it must produce byte-identical output. +- **Profile ID**: Call `.with_profile_id()` at the end of profile construction to compute and + embed the MD5 checksum (fields 84–99 of the header, zeroed during computation per the ICC spec). +- **Tag data sharing**: Multiple tag signatures can reference the same offset in the file. + The library detects and preserves this optimization. +- **Consuming builder**: `with_tag(...)` and `as_*` methods consume `self` and return a new value. + Do not attempt to reuse intermediate `TagSetter` values. - **Feature flags**: ICC v5 support is gated behind a `v5` feature flag. --- @@ -166,7 +176,10 @@ profile.write("output.icc")?; ## Testing Notes -Integration tests in `tests/` use real `.icc` files stored under `tests/profiles/`. When adding new tag parsers or modifying serialization, always run the round-trip tests to confirm byte-identical output. The `displayP3` test compares a programmatically constructed profile against Apple's shipped Display P3 profile binary. +Integration tests in `tests/` use real `.icc` files stored under `tests/profiles/`. When adding +new tag parsers or modifying serialization, always run the round-trip tests to confirm +byte-identical output. The `displayP3` test compares a programmatically constructed profile +against Apple's shipped Display P3 profile binary. --- @@ -207,7 +220,7 @@ patch increment. Use a minor increment (`0.1.0`) when the public API is conside enough to declare a broader interface contract. | Change type | Version bump | -|---|---| +| --- | --- | | Bug fixes, internal refactors, docs | patch (`0.0.x → 0.0.x+1`) | | New public API, new features | minor (`0.x.0 → 0.x+1.0`) | | Breaking API changes | major (`x.0.0 → x+1.0.0`) | diff --git a/README.md b/README.md index 21832a0..2c2c67e 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ Profile::read("input.icc")? ## Modules | Module | Contents | -|---|---| +| --- | --- | | [`profile`] | `Profile` enum and per-device-class types (`DisplayProfile`, `InputProfile`, …) | | [`tag`] | Tag signatures, tag data types, and the `TagSetter` builder | | [`header`] | ICC 128-byte header fields and accessors | @@ -167,7 +167,7 @@ An ICC profile is a binary file with three sections: The ICC specification defines eight device classes. This crate provides a dedicated type for each: | Type | ICC class code | Typical use | -|---|---|---| +| --- | --- | --- | | [`profile::DisplayProfile`] | `mntr` | Monitors, projectors | | [`profile::InputProfile`] | `scnr` | Cameras, scanners | | [`profile::OutputProfile`] | `prtr` | Printers | @@ -194,7 +194,7 @@ The rendering intent controls how out-of-gamut colors are handled during color c Four intents are defined: | Intent | Typical use | -|---|---| +| --- | --- | | Perceptual | Photographic images — compresses the gamut smoothly | | Relative Colorimetric | Graphics — clips and maps the source white point | | Saturation | Business graphics — maximises saturation | @@ -209,14 +209,14 @@ All well-known tag signatures are re-exported from [`tag::tags`]. Tag types supported for reading and writing include: | ICC type | Rust type | Common tags | -|---|---|---| +| --- | --- | --- | | `XYZ` | `XYZArrayData` | `rXYZ`, `gXYZ`, `bXYZ`, `wtpt` | | `para` | `ParametricCurveData` | `rTRC`, `gTRC`, `bTRC` | | `curv` | `CurveData` | `rTRC`, `gTRC`, `bTRC` | | `mluc` | `MultiLocalizedUnicodeData` | `desc` | | `desc` | `TextDescriptionData` | `desc` | | `sf32` | `S15Fixed16ArrayData` | `chad` | -| `sig ` | `SignatureData` | `tech` | +| `sig` | `SignatureData` | `tech` | | `text` | `TextData` | `cprt` | | `mft1` | `Lut8Data` | `A2B0`, `B2A0` | | `mft2` | `Lut16Data` | `A2B0`, `B2A0` | @@ -226,7 +226,7 @@ Tags not yet parsed are stored as `RawData` and written back verbatim — no dat ## Key Types | Type | Description | -|---|---| +| --- | --- | | [`S15Fixed16`] | ICC s15Fixed16 fixed-point number used in matrices and XYZ values | | [`profile::Profile`] | Parsed profile, dispatched to one of eight device-class variants | | [`profile::TagSetter`] | Consuming builder returned by `with_tag(…)` | diff --git a/src/lib.rs b/src/lib.rs index 1a3ccaf..9488538 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -153,7 +153,7 @@ Profile::read("input.icc")? # Modules | Module | Contents | -|---|---| +| --- | --- | | [`profile`] | `Profile` enum and per-device-class types (`DisplayProfile`, `InputProfile`, …) | | [`tag`] | Tag signatures, tag data types, and the `TagSetter` builder | | [`header`] | ICC 128-byte header fields and accessors | @@ -173,7 +173,7 @@ An ICC profile is a binary file with three sections: The ICC specification defines eight device classes. This crate provides a dedicated type for each: | Type | ICC class code | Typical use | -|---|---|---| +| --- | --- | --- | | [`profile::DisplayProfile`] | `mntr` | Monitors, projectors | | [`profile::InputProfile`] | `scnr` | Cameras, scanners | | [`profile::OutputProfile`] | `prtr` | Printers | @@ -200,7 +200,7 @@ The rendering intent controls how out-of-gamut colors are handled during color c Four intents are defined: | Intent | Typical use | -|---|---| +| --- | --- | | Perceptual | Photographic images — compresses the gamut smoothly | | Relative Colorimetric | Graphics — clips and maps the source white point | | Saturation | Business graphics — maximises saturation | @@ -215,14 +215,14 @@ All well-known tag signatures are re-exported from [`tag::tags`]. Tag types supported for reading and writing include: | ICC type | Rust type | Common tags | -|---|---|---| +| --- | --- | --- | | `XYZ` | `XYZArrayData` | `rXYZ`, `gXYZ`, `bXYZ`, `wtpt` | | `para` | `ParametricCurveData` | `rTRC`, `gTRC`, `bTRC` | | `curv` | `CurveData` | `rTRC`, `gTRC`, `bTRC` | | `mluc` | `MultiLocalizedUnicodeData` | `desc` | | `desc` | `TextDescriptionData` | `desc` | | `sf32` | `S15Fixed16ArrayData` | `chad` | -| `sig ` | `SignatureData` | `tech` | +| `sig` | `SignatureData` | `tech` | | `text` | `TextData` | `cprt` | | `mft1` | `Lut8Data` | `A2B0`, `B2A0` | | `mft2` | `Lut16Data` | `A2B0`, `B2A0` | @@ -232,7 +232,7 @@ Tags not yet parsed are stored as `RawData` and written back verbatim — no dat # Key Types | Type | Description | -|---|---| +| --- | --- | | [`S15Fixed16`] | ICC s15Fixed16 fixed-point number used in matrices and XYZ values | | [`profile::Profile`] | Parsed profile, dispatched to one of eight device-class variants | | [`profile::TagSetter`] | Consuming builder returned by `with_tag(…)` | From 023404559c651d9bd1ac58fc17708559891b830d Mon Sep 17 00:00:00 2001 From: Gerard Harbers Date: Fri, 24 Apr 2026 15:47:18 -0700 Subject: [PATCH 6/7] docs(cmx-icc): fix markdownlint errors in lib.rs and README.md - Add top-level heading (MD041) - Fix table separator pipe spacing (MD060) Regenerate cmx-icc/README.md via cargo rdme. Co-Authored-By: Claude Sonnet 4.6 --- cmx-icc/README.md | 8 +++++--- cmx-icc/src/lib.rs | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/cmx-icc/README.md b/cmx-icc/README.md index 0bd8cad..4e002b3 100644 --- a/cmx-icc/README.md +++ b/cmx-icc/README.md @@ -1,5 +1,7 @@ +# cmx-icc + WebAssembly bindings for the [cmx](https://crates.io/crates/cmx) ICC color profile library, published to npm as [`cmx-icc`](https://www.npmjs.com/package/cmx-icc). @@ -66,7 +68,7 @@ standard WebAssembly asset pipeline. ## API summary | Class | Purpose | -|---|---| +| --- | --- | | `Profile` | Parse an existing ICC profile from a `Uint8Array` | | `DisplayProfile` | Build a display-class ICC profile from scratch | | `RenderingIntent` | Enum: `Perceptual`, `RelativeColorimetric`, `Saturation`, `AbsoluteColorimetric` | @@ -74,7 +76,7 @@ standard WebAssembly asset pipeline. ### `Profile` | Method | Description | -|---|---| +| --- | --- | | `Profile.fromBytes(data)` | Parse a `Uint8Array`; throws on invalid data | | `profile.toBytes()` | Serialize back to `Uint8Array` (byte-identical round-trip) | | `profile.renderingIntent()` | Read the rendering intent from the header | @@ -82,7 +84,7 @@ standard WebAssembly asset pipeline. ### `DisplayProfile` | Method | Description | -|---|---| +| --- | --- | | `new DisplayProfile()` | Empty profile — set all tags manually | | `DisplayProfile.srgb(intent)` | sRGB preset | | `DisplayProfile.displayP3(intent)` | Display P3 preset | diff --git a/cmx-icc/src/lib.rs b/cmx-icc/src/lib.rs index b85225e..1d644b6 100644 --- a/cmx-icc/src/lib.rs +++ b/cmx-icc/src/lib.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT // Copyright (c) 2021-2025, Harbers Bik LLC +//! # cmx-icc +//! //! WebAssembly bindings for the [cmx](https://crates.io/crates/cmx) ICC color //! profile library, published to npm as //! [`cmx-icc`](https://www.npmjs.com/package/cmx-icc). @@ -67,7 +69,7 @@ //! ## API summary //! //! | Class | Purpose | -//! |---|---| +//! | --- | --- | //! | `Profile` | Parse an existing ICC profile from a `Uint8Array` | //! | `DisplayProfile` | Build a display-class ICC profile from scratch | //! | `RenderingIntent` | Enum: `Perceptual`, `RelativeColorimetric`, `Saturation`, `AbsoluteColorimetric` | @@ -75,7 +77,7 @@ //! ### `Profile` //! //! | Method | Description | -//! |---|---| +//! | --- | --- | //! | `Profile.fromBytes(data)` | Parse a `Uint8Array`; throws on invalid data | //! | `profile.toBytes()` | Serialize back to `Uint8Array` (byte-identical round-trip) | //! | `profile.renderingIntent()` | Read the rendering intent from the header | @@ -83,7 +85,7 @@ //! ### `DisplayProfile` //! //! | Method | Description | -//! |---|---| +//! | --- | --- | //! | `new DisplayProfile()` | Empty profile — set all tags manually | //! | `DisplayProfile.srgb(intent)` | sRGB preset | //! | `DisplayProfile.displayP3(intent)` | Display P3 preset | From d9d3517a5f94d449b9d113bfaad4345a53f90df0 Mon Sep 17 00:00:00 2001 From: Gerard Harbers Date: Fri, 24 Apr 2026 15:50:45 -0700 Subject: [PATCH 7/7] fix: address Copilot review comments on PR #24 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cmx-icc/src/lib.rs: replace .expect() with proper JsError propagation in set_{red,green,blue}_trc_parametric — invalid param counts now throw a JS error instead of panicking the WASM instance - cmx-icc/src/lib.rs: fix intra-doc links to use Rust paths instead of JS method names (WasmProfile::from_bytes, ::to_bytes, WasmDisplayProfile::set_profile_description_mluc, ::set_red_trc_parametric) so rustdoc --deny warnings passes - xtask/src/main.rs: add --all-features/--all-targets to publish-crate test, clippy, and doc invocations so feature-gated code is exercised Co-Authored-By: Claude Sonnet 4.6 --- cmx-icc/src/lib.rs | 43 +++++++++++++++++++++++++++++-------------- xtask/src/main.rs | 16 +++++++++++++--- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/cmx-icc/src/lib.rs b/cmx-icc/src/lib.rs index 1d644b6..f293deb 100644 --- a/cmx-icc/src/lib.rs +++ b/cmx-icc/src/lib.rs @@ -170,8 +170,8 @@ impl From for WasmRenderingIntent { /// A parsed ICC color profile. /// -/// Use [`WasmProfile.fromBytes`] to parse an existing ICC file that you have -/// loaded as a `Uint8Array`, and [`WasmProfile.toBytes`] to serialize it back. +/// Use [`WasmProfile::from_bytes`] to parse an existing ICC file that you have +/// loaded as a `Uint8Array`, and [`WasmProfile::to_bytes`] to serialize it back. #[wasm_bindgen(js_name = "Profile")] pub struct WasmProfile { inner: cmx::profile::Profile, @@ -289,7 +289,7 @@ impl WasmDisplayProfile { /// /// Writes a legacy `desc` (v2 `TextDescriptionData`) tag, which is the /// most broadly compatible format. For v4 multi-language descriptions use - /// [`setProfileDescriptionMluc`]. + /// [`WasmDisplayProfile::set_profile_description_mluc`]. #[wasm_bindgen(js_name = setProfileDescription)] pub fn set_profile_description(&mut self, description: &str) { self.mutate(|p| { @@ -401,43 +401,58 @@ impl WasmDisplayProfile { #[wasm_bindgen(js_name = setRedTrcParametric)] pub fn set_red_trc_parametric(&mut self, params: &[f64]) -> Result<(), JsError> { let params = params.to_vec(); + let mut err: Option = None; self.mutate(|p| { p.with_tag(RedTRCTag).as_parametric_curve(|c| { - c.set_parameters_slice(¶ms) - .expect("invalid parametric curve parameter count"); + if let Err(e) = c.set_parameters_slice(¶ms) { + err = Some(JsError::new(&e.to_string())); + } }) }); - Ok(()) + match err { + Some(e) => Err(e), + None => Ok(()), + } } /// Set the green TRC as a parametric curve (`para` tag). /// - /// See [`setRedTrcParametric`] for parameter details. + /// See [`WasmDisplayProfile::set_red_trc_parametric`] for parameter details. #[wasm_bindgen(js_name = setGreenTrcParametric)] pub fn set_green_trc_parametric(&mut self, params: &[f64]) -> Result<(), JsError> { let params = params.to_vec(); + let mut err: Option = None; self.mutate(|p| { p.with_tag(GreenTRCTag).as_parametric_curve(|c| { - c.set_parameters_slice(¶ms) - .expect("invalid parametric curve parameter count"); + if let Err(e) = c.set_parameters_slice(¶ms) { + err = Some(JsError::new(&e.to_string())); + } }) }); - Ok(()) + match err { + Some(e) => Err(e), + None => Ok(()), + } } /// Set the blue TRC as a parametric curve (`para` tag). /// - /// See [`setRedTrcParametric`] for parameter details. + /// See [`WasmDisplayProfile::set_red_trc_parametric`] for parameter details. #[wasm_bindgen(js_name = setBlueTrcParametric)] pub fn set_blue_trc_parametric(&mut self, params: &[f64]) -> Result<(), JsError> { let params = params.to_vec(); + let mut err: Option = None; self.mutate(|p| { p.with_tag(BlueTRCTag).as_parametric_curve(|c| { - c.set_parameters_slice(¶ms) - .expect("invalid parametric curve parameter count"); + if let Err(e) = c.set_parameters_slice(¶ms) { + err = Some(JsError::new(&e.to_string())); + } }) }); - Ok(()) + match err { + Some(e) => Err(e), + None => Ok(()), + } } // -- Finalization & serialization ---------------------------------------- diff --git a/xtask/src/main.rs b/xtask/src/main.rs index f6456c1..f42cf9a 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -102,16 +102,26 @@ impl Commands { Commands::PublishCrate { dry_run } => { // ── Pre-publish checks (mirrors CLAUDE.md release checklist) ── println!("Running tests..."); - run("cargo", &["test"]); + run("cargo", &["test", "--all-features"]); run("cargo", &["test", "--doc"]); println!("Running clippy..."); - run("cargo", &["clippy", "--", "-D", "warnings"]); + run( + "cargo", + &[ + "clippy", + "--all-targets", + "--all-features", + "--", + "-D", + "warnings", + ], + ); println!("Checking docs..."); run_env( "cargo", - &["doc", "--no-deps"], + &["doc", "--all-features", "--no-deps"], &[("RUSTDOCFLAGS", "--deny warnings")], ) .expect("cargo doc failed");