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);