diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5a3056d8f..5c9cd2bc5 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -939,7 +939,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if pp.rule_above.on { let r = &pp.rule_above; let y = bl - asc - r.offset; - let col = ft.lines.iter().rev().find(|l| l.para == pi).map(|l| (l.x0, l.x1)).unwrap_or((0.0, 0.0)); + let col = ft + .lines + .iter() + .find(|l| l.para == pi && l.first_in_para) + .map(|l| if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }) + .unwrap_or((0.0, 0.0)); ft.decos.push(Deco { rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), color: r.color.clone(), diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index ed3a5eca3..ce658e907 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -817,10 +817,48 @@ attr_set! { } } +impl ParaAttrs { + /// [`set_json`](Self::set_json), except that a `ruleAbove` / `ruleBelow` object names only the + /// rule fields to change; the others keep their values in `current` (the resolved attributes + /// of the paragraph or style being edited). + pub fn set_json_over(&mut self, key: &str, value: &serde_json::Value, current: &ParaProps) -> Result<(), String> { + let rule = match key.chars().filter(|c| *c != '_').collect::().to_ascii_lowercase().as_str() { + "ruleabove" => ¤t.rule_above, + "rulebelow" => ¤t.rule_below, + _ => return self.set_json(key, value), + }; + let serde_json::Value::Object(patch) = value else { return self.set_json(key, value) }; + let mut merged = serde_json::to_value(rule).map_err(|e| format!("{key}: {e}"))?; + let serde_json::Value::Object(fields) = &mut merged else { return Err(format!("{key}: not an object")) }; + for (k, v) in patch { + if !fields.contains_key(k) { + return Err(format!("{key}: unknown rule field `{k}`")); + } + fields.insert(k.clone(), v.clone()); + } + self.set_json(key, &merged) + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn a_partial_rule_keeps_the_other_fields() { + let current = ParaProps { + rule_below: Rule { on: true, weight: 3.0, color: "Red".into(), offset: 4.0, column_width: false, ..Rule::default() }, + ..ParaProps::default() + }; + let mut a = ParaAttrs::default(); + a.set_json_over("ruleBelow", &serde_json::json!({"color": "Blue"}), ¤t).unwrap(); + assert_eq!(a.rule_below, Some(Rule { color: "Blue".into(), ..current.rule_below.clone() })); + assert_eq!(a.rule_above, None); + assert!(a.set_json_over("ruleAbove", &serde_json::json!({"colour": "Blue"}), ¤t).is_err(), "unknown field"); + a.set_json_over("ruleBelow", &serde_json::Value::Null, ¤t).unwrap(); + assert_eq!(a.rule_below, None, "null clears the override"); + } + #[test] fn digits_map_to_their_script() { assert_eq!(Digits::Default.map('7', "Arabic"), '7'); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 3fb86fb6c..5c1d03df5 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -95,7 +95,7 @@ pub fn specs() -> Vec { "Paragraph Style Options…", [], None, - "{name, rename?, basedOn?, nextStyle?, para?: {…}, chars?: {…}}", + "{name, rename?, basedOn?, nextStyle?, para?: {… ruleAbove?/ruleBelow?: only the named rule fields change}, chars?: {…}}", has_doc, edit_para ), @@ -453,7 +453,6 @@ fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs { fn edit_para(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string(); - let para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json(k, v))?; let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; let rename = str_param(p, "rename").map(str::to_string); let based = p.get("basedOn").cloned(); @@ -463,6 +462,9 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { { return Err(bad("style.paragraph.edit", "based-on would create a cycle")); } + // A rule object changes only the rule fields it names, over the style's resolved rule. + let (current, _) = d.styles.resolve_para_style(&name); + let para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; st.para.merge(¶); st.chars.merge(&chars); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index a75d6d473..af98fe140 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -135,7 +135,7 @@ pub fn specs() -> Vec { "Paragraph Formatting", [], None, - "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, hyphenate?, composer?, tabs?, …}}", + "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, hyphenate?, composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}", has_text_or_frames, |s, p| format_paras(s, p.get("attrs").unwrap_or(p)) ), @@ -772,19 +772,27 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { } pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result { + let empty = serde_json::Map::new(); + let obj = attrs.as_object().unwrap_or(&empty); + // A rule object changes only the rule fields it names, so each paragraph gets its own attrs + // over its resolved rule; this pass validates them. let mut a = ParaAttrs::default(); - if let Some(o) = attrs.as_object() { - for (k, v) in o { - a.set_json(k, v).map_err(|e| bad("type.para", e))?; - } + for (k, v) in obj { + a.set_json_over(k, v, &designcraft_doc::ParaProps::default()).map_err(|e| bad("type.para", e))?; } // `null` removes the override (back to the style's value). - let cleared: Vec = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); + let cleared: Vec = obj.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect(); let targets = format_targets(s); s.edit(|d, _| { + let styles = d.styles.clone(); for t in &targets { if let Some(st) = d.text_story_mut(t.story, t.cell) { st.format_paras(t.range.clone(), |p| { + let (current, _) = styles.resolve_para(p); + let mut a = a.clone(); + for (k, v) in obj { + let _ = a.set_json_over(k, v, ¤t); + } p.para.merge(&a); for k in &cleared { let _ = p.para.set_json(k, &Value::Null); @@ -1227,6 +1235,56 @@ mod nested_line_style_tests { } } +#[cfg(test)] +mod rule_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn a_partial_rule_edit_keeps_the_other_fields() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let rule = json!({"on": true, "weight": 3.0, "color": "[Black]", "tint": 0.5, "columnWidth": false, "offset": 4.0, "leftIndent": 6.0, "rightIndent": 2.0}); + s.execute("style.paragraph.create", &json!({"name": "Ruled", "para": {"ruleBelow": rule}})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": "One\nTwo"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Ruled"})).unwrap(); + // Local: only the colour changes; the rest is inherited from the style. + s.execute("type.para", &json!({"attrs": {"ruleBelow": {"color": "[Registration]"}}})).unwrap(); + let attrs = s.execute("type.selectionAttrs", &json!({})).unwrap(); + let mut want = rule.clone(); + want["color"] = json!("[Registration]"); + assert_eq!(attrs["para"]["ruleBelow"], want); + assert_eq!(attrs["para"]["ruleAbove"]["on"], false, "the other rule is untouched"); + // Style: only the weight changes. + s.execute("style.paragraph.edit", &json!({"name": "Ruled", "para": {"ruleBelow": {"weight": 1.5}}})).unwrap(); + let st = s.doc().unwrap().doc.styles.para("Ruled").cloned().unwrap(); + let below = serde_json::to_value(st.para.rule_below.unwrap()).unwrap(); + let mut want = rule.clone(); + want["weight"] = json!(1.5); + assert_eq!(below, want); + assert!(s.execute("type.para", &json!({"attrs": {"ruleBelow": {"thickness": 2}}})).is_err(), "unknown rule field"); + } + + #[test] + fn rule_above_width_follows_column_or_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Short"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let mut widths = vec![]; + for column in [true, false] { + s.execute("type.para", &json!({"ruleAbove": {"on": true, "columnWidth": column}})).unwrap(); + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + widths.push(cs.frames[0].decos[0].rect.width()); + } + assert!((widths[0] - 328.0).abs() < 1e-6, "column: {widths:?}"); + assert!(widths[1] < 100.0, "text: {widths:?}"); + } +} + #[cfg(test)] mod border_tests { use serde_json::json;