diff --git a/crates/format/src/fig/image_filter_tests.rs b/crates/format/src/fig/image_filter_tests.rs new file mode 100644 index 00000000..b8d64694 --- /dev/null +++ b/crates/format/src/fig/image_filter_tests.rs @@ -0,0 +1,83 @@ +//! Image paint filters read from `.fig` files, on fixtures built in code with the parent module's +//! helpers. + +use super::*; + +/// The shared schema plus the image filter containers only these tests use. +fn image_filter_schema() -> Schema { + let text = SCHEMA + .replace( + "message Paint ", + "message FilterColorAdjust float tint, float shadows, float highlights, float detail, float exposure, float vignette, float temperature, float vibrance\n\ + message PaintFilterMessage float tint, float shadows, float highlights, float detail, float exposure, float vignette, float temperature, float vibrance, float contrast, float brightness, float mystery\n\ + message Paint ", + ) + .replace("ImageScaleMode imageScaleMode\n", "ImageScaleMode imageScaleMode, PaintFilterMessage paintFilter, FilterColorAdjust filterColorAdjust\n"); + Schema::parse(&text).unwrap() +} + +#[test] +fn image_filters_import_from_current_container_and_skip_ambiguous_data() { + let filter = |fields: Vec<(&'static str, Kv<'static>)>| o(fields); + let image_paint = |id: u64, name: &'static str, mut extra: Vec<(&'static str, Kv<'static>)>| { + let mut paint = vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))]))]; + paint.append(&mut extra); + node(id, Some((1, "!")), "RECTANGLE", name, vec![("size", xy(2.0, 1.0)), ("fillPaints", arr(vec![o(paint)]))]) + }; + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + image_paint( + 2, + "Current", + vec![( + "paintFilter", + filter(vec![ + ("exposure", f(0.25)), + ("contrast", f(0.3)), + ("vibrance", f(-0.4)), + ("temperature", f(0.5)), + ("tint", f(1.0)), + ("highlights", f(-0.6)), + ("shadows", f(0.7)), + ]), + )], + ), + image_paint(3, "Negative tint", vec![("paintFilter", filter(vec![("tint", f(-1.0)), ("contrast", f(-0.3))]))]), + image_paint(4, "Neutral", vec![]), + image_paint(5, "Explicit neutral", vec![("paintFilter", filter(vec![("exposure", f(0.0)), ("vibrance", f(0.0))]))]), + image_paint(6, "Legacy", vec![("filterColorAdjust", filter(vec![("exposure", f(0.8))]))]), + image_paint(7, "Both", vec![("filterColorAdjust", filter(vec![("exposure", f(0.8))])), ("paintFilter", filter(vec![("contrast", f(0.2))]))]), + image_paint( + 8, + "Unsupported", + vec![("paintFilter", filter(vec![("brightness", f(0.4)), ("detail", f(-0.2)), ("vignette", f(0.3)), ("mystery", f(0.1))]))], + ), + ]; + let bytes = zip_file(&canvas(&image_filter_schema(), &o(vec![("nodeChanges", arr(nodes))]))); + let (doc, warnings) = import(&bytes, "ImageFilters.fig").unwrap(); + let get = |name: &str| { + let node = doc.nodes().find(|node| node.name == name).unwrap(); + let figcraft_doc::color::PaintKind::Image(image) = &node.fills[0].kind else { panic!("{name} is not an image fill") }; + image + }; + let current = get("Current"); + assert!((current.filters.exposure - 0.25).abs() < 1e-6); + assert!((current.filters.contrast - 0.3).abs() < 1e-6, "observed raw contrast remains 0.3"); + assert!((current.filters.saturation + 0.4).abs() < 1e-6, "vibrance maps to saturation"); + assert!((current.filters.temperature - 0.5).abs() < 1e-6); + assert!((current.filters.tint + 1.0).abs() < 1e-6, "positive Figma tint converts once at the REST boundary"); + assert!((current.filters.highlights + 0.6).abs() < 1e-6); + assert!((current.filters.shadows - 0.7).abs() < 1e-6); + assert!((get("Negative tint").filters.tint - 1.0).abs() < 1e-6); + assert!((get("Negative tint").filters.contrast + 0.3).abs() < 1e-6); + assert_eq!(get("Neutral").filters, Default::default()); + assert_eq!(get("Explicit neutral").filters, Default::default()); + assert_eq!(doc.images.get(¤t.image).map(|image| image.bytes.as_slice()), Some(png().as_slice())); + assert!(warnings.iter().any(|w| w.contains("legacy image filter container is unsupported")), "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("both filter containers are present")), "{warnings:?}"); + for field in ["brightness", "detail", "vignette", "mystery"] { + assert!(warnings.iter().any(|w| w.contains(field)), "missing {field} warning: {warnings:?}"); + } + assert_eq!(warnings.iter().filter(|w| w.contains("approximate renderer")).count(), 1, "deduplicated approximation warning: {warnings:?}"); +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 0fe44662..62d10d71 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -55,6 +55,10 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec) { (out, m.warnings) } +#[cfg(test)] +#[path = "map_image_filter_tests.rs"] +mod image_filter_tests; + /// REST importer warnings reworded for a `.fig` import (`None`: drop it). pub(super) fn adapt_warning(w: String) -> Option { if w.contains("geometry=paths") { @@ -73,6 +77,16 @@ fn num(n: &Kv, k: &str) -> Option { n.get(k).and_then(Kv::f64) } +fn filter_number(value: &Kv) -> Option { + let number = match value { + Kv::Float(value) => f64::from(*value), + Kv::Int(value) => *value as f64, + Kv::Uint(value) => *value as f64, + _ => return None, + }; + number.is_finite().then_some(number) +} + fn guid(v: &Kv) -> Option { Some(format!("{}:{}", num(v, "sessionID")? as u64, num(v, "localID")? as u64)) } @@ -964,11 +978,11 @@ impl<'a> Mapper<'a> { let fill_style = self.style(v, "styleIdForFill", "inheritFillStyleID", "fill", &mut styles); let fills = own("fillPaints") .unwrap_or_else(|| fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default())); - o.insert("fills".into(), Value::Array(fills.iter().filter_map(paint).collect())); + o.insert("fills".into(), Value::Array(fills.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect())); let stroke_style = self.style(v, "styleIdForStrokeFill", "inheritFillStyleIDForStroke", "stroke", &mut styles); let strokes = own("strokePaints") .unwrap_or_else(|| stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default())); - o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(paint).collect())); + o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect())); let effect_style = self.style(v, "styleIdForEffect", "inheritEffectStyleID", "effect", &mut styles); let effects = effect_style.map_or_else(|| v.list("effects"), |s| s.get("effects").map(Kv::list).unwrap_or_default()); if !effects.is_empty() { @@ -1138,7 +1152,7 @@ impl<'a> Mapper<'a> { } o.insert("style".into(), Value::Object(style)); let Some(td) = td else { return }; - char_styles(o, td, size); + char_styles(o, td, size, &mut |w| self.warn(w)); let lines: Vec<&str> = td .get("lines") .map(Kv::list) @@ -1333,7 +1347,7 @@ impl<'a> Mapper<'a> { o.remove("styleOverrideTable"); if let Some(tv) = val.get("textValue") { let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64); - char_styles(o, tv, size); + char_styles(o, tv, size, &mut |w| self.warn(w)); } } } @@ -1920,7 +1934,7 @@ fn paint_bound(p: &Kv) -> bool { alias(p.get("colorVar")).is_some() || p.get("stopsVar").map(Kv::list).unwrap_or_default().iter().any(|s| alias(s.get("colorVar")).is_some()) } -fn paint(p: &Kv) -> Option { +fn paint(p: &Kv, warn: &mut dyn FnMut(String)) -> Option { let ty = p.get("type").and_then(Kv::str)?; let mut o = Obj::new(); o.insert("type".into(), json!(ty)); @@ -1989,6 +2003,52 @@ fn paint(p: &Kv) -> Option { if let Some(r) = num(p, "rotation") { o.insert("rotation".into(), json!(r)); } + match (p.get("filterColorAdjust"), p.get("paintFilter")) { + (Some(_), Some(_)) => { + warn("image filters are ambiguous because both filter containers are present; filter conversion was skipped".into()) + } + (Some(_), None) => warn("legacy image filter container is unsupported; filter conversion was skipped".into()), + (None, Some(filters)) => { + let mut mapped = Obj::new(); + let fields = [ + ("exposure", "exposure"), + ("contrast", "contrast"), + ("vibrance", "saturation"), + ("temperature", "temperature"), + ("tint", "tint"), + ("highlights", "highlights"), + ("shadows", "shadows"), + ]; + if let Kv::Object(values) = filters { + for (source, target) in fields { + if let Some(value) = filters.get(source) { + match filter_number(value) { + Some(value) => { + mapped.insert(target.into(), json!(value)); + } + None => warn(format!("malformed image filter `{source}` was skipped")), + } + } + } + for (name, value) in values { + if fields.iter().any(|(known, _)| *known == *name) { + continue; + } + match filter_number(value) { + Some(0.0) => {} + Some(_) => warn(format!("nonzero unsupported image filter `{}` was skipped", crate::rest::props::clip_str(name))), + None => warn(format!("malformed unsupported image filter `{}` was skipped", crate::rest::props::clip_str(name))), + } + } + } else { + warn("malformed image paintFilter container was skipped".into()); + } + if !mapped.is_empty() { + o.insert("filters".into(), Value::Object(mapped)); + } + } + (None, None) => {} + } } _ => {} } @@ -2046,7 +2106,7 @@ fn variable_value(d: &Kv) -> Option { /// Per-character styles of text data `td` (`characterStyleIDs` + `styleOverrideTable`) as REST /// `characterStyleOverrides` + `styleOverrideTable`; `size`: the base font size. -fn char_styles(o: &mut Obj, td: &Kv, size: Option) { +fn char_styles(o: &mut Obj, td: &Kv, size: Option, warn: &mut dyn FnMut(String)) { let ids: Vec = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect(); if ids.iter().all(|i| *i == 0) { return; @@ -2056,7 +2116,7 @@ fn char_styles(o: &mut Obj, td: &Kv, size: Option) { let Some(sid) = num(e, "styleID") else { continue }; let mut s = text_style(&View::of(e), size); if let Some(fills) = e.get("fillPaints") { - s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(paint).collect())); + s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(|p| paint(p, warn)).collect())); } table.insert((sid as u64).to_string(), Value::Object(s)); } diff --git a/crates/format/src/fig/map_image_filter_tests.rs b/crates/format/src/fig/map_image_filter_tests.rs new file mode 100644 index 00000000..a8db39a7 --- /dev/null +++ b/crates/format/src/fig/map_image_filter_tests.rs @@ -0,0 +1,27 @@ +//! Malformed image paint filters are skipped with a warning. + +use super::*; + +#[test] +fn malformed_non_finite_filter_is_skipped_with_warning() { + let paint = Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("paintFilter", Kv::Object(vec![("exposure", Kv::Float(f32::NAN))]))]); + let mut warnings = Vec::new(); + let value = super::paint(&paint, &mut |warning| warnings.push(warning)).unwrap(); + assert!(value.get("filters").is_none(), "{value}"); + assert!(warnings.iter().any(|warning| warning.contains("malformed image filter `exposure`")), "{warnings:?}"); +} + +#[test] +fn boolean_filter_values_are_malformed_even_when_false() { + for (field, value, expected_warning) in [ + ("exposure", Kv::Bool(true), "malformed image filter `exposure`"), + ("exposure", Kv::Bool(false), "malformed image filter `exposure`"), + ("mystery", Kv::Bool(false), "malformed unsupported image filter `mystery`"), + ] { + let paint = Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("paintFilter", Kv::Object(vec![(field, value)]))]); + let mut warnings = Vec::new(); + let value = super::paint(&paint, &mut |warning| warnings.push(warning)).unwrap(); + assert!(value.get("filters").is_none(), "{value}"); + assert!(warnings.iter().any(|warning| warning.contains(expected_warning)), "{warnings:?}"); + } +} diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 19c4fc15..e98a1f34 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -873,6 +873,9 @@ fn nested_instances_resized_again_drop_the_layout_derived_for_their_old_size() { #[path = "tail_tests.rs"] mod tail; +#[path = "image_filter_tests.rs"] +mod image_filters; + /// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset). pub(super) fn samples() -> Vec { let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() }; diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index 67ec8063..ac4d3519 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -178,6 +178,24 @@ fn image(cx: &mut Ctx, p: &Value) -> ImagePaint { .filter(|c| c[0].abs() > 1e-9 && c[3].abs() > 1e-9); let f = p.get("filters"); let filter = |k: &str| f.and_then(|f| num(f, k)).unwrap_or(0.0).clamp(-1.0, 1.0) as f32; + let filter_fields = ["exposure", "contrast", "saturation", "temperature", "tint", "highlights", "shadows"]; + let mut has_filters = false; + if let Some(filters) = f.and_then(Value::as_object) { + for (key, value) in filters { + match value.as_f64().filter(|value| value.is_finite()) { + Some(value) if filter_fields.contains(&key.as_str()) => has_filters |= value != 0.0, + Some(0.0) => {} + Some(_) => cx.warn(format!("nonzero unsupported image filter `{}` was skipped", clip_str(key))), + None => cx.warn(format!("malformed image filter `{}` was skipped", clip_str(key))), + } + } + } else if f.is_some_and(|filters| !filters.is_null()) { + cx.warn("malformed image filters container was skipped"); + } + let fig = cx.fig_sources.is_some(); + if has_filters && fig { + cx.warn("image filters use FigCraft's approximate renderer"); + } ImagePaint { image: cx.image_key(text(p, "imageRef").unwrap_or("")), scale_mode, @@ -189,7 +207,8 @@ fn image(cx: &mut Ctx, p: &Value) -> ImagePaint { contrast: filter("contrast"), saturation: filter("saturation"), temperature: filter("temperature"), - tint: filter("tint"), + // A positive tint in .fig sources moves toward magenta; native positive tint adds green. + tint: if fig { -filter("tint") } else { filter("tint") }, highlights: filter("highlights"), shadows: filter("shadows"), }, @@ -550,6 +569,6 @@ pub(super) fn export_settings(v: &Value) -> Vec { } /// At most 200 chars of `s` (names and labels from the file stay readable in warnings). -pub(super) fn clip_str(s: &str) -> &str { +pub(crate) fn clip_str(s: &str) -> &str { s.char_indices().nth(200).and_then(|(i, _)| s.get(..i)).unwrap_or(s) } diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index d7117fd0..282476ef 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -186,6 +186,24 @@ fn paints_gradients_and_image_placeholders() { assert!(warnings.iter().any(|w| w.starts_with("1 image without image data shows")), "{warnings:?}"); } +#[test] +fn public_rest_image_filter_tint_keeps_its_sign() { + let rect = |name: &str, tint: f64| { + json!({ + "id": name, "name": name, "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, + "fills": [{"type": "IMAGE", "imageRef": "tint", "filters": {"tint": tint}}] + }) + }; + let (doc, warnings) = one(json!([rect("Magenta", 0.75), rect("Green", -0.5)])); + let image = |name: &str| { + let PaintKind::Image(image) = &named(&doc, name).fills[0].kind else { panic!("{name} is not an image fill") }; + image + }; + assert!(close(f64::from(image("Magenta").filters.tint), 0.75)); + assert!(close(f64::from(image("Green").filters.tint), -0.5)); + assert!(!warnings.iter().any(|w| w.contains("approximate renderer")), "{warnings:?}"); +} + #[test] fn image_bytes_come_from_the_resolver() { let png = {