Tabs: tab stops set through paragraph attributes are checked like the Tabs commands

This commit is contained in:
aiongg
2026-10-10 15:35:05 +00:00
parent ea3f11b9b7
commit cd23a5fbbf
3 changed files with 112 additions and 13 deletions
+75 -1
View File
@@ -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<f64, String> {
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<TabStop>) -> Result<Vec<TabStop>, String> {
if tabs.len() > MAX_TAB_STOPS {
return Err(format!("a paragraph holds at most {MAX_TAB_STOPS} tab stops"));
}
let mut out: Vec<TabStop> = 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::<String>().to_ascii_lowercase().as_str() {
"ruleabove" => &current.rule_above,
"rulebelow" => &current.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<TabStop> = 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::<Vec<_>>(), [10.0, 50.001], "sorted, one stop per position");
}
#[test]
fn set_json_by_name() {
let mut a = ParaAttrs::default();
+1 -1
View File
@@ -427,7 +427,7 @@ fn apply_char(s: &mut Session, p: &Value) -> Result<Value> {
fn create_para(s: &mut Session, p: &Value) -> Result<Value> {
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!({}))
+36 -11
View File
@@ -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<CommandSpec> {
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<f64, String> {
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<TabAlign> {
/// A leader: up to [`MAX_LEADER`] characters, no control characters.
pub fn leader_value(v: &str, c: &str) -> Result<String> {
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<String> {
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<Value> = (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<f64> = 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");