From 505e748aacddfdbeeb83f857334535bf88670cf5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 23:21:17 +0000 Subject: [PATCH] IDML: gradient and directional feathers import and export Import reads GradientFeatherSetting (Applied, Type, Angle, Length, GradientStart, first and last OpacityGradientStop) and DirectionalFeatherSetting (Applied and the four widths) on page items. Export writes both when the effect is on. --- crates/idml/src/export.rs | 37 +++++++++++++++- crates/idml/src/import.rs | 56 ++++++++++++++++++++++-- crates/idml/src/lib.rs | 7 +-- crates/idml/src/tests.rs | 90 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 8 deletions(-) diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 9d73562b9..5abdd2179 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1683,7 +1683,8 @@ impl<'a> Ex<'a> { let mut el = with_props(el, props); // Transparency. let ds = &it.effects.drop_shadow; - if it.opacity < 1.0 || it.blend != designcraft_color::BlendMode::Normal || ds.on || it.effects.feather > 0.0 { + let (gf, df) = (&it.effects.gradient_feather, &it.effects.directional_feather); + if it.opacity < 1.0 || it.blend != designcraft_color::BlendMode::Normal || ds.on || it.effects.feather > 0.0 || gf.on || df.on { let mut t = El::new("TransparencySetting"); t.push(El::new("BlendingSetting").attr("BlendMode", names::blend_out(it.blend)).attr("Opacity", pct(it.opacity as f64))); if ds.on { @@ -1702,6 +1703,40 @@ impl<'a> Ex<'a> { if it.effects.feather > 0.0 { t.push(El::new("FeatherSetting").attr("Mode", "Standard").attr("Width", num(it.effects.feather))); } + if df.on { + let [top, left, bottom, right] = df.widths; + t.push( + El::new("DirectionalFeatherSetting") + .attr("Applied", "true") + .attr("LeftWidth", num(left)) + .attr("RightWidth", num(right)) + .attr("TopWidth", num(top)) + .attr("BottomWidth", num(bottom)), + ); + } + if gf.on { + let mut g = El::new("GradientFeatherSetting").attr("Applied", "true").attr("Type", if gf.radial { "Radial" } else { "Linear" }); + if let Some([x0, y0, x1, y1]) = gf.vector { + // Gradient Feather tool vector: start, length and angle in the item's space. + g.set("Angle", num((-(y1 - y0)).atan2(x1 - x0).to_degrees())); + g.set("Length", num((x1 - x0).hypot(y1 - y0))); + g.set("GradientStart", format!("{} {}", num(x0), num(y0))); + } else { + g.set("Angle", num(gf.angle)); + } + let id = uid(it.id.0); + for (i, (opacity, location)) in [(gf.start, 0.0), (gf.end, 100.0)].into_iter().enumerate() { + let mut s = El::new("OpacityGradientStop") + .attr("Self", format!("{id}TransparencySetting1GradientFeatherSetting1OpacityGradientStop{i}")) + .attr("Opacity", pct(opacity as f64)) + .attr("Location", num(location)); + if i > 0 { + s.set("Midpoint", "50"); + } + g.push(s); + } + t.push(g); + } el.push(t); } if let Content::Text(t) = &it.content { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d85d9ee02..1a4fa1565 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -7,10 +7,10 @@ use std::sync::Arc; use designcraft_color::cms::lab; use designcraft_color::{Color, ColorType, Gradient, GradientKind, GradientStop, Swatch, SwatchValue, swatch}; use designcraft_doc::{ - Asset, AssetId, BaselineGrid, CellRange, CharAttrs, CharFormat, CharRun, CharacterStyle, Columns, ColumnsKind, Content, DocSettings, Document, - DropShadow, Effects, Fill, GridAlign, GridRelative, Guide, Item, ItemId, Kerning, LAYER_COLORS, Layer, LayerId, ListType, Margins, ObjectStyle, - Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, Shape, SpanColumns, Spread, SpreadId, - Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, story as st, + Asset, AssetId, BaselineGrid, CellRange, CharAttrs, CharFormat, CharRun, CharacterStyle, Columns, ColumnsKind, Content, DirectionalFeather, + DocSettings, Document, DropShadow, Effects, Fill, GradientFeather, GridAlign, GridRelative, Guide, Item, ItemId, Kerning, LAYER_COLORS, Layer, + LayerId, ListType, Margins, ObjectStyle, Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, + Shape, SpanColumns, Spread, SpreadId, Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, story as st, }; use designcraft_geom::corners::{Corner, CornerOptions}; use designcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath}; @@ -2092,6 +2092,18 @@ impl<'r> Importer<'r> { { it.effects = Effects { feather: f.num("Width").unwrap_or(9.0), ..it.effects.clone() }; } + if let Some(g) = t.find("GradientFeatherSetting") + && g.boolean("Applied") == Some(true) + { + it.effects.gradient_feather = gradient_feather_in(g); + } + if let Some(df) = t.find("DirectionalFeatherSetting") + && df.boolean("Applied") == Some(true) + { + let w = |k: &str| finite(df.num(k)).unwrap_or(0.0).max(0.0); + it.effects.directional_feather = + DirectionalFeather { on: true, widths: [w("TopWidth"), w("LeftWidth"), w("BottomWidth"), w("RightWidth")] }; + } } // Text wrap. if let Some(w) = e.find("TextWrapPreference") { @@ -2345,6 +2357,42 @@ impl<'r> Importer<'r> { } } +fn finite(v: Option) -> Option { + v.filter(|v| v.is_finite()) +} + +/// A `GradientFeatherSetting`. The model holds two opacity stops at the ends of the gradient: the +/// first and last `OpacityGradientStop` give them; with a linear vector, their `Location`s move its +/// ends. +fn gradient_feather_in(g: &El) -> GradientFeather { + let def = GradientFeather::default(); + let angle = finite(g.num("Angle")).unwrap_or(0.0); + let mut stops: Vec<(f64, f64)> = g + .find_all("OpacityGradientStop") + .filter_map(|s| Some((finite(s.num("Location"))?.clamp(0.0, 100.0) / 100.0, finite(s.num("Opacity"))?.clamp(0.0, 100.0) / 100.0))) + .collect(); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + let (first, last) = match (stops.first(), stops.last()) { + (Some(&a), Some(&b)) => (a, b), + _ => ((0.0, def.start as f64), (1.0, def.end as f64)), + }; + let start: Option<[f64; 2]> = g + .prop("GradientStart") + .and_then(|s| s.split_whitespace().map(|v| v.parse().ok().filter(|v: &f64| v.is_finite())).collect::>>()) + .and_then(|v| v.try_into().ok()); + let radial = g.get("Type") == Some("Radial"); + let vector = match (start, finite(g.num("Length"))) { + (Some([x, y]), Some(len)) if len > 0.0 => { + let a = angle.to_radians(); + let (dx, dy) = (len * a.cos(), -len * a.sin()); + let (l0, l1) = if !radial && last.0 > first.0 { (first.0, last.0) } else { (0.0, 1.0) }; + Some([x + dx * l0, y + dy * l0, x + dx * l1, y + dy * l1]) + } + _ => None, + }; + GradientFeather { on: true, radial, angle, start: first.1 as f32, end: last.1 as f32, vector } +} + /// Drop-shadow angle (degrees) from IDML offsets (inverse of export's mapping). fn names_angle(x: f64, y: f64) -> f64 { if x == 0.0 && y == 0.0 { diff --git a/crates/idml/src/lib.rs b/crates/idml/src/lib.rs index 71d688255..5e37dd83f 100644 --- a/crates/idml/src/lib.rs +++ b/crates/idml/src/lib.rs @@ -21,7 +21,7 @@ //! parent (master) spreads and applied parents, spreads and pages, page items (`TextFrame`, //! `Rectangle`, `Oval`, `Polygon`, `GraphicLine`, `Group`) with fill/stroke, corner options, text //! frame options (with column rules), text wrap, transparency (opacity, blend mode, drop shadow, -//! feather), images (embedded `Contents` or linked), stories with paragraph/character style ranges and local overrides, special +//! feather, gradient feather, directional feather), images (embedded `Contents` or linked), stories with paragraph/character style ranges and local overrides, special //! characters and breaks, and text threading. //! //! ## TODO (ignored on import / not written on export) @@ -32,8 +32,9 @@ //! - Nested/GREP/line styles, bullets & numbering details (bullet glyph, number format), OpenType //! feature flags, paragraph borders, grid alignment, custom dashed stroke styles; column rule //! stroke type and overprint. -//! - Mixed inks, gradient stop opacity and gradient feathers, effects other than drop shadow and -//! basic feather, EPS/PDF/AI placed graphics (imported as images when the data is available), +//! - Mixed inks, gradient stop opacity, gradient feather stops between the first and last, its +//! midpoints and highlight, directional feather angle, noise, choke and shape mode, other effects +//! than drop shadow and the feathers, stroke/fill/content transparency, EPS/PDF/AI placed graphics (imported as images when the data is available), //! clipping paths, compound-path fill rules. //! - Unknown elements are ignored; nothing is preserved opaquely for round-trip yet. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 7b747587a..976d308ae 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1399,3 +1399,93 @@ fn list_numbering_format_expression_and_bullet_import_and_round_trip() { check(&d); check(&import_idml(&export_idml(&d)).unwrap()); } + +fn feather_fixture(transparency: &str) -> Document { + let designmap = r#" + + "#; + let rect = |id: &str, inner: &str| { + format!( + r#" + + + + + {inner} + "# + ) + }; + let spread = format!( + r#" + + + {}{} + + "#, + rect("a", transparency), + rect("b", "") + ); + import_idml(&zip_files(&[("designmap.xml", designmap), ("Spreads/Spread_s.xml", &spread)])).unwrap() +} + +#[test] +fn imports_gradient_and_directional_feathers() { + let d = feather_fixture( + r#" + + + + + + + "#, + ); + let items = &d.spreads[0].items; + let gf = &items[0].effects.gradient_feather; + assert!(gf.on && !gf.radial); + assert!((gf.start - 0.9).abs() < 1e-6 && (gf.end - 0.1).abs() < 1e-6, "{gf:?}"); + let v = gf.vector.unwrap(); + for (a, b) in v.iter().zip([20.0, 20.0, 50.0, 20.0]) { + assert!((a - b).abs() < 1e-9, "{v:?}"); + } + let df = &items[0].effects.directional_feather; + assert!(df.on); + assert_eq!(df.widths, [5.0, 3.0, 6.0, 4.0]); + assert_eq!(items[1].effects, designcraft_doc::Effects::default()); + + // Not applied: the settings stay off. + let off = feather_fixture( + r#" + + + "#, + ); + assert_eq!(off.spreads[0].items[0].effects, designcraft_doc::Effects::default()); +} + +#[test] +fn gradient_and_directional_feathers_round_trip() { + use designcraft_doc::{DirectionalFeather, GradientFeather, Item, ItemId}; + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let add = |d: &mut Document, effects: designcraft_doc::Effects| { + let mut it = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(40.0, 40.0, 140.0, 120.0))); + it.effects = effects; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + }; + let linear = designcraft_doc::Effects { + gradient_feather: GradientFeather { on: true, radial: false, angle: 0.0, start: 0.75, end: 0.25, vector: Some([50.0, 60.0, 110.0, 60.0]) }, + directional_feather: DirectionalFeather { on: true, widths: [1.0, 2.0, 3.0, 4.0] }, + ..Default::default() + }; + let radial = designcraft_doc::Effects { + gradient_feather: GradientFeather { on: true, radial: true, angle: 30.0, start: 0.0, end: 1.0, vector: None }, + ..Default::default() + }; + add(&mut d, linear.clone()); + add(&mut d, radial.clone()); + add(&mut d, designcraft_doc::Effects::default()); + let back = import_idml(&export_idml(&d)).unwrap(); + let effects: Vec<_> = back.spreads[0].items.iter().map(|i| i.effects.clone()).collect(); + assert_eq!(effects, [linear, radial, designcraft_doc::Effects::default()]); +}