Browse / github.com/storytold/craft-libs / patch
cms: an optional serde feature for Intent and TransformOptions (#64)
craft-cms: optional serde for Intent and TransformOptions Apps store rendering intents and conversion options in documents and settings (proof setup, PDF export, colour settings). `Intent` and `TransformOptions` derive no serde traits, so each app writes its own mapping. Proposal: an optional `serde` feature that derives `Serialize`/`Deserialize` for `Intent` (names: `perceptual`, `relative-colorimetric`, `saturation`, `absolute-colorimetric`) and `TransformOptions` (with defaults for missing fields). `Intent::from_u32` stays for ICC headers.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Hamza0101 (imported from pull request #222, open)
- Base
1f805378d454dac48b5c214029d254055fdf961b- Size
- +88 −0 in 5 files
- Review
- pending
- Shared
- 2026-10-28
- Id
fc0cf09156d4a5a0f3d50cf1
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock crates/cms/Cargo.toml crates/cms/src/lib.rs crates/cms/src/transform.rs crates/cms/tests/serde.rs
@@ -646,6 +646,8 @@ version = "0.1.0" dependencies = [ "moxcms", "rayon",+ "serde",+ "serde_json", ] [[package]]@@ -12,8 +12,16 @@ exclude = ["tests/data", "tests/fogra39.rs"] [lints] workspace = true +[dependencies]+serde = { workspace = true, optional = true }++[features]+# Serialize and deserialize `Intent` and `TransformOptions`, for documents and settings.+serde = ["dep:serde"]+ [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon = { workspace = true } [dev-dependencies] moxcms = "0.8"+serde_json = { workspace = true }@@ -83,6 +83,26 @@ impl Intent { } } +/// An intent is stored by the name [`Intent::id`] gives it, and read back by+/// [`Intent::parse`], which also accepts the longer spellings (`relativeColorimetric`,+/// `relative-colorimetric`) and the ICC numbers as strings. One vocabulary, so a document written+/// by one app reads in another.+#[cfg(feature = "serde")]+impl serde::Serialize for Intent {+ fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {+ s.serialize_str(self.id())+ }+}++#[cfg(feature = "serde")]+impl<'de> serde::Deserialize<'de> for Intent {+ fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Intent, D::Error> {+ let name = <std::borrow::Cow<'de, str> as serde::Deserialize>::deserialize(d)?;+ Intent::parse(&name)+ .ok_or_else(|| serde::de::Error::custom(format!("unknown rendering intent `{name}` (perceptual, relative, saturation, absolute)")))+ }+}+ /// Errors from parsing profiles, building transforms, or arguments that do not fit an operation. #[derive(Clone, Debug, PartialEq, Eq)] pub enum CmsError {@@ -20,7 +20,12 @@ pub enum SampleKind { } /// Options for building a [`Transform`].+///+/// With the `serde` feature: field names in kebab-case, every field optional and filled from+/// [`Default`], so `{}` and `{"bpc": false}` both read. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]+#[cfg_attr(feature = "serde", serde(default, rename_all = "kebab-case"))] pub struct TransformOptions { pub intent: Intent, /// Adobe-style black point compensation (ignored for absolute colorimetric).new file mode 100644@@ -0,0 +1,53 @@+//! The optional `serde` feature (#64): what documents and settings store for an intent and for+//! the conversion options, and what they read back.+#![cfg(feature = "serde")]++use craft_cms::{Intent, TransformOptions};++#[test]+fn an_intent_is_stored_under_the_name_the_crate_already_gives_it() {+ for intent in Intent::ALL {+ let json = serde_json::to_string(&intent).unwrap();+ assert_eq!(json, format!("\"{}\"", intent.id()), "{intent:?}");+ assert_eq!(serde_json::from_str::<Intent>(&json).unwrap(), intent);+ }+ assert_eq!(serde_json::to_string(&Intent::RelativeColorimetric).unwrap(), "\"relative\"");+}++#[test]+fn every_spelling_parse_accepts_reads_back() {+ for name in ["relative", "relativeColorimetric", "relative-colorimetric", "Relative Colorimetric", "colorimetric", "1"] {+ let got: Intent = serde_json::from_str(&format!("\"{name}\"")).unwrap_or_else(|e| panic!("{name}: {e}"));+ assert_eq!(got, Intent::RelativeColorimetric, "{name}");+ }+ for (name, want) in [+ ("perceptual", Intent::Perceptual),+ ("saturation", Intent::Saturation),+ ("absolute", Intent::AbsoluteColorimetric),+ ("absoluteColorimetric", Intent::AbsoluteColorimetric),+ ] {+ assert_eq!(serde_json::from_str::<Intent>(&format!("\"{name}\"")).unwrap(), want, "{name}");+ }+}++#[test]+fn an_unknown_intent_is_an_error_that_names_the_choices() {+ let e = serde_json::from_str::<Intent>("\"photographic\"").unwrap_err().to_string();+ assert!(e.contains("photographic") && e.contains("perceptual") && e.contains("absolute"), "{e}");+ // Not a string at all.+ assert!(serde_json::from_str::<Intent>("1").is_err(), "the ICC number has `Intent::from_u32`");+}++#[test]+fn transform_options_fill_missing_fields_from_their_defaults() {+ let d = TransformOptions::default();+ assert_eq!(serde_json::from_str::<TransformOptions>("{}").unwrap(), d, "an empty table is the default");+ let one = serde_json::from_str::<TransformOptions>(r#"{"bpc": false}"#).unwrap();+ assert_eq!(one, TransformOptions { bpc: false, ..d });+ let all = serde_json::from_str::<TransformOptions>(r#"{"intent": "perceptual", "bpc": false, "precise-float": false}"#).unwrap();+ assert_eq!(all, TransformOptions { intent: Intent::Perceptual, bpc: false, precise_float: false });+ // And what it writes reads back as itself, under the names it wrote.+ let json = serde_json::to_string(&all).unwrap();+ assert_eq!(json, r#"{"intent":"perceptual","bpc":false,"precise-float":false}"#);+ assert_eq!(serde_json::from_str::<TransformOptions>(&json).unwrap(), all);+}