mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 03:36:51 -06:00
Tabs: tab stops set through paragraph attributes are checked like the Tabs commands
This commit is contained in:
+75
-1
@@ -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" => ¤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<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();
|
||||
|
||||
@@ -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!({}))
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user