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()]); +}