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..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
@@ -54,7 +56,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)
```
---
@@ -66,7 +69,7 @@ xtask/ # Code generation / maintenance utilities
The `Profile` enum wraps eight device-class-specific structs, all backed by `RawProfile`:
| Struct | Device class |
-|---|---|
+| --- | --- |
| `DisplayProfile` | Monitors, projectors |
| `InputProfile` | Cameras, scanners |
| `OutputProfile` | Printers |
@@ -76,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
@@ -91,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
@@ -113,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 |
@@ -137,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.
---
@@ -165,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.
---
@@ -206,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`) |
@@ -224,8 +238,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 +254,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 +295,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/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/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..4e002b3
--- /dev/null
+++ b/cmx-icc/README.md
@@ -0,0 +1,115 @@
+
+
+# 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).
+
+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
+
+```sh
+npm install cmx-icc
+```
+
+## Quick start
+
+```js
+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 = 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 = 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 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
+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 |
+| --- | --- |
+| `Profile` | Parse an existing ICC profile from a `Uint8Array` |
+| `DisplayProfile` | Build a display-class ICC profile from scratch |
+| `RenderingIntent` | Enum: `Perceptual`, `RelativeColorimetric`, `Saturation`, `AbsoluteColorimetric` |
+
+### `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 |
+
+### `DisplayProfile`
+
+| Method | Description |
+| --- | --- |
+| `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 |
+| `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..f293deb
--- /dev/null
+++ b/cmx-icc/src/lib.rs
@@ -0,0 +1,478 @@
+// 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).
+//!
+//! 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
+//!
+//! ```sh
+//! npm install cmx-icc
+//! ```
+//!
+//! ## Quick start
+//!
+//! ```js
+//! 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 = 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 = 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 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
+//! 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 |
+//! | --- | --- |
+//! | `Profile` | Parse an existing ICC profile from a `Uint8Array` |
+//! | `DisplayProfile` | Build a display-class ICC profile from scratch |
+//! | `RenderingIntent` | Enum: `Perceptual`, `RelativeColorimetric`, `Saturation`, `AbsoluteColorimetric` |
+//!
+//! ### `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 |
+//!
+//! ### `DisplayProfile`
+//!
+//! | Method | Description |
+//! | --- | --- |
+//! | `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 |
+//! | `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(js_name = "RenderingIntent")]
+#[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::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,
+}
+
+#[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(js_name = "DisplayProfile")]
+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
+ /// [`WasmDisplayProfile::set_profile_description_mluc`].
+ #[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();
+ let mut err: Option = None;
+ self.mutate(|p| {
+ p.with_tag(RedTRCTag).as_parametric_curve(|c| {
+ if let Err(e) = c.set_parameters_slice(¶ms) {
+ err = Some(JsError::new(&e.to_string()));
+ }
+ })
+ });
+ match err {
+ Some(e) => Err(e),
+ None => Ok(()),
+ }
+ }
+
+ /// Set the green TRC as a parametric curve (`para` tag).
+ ///
+ /// 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| {
+ if let Err(e) = c.set_parameters_slice(¶ms) {
+ err = Some(JsError::new(&e.to_string()));
+ }
+ })
+ });
+ match err {
+ Some(e) => Err(e),
+ None => Ok(()),
+ }
+ }
+
+ /// Set the blue TRC as a parametric curve (`para` tag).
+ ///
+ /// 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| {
+ if let Err(e) = c.set_parameters_slice(¶ms) {
+ err = Some(JsError::new(&e.to_string()));
+ }
+ })
+ });
+ match err {
+ Some(e) => Err(e),
+ None => 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..af659ae
--- /dev/null
+++ b/cmx-icc/tests/bindings.rs
@@ -0,0 +1,200 @@
+// 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/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(…)` |
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/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 {
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index 9350d4e..f42cf9a 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,78 @@ 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", "--all-features"]);
+ run("cargo", &["test", "--doc"]);
+
+ println!("Running clippy...");
+ run(
+ "cargo",
+ &[
+ "clippy",
+ "--all-targets",
+ "--all-features",
+ "--",
+ "-D",
+ "warnings",
+ ],
+ );
+
+ println!("Checking docs...");
+ run_env(
+ "cargo",
+ &["doc", "--all-features", "--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 +147,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 +208,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);