mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 03:46:50 -06:00
M1: import image adjustments from .fig files
Image paints that carry a current filter container now import their exposure, contrast, vibrance (as saturation), temperature, tint, highlights and shadows as native image filters. Neutral values leave the paint untouched. The older color-adjust container alone, both containers together, filters that have no native equivalent (brightness, detail, vignette and unknown fields) and malformed or non-finite values are skipped and reported as import notes. A positive tint in .fig data moves toward magenta, so it is flipped once at the .fig boundary; public REST image filters keep their sign and are not reported as approximated. Imported filters use the approximate native renderer, which the import says once.
This commit is contained in:
committed by
bflatastic
parent
ab6cf4fd58
commit
d84aa7f1e1
@@ -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:?}");
|
||||
}
|
||||
@@ -55,6 +55,10 @@ pub(super) fn to_rest(msg: &Kv, name: &str) -> (Value, Vec<String>) {
|
||||
(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<String> {
|
||||
if w.contains("geometry=paths") {
|
||||
@@ -73,6 +77,16 @@ fn num(n: &Kv, k: &str) -> Option<f64> {
|
||||
n.get(k).and_then(Kv::f64)
|
||||
}
|
||||
|
||||
fn filter_number(value: &Kv) -> Option<f64> {
|
||||
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<String> {
|
||||
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<Value> {
|
||||
fn paint(p: &Kv, warn: &mut dyn FnMut(String)) -> Option<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
|
||||
/// 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<f64>) {
|
||||
fn char_styles(o: &mut Obj, td: &Kv, size: Option<f64>, warn: &mut dyn FnMut(String)) {
|
||||
let ids: Vec<u64> = 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<f64>) {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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<std::path::PathBuf> {
|
||||
let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() };
|
||||
|
||||
@@ -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<ExportSetting> {
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user