Paragraph rules: a partial ruleAbove/ruleBelow edit keeps the other rule fields; Rule Above honours Text width

This commit is contained in:
aiongg
2026-10-09 23:42:11 +00:00
parent 66c7ce7e96
commit fa13bf24f4
4 changed files with 112 additions and 9 deletions
+6 -1
View File
@@ -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(),
+38
View File
@@ -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::<String>().to_ascii_lowercase().as_str() {
"ruleabove" => &current.rule_above,
"rulebelow" => &current.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"}), &current).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"}), &current).is_err(), "unknown field");
a.set_json_over("ruleBelow", &serde_json::Value::Null, &current).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');
+4 -2
View File
@@ -95,7 +95,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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<Value> {
{
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, &current))?;
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
st.para.merge(&para);
st.chars.merge(&chars);
+64 -6
View File
@@ -135,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
}
pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result<Value> {
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<String> = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default();
let cleared: Vec<String> = 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, &current);
}
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;