From cd23a5fbbf5d5f72fcc0e0788294232f6721e4ce Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 15:35:05 +0000 Subject: [PATCH] Tabs: tab stops set through paragraph attributes are checked like the Tabs commands --- crates/doc/src/attrs.rs | 76 +++++++++++++++++++++++++++++++++- crates/engine/src/cmd/style.rs | 2 +- crates/engine/src/cmd/tabs.rs | 47 ++++++++++++++++----- 3 files changed, 112 insertions(+), 13 deletions(-) diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index b99740e2c..3a9eae0a2 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -530,6 +530,60 @@ pub struct TabStop { pub align_on: String, } +/// Most stops a paragraph holds. +pub const MAX_TAB_STOPS: usize = 100; +/// Furthest stop position: 216 in, the largest page. +pub const MAX_TAB_POSITION: f64 = 15_552.0; +/// Longest leader, in characters. +pub const MAX_TAB_LEADER: usize = 8; +/// Stops closer than this share a position. +pub const SAME_TAB_POSITION: f64 = 0.01; + +impl TabStop { + /// A finite position clamped to `0..=MAX_TAB_POSITION`. + pub fn clamp_position(v: f64) -> Result { + if v.is_finite() { Ok(v.clamp(0.0, MAX_TAB_POSITION)) } else { Err("position must be a finite number".into()) } + } + + /// A leader: up to [`MAX_TAB_LEADER`] characters, no control characters. + pub fn check_leader(v: &str) -> Result<(), String> { + if v.chars().count() > MAX_TAB_LEADER || v.chars().any(char::is_control) { + return Err(format!("`leader`: at most {MAX_TAB_LEADER} printable characters")); + } + Ok(()) + } + + /// An align-on character: one printable character or none (a decimal point). + pub fn check_align_on(v: &str) -> Result<(), String> { + if v.chars().count() > 1 || v.chars().any(char::is_control) { + return Err("`alignOn`: one printable character".into()); + } + Ok(()) + } + + /// A tab-stop list as the Tabs commands keep it: at most [`MAX_TAB_STOPS`] stops, positions + /// clamped, leaders and align-on characters checked, a char stop aligning on `.` unless it + /// names a character, sorted by position, and one stop per position (the later one wins). + pub fn checked_list(tabs: Vec) -> Result, String> { + if tabs.len() > MAX_TAB_STOPS { + return Err(format!("a paragraph holds at most {MAX_TAB_STOPS} tab stops")); + } + let mut out: Vec = Vec::with_capacity(tabs.len()); + for mut t in tabs { + t.position = Self::clamp_position(t.position)?; + Self::check_leader(&t.leader)?; + Self::check_align_on(&t.align_on)?; + if t.align == TabAlign::Char && t.align_on.is_empty() { + t.align_on = ".".into(); + } + out.retain(|o| (o.position - t.position).abs() >= SAME_TAB_POSITION); + out.push(t); + } + out.sort_by(|a, b| a.position.total_cmp(&b.position)); + Ok(out) + } +} + /// A paragraph rule (Rule Above / Rule Below). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -908,11 +962,21 @@ 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). + /// of the paragraph or style being edited). A `tabs` list is checked and sorted as + /// [`TabStop::checked_list`] does. 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, + "tabs" if !value.is_null() => { + // Count before deserializing: the list may be arbitrarily long. + if value.as_array().is_some_and(|a| a.len() > MAX_TAB_STOPS) { + return Err(format!("{key}: a paragraph holds at most {MAX_TAB_STOPS} tab stops")); + } + let tabs: Vec = serde_json::from_value(value.clone()).map_err(|e| format!("{key}: {e}"))?; + self.tabs = Some(TabStop::checked_list(tabs).map_err(|e| format!("{key}: {e}"))?); + return Ok(()); + } _ => return self.set_json(key, value), }; let serde_json::Value::Object(patch) = value else { return self.set_json(key, value) }; @@ -1082,6 +1146,16 @@ mod tests { assert_eq!(p.font_family, "Source Serif 4"); } + #[test] + fn tab_lists_are_checked() { + let stop = |position: f64| TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: String::new() }; + assert!(TabStop::checked_list(vec![stop(f64::NAN)]).is_err()); + assert!(TabStop::checked_list(vec![stop(f64::INFINITY)]).is_err()); + assert!(TabStop::checked_list((0..=MAX_TAB_STOPS).map(|i| stop(i as f64)).collect()).is_err()); + let list = TabStop::checked_list(vec![stop(50.0), TabStop { leader: ".".into(), ..stop(10.0) }, stop(50.001)]).unwrap(); + assert_eq!(list.iter().map(|t| t.position).collect::>(), [10.0, 50.001], "sorted, one stop per position"); + } + #[test] fn set_json_by_name() { let mut a = ParaAttrs::default(); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 94bd92c6d..8397c47bf 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -427,7 +427,7 @@ fn apply_char(s: &mut Session, p: &Value) -> Result { fn create_para(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string(); - let mut para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json(k, v))?; + let mut para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &designcraft_doc::ParaProps::default()))?; let mut chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) && let Ok(cur) = s.execute("type.selectionAttrs", &json!({})) diff --git a/crates/engine/src/cmd/tabs.rs b/crates/engine/src/cmd/tabs.rs index 1cfb793c3..59b604539 100644 --- a/crates/engine/src/cmd/tabs.rs +++ b/crates/engine/src/cmd/tabs.rs @@ -12,13 +12,13 @@ use super::{CommandSpec, bad, cmd, has_doc, str_param}; use crate::{Result, Session}; /// Most stops a paragraph holds. -pub const MAX_TABS: usize = 100; +pub const MAX_TABS: usize = designcraft_doc::MAX_TAB_STOPS; /// Furthest stop position: 216 in, the largest page. -pub const MAX_POSITION: f64 = 15_552.0; +pub const MAX_POSITION: f64 = designcraft_doc::MAX_TAB_POSITION; /// Longest leader, in characters. -pub const MAX_LEADER: usize = 8; +pub const MAX_LEADER: usize = designcraft_doc::MAX_TAB_LEADER; /// Stops closer than this share a position. -const SAME: f64 = 0.01; +const SAME: f64 = designcraft_doc::SAME_TAB_POSITION; pub fn specs() -> Vec { vec![ @@ -101,7 +101,7 @@ pub fn sort(tabs: &mut [TabStop]) { /// A finite position clamped to `0..=MAX_POSITION`. pub fn clamp_position(v: f64) -> std::result::Result { - if v.is_finite() { Ok(v.clamp(0.0, MAX_POSITION)) } else { Err("position must be a finite number".into()) } + TabStop::clamp_position(v) } /// Insert `stop` (replacing one at the same position); returns its index. @@ -210,17 +210,13 @@ fn align_value(v: &Value, c: &str) -> Result { /// A leader: up to [`MAX_LEADER`] characters, no control characters. pub fn leader_value(v: &str, c: &str) -> Result { - if v.chars().count() > MAX_LEADER || v.chars().any(char::is_control) { - return Err(bad(c, format!("`leader`: at most {MAX_LEADER} printable characters"))); - } + TabStop::check_leader(v).map_err(|e| bad(c, e))?; Ok(v.to_string()) } /// An align-on character: one printable character or none (a decimal point). pub fn align_on_value(v: &str, c: &str) -> Result { - if v.chars().count() > 1 || v.chars().any(char::is_control) { - return Err(bad(c, "`alignOn`: one printable character")); - } + TabStop::check_align_on(v).map_err(|e| bad(c, e))?; Ok(v.to_string()) } @@ -431,6 +427,35 @@ mod tests { assert!(s.execute("type.tabs.add", &json!({"style": "Nope", "position": 1})).is_err()); } + /// `type.para` and the paragraph style commands take a whole `tabs` list; it gets the checks + /// the Tabs commands apply. + #[test] + fn tab_lists_set_as_paragraph_attributes_are_checked() { + let (mut s, sid) = session("x"); + let many: Vec = (0..=super::MAX_TABS).map(|i| json!({"position": i})).collect(); + for tabs in [ + json!(many), + json!([{"position": f64::NAN}]), + json!([{"position": "NaN"}]), + json!([{"position": 10, "leader": "123456789"}]), + json!([{"position": 10, "leader": "\u{7}"}]), + json!([{"position": 10, "align": "char", "alignOn": ".,"}]), + ] { + assert!(s.execute("type.para", &json!({"attrs": {"tabs": tabs}})).is_err(), "{tabs}"); + assert!(s.execute("style.paragraph.create", &json!({"name": "Bad", "para": {"tabs": tabs}})).is_err(), "{tabs}"); + } + s.execute("style.paragraph.create", &json!({"name": "Ok"})).unwrap(); + assert!(s.execute("style.paragraph.edit", &json!({"name": "Ok", "para": {"tabs": [{"position": 1, "leader": "123456789"}]}})).is_err()); + + let tabs = json!([{"position": 200, "align": "char"}, {"position": 1e300}, {"position": -5, "leader": ". "}, {"position": 72}]); + s.execute("type.para", &json!({"attrs": {"tabs": tabs}})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let stored = d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0].para.tabs.clone().unwrap(); + let at: Vec = stored.iter().map(|t| t.position).collect(); + assert_eq!(at, [0.0, 72.0, 200.0, super::MAX_POSITION], "clamped and sorted"); + assert_eq!((stored[0].leader.as_str(), stored[2].align_on.as_str()), (". ", "."), "a char stop aligns on a point"); + } + #[test] fn hostile_parameters_are_errors() { let (mut s, _) = session("x");