fix(styles): object style effects keep local effects, validate values, follow BasedOn in IDML (#653)

This commit is contained in:
aiongg
2026-10-13 00:58:47 +00:00
parent 98ff430022
commit 462831bf81
13 changed files with 382 additions and 190 deletions
+1 -1
View File
@@ -129,7 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-13** · Object styles carry effects (drop shadow, feather, inner and outer shadows and glows, bevel, satin, gradient and directional feather). New styles from a selection keep its effects, `style.object.edit` changes a style's effects and every item that follows them, `style.object.clearOverrides` returns items to their style, and IDML ObjectStyles write and read the drop shadow and feather (#653). No readiness percentage change.
- **2026-10-13** · Object styles carry effects (drop shadow, feather, inner and outer shadows and glows, bevel, satin, gradient and directional feather). New styles from a selection keep its effects, `style.object.edit` changes a style's effects and every item that follows them, `style.object.clearOverrides` returns items to their style, and new frames take their default style's effects. IDML carries only the drop shadow and feather, for ObjectStyles (with their effects category and BasedOn chain) as for items (#653). No readiness percentage change.
- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
+3 -7
View File
@@ -187,13 +187,9 @@ impl Document {
self.stories.insert(sid, Arc::new(story));
let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect));
item.object_style = self.styles.default_text_frame.clone();
if let Some(os) = self.styles.object_style(&self.styles.default_text_frame).cloned() {
if let Some(f) = os.fill {
item.fill = f;
}
if let Some(s) = os.stroke {
item.stroke = s;
}
// Applied before the content exists, so the frame keeps default text frame options.
if let Some(os) = self.styles.object_style(&self.styles.default_text_frame) {
os.apply_to(&mut item);
}
item.content = Content::Text(TextFrame { story: sid, options: TextFrameOptions::default() });
self.insert_item(r, item, None)?;
+56
View File
@@ -911,6 +911,62 @@ impl GradientFeather {
}
impl Effects {
/// These effects with opacities in 0–1, percentages in 0–100 and sizes, widths and distances
/// not below 0, or an error naming the first value that isn't a finite number.
pub fn sanitized(mut self) -> Result<Effects, String> {
fn finite(name: &str, v: f64) -> Result<f64, String> {
if v.is_finite() { Ok(v) } else { Err(format!("{name} must be a finite number")) }
}
let len = |name: &str, v: f64| finite(name, v).map(|v| v.max(0.0));
let pct = |name: &str, v: f64| finite(name, v).map(|v| v.clamp(0.0, 100.0));
let opacity = |name: &str, v: f32| finite(name, f64::from(v)).map(|v| v.clamp(0.0, 1.0) as f32);
let ds = &mut self.drop_shadow;
ds.opacity = opacity("dropShadow.opacity", ds.opacity)?;
ds.angle = finite("dropShadow.angle", ds.angle)?;
ds.distance = len("dropShadow.distance", ds.distance)?;
ds.size = len("dropShadow.size", ds.size)?;
ds.spread = pct("dropShadow.spread", ds.spread)?;
self.feather = len("feather", self.feather)?;
let e = &mut self.inner_shadow;
e.opacity = opacity("innerShadow.opacity", e.opacity)?;
e.angle = finite("innerShadow.angle", e.angle)?;
e.distance = len("innerShadow.distance", e.distance)?;
e.size = len("innerShadow.size", e.size)?;
e.choke = pct("innerShadow.choke", e.choke)?;
let e = &mut self.outer_glow;
e.opacity = opacity("outerGlow.opacity", e.opacity)?;
e.size = len("outerGlow.size", e.size)?;
e.spread = pct("outerGlow.spread", e.spread)?;
let e = &mut self.gradient_feather;
e.angle = finite("gradientFeather.angle", e.angle)?;
e.start = opacity("gradientFeather.start", e.start)?;
e.end = opacity("gradientFeather.end", e.end)?;
if let Some(v) = &mut e.vector {
for c in v.iter_mut() {
*c = finite("gradientFeather.vector", *c)?;
}
}
let e = &mut self.inner_glow;
e.opacity = opacity("innerGlow.opacity", e.opacity)?;
e.size = len("innerGlow.size", e.size)?;
e.choke = pct("innerGlow.choke", e.choke)?;
let e = &mut self.bevel;
e.size = len("bevel.size", e.size)?;
e.depth = len("bevel.depth", e.depth)?;
e.angle = finite("bevel.angle", e.angle)?;
e.highlight_opacity = opacity("bevel.highlightOpacity", e.highlight_opacity)?;
e.shadow_opacity = opacity("bevel.shadowOpacity", e.shadow_opacity)?;
let e = &mut self.satin;
e.opacity = opacity("satin.opacity", e.opacity)?;
e.angle = finite("satin.angle", e.angle)?;
e.distance = len("satin.distance", e.distance)?;
e.size = len("satin.size", e.size)?;
for w in self.directional_feather.widths.iter_mut() {
*w = len("directionalFeather.widths", *w)?;
}
Ok(self)
}
/// Any effect drawn with soft (raster) filters?
pub fn any(&self) -> bool {
self.drop_shadow.on
+23 -1
View File
@@ -3,7 +3,7 @@
use serde::{Deserialize, Serialize};
use crate::attrs::{CharAttrs, CharProps, ParaAttrs, ParaProps};
use crate::item::{Effects, Fill, Stroke, TextFrameOptions};
use crate::item::{Effects, Fill, Item, Stroke, TextFrameOptions};
use crate::story::{BASIC_PARAGRAPH, CharFormat, NO_CHAR_STYLE, ParaFormat};
pub const NO_PARA_STYLE: &str = "[No Paragraph Style]";
@@ -61,6 +61,24 @@ pub struct ObjectStyle {
pub effects: Option<Effects>,
}
impl ObjectStyle {
/// Gives `it` the fill, stroke, effects and text frame options this style sets.
pub fn apply_to(&self, it: &mut Item) {
if let Some(f) = &self.fill {
it.fill = f.clone();
}
if let Some(st) = &self.stroke {
it.stroke = st.clone();
}
if let Some(e) = &self.effects {
it.effects = e.clone();
}
if let (Some(tf), Some(o)) = (it.text_frame_mut(), &self.text_frame) {
tf.options = o.clone();
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Styles {
@@ -695,6 +713,10 @@ impl Styles {
pub fn object_style(&self, name: &str) -> Option<&ObjectStyle> {
self.object.iter().find(|s| s.name == name)
}
/// The effects object style `name` sets, if any (for new items that get a default style).
pub fn object_effects(&self, name: &str) -> Option<&Effects> {
self.object_style(name)?.effects.as_ref()
}
/// The paragraph style chain from root to `name` (cycle-safe).
fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> {
+6
View File
@@ -376,6 +376,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
let (bx, by, bw, bh) = crop_box.unwrap_or((0.0, 0.0, nw, nh));
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(x, y, x + bw * k, y + bh * k)));
it.object_style = d.styles.default_graphic_frame.clone();
if let Some(e) = d.styles.object_effects(&it.object_style) {
it.effects = e.clone();
}
it.content = Content::Graphic(Graphic {
asset: aid,
size: (nw, nh),
@@ -491,6 +494,9 @@ fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
let id = ItemId(d.alloc());
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(frame_r));
it.object_style = d.styles.default_graphic_frame.clone();
if let Some(e) = d.styles.object_effects(&it.object_style) {
it.effects = e.clone();
}
it.content = Content::Graphic(Graphic {
asset: aid,
size: (nw, nh),
+111 -126
View File
@@ -6,7 +6,8 @@ use std::sync::Arc;
use designcraft_color::BlendMode;
use designcraft_doc::{
Content, Document, Fill, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame, TextFrameOptions, TextSel,
Content, Document, Effects, Fill, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame, TextFrameOptions,
TextSel,
};
use designcraft_geom::{Affine, Point, Rect, Vec2, shapes};
use serde_json::{Value, json};
@@ -276,24 +277,19 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
|s, p| {
let p2 = p.clone();
set_flag(
s,
p,
move |i| {
let ds = &mut i.effects.drop_shadow;
ds.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!ds.on);
ds.distance = f64_or(&p2, "distance", ds.distance);
ds.angle = f64_or(&p2, "angle", ds.angle);
ds.global_light = p2.get("globalLight").and_then(Value::as_bool).unwrap_or(ds.global_light);
ds.opacity = f64_or(&p2, "opacity", ds.opacity as f64) as f32;
ds.size = f64_or(&p2, "size", ds.size);
ds.spread = f64_or(&p2, "spread", ds.spread);
if let Some(c) = str_param(&p2, "color") {
ds.color = c.into();
}
},
false,
)
set_effects(s, p, "object.dropShadow", move |fx| {
let ds = &mut fx.drop_shadow;
ds.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!ds.on);
ds.distance = f64_or(&p2, "distance", ds.distance);
ds.angle = f64_or(&p2, "angle", ds.angle);
ds.global_light = p2.get("globalLight").and_then(Value::as_bool).unwrap_or(ds.global_light);
ds.opacity = f64_or(&p2, "opacity", ds.opacity as f64) as f32;
ds.size = f64_or(&p2, "size", ds.size);
ds.spread = f64_or(&p2, "spread", ds.spread);
if let Some(c) = str_param(&p2, "color") {
ds.color = c.into();
}
})
}
),
cmd!(
@@ -305,24 +301,19 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
|s, p| {
let p2 = p.clone();
set_flag(
s,
p,
move |i| {
let e = &mut i.effects.inner_shadow;
e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on);
e.distance = f64_or(&p2, "distance", e.distance);
e.angle = f64_or(&p2, "angle", e.angle);
e.global_light = p2.get("globalLight").and_then(Value::as_bool).unwrap_or(e.global_light);
e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32;
e.size = f64_or(&p2, "size", e.size);
e.choke = f64_or(&p2, "choke", e.choke);
if let Some(c) = str_param(&p2, "color") {
e.color = c.into();
}
},
false,
)
set_effects(s, p, "object.innerShadow", move |fx| {
let e = &mut fx.inner_shadow;
e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on);
e.distance = f64_or(&p2, "distance", e.distance);
e.angle = f64_or(&p2, "angle", e.angle);
e.global_light = p2.get("globalLight").and_then(Value::as_bool).unwrap_or(e.global_light);
e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32;
e.size = f64_or(&p2, "size", e.size);
e.choke = f64_or(&p2, "choke", e.choke);
if let Some(c) = str_param(&p2, "color") {
e.color = c.into();
}
})
}
),
cmd!(
@@ -334,21 +325,16 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
|s, p| {
let p2 = p.clone();
set_flag(
s,
p,
move |i| {
let e = &mut i.effects.outer_glow;
e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on);
e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32;
e.size = f64_or(&p2, "size", e.size);
e.spread = f64_or(&p2, "spread", e.spread);
if let Some(c) = str_param(&p2, "color") {
e.color = c.into();
}
},
false,
)
set_effects(s, p, "object.outerGlow", move |fx| {
let e = &mut fx.outer_glow;
e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on);
e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32;
e.size = f64_or(&p2, "size", e.size);
e.spread = f64_or(&p2, "spread", e.spread);
if let Some(c) = str_param(&p2, "color") {
e.color = c.into();
}
})
}
),
cmd!(
@@ -394,22 +380,17 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
|s, p| {
let p2 = p.clone();
set_flag(
s,
p,
move |i| {
let e = &mut i.effects.inner_glow;
e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on);
e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32;
e.size = f64_or(&p2, "size", e.size);
e.choke = f64_or(&p2, "choke", e.choke);
e.center = p2.get("center").and_then(Value::as_bool).unwrap_or(e.center);
if let Some(c) = str_param(&p2, "color") {
e.color = c.into();
}
},
false,
)
set_effects(s, p, "object.innerGlow", move |fx| {
let e = &mut fx.inner_glow;
e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on);
e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32;
e.size = f64_or(&p2, "size", e.size);
e.choke = f64_or(&p2, "choke", e.choke);
e.center = p2.get("center").and_then(Value::as_bool).unwrap_or(e.center);
if let Some(c) = str_param(&p2, "color") {
e.color = c.into();
}
})
}
),
cmd!(
@@ -421,27 +402,22 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
|s, p| {
let p2 = p.clone();
set_flag(
s,
p,
move |i| {
let e = &mut i.effects.bevel;
e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on);
e.size = f64_or(&p2, "size", e.size);
e.depth = f64_or(&p2, "depth", e.depth);
e.angle = f64_or(&p2, "angle", e.angle);
e.global_light = p2.get("globalLight").and_then(Value::as_bool).unwrap_or(e.global_light);
e.highlight_opacity = f64_or(&p2, "highlightOpacity", e.highlight_opacity as f64) as f32;
e.shadow_opacity = f64_or(&p2, "shadowOpacity", e.shadow_opacity as f64) as f32;
if let Some(c) = str_param(&p2, "highlight") {
e.highlight = c.into();
}
if let Some(c) = str_param(&p2, "shadow") {
e.shadow = c.into();
}
},
false,
)
set_effects(s, p, "object.bevel", move |fx| {
let e = &mut fx.bevel;
e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on);
e.size = f64_or(&p2, "size", e.size);
e.depth = f64_or(&p2, "depth", e.depth);
e.angle = f64_or(&p2, "angle", e.angle);
e.global_light = p2.get("globalLight").and_then(Value::as_bool).unwrap_or(e.global_light);
e.highlight_opacity = f64_or(&p2, "highlightOpacity", e.highlight_opacity as f64) as f32;
e.shadow_opacity = f64_or(&p2, "shadowOpacity", e.shadow_opacity as f64) as f32;
if let Some(c) = str_param(&p2, "highlight") {
e.highlight = c.into();
}
if let Some(c) = str_param(&p2, "shadow") {
e.shadow = c.into();
}
})
}
),
cmd!(
@@ -453,23 +429,18 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
|s, p| {
let p2 = p.clone();
set_flag(
s,
p,
move |i| {
let e = &mut i.effects.satin;
e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on);
e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32;
e.angle = f64_or(&p2, "angle", e.angle);
e.distance = f64_or(&p2, "distance", e.distance);
e.size = f64_or(&p2, "size", e.size);
e.invert = p2.get("invert").and_then(Value::as_bool).unwrap_or(e.invert);
if let Some(c) = str_param(&p2, "color") {
e.color = c.into();
}
},
false,
)
set_effects(s, p, "object.satin", move |fx| {
let e = &mut fx.satin;
e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on);
e.opacity = f64_or(&p2, "opacity", e.opacity as f64) as f32;
e.angle = f64_or(&p2, "angle", e.angle);
e.distance = f64_or(&p2, "distance", e.distance);
e.size = f64_or(&p2, "size", e.size);
e.invert = p2.get("invert").and_then(Value::as_bool).unwrap_or(e.invert);
if let Some(c) = str_param(&p2, "color") {
e.color = c.into();
}
})
}
),
cmd!(
@@ -481,32 +452,27 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
|s, p| {
let p2 = p.clone();
set_flag(
s,
p,
move |i| {
let e = &mut i.effects.directional_feather;
e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on);
match p2.get("widths") {
Some(Value::Number(n)) => e.widths = [n.as_f64().unwrap_or(0.0).max(0.0); 4],
Some(v @ Value::Array(_)) => {
if let Ok(w) = serde_json::from_value::<[f64; 4]>(v.clone()) {
e.widths = w.map(|x| x.max(0.0));
}
set_effects(s, p, "object.directionalFeather", move |fx| {
let e = &mut fx.directional_feather;
e.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!e.on);
match p2.get("widths") {
Some(Value::Number(n)) => e.widths = [n.as_f64().unwrap_or(0.0).max(0.0); 4],
Some(v @ Value::Array(_)) => {
if let Ok(w) = serde_json::from_value::<[f64; 4]>(v.clone()) {
e.widths = w.map(|x| x.max(0.0));
}
_ => {}
}
if e.on && e.widths.iter().all(|w| *w <= 0.0) {
e.widths = [9.0; 4];
}
},
false,
)
_ => {}
}
if e.on && e.widths.iter().all(|w| *w <= 0.0) {
e.widths = [9.0; 4];
}
})
}
),
cmd!("object.feather", "Basic Feather", ["Object", "Effects"], None, "{width (0 = off), ids?}", has_selection, |s, p| {
let w = f64_or(p, "width", 9.0).max(0.0);
set_flag(s, p, move |i| i.effects.feather = w, false)
set_effects(s, p, "object.feather", move |fx| fx.feather = w)
}),
cmd!("object.content", "Content", ["Object", "Content"], None, "{type: graphic|text|unassigned, ids?}", has_selection, content_type),
cmd!(
@@ -863,6 +829,9 @@ fn frame_create(s: &mut Session, p: &Value) -> Result<Value> {
let mut it = Item::new(id, lid, sh, path);
if content == "graphic" {
it.object_style = d.styles.default_graphic_frame.clone();
if let Some(e) = d.styles.object_effects(&it.object_style) {
it.effects = e.clone();
}
} else {
// Shapes drawn with Rectangle/Ellipse/Polygon get the default 1 pt black stroke.
it.stroke = Stroke::default();
@@ -1404,6 +1373,22 @@ fn set_flag(s: &mut Session, p: &Value, f: impl Fn(&mut Item), deselect: bool) -
})
}
/// Edits the targets' effects. A value that isn't a finite number is refused; the others are
/// clamped to their ranges.
fn set_effects(s: &mut Session, p: &Value, cmd: &'static str, f: impl Fn(&mut Effects)) -> Result<Value> {
let ids = targets(s, p)?;
s.edit(|d, _| {
for id in &ids {
if let Some(it) = d.item_mut(*id) {
let mut fx = it.effects.clone();
f(&mut fx);
it.effects = fx.sanitized().map_err(|m| bad(cmd, m))?;
}
}
Ok(json!({"changed": ids.len()}))
})
}
fn all_flag(s: &mut Session, f: impl Fn(&mut Item)) -> Result<Value> {
s.edit(|d, _| {
for i in 0..d.spreads.len() {
+1
View File
@@ -194,6 +194,7 @@ fn gradient_feather(s: &mut Session, p: &Value) -> Result<Value> {
gf.vector = Some([a.x, a.y, b.x, b.y]);
}
}
it.effects = it.effects.clone().sanitized().map_err(|m| bad("object.gradientFeather", m))?;
}
Ok(json!({"changed": ids.len()}))
})
+31 -30
View File
@@ -151,7 +151,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Object Style Options…",
[],
None,
"{name, effects?: {…} | null (the style no longer sets effects), dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} — effect objects merge field by field into the style's effects (fields as in `document.inspect` item effects); items using the style whose effects match the style's take the new effects, items with their own effects keep them → {name, updated}",
"{name, effects?: {…} | null (the style no longer sets effects), dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} — effect objects merge field by field into the style's effects (fields as in `document.inspect` item effects); values that aren't finite numbers are refused, the others clamped (opacities 0–1, sizes and distances ≥ 0); items using the style whose effects match the style's (no effects, when the style set none) take the new effects, items with their own effects keep them → {name, updated}",
has_doc,
edit_object
),
@@ -160,7 +160,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Clear Object Style Overrides",
[],
None,
"{ids?} — each item takes its object style's fill, stroke, effects and text frame options again",
"{ids?} — each item takes its object style's fill, stroke, effects and text frame options again → {changed}",
super::has_selection,
clear_object_overrides
),
@@ -780,7 +780,7 @@ fn apply_object(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
let Some(it) = d.item_mut(*id) else { continue };
it.object_style = name.clone();
set_object_attrs(it, &os);
os.apply_to(it);
if let Some(tf) = it.text_frame() {
stories.push(tf.story);
}
@@ -797,32 +797,29 @@ fn apply_object(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Gives `it` the attributes the object style `os` sets.
fn set_object_attrs(it: &mut Item, os: &ObjectStyle) {
if let Some(f) = &os.fill {
it.fill = f.clone();
}
if let Some(st) = &os.stroke {
it.stroke = st.clone();
}
if let Some(e) = &os.effects {
it.effects = e.clone();
}
if let (Some(tf), Some(o)) = (it.text_frame_mut(), &os.text_frame) {
tf.options = o.clone();
}
}
fn clear_object_overrides(s: &mut Session, p: &Value) -> Result<Value> {
let ids = super::targets(s, p)?;
// Each target with its style's attributes applied, kept only where that changes the item.
let d = &s.doc()?.doc;
let changed: Vec<Item> = ids
.iter()
.filter_map(|id| {
let it = d.item(*id)?;
let mut next = it.clone();
d.styles.object_style(&it.object_style)?.apply_to(&mut next);
(next != *it).then_some(next)
})
.collect();
if changed.is_empty() {
return Ok(json!({"changed": 0}));
}
s.edit(|d, _| {
for id in &ids {
let Some(os) = d.item(*id).and_then(|it| d.styles.object_style(&it.object_style)).cloned() else { continue };
if let Some(it) = d.item_mut(*id) {
set_object_attrs(it, &os);
for next in &changed {
if let Some(it) = d.item_mut(next.id) {
*it = next.clone();
}
}
Ok(Value::Null)
Ok(json!({"changed": changed.len()}))
})
}
@@ -850,7 +847,8 @@ fn effects_param(cmd: &str, p: &Value, base: &Effects) -> Result<Option<Effects>
}
let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?;
merge_json(&mut v, Value::Object(patch), 0);
serde_json::from_value(v).map(Some).map_err(|e| bad(cmd, format!("effects: {e}")))
let e: Effects = serde_json::from_value(v).map_err(|e| bad(cmd, format!("effects: {e}")))?;
e.sanitized().map(Some).map_err(|m| bad(cmd, m))
}
/// Merges `top` into `base`: objects key by key, anything else replaced.
@@ -916,17 +914,20 @@ fn edit_object(s: &mut Session, p: &Value) -> Result<Value> {
None => return Ok(json!({"name": name, "updated": 0})),
}
};
if effects == old.effects {
return Ok(json!({"name": name, "updated": 0}));
}
s.edit(|d, _| {
let Some(os) = d.styles_mut().object.iter_mut().find(|o| o.name == name) else {
return Err(bad(CMD, format!("no object style `{name}`")));
};
os.effects = effects.clone();
// An item follows the style while its effects are the style's; when the style didn't set
// effects before, every item using it takes them.
// An item follows the style while its effects are the style's. When the style didn't set
// effects before, only items without effects of their own follow (the default styles are
// on most frames).
let follows = old.effects.clone().unwrap_or_default();
let updated = match &effects {
Some(new) => {
d.update_items(|it| it.object_style == name && old.effects.as_ref().is_none_or(|e| it.effects == *e), |it| it.effects = new.clone())
}
Some(new) => d.update_items(|it| it.object_style == name && it.effects == follows, |it| it.effects = new.clone()),
None => 0,
};
Ok(json!({"name": name, "updated": updated}))
+54 -5
View File
@@ -481,16 +481,65 @@ fn object_style_effects_follow_the_style_until_overridden() {
assert_eq!((shadow(&s, a).size, shadow(&s, b).size), (12.0, 12.0));
assert_eq!(s.doc().unwrap().doc.styles.object_style("Photo").unwrap().effects.as_ref().unwrap().drop_shadow.size, 12.0);
// A style that didn't set effects gives them to every item using it; `null` stops setting them.
// A style that didn't set effects gives them to the items using it that have no effects of
// their own, and to new frames that get it by default; `null` stops setting them.
let e = frame(&mut s, 36.0);
s.execute("style.object.apply", &json!({"name": BASIC_GRAPHICS_FRAME, "ids": [e]})).unwrap();
let f = frame(&mut s, 200.0);
s.execute("style.object.apply", &json!({"name": BASIC_GRAPHICS_FRAME, "ids": [e, f]})).unwrap();
s.execute("object.dropShadow", &json!({"on": true, "ids": [e]})).unwrap();
s.execute("style.object.edit", &json!({"name": BASIC_GRAPHICS_FRAME, "feather": 4})).unwrap();
assert_eq!(item(&s, e).effects, designcraft_doc::Effects { feather: 4.0, ..Default::default() });
let r = s.execute("style.object.edit", &json!({"name": BASIC_GRAPHICS_FRAME, "feather": 4})).unwrap();
assert_eq!(r["updated"], 1);
assert!(item(&s, e).effects.drop_shadow.on && item(&s, e).effects.feather == 0.0);
assert_eq!(item(&s, f).effects, designcraft_doc::Effects { feather: 4.0, ..Default::default() });
let g = s.execute("frame.create", &json!({"rect": [36, 300, 136, 400], "content": "graphic"})).unwrap()["id"].as_u64().unwrap();
assert_eq!((item(&s, g).object_style.as_str(), item(&s, g).effects.feather), (BASIC_GRAPHICS_FRAME, 4.0));
s.execute("style.object.edit", &json!({"name": BASIC_GRAPHICS_FRAME, "effects": null})).unwrap();
assert!(s.doc().unwrap().doc.styles.object_style(BASIC_GRAPHICS_FRAME).unwrap().effects.is_none());
assert_eq!(item(&s, e).effects.feather, 4.0);
assert_eq!(item(&s, f).effects.feather, 4.0);
// Nothing to change: no edit, no undo step.
let rev = s.doc().unwrap().revision;
s.execute("style.object.edit", &json!({"name": BASIC_GRAPHICS_FRAME, "effects": null})).unwrap();
s.execute("style.object.clearOverrides", &json!({"ids": [a, b]})).unwrap();
assert_eq!(s.doc().unwrap().revision, rev);
}
#[test]
fn object_style_effects_reach_grouped_and_anchored_items() {
use designcraft_doc::ItemId;
let mut s = session();
let frame = |s: &mut Session, x: f64| {
s.execute("frame.create", &json!({"rect": [x, 36, x + 50.0, 86], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap()
};
let (a, b, c) = (frame(&mut s, 36.0), frame(&mut s, 100.0), frame(&mut s, 200.0));
s.execute("style.object.create", &json!({"name": "Photo", "fromSelection": false, "dropShadow": {"on": true}})).unwrap();
s.execute("style.object.apply", &json!({"name": "Photo", "ids": [a, b, c]})).unwrap();
s.execute("object.group", &json!({"ids": [a, b]})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [36, 200, 400, 400], "content": "text", "text": "Before after."})).unwrap();
let sid = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
s.execute("anchored.insert", &json!({"ids": [c], "story": sid.0, "pos": 7})).unwrap();
let r = s.execute("style.object.edit", &json!({"name": "Photo", "dropShadow": {"size": 25}})).unwrap();
assert_eq!(r["updated"], 3);
let d = &s.doc().unwrap().doc;
assert_eq!(d.item(ItemId(a)).unwrap().effects.drop_shadow.size, 25.0);
assert_eq!(d.story(sid).unwrap().objects[0].item.effects.drop_shadow.size, 25.0);
// Values that aren't finite numbers are refused (an f32 opacity of 1e39 is infinite); the
// others are clamped, and the document still saves and opens.
assert!(s.execute("style.object.edit", &json!({"name": "Photo", "dropShadow": {"opacity": 1e39}})).is_err());
assert!(s.execute("object.dropShadow", &json!({"opacity": 1e39, "ids": [b]})).is_err());
assert!(s.execute("style.object.edit", &json!({"name": "Photo", "dropShadow": {"size": "big"}})).is_err());
s.execute("style.object.edit", &json!({"name": "Photo", "dropShadow": {"opacity": 3, "size": -5}})).unwrap();
s.execute("object.innerShadow", &json!({"on": true, "distance": -2, "ids": [b]})).unwrap();
let d = &s.doc().unwrap().doc;
let ds = &d.styles.object_style("Photo").unwrap().effects.as_ref().unwrap().drop_shadow;
assert_eq!((ds.opacity, ds.size), (1.0, 0.0));
assert_eq!(d.item(ItemId(b)).unwrap().effects.inner_shadow.distance, 0.0);
let bytes = s.execute("file.serialize", &json!({})).unwrap();
let mut back = Session::new();
back.execute("file.openBytes", &json!({"name": "copy", "base64": bytes["base64"]})).unwrap();
assert_eq!(back.doc().unwrap().doc.styles.object_style("Photo").unwrap().effects.as_ref().unwrap().drop_shadow.opacity, 1.0);
}
/// Unicode text per page, through hayro's interpreter (ToUnicode / ActualText).
+5 -2
View File
@@ -1704,11 +1704,14 @@ impl<'a> Ex<'a> {
}
let mut el = with_props(el, props);
// Transparency.
// An item whose style sets effects writes the effects it has turned off too, so that on
// import it doesn't take the style's.
let styled = self.d.styles.object_effects(os).is_some();
let ds = &it.effects.drop_shadow;
if it.opacity < 1.0 || it.blend != designcraft_color::BlendMode::Normal || ds.on || it.effects.feather > 0.0 {
if it.opacity < 1.0 || it.blend != designcraft_color::BlendMode::Normal || ds.on || it.effects.feather > 0.0 || styled {
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)));
for c in self.effect_els(&it.effects, false) {
for c in self.effect_els(&it.effects, styled) {
t.push(c);
}
el.push(t);
+38 -14
View File
@@ -2035,24 +2035,48 @@ impl<'r> Importer<'r> {
}
}
/// An object style's effects: set when its effects category turns on an effect this model
/// reads, or (without category settings) when it gives a drop shadow or feather that is on.
/// An object style element and the styles it is based on, nearest first (cycle-safe, bounded).
fn object_style_chain(&self, e: &El) -> Vec<El> {
let mut chain = vec![e.clone()];
let mut seen: HashSet<String> = e.get("Self").map(str::to_string).into_iter().collect();
let mut next = e.prop("BasedOn");
while let Some(b) = next {
let id = if b.starts_with("ObjectStyle/") { b } else { format!("ObjectStyle/{}", names::escape_id(&b)) };
if chain.len() >= 16 || !seen.insert(id.clone()) {
break;
}
let Some(s) = self.object_els.get(&id) else { break };
chain.push(s.clone());
next = s.prop("BasedOn");
}
chain
}
/// An object style's drop shadow and feather. Each is read when the effects category turns it
/// on (without category settings: when its setting is on), from the nearest style in the
/// BasedOn chain that has the setting. `None` when the style sets neither.
fn style_effects(&mut self, e: &El) -> Option<Effects> {
let t = e.find("TransparencySetting");
let set = match e.find("ObjectStyleObjectEffectsCategorySettings") {
Some(c) => ["EnableDropShadow", "EnableFeather"].iter().any(|k| c.get(k) == Some("true")),
None => t.is_some_and(|t| {
t.find("DropShadowSetting").is_some_and(|d| d.get("Mode") == Some("Drop"))
|| t.find("FeatherSetting").is_some_and(|f| f.get("Mode").is_some_and(|m| m != "None"))
}),
};
if !set {
let chain = self.object_style_chain(e);
let category = chain.iter().find_map(|s| s.find("ObjectStyleObjectEffectsCategorySettings")).cloned();
let setting = |name: &str| chain.iter().find_map(|s| s.find("TransparencySetting")?.find(name)).cloned();
let mut t = El::new("TransparencySetting");
let mut any = false;
for (name, flag) in [("DropShadowSetting", "EnableDropShadow"), ("FeatherSetting", "EnableFeather")] {
let el = setting(name);
let on = match &category {
Some(c) => c.get(flag) == Some("true"),
None => el.as_ref().and_then(|x| x.get("Mode")).is_some_and(|m| m != "None"),
};
any |= on;
if let (true, Some(el)) = (on, el) {
t.push(el);
}
}
if !any {
return None;
}
let mut fx = Effects::default();
if let Some(t) = t {
self.effects_in(t, &mut fx);
}
self.effects_in(&t, &mut fx);
Some(fx)
}
+52 -3
View File
@@ -1472,8 +1472,23 @@ fn object_style_effects_round_trip() {
it.object_style = "Photo".into();
it.effects = fx.clone();
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
// A frame that turned the style's effects off writes them off, so it doesn't take the style's.
let id = designcraft_doc::ItemId(d.alloc());
let mut off = designcraft_doc::Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(200.0, 40.0, 300.0, 140.0)));
off.object_style = "Photo".into();
d.insert_item(SpreadRef::Doc(0), off, None).unwrap();
let back = import_idml(&export_idml(&d)).unwrap();
let bytes = export_idml(&d);
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
let mut spreads = String::new();
for i in 0..zip.len() {
let mut f = zip.by_index(i).unwrap();
if f.name().starts_with("Spreads/") {
std::io::Read::read_to_string(&mut f, &mut spreads).unwrap();
}
}
assert!(spreads.contains(r#"<DropShadowSetting Mode="None""#) && spreads.contains(r#"<FeatherSetting Mode="None""#), "{spreads}");
let back = import_idml(&bytes).unwrap();
let style = |n: &str| back.styles.object.iter().find(|s| s.name == n).unwrap();
let got = style("Photo").effects.clone().unwrap();
let ds = &got.drop_shadow;
@@ -1482,6 +1497,40 @@ fn object_style_effects_round_trip() {
assert_eq!(got.feather, 3.0);
assert!(style("Plain").effects.is_none());
// The frame's effects are the style's, so it follows later edits of the style.
let frame = back.spreads[0].items.iter().find(|i| i.object_style == "Photo").unwrap();
assert_eq!(frame.effects, got);
let frames: Vec<_> = back.spreads[0].items.iter().filter(|i| i.object_style == "Photo").collect();
assert_eq!(frames[0].effects, got);
assert_eq!(frames[1].effects, Effects::default());
}
#[test]
fn object_style_effects_import_by_category_and_based_on() {
let styles = r#"
<ObjectStyle Self="ObjectStyle/Base" Name="Base">
<TransparencySetting>
<DropShadowSetting Mode="Drop" XOffset="3" YOffset="4" Size="9" Opacity="50"/>
<FeatherSetting Mode="Standard" Width="6"/>
</TransparencySetting>
<ObjectStyleObjectEffectsCategorySettings EnableDropShadow="true" EnableFeather="false"/>
</ObjectStyle>
<ObjectStyle Self="ObjectStyle/Child" Name="Child">
<Properties><BasedOn type="object">ObjectStyle/Base</BasedOn></Properties>
<ObjectStyleObjectEffectsCategorySettings EnableDropShadow="true" EnableFeather="true"/>
</ObjectStyle>
<ObjectStyle Self="ObjectStyle/Off" Name="Off">
<TransparencySetting><DropShadowSetting Mode="Drop" Size="9"/></TransparencySetting>
<ObjectStyleObjectEffectsCategorySettings EnableDropShadow="false" EnableFeather="false"/>
</ObjectStyle>
<ObjectStyle Self="ObjectStyle/Loop" Name="Loop">
<Properties><BasedOn type="object">ObjectStyle/Loop</BasedOn></Properties>
<ObjectStyleObjectEffectsCategorySettings EnableDropShadow="true"/>
</ObjectStyle>"#;
let d = inset_fixture("", styles);
let fx = |n: &str| d.styles.object.iter().find(|s| s.name == n).unwrap().effects.clone();
let base = fx("Base").unwrap();
assert!(base.drop_shadow.on && base.drop_shadow.size == 9.0 && (base.drop_shadow.distance - 5.0).abs() < 1e-9);
assert_eq!(base.feather, 0.0, "EnableFeather=false");
let child = fx("Child").unwrap();
assert_eq!((child.drop_shadow.size, child.feather), (9.0, 6.0), "settings from the BasedOn style");
assert!(fx("Off").is_none());
assert!(!fx("Loop").unwrap().drop_shadow.on);
}
+1 -1
View File
@@ -401,7 +401,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML.
- Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`.
- Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles).
- Object styles carry effects: `style.object.create` keeps the selection's effects, `style.object.apply` sets them, and `style.object.edit {name, dropShadow?, feather?, …, effects?: null}` changes them on the style and on every item whose effects still match it (items with their own effects keep them; `style.object.clearOverrides` returns them to the style). IDML ObjectStyles carry the drop shadow and feather. There is no Object Style Options dialog yet.
- Object styles carry effects: `style.object.create` keeps the selection's effects, `style.object.apply` sets them, and `style.object.edit {name, dropShadow?, feather?, …, effects?: null}` changes them on the style and on every item whose effects still match it (items with their own effects keep them; `style.object.clearOverrides` returns them to the style). New frames take their default style's effects. Effect values that aren't finite numbers are refused and the others clamped, in these commands and in the `object.*` effect commands. IDML carries only the drop shadow and feather, for ObjectStyles (read per effects category flag, settings from the BasedOn chain) as for items; the other effects stay in `.designcraft` files. There is no Object Style Options dialog yet.
- Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point.
- Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it.
- Pencil tool (N): freehand strokes simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`).