# CMX — Rust ICC Color Profile Library
## 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.
---
## Build, Test, and Run
```bash
# Build
cargo build
cargo build --release
# Test (all integration tests live in tests/)
cargo test
cargo test --test roundtrip # round-trip serialization
cargo test --test displayP3 # Display P3 profile construction
# CLI tool
cargo install --path . # installs `cmx` binary
cmx profile.icc # print TOML to stdout
cmx profile.icc -o profile.toml # write TOML to file
# Examples
cargo run --example primaries
cargo run --example set_relcolorimetric_intent -- path/to/profile.icc
# Docs
cargo doc --open
```
---
## Project Layout
```
src/
lib.rs # Library root, utility functions
error.rs # Error types
bin/cmx.rs # CLI entry point
header.rs / header/ # ICC header (128-byte structure)
profile.rs / profile/ # Profile enum + per-device-class types
signatures.rs / signatures/ # ICC 4-byte signature enums
tag.rs / tag/ # Tag definitions, parsed tags
tag/tagdata/ # Individual tag data types
tests/ # Integration tests + test .icc files
examples/ # Runnable example programs
xtask/ # Code generation / maintenance utilities
```
---
## Architecture
### Profile Type System
The `Profile` enum wraps eight device-class-specific structs, all backed by `RawProfile`:
| `DisplayProfile` | Monitors, projectors |
| `InputProfile` | Cameras, scanners |
| `OutputProfile` | Printers |
| `DeviceLinkProfile` | Device-to-device transforms |
| `AbstractProfile` | Abstract color transforms |
| `ColorSpaceProfile` | Color space definitions |
| `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.
### Builder / Tag Setter Pattern
Tags are set via a consuming builder chain:
```rust
let profile = DisplayProfile::new()
.with_tag(ProfileDescriptionTag)
.as_text_description(|t| t.set_ascii("My Profile"))
.with_tag(MediaWhitePointTag)
.as_xyz_array(|xyz| xyz.set([0.9505, 1.0, 1.0891]))
.with_profile_id(); // computes MD5 checksum in place
```
`TagSetter<P, S>` 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
`TagData` is the core enum covering all supported ICC tag payload types:
`CurveData`, `ParametricCurveData`, `XYZArrayData`, `TextData`, `TextDescriptionData`,
`MultiLocalizedUnicodeData`, `S15Fixed16ArrayData`, `ChromaticityData`, `MeasurementData`,
`Lut8Data`, `Lut16Data`, `SignatureData`, `RawData`, and more.
Unknown/vendor tags are preserved via `RawData` — round-trips are lossless.
### Key Types
- `S15Fixed16` — 15.16 fixed-point number (used for matrices and XYZ values)
- `Signature` — generic 4-byte ICC identifier parser
- `TagSignature` — 70+ known ICC tag type identifiers + `Unknown(u32)`
- `ColorSpace`, `DeviceClass`, `Pcs`, `Platform`, `Technology` — signature enums
- `RenderingIntent` — Perceptual, RelativeColorimetric, Saturation, AbsoluteColorimetric
- `Illuminant` — D50, D65, D93, F2, A, E, Daylight, BlackBody, etc.
### 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.
---
## Dependencies (Notable)
| `colorimetry = "0.0.9"` | Color space definitions (RgbSpace, etc.) |
| `zerocopy = "0.8"` | Safe binary overlay for ICC header |
| `nalgebra = "0.33"` | Matrix math |
| `chrono = "0.4"` | DateTime in profile headers |
| `indexmap = "2"` | Order-preserving tag map |
| `serde` / `toml` | TOML serialization |
| `md5 = "0.8"` | Profile ID checksum |
| `thiserror = "2"` | Error types |
| `clap = "4"` | CLI arg parsing |
| `strum = "0.25"` | Enum utilities |
| `delegate = "0.13"` | Method delegation to RawProfile |
---
## 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.
- **Feature flags**: ICC v5 support is gated behind a `v5` feature flag.
---
## Common Entry Points
```rust
// Read an existing profile
let profile = Profile::read("path/to/profile.icc")?;
// Build a standard display profile
let p3 = DisplayProfile::cmx_display_p3(RenderingIntent::RelativeColorimetric);
// Serialize to bytes (for writing or embedding)
let bytes = profile.into_bytes()?;
// Write to disk
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.
---
## Commit and PR Policy
### Direct commits to `main`
Permitted only for changes that carry no behavioral risk:
- Typo or grammar fixes in documentation or comments
- `CHANGELOG.md` updates (the release commit itself)
- `Cargo.toml` version bump (the release commit itself)
- CI / GitHub Actions configuration changes with no code impact
- Dependency-only bumps that pass all tests and change no public API
### Pull Requests (required)
Everything else goes through a PR so that CI runs and a second pair of eyes can review before the change lands on `main`:
- Any change to `src/` (new features, bug fixes, refactors, API changes)
- New or modified integration tests in `tests/`
- Changes to examples in `examples/`
- Any change that touches public API surface (adding/removing/renaming types, methods, or error variants)
Branch naming convention: `fix/<short-description>`, `feat/<short-description>`, `docs/<short-description>`.
---
## Release Process
Follow these steps in order. All commands run from the repository root on a clean `main` branch.
### 1. Decide the new version number
This crate uses [Semantic Versioning](https://semver.org/). While the version is `0.0.x`
(pre-1.0), any user-visible change — including new public API or bug fixes — warrants a
patch increment. Use a minor increment (`0.1.0`) when the public API is considered stable
enough to declare a broader interface contract.
| 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`) |
### 2. Merge all pending PRs
Ensure the `main` branch is up to date and all intended changes are merged.
### 3. Update `CHANGELOG.md`
- Rename the `## [Unreleased]` heading to `## [X.Y.Z] - YYYY-MM-DD` (today's date).
- Add a fresh `## [Unreleased]` section above it (empty, ready for the next cycle).
- Review the entries: make sure every user-visible change since the previous release is listed.
Compare against `git log vPREV..HEAD --oneline` to catch anything missing.
### 4. Bump the version in `Cargo.toml`
```bash
# Edit the `version = "..."` field in [package]
```
Search the codebase for any hard-coded version strings in documentation and update them too:
```bash
grep -rn "0\.0\.X" --include="*.rs" --include="*.toml" --include="*.md"
```
### 5. Run the full test suite and quality checks
```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
```
All tests must pass and both `clippy` and `cargo doc` must be warning-free before tagging.
### 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:
```bash
cargo rdme
```
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
Stage only the version-bump and generated files:
```bash
git add Cargo.toml CHANGELOG.md README.md
git commit -m "chore: release v0.0.X"
```
Do **not** amend earlier commits or squash history — keep the release commit separate and minimal.
### 8. Tag the release
```bash
git tag -a v0.0.X -m "Release v0.0.X"
git push origin main --tags
```
### 9. Publish to crates.io
```bash
cargo publish
```
`cargo publish` performs a dry-run check internally; if it fails, fix the issue before pushing the tag.