Merge object-style-effects: Object styles carry effects (#653)

This commit is contained in:
aiongg
2026-10-13 01:05:37 +00:00
13 changed files with 728 additions and 203 deletions
+2 -1
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill) · **Target:** Adobe InDesign 2026
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects) · **Target:** Adobe InDesign 2026
DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON
format + IDML), scriptable by agents (MCP), and available on the web.
@@ -128,6 +128,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 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-13** · A parent can be based on another parent (`layout.parents.options`). Pages draw the base parent's items, then the parent's, then their own, on screen and in PDF and image export; chains work, cycles are refused, overrides reach base items, `document.inspect` reports `basedOn`, and IDML round-trips it. No readiness percentage change.
- **2026-10-12** · The Effects dialog, as in InDesign: Object › Effects (Transparency…, Drop Shadow… and the rest, and their shortcuts) and the Effects panel's fx button open it on the chosen effect, turned on. Transparency and every effect in one window, in the document's units, with X/Y offsets, Preview on the page, Cancel putting things back, and OK as one undo step (`object.effects`). Shadows default to InDesign's 120° light angle. No readiness percentage change.
- **2026-10-12** · Editing type on a path: the Type tool clicks, drags and double-clicks along the curve (the character under the pointer, not one on the straightened line), a click on the letters themselves hits the frame, and the caret and highlight bend with the path. No readiness percentage change.
+47 -7
View File
@@ -277,13 +277,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)?;
@@ -544,6 +540,50 @@ impl Document {
v
}
/// Calls `f` on every item `hit` selects: on document and parent spreads, inside groups and
/// among anchored objects. Only the branches holding a hit are copied (copy-on-write). Returns
/// how many items `f` saw.
pub fn update_items(&mut self, hit: impl Fn(&Item) -> bool, mut f: impl FnMut(&mut Item)) -> usize {
const MAX_GROUP_DEPTH: usize = 64;
fn any(it: &Item, hit: &impl Fn(&Item) -> bool) -> bool {
let mut found = false;
it.walk(&mut |i| found |= hit(i));
found
}
fn update(it: &mut Item, hit: &impl Fn(&Item) -> bool, f: &mut impl FnMut(&mut Item), n: &mut usize, depth: usize) {
if hit(it) {
f(it);
*n += 1;
}
if depth >= MAX_GROUP_DEPTH {
return;
}
if let Some(children) = it.children_mut() {
for c in children.iter_mut().filter(|c| any(c, hit)) {
update(Arc::make_mut(c), hit, f, n, depth + 1);
}
}
}
let mut n = 0;
for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) {
if !sp.items.iter().any(|i| any(i, &hit)) {
continue;
}
for it in Arc::make_mut(sp).items.iter_mut().filter(|i| any(i, &hit)) {
update(Arc::make_mut(it), &hit, &mut f, &mut n, 0);
}
}
for st in self.stories.values_mut() {
if !st.objects.iter().any(|o| any(&o.item, &hit)) {
continue;
}
for o in Arc::make_mut(st).objects.iter_mut().filter(|o| any(&o.item, &hit)) {
update(&mut Arc::make_mut(o).item, &hit, &mut f, &mut n, 0);
}
}
n
}
/// Plain story text with frame/story stats (for agents).
pub fn story_summary(&self, sid: StoryId) -> Option<serde_json::Value> {
let st = self.story(sid)?;
+56
View File
@@ -1129,6 +1129,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
+27 -1
View File
@@ -3,7 +3,7 @@
use serde::{Deserialize, Serialize};
use crate::attrs::{CharAttrs, CharProps, ParaAttrs, ParaProps};
use crate::item::{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]";
@@ -55,6 +55,28 @@ pub struct ObjectStyle {
pub paragraph_style: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_frame: Option<TextFrameOptions>,
/// Effects (drop shadow, feather, glows…). An item using the style follows them until its own
/// effects differ from the style's (a local override).
#[serde(skip_serializing_if = "Option::is_none")]
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)]
@@ -725,6 +747,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): resolution starts at its first.
pub fn para_chain(&self, name: &str) -> Vec<&ParagraphStyle> {
+6
View File
@@ -517,6 +517,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
let k = w / nw;
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: (bw, bh),
@@ -662,6 +665,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),
+112 -128
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use designcraft_color::BlendMode;
use designcraft_doc::{
Content, Document, Fill, Item, ItemId, ItemLoc, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame, TextFrameOptions,
Content, Document, Effects, Fill, Item, ItemId, ItemLoc, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame, TextFrameOptions,
TextSel,
};
use designcraft_geom::{Affine, Point, Rect, Vec2, shapes};
@@ -303,24 +303,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!(
@@ -332,24 +327,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!(
@@ -361,21 +351,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!(
@@ -421,22 +406,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!(
@@ -448,27 +428,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!(
@@ -480,23 +455,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!(
@@ -508,27 +478,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.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!(
@@ -538,11 +503,11 @@ pub fn specs() -> Vec<CommandSpec> {
None,
"{effects?: {dropShadow?: {on?, color?, opacity?, angle?, globalLight?, distance?, size?, spread?}, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, feather? (width), directionalFeather?, gradientFeather?} (the fields given replace the object's, as the Effects dialog sets them), opacity?: 0..1, blend?, ids?} — every effect at once, one undo step",
has_selection,
set_effects
effects_all
),
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!(
@@ -921,6 +886,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();
@@ -1999,7 +1967,7 @@ fn sanitize_effects(e: &mut designcraft_doc::Effects) -> Result<()> {
Ok(())
}
fn set_effects(s: &mut Session, p: &Value) -> Result<Value> {
fn effects_all(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "object.effects";
let patch = match p.get("effects") {
None | Some(Value::Null) => None,
@@ -2113,6 +2081,22 @@ fn default_fill_stroke(it: &mut Item) {
visible_weight(&mut it.stroke);
}
/// 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()}))
})
+153 -14
View File
@@ -1,7 +1,7 @@
//! Paragraph / character styles and swatches.
use designcraft_color::{Swatch, SwatchValue};
use designcraft_doc::{CharAttrs, CharacterStyle, ParaAttrs, ParagraphStyle, Styles};
use designcraft_doc::{CharAttrs, CharacterStyle, Effects, Item, ObjectStyle, ParaAttrs, ParagraphStyle, Styles};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, has_text_or_frames, ok, str_param};
@@ -129,8 +129,42 @@ pub fn specs() -> Vec<CommandSpec> {
"table": st.doc.styles.table.iter().map(|p| &p.name).collect::<Vec<_>>(),
}))
}),
cmd!("style.object.apply", "Apply Object Style", [], None, "{name, ids?}", super::has_selection, apply_object),
cmd!("style.object.create", "New Object Style…", [], None, "{name, fromSelection?: true, fill?, paragraphStyle?}", has_doc, create_object),
cmd!(
"style.object.apply",
"Apply Object Style",
[],
None,
"{name, ids?} — sets the style's fill, stroke, effects and text frame options on the items",
super::has_selection,
apply_object
),
cmd!(
"style.object.create",
"New Object Style…",
[],
None,
"{name, fromSelection?: true (fill, stroke, effects and text frame options of the first selected item), fill?, paragraphStyle?, effects?: {dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?}, dropShadow?: {on?, color?, opacity? (0–1), angle?, globalLight?, distance?, size?, spread?}, feather?: width, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} → {name} (made unique)",
has_doc,
create_object
),
cmd!(
"style.object.edit",
"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); 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
),
cmd!(
"style.object.clearOverrides",
"Clear Object Style Overrides",
[],
None,
"{ids?} — each item takes its object style's fill, stroke, effects and text frame options again → {changed}",
super::has_selection,
clear_object_overrides
),
cmd!(
"swatch.create",
"New Color Swatch…",
@@ -888,16 +922,8 @@ 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();
if let Some(f) = &os.fill {
it.fill = f.clone();
}
if let Some(st) = &os.stroke {
it.stroke = st.clone();
}
if let Some(tf) = it.text_frame_mut() {
if let Some(o) = &os.text_frame {
tf.options = o.clone();
}
os.apply_to(it);
if let Some(tf) = it.text_frame() {
stories.push(tf.story);
}
}
@@ -913,15 +939,87 @@ fn apply_object(s: &mut Session, p: &Value) -> Result<Value> {
})
}
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 next in &changed {
if let Some(it) = d.item_mut(next.id) {
*it = next.clone();
}
}
Ok(json!({"changed": changed.len()}))
})
}
/// Effect keys accepted at the top level of `style.object.create` / `edit` as shorthand for
/// `effects.<key>`.
const EFFECT_KEYS: [&str; 9] =
["dropShadow", "feather", "innerShadow", "outerGlow", "innerGlow", "bevel", "satin", "gradientFeather", "directionalFeather"];
/// The effects `p` gives (`effects`, then the top-level effect keys) merged field by field over
/// `base`, or `None` when `p` names no effect.
fn effects_param(cmd: &str, p: &Value, base: &Effects) -> Result<Option<Effects>> {
let mut patch = serde_json::Map::new();
match p.get("effects") {
None | Some(Value::Null) => {}
Some(Value::Object(o)) => patch.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))),
Some(_) => return Err(bad(cmd, "`effects` must be an object or null")),
}
for k in EFFECT_KEYS {
if let Some(v) = p.get(k) {
patch.insert(k.to_string(), v.clone());
}
}
if patch.is_empty() {
return Ok(None);
}
let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?;
merge_json(&mut v, Value::Object(patch), 0);
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.
fn merge_json(base: &mut Value, top: Value, depth: usize) {
match (base, top) {
(Value::Object(b), Value::Object(t)) if depth < 8 => {
for (k, v) in t {
match b.get_mut(&k) {
Some(slot) => merge_json(slot, v, depth + 1),
None => {
b.insert(k, v);
}
}
}
}
(b, t) => *b = t,
}
}
fn create_object(s: &mut Session, p: &Value) -> Result<Value> {
let base = str_param(p, "name").unwrap_or("Object Style 1").to_string();
let from_sel = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true);
let src = if from_sel { s.doc()?.selection.items.first().and_then(|i| s.doc().ok()?.doc.item(*i).cloned()) } else { None };
let mut os = designcraft_doc::ObjectStyle::default();
let mut os = ObjectStyle::default();
if let Some(it) = &src {
os.fill = Some(it.fill.clone());
os.stroke = Some(it.stroke.clone());
os.text_frame = it.text_frame().map(|t| t.options.clone());
os.effects = Some(it.effects.clone());
}
if let Some(sw) = str_param(p, "fill") {
os.fill = Some(designcraft_doc::Fill::swatch(sw));
@@ -929,6 +1027,9 @@ fn create_object(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(ps) = str_param(p, "paragraphStyle") {
os.paragraph_style = Some(ps.to_string());
}
if let Some(e) = effects_param("style.object.create", p, os.effects.as_ref().unwrap_or(&Effects::default()))? {
os.effects = Some(e);
}
s.edit(|d, _| {
let name = Styles::unique_name(|n| d.styles.object_style(n).is_some(), &base);
os.name = name.clone();
@@ -937,6 +1038,44 @@ fn create_object(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn edit_object(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "style.object.edit";
let name = str_param(p, "name").ok_or_else(|| bad(CMD, "missing name"))?.to_string();
if name == designcraft_doc::NO_OBJECT_STYLE {
return Err(bad(CMD, format!("`{name}` can't be edited")));
}
let old = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad(CMD, format!("no object style `{name}`")))?;
let effects = if p.get("effects").is_some_and(Value::is_null) {
if EFFECT_KEYS.iter().any(|k| p.get(k).is_some()) {
return Err(bad(CMD, "`effects: null` can't be combined with effect settings"));
}
None
} else {
match effects_param(CMD, p, old.effects.as_ref().unwrap_or(&Effects::default()))? {
Some(e) => Some(e),
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, 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 && it.effects == follows, |it| it.effects = new.clone()),
None => 0,
};
Ok(json!({"name": name, "updated": updated}))
})
}
/// The swatch name of a colour value ("C=… M=… Y=… K=…" / "R=… G=… B=…").
fn value_name(color: designcraft_color::Color) -> String {
designcraft_color::swatch::value_name(color)
+104
View File
@@ -543,6 +543,110 @@ fn object_styles_create_and_apply() {
assert_eq!(ib.object_style, "Magenta Box");
}
#[test]
fn object_style_effects_follow_the_style_until_overridden() {
use designcraft_doc::{BASIC_GRAPHICS_FRAME, ItemId};
let mut s = session();
let frame = |s: &mut Session, x: f64| {
let r = s.execute("frame.create", &json!({"rect": [x, 36, x + 100.0, 136], "content": "unassigned"})).unwrap();
r["id"].as_u64().unwrap()
};
let item = |s: &Session, id: u64| s.doc().unwrap().doc.item(ItemId(id)).unwrap().clone();
let shadow = |s: &Session, id: u64| item(s, id).effects.drop_shadow;
let a = frame(&mut s, 36.0);
s.execute("object.dropShadow", &json!({"on": true, "distance": 0, "size": 12, "opacity": 0.28, "ids": [a]})).unwrap();
s.execute("selection.set", &json!({"ids": [a]})).unwrap();
s.execute("style.object.create", &json!({"name": "Photo", "fromSelection": true})).unwrap();
let b = frame(&mut s, 200.0);
let c = frame(&mut s, 360.0);
s.execute("style.object.apply", &json!({"name": "Photo", "ids": [a, b, c]})).unwrap();
assert_eq!(shadow(&s, b), shadow(&s, a));
assert!(shadow(&s, b).on && shadow(&s, b).size == 12.0);
s.execute("object.dropShadow", &json!({"on": true, "size": 30, "ids": [c]})).unwrap();
// Editing the style updates the items that follow it; `c`'s local shadow stays.
let r = s.execute("style.object.edit", &json!({"name": "Photo", "dropShadow": {"size": 20}})).unwrap();
assert_eq!(r["updated"], 2);
assert_eq!((shadow(&s, a).size, shadow(&s, b).size, shadow(&s, c).size), (20.0, 20.0, 30.0));
assert_eq!(shadow(&s, a).opacity, 0.28);
s.execute("style.object.clearOverrides", &json!({"ids": [c]})).unwrap();
assert_eq!(shadow(&s, c), shadow(&s, a));
// Saved and opened again, the style keeps its effects.
let bytes = s.execute("file.serialize", &json!({})).unwrap();
let mut back = Session::new();
back.execute("file.openBytes", &json!({"name": "copy", "base64": bytes["base64"]})).unwrap();
let d = &back.doc().unwrap().doc;
assert_eq!(d.styles.object_style("Photo").unwrap().effects.as_ref().unwrap().drop_shadow.size, 20.0);
assert!(d.styles.object_style(BASIC_GRAPHICS_FRAME).unwrap().effects.is_none());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(shadow(&s, c).size, 30.0);
s.execute("edit.undo", &json!({})).unwrap();
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 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);
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();
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, 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).
fn pdf_text(bytes: &[u8]) -> Vec<String> {
use hayro_interpret::font::Glyph;
+52 -16
View File
@@ -1054,9 +1054,31 @@ impl<'a> Ex<'a> {
el.set("AppliedParagraphStyle", names::style_self("ParagraphStyle", PARA_BUILTINS, ps));
}
let mut el = with_props(el, props);
if let Some(e) = &os.effects {
let mut t = El::new("TransparencySetting");
for c in self.effect_els(e, true) {
t.push(c);
}
el.push(t);
}
if let Some(tf) = &os.text_frame {
el.push(text_frame_pref(tf, &self.sw(&tf.column_rule_color)));
}
// Effects: a style with effects sets the drop shadow and feather, never blending.
let on = bool_s(os.effects.is_some());
el.push(
El::new("ObjectStyleObjectEffectsCategorySettings")
.attr("EnableTransparency", "false")
.attr("EnableDropShadow", on)
.attr("EnableFeather", on)
.attr("EnableInnerShadow", "false")
.attr("EnableOuterGlow", "false")
.attr("EnableInnerGlow", "false")
.attr("EnableBevelEmboss", "false")
.attr("EnableSatin", "false")
.attr("EnableDirectionalFeather", "false")
.attr("EnableGradientFeather", "false"),
);
og.insert(&os.name, el);
}
let mut ro = El::new("RootObjectStyleGroup").attr("Self", self.fresh());
@@ -1845,26 +1867,16 @@ 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;
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 {
if it.opacity < 1.0 || it.blend != designcraft_color::BlendMode::Normal || ds.on || it.effects.feather > 0.0 || gf.on || df.on || 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)));
if ds.on {
let a = self.d.light_angle(ds.angle, ds.global_light).to_radians();
t.push(
El::new("DropShadowSetting")
.attr("Mode", "Drop")
.attr("XOffset", num(-ds.distance * a.cos()))
.attr("YOffset", num(ds.distance * a.sin()))
.attr("Size", num(ds.size))
.attr("Spread", num(ds.spread))
.attr("Opacity", pct(ds.opacity as f64))
.attr("EffectColor", self.sw(&ds.color)),
);
}
if it.effects.feather > 0.0 {
t.push(El::new("FeatherSetting").attr("Mode", "Standard").attr("Width", num(it.effects.feather)));
for c in self.effect_els(&it.effects, styled) {
t.push(c);
}
if df.on {
let [top, left, bottom, right] = df.widths;
@@ -1944,6 +1956,30 @@ impl<'a> Ex<'a> {
el
}
/// The `TransparencySetting` children for `e`'s drop shadow and feather; `all` also writes the
/// effects that are off (`Mode="None"`), as an object style that sets effects does.
fn effect_els(&self, e: &designcraft_doc::Effects, all: bool) -> Vec<El> {
let mut out = Vec::new();
let ds = &e.drop_shadow;
if ds.on || all {
let a = self.d.light_angle(ds.angle, ds.global_light).to_radians();
out.push(
El::new("DropShadowSetting")
.attr("Mode", if ds.on { "Drop" } else { "None" })
.attr("XOffset", num(-ds.distance * a.cos()))
.attr("YOffset", num(ds.distance * a.sin()))
.attr("Size", num(ds.size))
.attr("Spread", num(ds.spread))
.attr("Opacity", pct(ds.opacity as f64))
.attr("EffectColor", self.sw(&ds.color)),
);
}
if e.feather > 0.0 || all {
out.push(El::new("FeatherSetting").attr("Mode", if e.feather > 0.0 { "Standard" } else { "None" }).attr("Width", num(e.feather)));
}
out
}
fn image_el(&mut self, asset: &designcraft_doc::Asset, g: &designcraft_doc::Graphic) -> El {
let is_pdf = asset.mime == "application/pdf";
let mut el = El::new(if is_pdf { "PDF" } else { "Image" }).attr("Self", self.fresh());
+86 -35
View File
@@ -1137,7 +1137,8 @@ impl<'r> Importer<'r> {
} else {
None
};
let s = ObjectStyle { name: name.clone(), based_on, fill, stroke, paragraph_style, text_frame };
let effects = self.style_effects(e);
let s = ObjectStyle { name: name.clone(), based_on, fill, stroke, paragraph_style, text_frame, effects };
match self.styles.object.iter_mut().find(|o| o.name == name) {
Some(slot) => *slot = s,
None => self.styles.object.push(s),
@@ -2425,6 +2426,89 @@ impl<'r> Importer<'r> {
out
}
/// Reads the drop shadow, feather, gradient feather and directional feather of a
/// `TransparencySetting` into `fx`.
fn effects_in(&mut self, t: &El, fx: &mut Effects) {
if let Some(ds) = t.find("DropShadowSetting")
&& ds.get("Mode") == Some("Drop")
{
let x = ds.num("XOffset").unwrap_or(4.95);
let y = ds.num("YOffset").unwrap_or(4.95);
let def = DropShadow::default();
fx.drop_shadow = DropShadow {
on: true,
color: ds.get("EffectColor").map(|c| self.swatch_ref(c)).unwrap_or(def.color),
opacity: ds.num("Opacity").map(|o| (o / 100.0) as f32).unwrap_or(def.opacity),
angle: names_angle(x, y),
distance: x.hypot(y),
size: ds.num("Size").unwrap_or(def.size),
spread: ds.num("Spread").unwrap_or(def.spread),
global_light: false,
};
}
if let Some(f) = t.find("FeatherSetting")
&& f.get("Mode").is_some_and(|m| m != "None")
{
fx.feather = f.num("Width").unwrap_or(9.0);
}
if let Some(g) = t.find("GradientFeatherSetting")
&& g.boolean("Applied") == Some(true)
{
fx.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);
fx.directional_feather = DirectionalFeather { on: true, widths: [w("TopWidth"), w("LeftWidth"), w("BottomWidth"), w("RightWidth")] };
}
}
/// An object style element and the styles it is based on, nearest first (cycle-safe, bounded).
fn object_style_bases(&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 chain = self.object_style_bases(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();
self.effects_in(&t, &mut fx);
Some(fx)
}
fn stroke_from(&mut self, e: &El, item: Option<&El>) -> Stroke {
let g = |s: &Self, k: &str| -> Option<String> { if item.is_some() { s.attr_or_style(e, k) } else { e.get(k).map(str::to_string) } };
let color = g(self, "StrokeColor");
@@ -2554,40 +2638,7 @@ impl<'r> Importer<'r> {
it.blend = names::blend_in(m);
}
}
if let Some(ds) = t.find("DropShadowSetting")
&& ds.get("Mode") == Some("Drop")
{
let x = ds.num("XOffset").unwrap_or(4.95);
let y = ds.num("YOffset").unwrap_or(4.95);
let def = DropShadow::default();
it.effects.drop_shadow = DropShadow {
on: true,
color: ds.get("EffectColor").map(|c| self.swatch_ref(c)).unwrap_or(def.color),
opacity: ds.num("Opacity").map(|o| (o / 100.0) as f32).unwrap_or(def.opacity),
angle: names_angle(x, y),
distance: x.hypot(y),
size: ds.num("Size").unwrap_or(def.size),
spread: ds.num("Spread").unwrap_or(def.spread),
global_light: false,
};
}
if let Some(f) = t.find("FeatherSetting")
&& f.get("Mode").is_some_and(|m| m != "None")
{
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")] };
}
self.effects_in(&t, &mut it.effects);
}
// Text wrap.
if let Some(w) = self.with_style(e, "TextWrapPreference") {
+80
View File
@@ -3480,3 +3480,83 @@ fn parent_based_on_round_trips_and_cycles_are_broken() {
assert!(r.warnings.iter().any(|w| w.contains("A-Parent") && w.contains("cycle")), "{:?}", r.warnings);
assert_eq!((base_of(&r.document, 0), base_of(&r.document, 1)), (None, Some("A".into())));
}
#[test]
fn object_style_effects_round_trip() {
use designcraft_doc::{DropShadow, Effects, ObjectStyle};
let mut d = Document::new(&NewDocument::default());
let fx = Effects {
drop_shadow: DropShadow { on: true, distance: 5.0, size: 12.0, opacity: 0.28, ..Default::default() },
feather: 3.0,
..Default::default()
};
d.styles_mut().object.push(ObjectStyle { name: "Photo".into(), effects: Some(fx.clone()), ..Default::default() });
d.styles_mut().object.push(ObjectStyle { name: "Plain".into(), fill: Some(Fill::swatch("Black")), ..Default::default() });
let lid = d.default_layer();
let id = designcraft_doc::ItemId(d.alloc());
let mut it = designcraft_doc::Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(40.0, 40.0, 140.0, 140.0)));
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 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;
assert!(ds.on && ds.size == 12.0 && (ds.opacity - 0.28).abs() < 1e-6, "{ds:?}");
assert!((ds.distance - 5.0).abs() < 1e-6 && (ds.angle - 135.0).abs() < 1e-6, "{ds:?}");
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 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);
}
+2 -1
View File
@@ -1,6 +1,6 @@
# DesignCraft parity with Adobe InDesign
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md)
summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature
@@ -410,6 +410,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). 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.
- Spring-loaded Zoom: hold Cmd+Space (Ctrl+Space on Windows/Linux) for the Zoom cursor and click/scrubby zoom; Alt-click zooms out. Release returns to the previous tool without changing the document or undo history. Text-frame and text-field typing suppress the shortcut; Space remains Hand.
- Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it.