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.
This commit is contained in:
aiongg
2026-10-10 23:21:17 +00:00
parent a127c6d5c3
commit 505e748aac
4 changed files with 182 additions and 8 deletions
+36 -1
View File
@@ -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 {
+52 -4
View File
@@ -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<f64>) -> Option<f64> {
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::<Option<Vec<f64>>>())
.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 {
+4 -3
View File
@@ -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)]
+90
View File
@@ -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#"<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" Self="d">
<idPkg:Spread src="Spreads/Spread_s.xml"/>
</Document>"#;
let rect = |id: &str, inner: &str| {
format!(
r#"<Rectangle Self="{id}" ItemTransform="1 0 0 1 0 0">
<Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 100"/>
<PathPointType Anchor="100 100"/><PathPointType Anchor="100 0"/>
</PathPointArray></GeometryPathType></PathGeometry></Properties>
{inner}
</Rectangle>"#
)
};
let spread = format!(
r#"<idPkg:Spread xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<Spread Self="s">
<Page Self="p" GeometricBounds="0 0 200 200" ItemTransform="1 0 0 1 0 0"/>
{}{}
</Spread>
</idPkg:Spread>"#,
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#"<TransparencySetting>
<GradientFeatherSetting Applied="true" Type="Linear" Angle="0" Length="50" GradientStart="10 20">
<OpacityGradientStop Self="s0" Opacity="90" Location="20"/>
<OpacityGradientStop Self="s2" Opacity="10" Location="80" Midpoint="40"/>
<OpacityGradientStop Self="s1" Opacity="55" Location="50" Midpoint="50"/>
</GradientFeatherSetting>
<DirectionalFeatherSetting Applied="true" LeftWidth="3" RightWidth="4" TopWidth="5" BottomWidth="6" Angle="0" Noise="0" ChokeAmount="0"/>
</TransparencySetting>"#,
);
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#"<TransparencySetting>
<GradientFeatherSetting Applied="false" Length="50" GradientStart="10 20"/>
<DirectionalFeatherSetting Applied="false" LeftWidth="3"/>
</TransparencySetting>"#,
);
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()]);
}