diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -646,6 +646,8 @@ version = "0.1.0"
 dependencies = [
  "moxcms",
  "rayon",
+ "serde",
+ "serde_json",
 ]
 
 [[package]]
diff --git a/crates/cms/Cargo.toml b/crates/cms/Cargo.toml
--- a/crates/cms/Cargo.toml
+++ b/crates/cms/Cargo.toml
@@ -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 }
diff --git a/crates/cms/src/lib.rs b/crates/cms/src/lib.rs
--- a/crates/cms/src/lib.rs
+++ b/crates/cms/src/lib.rs
@@ -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 {
diff --git a/crates/cms/src/transform.rs b/crates/cms/src/transform.rs
--- a/crates/cms/src/transform.rs
+++ b/crates/cms/src/transform.rs
@@ -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).
diff --git a/crates/cms/tests/serde.rs b/crates/cms/tests/serde.rs
new file mode 100644
--- /dev/null
+++ b/crates/cms/tests/serde.rs
@@ -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);
+}
