Merge pull request #2301 from TrevorBrown-dev/feat/fig-export-image-adjustments-2297

M6: .fig export writes image adjustments (#2297)
This commit is contained in:
BFlat
2026-11-13 22:58:48 +07:00
committed by GitHub
4 changed files with 102 additions and 9 deletions
+2 -1
View File
@@ -142,7 +142,8 @@ message StyleId GUID guid=1
message VariableID GUID guid=1
message VariableSetID GUID guid=1
message ColorStopVar Color color=1, VariableData colorVar=2, float position=3
message Paint PaintType type=1, Color color=2, float opacity=3, bool visible=4, BlendMode blendMode=5, ColorStop[] stops=6, Matrix transform=7, Image image=8, ImageScaleMode imageScaleMode=10, float rotation=11, float scale=12, VariableData colorVar=21, ColorStopVar[] stopsVar=23
message PaintFilterMessage float tint=1, float shadows=2, float highlights=3, float exposure=5, float temperature=7, float vibrance=8, float contrast=9
message Paint PaintType type=1, Color color=2, float opacity=3, bool visible=4, BlendMode blendMode=5, ColorStop[] stops=6, Matrix transform=7, Image image=8, ImageScaleMode imageScaleMode=10, float rotation=11, float scale=12, PaintFilterMessage paintFilter=14, VariableData colorVar=21, ColorStopVar[] stopsVar=23
message Effect EffectType type=1, Color color=2, Vector offset=3, float radius=4, bool visible=5, BlendMode blendMode=6, float spread=7, bool showShadowBehindNode=8, BlurOpType blurOpType=19, float startRadius=21, Vector startOffset=20, Vector endOffset=28, NoiseType noiseType=26, Vector noiseSize=22, float density=25, float opacity=27, Color secondaryColor=24, bool clipToShape=23, VariableData radiusVar=9, VariableData colorVar=10, VariableData spreadVar=11, VariableData xVar=12, VariableData yVar=13, VariableData startRadiusVar=45
struct FontName string family, string style, string postscript
struct Number float value, NumberUnits units
+15 -2
View File
@@ -76,8 +76,21 @@ impl<'d> Exporter<'d> {
if let (ImageScaleMode::Crop, Some(c)) = (img.scale_mode, img.crop) {
f.push(("transform", matrix(Affine::new(c))));
}
if img.filters != Default::default() {
self.warn("image adjustments (exposure, contrast, …) were left out");
let x = img.filters.sanitized();
if !x.is_identity() {
// The file's tint is positive towards magenta, ours towards green; `0.0 -` keeps zero positive.
f.push((
"paintFilter",
o(vec![
("exposure", fl(f64::from(x.exposure))),
("contrast", fl(f64::from(x.contrast))),
("vibrance", fl(f64::from(x.saturation))),
("temperature", fl(f64::from(x.temperature))),
("tint", fl(0.0 - f64::from(x.tint))),
("highlights", fl(f64::from(x.highlights))),
("shadows", fl(f64::from(x.shadows))),
]),
));
}
}
PaintKind::Pattern(_) => {
+81 -3
View File
@@ -16,8 +16,8 @@ fn image_filter_schema() -> Schema {
Schema::parse(&text).unwrap()
}
#[test]
fn image_filters_import_from_current_container_and_skip_ambiguous_data() {
/// A file with one image rectangle per filter case (current container, legacy, both, neutral, unknown fields).
fn image_filter_fixture() -> Vec<u8> {
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()))]))];
@@ -54,7 +54,12 @@ fn image_filters_import_from_current_container_and_skip_ambiguous_data() {
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))])));
zip_file(&canvas(&image_filter_schema(), &o(vec![("nodeChanges", arr(nodes))])))
}
#[test]
fn image_filters_import_from_current_container_and_skip_ambiguous_data() {
let bytes = image_filter_fixture();
let (doc, warnings) = import(&bytes, "ImageFilters.fig").unwrap();
let get = |name: &str| {
let node = doc.nodes().find(|node| node.name == name).unwrap();
@@ -102,3 +107,76 @@ fn warnings_naming_unknown_filter_fields_are_capped() {
let named = warnings.iter().filter(|w| w.contains("unsupported image filter")).count();
assert!((100..=crate::rest::MAX_WARNINGS).contains(&named), "{named} of {} warnings", warnings.len());
}
#[test]
fn image_adjustments_survive_a_fig_export() {
let (mut doc, _) = import(&image_filter_fixture(), "ImageFilters.fig").unwrap();
let filters_of = |doc: &figcraft_doc::Document, 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.filters
};
// Values a document can hold and the file can't: out of range, and not a number.
let ids: Vec<figcraft_doc::NodeId> = doc.ids().collect();
for id in ids {
let Some(node) = doc.node_mut(id) else { continue };
let name = node.name.clone();
let Some(figcraft_doc::color::PaintKind::Image(image)) = node.fills.first_mut().map(|p| &mut p.kind) else { continue };
match name.as_str() {
"Legacy" => image.filters.exposure = 5.0,
"Explicit neutral" => image.filters.contrast = f32::NAN,
_ => {}
}
}
let (bytes, warnings) = export::export(&doc, None).unwrap();
assert!(!warnings.iter().any(|w| w.contains("image adjustments")), "{warnings:?}");
let (back, _) = import(&bytes, "Back.fig").unwrap();
for name in ["Current", "Negative tint", "Neutral"] {
let (a, b) = (filters_of(&doc, name), filters_of(&back, name));
for (x, y) in [
(a.exposure, b.exposure),
(a.contrast, b.contrast),
(a.saturation, b.saturation),
(a.temperature, b.temperature),
(a.tint, b.tint),
(a.highlights, b.highlights),
(a.shadows, b.shadows),
] {
assert!((x - y).abs() < 1e-6, "{name}: {a:?} came back as {b:?}");
}
}
assert!(filters_of(&back, "Neutral").is_identity());
assert!(!filters_of(&back, "Current").is_identity());
// What is written: the file's names and signs.
let archive = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let fill_of = |name: &str| {
let nc = msg.get("nodeChanges").unwrap().list().iter().find(|nc| nc.get("name").and_then(Kv::str) == Some(name)).unwrap();
nc.get("fillPaints").unwrap().list()[0].clone()
};
let current = fill_of("Current");
let written = |paint: &Kv, field: &str| paint.get("paintFilter").and_then(|f| f.get(field)).and_then(Kv::f64);
assert!((written(&current, "vibrance").unwrap() + 0.4).abs() < 1e-6);
assert!((written(&current, "tint").unwrap() - 1.0).abs() < 1e-6);
assert!((written(&current, "shadows").unwrap() - 0.7).abs() < 1e-6);
assert!(fill_of("Neutral").get("paintFilter").is_none());
assert!((written(&fill_of("Legacy"), "exposure").unwrap() - 1.0).abs() < 1e-6, "written within the file's range");
assert!(fill_of("Explicit neutral").get("paintFilter").is_none(), "not a number is no adjustment");
let partly = fill_of("Negative tint");
for field in ["exposure", "contrast", "vibrance", "temperature", "tint", "highlights", "shadows"] {
assert!(written(&partly, field).is_some(), "{field} is written");
}
assert!((written(&partly, "tint").unwrap() + 1.0).abs() < 1e-6);
// The numbers the format uses.
let s = Schema::parse(export::SCHEMA).unwrap();
let def = |n: &str| s.defs.iter().find(|d| d.name == n).unwrap();
let id = |d: &str, f: &str| def(d).fields.iter().find(|x| x.name == f).unwrap().value;
assert_eq!(id("Paint", "paintFilter"), 14);
assert_eq!(
["tint", "shadows", "highlights", "exposure", "temperature", "vibrance", "contrast"].map(|f| id("PaintFilterMessage", f)),
[1, 2, 3, 5, 7, 8, 9]
);
}
File diff suppressed because one or more lines are too long