diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index c4ec92b70..5ed95b78e 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -28,6 +28,15 @@ pub struct TabStop { pub align_on: char, } +impl TabStop { + /// The largest stop position `text.tabs.set` and a paragraph style take, in points. + pub const MAX_POSITION: f64 = 100_000.0; + /// The most leader characters a stop repeats. + pub const MAX_LEADER: usize = 8; + /// The most stops `text.tabs.set` and a paragraph style take in one list. + pub const MAX_STOPS: usize = 256; +} + fn default_align_on() -> char { '.' } diff --git a/crates/engine/src/cmd/tabs.rs b/crates/engine/src/cmd/tabs.rs index 331227e3b..04a2c5504 100644 --- a/crates/engine/src/cmd/tabs.rs +++ b/crates/engine/src/cmd/tabs.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec { "Set Tab Stops", ["Window", "Tabs"], None, - "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); supplied align, leader and alignOn must be strings, and alignOn cannot be empty", + "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); at most 256 stops, positions 0..=100000 pt; supplied align, leader and alignOn must be strings, a leader has at most 8 characters and alignOn exactly one; stops are sorted by position, and of stops at one position the first given is kept", has_selection, set ), @@ -38,10 +38,14 @@ pub fn specs() -> Vec { fn parse_stops(p: &Value) -> Result> { const C: &str = "text.tabs.set"; let arr = p.get("stops").and_then(Value::as_array).ok_or_else(|| bad(C, "missing stops"))?; + let max = TabStop::MAX_STOPS; + if arr.len() > max { + return Err(bad(C, format!("at most {max} tab stops"))); + } let mut stops = vec![]; for s in arr { let position = s.get("position").and_then(Value::as_f64).ok_or_else(|| bad(C, "each stop needs a position"))?; - if !(0.0..=100_000.0).contains(&position) { + if !(0.0..=TabStop::MAX_POSITION).contains(&position) { return Err(bad(C, "positions must be between 0 and 100000 pt")); } let align = match s.get("align") { @@ -60,16 +64,25 @@ fn parse_stops(p: &Value) -> Result> { None | Some(Value::Null) => "", Some(Value::String(value)) => value.as_str(), Some(_) => return Err(bad(C, "`leader` must be a string")), + }; + let (label, max) = ("`leader`", TabStop::MAX_LEADER); + if leader.chars().count() > max { + return Err(bad(C, format!("{label} must be at most {max} characters"))); } - .chars() - .take(8) - .collect(); let align_on = match s.get("alignOn") { None | Some(Value::Null) => '.', - Some(Value::String(value)) => value.chars().next().ok_or_else(|| bad(C, "`alignOn` cannot be empty"))?, + Some(Value::String(value)) => { + let mut chars = value.chars(); + let c = chars.next().ok_or_else(|| bad(C, "`alignOn` cannot be empty"))?; + if chars.next().is_some() { + let label = "`alignOn`"; + return Err(bad(C, format!("{label} must be one character"))); + } + c + } Some(_) => return Err(bad(C, "`alignOn` must be a string")), }; - stops.push(TabStop { position, align, leader, align_on }); + stops.push(TabStop { position, align, leader: leader.to_string(), align_on }); } stops.sort_by(|a, b| a.position.total_cmp(&b.position)); stops.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6); @@ -128,7 +141,7 @@ fn get(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod tests { - use serde_json::json; + use serde_json::{Value, json}; use crate::Session; @@ -139,6 +152,21 @@ mod tests { } } + /// A leader of more than eight characters, an `alignOn` of more than one character and a list + /// of more than 256 stops are errors, not cut short. + #[test] + fn long_leaders_long_align_on_and_too_many_stops_are_errors() { + let err = |stops: Value| super::parse_stops(&json!({ "stops": stops })).unwrap_err().to_string(); + assert!(err(json!([{"position": 40, "leader": "123456789"}])).contains("`leader` must be at most 8 characters")); + assert!(err(json!([{"position": 40, "alignOn": ",."}])).contains("`alignOn` must be one character")); + let many: Vec = (0..257).map(|i| json!({"position": i})).collect(); + assert!(err(Value::from(many.clone())).contains("at most 256 tab stops")); + let ok = super::parse_stops(&json!({ "stops": &many[..256] })).unwrap(); + assert_eq!(ok.len(), 256); + let ok = super::parse_stops(&json!({"stops": [{"position": 0, "leader": "12345678", "alignOn": "€"}]})).unwrap(); + assert_eq!((ok[0].leader.as_str(), ok[0].align_on), ("12345678", '€')); + } + #[test] fn set_get_clear_tabs() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index d30e69df6..dc424e115 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -8,7 +8,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::{Map, Value, json}; -use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TextStyleDef}; +use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TabStop, TextStyleDef}; use super::clipboard::resources::import_text_styles; use super::typecmd::{refresh_bounds, text_targets}; @@ -125,7 +125,7 @@ macro_rules! style_cmds { } /// The params of `charStyle.load`, `paraStyle.load` and `textStyle.loadAll`. -const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, the symbols and brushes those patterns use, and the styles of either kind that type in those patterns and symbols is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names in the file]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}}); charStyle.load and paraStyle.load list only the styles of their own kind"; +const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, the symbols and brushes those patterns use, and the styles of either kind that type in those patterns and symbols is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new or paraStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names in the file]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}}); charStyle.load and paraStyle.load list only the styles of their own kind"; pub fn specs() -> Vec { let mut v = style_cmds!( @@ -148,13 +148,13 @@ pub fn specs() -> Vec { "paraStyle", "Paragraph Styles", "Paragraph", - "; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; tab positions 0..=100000 pt" + "; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; changed tabs: at most 256 stops, each changed stop's position 0..=100000 pt and leader at most 8 characters, sorted by position keeping the first of stops at one position" )); v.push(spec( "paraStyle.edit", "Edit Paragraph Style", &[], - "{name, to, attrs: {…}} replace name and sparse attributes in one undo step; users keep local overrides; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; tab positions 0..=100000 pt", + "{name, to, attrs: {…}} replace name and sparse attributes in one undo step; users keep local overrides; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; changed tabs: at most 256 stops, each changed stop's position 0..=100000 pt and leader at most 8 characters, sorted by position keeping the first of stops at one position", |s, p| edit_style(s, p, Kind::Para), true, )); @@ -569,8 +569,9 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma char_attrs(&st) } Kind::Para => { - let st = with_attrs(&ParaStyle::default(), m, &c)?; + let mut st = with_attrs(&ParaStyle::default(), m, &c)?; para_ranges(&st, m, kept, &c)?; + sort_tabs(&mut st, m, kept); para_attrs(&st) } }; @@ -587,7 +588,8 @@ const PARA_DISTANCE: f64 = 1296.0; /// Check the paragraph attributes of `st` (read from `attrs`) that differ from `kept` against the /// ranges `text.setFormat` keeps them to: the indents and the space before and after, each /// Hyphenation option, each Justification option (Minimum, Desired and Maximum in order, as -/// `attrs` gives them) and the position of each tab stop. A value `kept` has is not checked. +/// `attrs` gives them), and the number of tab stops with the position and leader of each stop +/// `kept` does not hold. A value `kept` has is not checked. fn para_ranges(st: &ParaStyle, attrs: &Map, kept: Option<&Map>, c: &str) -> Result<()> { let within = |key: &str, value: f64, lo: f64, hi: f64| { if (lo..=hi).contains(&value) { Ok(()) } else { Err(bad(c, format!("`{key}` must be from {lo} to {hi}"))) } @@ -644,18 +646,37 @@ fn para_ranges(st: &ParaStyle, attrs: &Map, kept: Option<&Map = kept - .and_then(|k| k.get("tabs")) - .and_then(Value::as_array) - .map(|stops| stops.iter().filter_map(|s| s.get("position").and_then(Value::as_f64)).collect()) - .unwrap_or_default(); - for stop in st.tabs.iter().filter(|stop| !kept_tabs.contains(&stop.position)) { - within("tabs.position", stop.position, 0.0, 100_000.0)?; + let kept_tabs = kept_tab_stops(kept); + let max = TabStop::MAX_STOPS; + if st.tabs.len() > max.max(kept_tabs.len()) { + return Err(bad(c, format!("at most {max} tab stops"))); + } + for stop in st.tabs.iter().filter(|stop| !kept_tabs.contains(stop)) { + within("tabs.position", stop.position, 0.0, TabStop::MAX_POSITION)?; + let (label, max) = ("`tabs.leader`", TabStop::MAX_LEADER); + if stop.leader.chars().count() > max { + return Err(bad(c, format!("{label} must be at most {max} characters"))); + } } } Ok(()) } +/// The tab stops `kept` holds, each as it reads (a stop that does not read is left out). +fn kept_tab_stops(kept: Option<&Map>) -> Vec { + let stops = kept.and_then(|k| k.get("tabs")).and_then(Value::as_array); + stops.into_iter().flatten().filter_map(|s| serde_json::from_value(s.clone()).ok()).collect() +} + +/// `st`'s tab stops sorted by position, keeping the first of stops within 1e-6 pt of each other, +/// as `text.tabs.set` keeps them, when `attrs` changes them from `kept`. +fn sort_tabs(st: &mut ParaStyle, attrs: &Map, kept: Option<&Map>) { + if attrs.contains_key("tabs") && kept.and_then(|k| k.get("tabs")) != attrs.get("tabs") { + st.tabs.sort_by(|a, b| a.position.total_cmp(&b.position)); + st.tabs.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6); + } +} + /// Check a character style's `fill` or `stroke` (`key`): each solid color and gradient stop has a /// tint from 0 to 1, and a link to a global swatch of `d`, whose color at that tint it takes. fn style_paint(d: &Document, paint: &mut vectorcraft_color::Paint, key: &str, c: &str) -> Result<()> { @@ -1011,10 +1032,29 @@ fn unused(s: &mut Session, kind: Kind) -> Result { /// The styles of `kind` in `src` that load, with their source names so that art in the file keeps /// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes, -/// scales, leading, shifts or stroke width are out of the range `charStyle.new` takes, or whose name -/// is empty, which are skipped. → (the styles, the names in `src` of those skipped) +/// scales, leading, shifts or stroke width are out of the range `charStyle.new` takes, whose +/// indents, spacing, Hyphenation or Justification options or tab stops are out of the range +/// `paraStyle.new` takes, or whose name is empty, which are skipped. → (the styles, the names in +/// `src` of those skipped) fn loadable(src: &Document, kind: Kind) -> (Vec, Vec) { - const RANGED: [&str; 8] = ["size", "h_scale", "v_scale", "leading", "baseline_shift", "tcyUpDown", "tcyLeftRight", "stroke_width"]; + const RANGED: [&str; 16] = [ + "size", + "h_scale", + "v_scale", + "leading", + "baseline_shift", + "tcyUpDown", + "tcyLeftRight", + "stroke_width", + "left_indent", + "right_indent", + "first_line_indent", + "space_before", + "space_after", + "hyphenation", + "justification", + "tabs", + ]; let (mut defs, mut skipped) = (vec![], vec![]); for def in kind.defs(src) { let name = def.name.as_str(); diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 87bca2904..e38798401 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -1729,3 +1729,77 @@ fn paragraph_style_attributes_are_checked_before_a_definition_changes() { assert!(error.contains(&format!("`{key}`")), "{attrs}: {error}"); } } + +/// A paragraph style's changed tab stops are checked stop by stop: a stop the style holds is kept +/// as it is, and a stop at a held position with another alignment is a changed stop. A changed +/// stop's leader has at most eight characters and its Align On one character, a changed list has +/// at most 256 stops (or as many as the style holds, here 300), and the stops are stored sorted by position, +/// keeping the first of stops at one position. +#[test] +fn paragraph_style_tab_stops_are_checked_by_stop_and_sorted() { + let (mut s, _) = session_with_text("Text"); + s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {}})).unwrap(); + let def = |s: &Session| s.doc().unwrap().doc.para_styles.iter().find(|d| d.name == "Body").unwrap().attrs.clone(); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let held: Vec = + (0..300).map(|i| json!({"position": 200000.0 + f64::from(i), "align": "left", "leader": "123456789", "alignOn": "."})).collect(); + doc.para_styles.iter_mut().find(|d| d.name == "Body").unwrap().attrs.insert("tabs".into(), Value::from(held.clone())); + let before = s.doc().unwrap().doc.clone(); + let mut moved = held.clone(); + moved[0]["align"] = json!("right"); + for (stops, why) in [ + (Value::from(moved), "`tabs.position`"), + (json!([{"position": 36.0, "leader": "123456789"}]), "`tabs.leader` must be at most 8 characters"), + (Value::from((0..301).map(|i| json!({"position": f64::from(i)})).collect::>()), "at most 256 tab stops"), + (json!([{"position": 36.0, "alignOn": ",."}]), "expected a character"), + ] { + for cmd in ["paraStyle.edit", "paraStyle.setAttrs"] { + let params = match cmd { + "paraStyle.edit" => json!({"name": "Body", "to": "Body", "attrs": {"tabs": stops}}), + _ => json!({"name": "Body", "attrs": {"tabs": stops}}), + }; + let error = s.execute(cmd, ¶ms).unwrap_err().to_string(); + assert!(error.contains(why), "{cmd}: {error}"); + } + } + let many = Value::from((0..257).map(|i| json!({"position": f64::from(i)})).collect::>()); + let error = s.execute("paraStyle.new", &json!({"name": "Other", "attrs": {"tabs": many}})).unwrap_err().to_string(); + assert!(error.contains("at most 256 tab stops"), "{error}"); + assert_eq!(s.doc().unwrap().doc, before); + // The held stops stay while one is dropped and another attribute changes. + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": &held[1..], "left_indent": 4.0}})).unwrap(); + assert_eq!(def(&s)["tabs"].as_array().map(Vec::len), Some(299)); + let stops = json!([{"position": 72.0, "align": "right"}, {"position": 36.0}, {"position": 36.0000001, "align": "center"}, {"position": 0.0, "leader": "12345678"}]); + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap(); + assert_eq!( + def(&s)["tabs"], + json!([{"position": 0.0, "align": "left", "leader": "12345678", "alignOn": "."}, {"position": 36.0, "align": "left", "alignOn": "."}, + {"position": 72.0, "align": "right", "alignOn": "."}]) + ); + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": []}})).unwrap(); + assert_eq!(def(&s)["tabs"], json!([]), "an empty list stays set"); +} + +/// Load Paragraph Styles and Load All Styles skip a paragraph style whose indents, spacing, +/// Hyphenation or Justification options or tab stops are out of the range `paraStyle.new` takes, +/// and load the others with their stops. +#[test] +fn loading_skips_paragraph_styles_out_of_range() { + let file = [ + ("Good", json!({"left_indent": 6.0, "tabs": [{"position": 36.0, "align": "decimal", "alignOn": ","}]})), + ("Indent", json!({"left_indent": 2000.0})), + ("Space", json!({"space_after": -1300.0})), + ("Hyphens", json!({"hyphenation": {"words_longer_than": 40}})), + ("Spacing", json!({"justification": {"word_spacing": [120.0, 100.0, 133.0]}})), + ("Leader", json!({"tabs": [{"position": 10.0, "leader": "123456789"}]})), + ]; + let data = styles_file(&[], &file); + for cmd in ["paraStyle.load", "textStyle.loadAll"] { + let (mut s, _) = session_with_text("Title"); + let r = s.execute(cmd, &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap(); + let r = if cmd == "textStyle.loadAll" { r["paraStyles"].clone() } else { r }; + assert_eq!(r, json!({"added": ["Good"], "existing": [], "skipped": ["Indent", "Space", "Hyphens", "Spacing", "Leader"]}), "{cmd}"); + let good = super::style_attrs(&s.doc().unwrap().doc, true, "Good").unwrap(); + assert_eq!(good["tabs"], json!([{"position": 36.0, "align": "decimal", "alignOn": ","}]), "{cmd}"); + } +} diff --git a/crates/ui-egui/src/dialogs/new_text_style.rs b/crates/ui-egui/src/dialogs/new_text_style.rs index 91731006f..4978a6a1f 100644 --- a/crates/ui-egui/src/dialogs/new_text_style.rs +++ b/crates/ui-egui/src/dialogs/new_text_style.rs @@ -40,6 +40,7 @@ pub fn open(app: &mut VectorcraftApp, para: bool, target: Value) -> Result. use serde_json::{Map, Value, json}; -use vectorcraft_doc::{HyphenationOptions, JustificationOptions, Unit}; +use vectorcraft_doc::text::DEFAULT_TAB_INTERVAL; +use vectorcraft_doc::{HyphenationOptions, JustificationOptions, TabAlign, TabStop, Unit}; use super::{DialogSpec, form}; use crate::VectorcraftApp; @@ -21,7 +23,11 @@ use crate::state::Dialog; pub const KIND: &str = "paraStyleOptions"; /// The section ids, in the order the section list shows them. -pub(crate) const SECTIONS: [&str; 5] = ["general", "basic", "composition", "hyphenation", "justification"]; +pub(crate) const SECTIONS: [&str; 6] = ["general", "basic", "tabs", "composition", "hyphenation", "justification"]; +/// The alignments of a tab stop with the names the `tabs` field gives them, in the Tabs panel's +/// order. +const TAB_ALIGNS: [(TabAlign, &str); 4] = + [(TabAlign::Left, "left"), (TabAlign::Center, "center"), (TabAlign::Right, "right"), (TabAlign::Decimal, "decimal")]; /// The distance fields of Basic Paragraph Formats, which are also the attributes they set. const DISTANCES: [&str; 5] = ["left_indent", "right_indent", "first_line_indent", "space_before", "space_after"]; /// The range of the indents, the space before and after, and the Hyphenation Zone (pt), as @@ -98,6 +104,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { "glyph_max", "auto_leading", "single_word", + "tabs", "section", ] }, @@ -112,6 +119,7 @@ pub fn open(app: &mut VectorcraftApp, name: &str) -> Result { let doc = &app.session.active().ok_or("no document open")?.doc; let def = doc.para_styles.iter().find(|s| s.name == name).ok_or_else(|| format!("no style `{name}`"))?; let mut fields = form_fields(&def.attrs, unit); + fields.insert("__vertical".into(), json!(vertical_text(app))); fields.insert("name".into(), json!(name)); fields.insert("__style".into(), json!(name)); fields.insert("section".into(), json!("general")); @@ -129,6 +137,76 @@ pub(crate) fn form_fields(attrs: &Map, unit: Unit) -> Map bool { + crate::panels::character::first_selected_text(app).is_some_and(|t| t.vertical) +} + +/// The `tabs` field for a style's `tabs` attribute: unset (null) when the style leaves it out, +/// otherwise one entry per stop ([`stop_field`]). +fn tabs_field(value: Option<&Value>, unit: Unit) -> Value { + let Some(stops) = value.and_then(Value::as_array) else { return Value::Null }; + Value::Array(stops.iter().filter_map(|s| serde_json::from_value::(s.clone()).ok()).map(|s| stop_field(&s, unit)).collect()) +} + +/// A tab stop's entry in the `tabs` field: its position as text in `unit`, its alignment, leader +/// and Align On character, and the position in points it shows (`__pt`), which a position still as +/// it shows keeps exactly. +fn stop_field(s: &TabStop, unit: Unit) -> Value { + let align = TAB_ALIGNS.iter().find(|(a, _)| *a == s.align).map_or("left", |(_, key)| *key); + json!({"position": unit.format(s.position), "align": align, "leader": s.leader, "alignOn": s.align_on.to_string(), "__pt": s.position}) +} + +/// The position in points of a `tabs` entry: its `__pt` while its text is as that shows in `unit`, +/// else the text read in `unit`; `None` when the text is not a distance. +fn stop_position(row: &Value, unit: Unit) -> Option { + let text = row.get("position").and_then(Value::as_str).map_or("", str::trim); + row.get("__pt").and_then(Value::as_f64).filter(|pt| pt.is_finite() && unit.format(*pt) == text).or_else(|| unit.parse(text)) +} + +/// The tab stop a `tabs` entry sets, or why it sets none: its position must be a distance from 0 to +/// 100000 pt (`label` names the field), its alignment one of [`TAB_ALIGNS`], its leader text of at +/// most eight characters and its Align On character one character. A leader or Align On character +/// left out is none or a period. +fn read_stop(row: &Value, unit: Unit, label: &str) -> Result { + let position = stop_position(row, unit).ok_or_else(|| format!("{label} must be a number"))?; + if !(0.0..=TabStop::MAX_POSITION).contains(&position) { + let (min, max) = (unit.format(0.0), unit.format(TabStop::MAX_POSITION)); + return Err(format!("{label} must be a number from {min} to {max}")); + } + let other = match row.get("align") { + None | Some(Value::Null) => "left", + Some(v) => v.as_str().unwrap_or(""), + }; + let align = TAB_ALIGNS.iter().find(|(_, key)| *key == other).map(|(a, _)| *a).ok_or_else(|| format!("unknown align `{other}`"))?; + let leader = match row.get("leader") { + None | Some(Value::Null) => "", + Some(Value::String(s)) => s.as_str(), + Some(_) => { + let key = "leader"; + return Err(format!("`{key}` must be a string")); + } + }; + let (label, max) = (caption(tl!("Leader:")), TabStop::MAX_LEADER); + if leader.chars().count() > max { + return Err(format!("{label} must be at most {max} characters")); + } + let label = caption(tl!("Align On:")); + let align_on = match row.get("alignOn") { + None | Some(Value::Null) => '.', + Some(Value::String(s)) => { + let mut chars = s.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) => c, + _ => return Err(format!("{label} must be one character")), + } + } + Some(_) => return Err(format!("{label} must be one character")), + }; + Ok(TabStop { position, align, leader: leader.into(), align_on }) +} + /// A distance in points as its field shows it in `unit`. fn distance_field(value: Option<&Value>, unit: Unit) -> Value { value.and_then(Value::as_f64).map_or_else(|| json!(""), |pt| json!(unit.format(pt))) @@ -174,6 +252,7 @@ pub(crate) fn fields_from_attrs(attrs: &Map, unit: Unit) -> Map Result, String> { let same = JUSTIFICATION_FIELDS.iter().all(|key| unchanged(key, "__resetJustification")); let value = if same { None } else { justification(d, &original, &original_fields)? }; keep_or(&mut attrs, "justification", same, value); + + let same = unchanged("tabs", "__resetTabs"); + let value = if same { None } else { tab_stops(d, &original, unit)? }; + keep_or(&mut attrs, "tabs", same, value); Ok(attrs) } +/// The `tabs` attribute the Tabs section sets: none while the list is unset; otherwise its stops +/// sorted by position. Of stops within 1e-6 pt of each other, the one edited last (`__tabEdited`) +/// is kept, else the first in the list. A stop as the style has it is kept without a check, and any +/// other is read by [`read_stop`]. An error when the list has more than 256 stops and more than the +/// style has. +fn tab_stops(d: &Dialog, original: &Map, unit: Unit) -> Result, String> { + let rows = match d.fields.get("tabs") { + None | Some(Value::Null) => return Ok(None), + Some(Value::Array(rows)) => rows, + Some(_) => { + let key = "tabs"; + return Err(format!("`{key}` must be an array")); + } + }; + let kept: Vec = + original.get("tabs").and_then(Value::as_array).into_iter().flatten().filter_map(|s| serde_json::from_value(s.clone()).ok()).collect(); + let max = TabStop::MAX_STOPS; + if rows.len() > max.max(kept.len()) { + return Err(format!("at most {max} tab stops")); + } + let label = caption(if d.bool("__vertical") { tl!("Y:") } else { tl!("X:") }); + let kept_rows: Vec = kept.iter().map(|s| stop_field(s, unit)).collect(); + let mut stops = Vec::with_capacity(rows.len()); + for row in rows { + match kept_rows.iter().position(|k| k == row) { + Some(i) => stops.push(kept[i].clone()), + None => stops.push(read_stop(row, unit, label)?), + } + } + let edited = d.fields.get("__tabEdited").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + let mut order: Vec = (0..stops.len()).collect(); + order.sort_by(|&a, &b| stops[a].position.total_cmp(&stops[b].position).then(a.cmp(&b))); + let mut keep: Vec = Vec::with_capacity(order.len()); + for i in order { + match keep.last_mut() { + Some(last) if (stops[*last].position - stops[i].position).abs() < 1e-6 => { + if Some(i) == edited { + *last = i; + } + } + _ => keep.push(i), + } + } + let stops: Vec = keep.into_iter().map(|i| stops[i].clone()).collect(); + Ok(Some(serde_json::to_value(stops).map_err(|e| e.to_string())?)) +} + /// The `hyphenation` attribute the Hyphenation fields set: none when every field is empty or /// unset; otherwise the style's options (or the defaults) with each changed field written over /// them, an empty one at its default. @@ -416,15 +546,24 @@ fn reset_justification(d: &mut Dialog) { } } +/// Reset Panel in Tabs: the tab stops become unset, which leaves `tabs` out of the style. +fn reset_tabs(d: &mut Dialog) { + d.fields.insert("__resetTabs".into(), json!(true)); + d.fields.insert("tabs".into(), Value::Null); + d.fields.remove("__tabEdited"); + d.fields.insert("__tabSel".into(), json!(0)); +} + /// Whether the section `d` shows has a Reset Panel: every section but General. pub(crate) fn can_reset(d: &Dialog) -> bool { - ["basic", "composition", "hyphenation", "justification"].contains(&d.str("section").as_str()) + ["basic", "tabs", "composition", "hyphenation", "justification"].contains(&d.str("section").as_str()) } /// Reset Panel: clear the settings of the section `d` shows. pub(crate) fn reset_section(d: &mut Dialog) { match d.str("section").as_str() { "basic" => reset_basic(d), + "tabs" => reset_tabs(d), "composition" => reset_composition(d), "hyphenation" => reset_hyphenation(d), "justification" => reset_justification(d), @@ -449,9 +588,8 @@ pub fn reset_panel(app: &mut VectorcraftApp) -> Result { /// The section list of Paragraph Style Options and New Paragraph Style. pub(crate) fn section_list(ui: &mut egui::Ui, d: &mut Dialog) { ui.vertical(|ui| { - for (id, label) in - SECTIONS.into_iter().zip([tl!("General"), tl!("Basic Paragraph Formats"), tl!("Composition"), tl!("Hyphenation"), tl!("Justification")]) - { + let labels = [tl!("General"), tl!("Basic Paragraph Formats"), tl!("Tabs"), tl!("Composition"), tl!("Hyphenation"), tl!("Justification")]; + for (id, label) in SECTIONS.into_iter().zip(labels) { if ui.selectable_label(d.str("section") == id, label).clicked() { d.fields.insert("section".into(), json!(id)); } @@ -529,6 +667,156 @@ pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) { }); } +/// The editing fields of the selected tab stop, with the `tabs` entry key each one shows. +const TAB_EDITORS: [(&str, &str); 3] = [("__tabPosition", "position"), ("__tabLeader", "leader"), ("__tabAlignOn", "alignOn")]; + +/// Tabs: the selected stop's Alignment, X (Y in vertical type), Leader and Align On, which only a +/// decimal stop uses; Add ([`add_tab`]), Delete Tab ([`delete_tab`]) and Clear All Tabs +/// ([`clear_tabs`]); and the list of stops, one row each, a click on which selects its stop, which +/// scrolls when it is taller than 160 points. A change to a field makes its stop the one edited last. +pub(crate) fn tabs_body(ui: &mut egui::Ui, d: &mut Dialog) { + let vertical = d.bool("__vertical"); + let (mut rows, mut sel, _) = tab_rows(d); + let mut changed = false; + let has = sel < rows.len(); + let shown = TAB_EDITORS.map(|(_, key)| rows.get(sel).and_then(|r| r.get(key)).and_then(Value::as_str).unwrap_or("").to_string()); + for ((field, _), text) in TAB_EDITORS.iter().zip(&shown) { + d.fields.insert((*field).into(), json!(text)); + } + let align = rows.get(sel).and_then(|r| r.get("align")).and_then(Value::as_str).unwrap_or("left").to_string(); + egui::Grid::new("paragraph-style-tabs").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { + let row = ui.label(tl!("Alignment:")); + let names: Vec<&str> = TAB_ALIGNS.iter().map(|(a, _)| crate::panels::tabs::align_name(*a, vertical)).collect(); + let shown = TAB_ALIGNS.iter().position(|(_, key)| *key == align).and_then(|i| names.get(i).copied()).unwrap_or(""); + ui.add_enabled_ui(has, |ui| { + ui.label_inputs_by(row.id, |ui| { + if let Some(i) = crate::widgets::dropdown(ui, (KIND, "tab_align"), shown, &names, 190.0) + && let (Some(r), Some((_, key))) = (rows.get_mut(sel).and_then(Value::as_object_mut), TAB_ALIGNS.get(i)) + { + r.insert("align".into(), json!(key)); + changed = true; + } + }); + }); + ui.end_row(); + field_row(ui, d, "__tabPosition", if vertical { tl!("Y:") } else { tl!("X:") }, "", has); + field_row(ui, d, "__tabLeader", tl!("Leader:"), "", has); + field_row(ui, d, "__tabAlignOn", tl!("Align On:"), "", has && align == "decimal"); + }); + for ((field, key), was) in TAB_EDITORS.iter().zip(&shown) { + let typed = d.str(field); + if let Some(r) = rows.get_mut(sel).and_then(Value::as_object_mut) + && typed != *was + { + r.insert((*key).into(), json!(typed)); + changed = true; + } + } + if changed { + d.fields.insert("tabs".into(), Value::Array(rows.clone())); + d.fields.insert("__tabEdited".into(), json!(sel)); + } + ui.add_space(6.0); + let (mut add, mut delete, mut clear) = (false, false, false); + let can_add = new_tab_position(&rows, unit_of(d)).is_some(); + ui.horizontal(|ui| { + add = ui.add_enabled(can_add, egui::Button::new(tl!("Add"))).clicked(); + delete = ui.add_enabled(has, egui::Button::new(tl!("Delete Tab"))).clicked(); + clear = ui.add_enabled(!rows.is_empty(), egui::Button::new(tl!("Clear All Tabs"))).clicked(); + }); + ui.add_space(4.0); + let reveal = d.fields.remove("__tabScroll").is_some(); + egui::ScrollArea::vertical().id_salt("paragraph-style-tab-list").max_height(160.0).auto_shrink([false, true]).show(ui, |ui| { + for (i, r) in rows.iter().enumerate() { + let a = r.get("align").and_then(Value::as_str).and_then(|key| TAB_ALIGNS.iter().find(|(_, k)| *k == key)).map(|(a, _)| *a); + let name = a.map_or("", |a| crate::panels::tabs::align_name(a, vertical)); + let mut text = format!("{} {name}", r.get("position").and_then(Value::as_str).unwrap_or("")); + if let Some(leader) = r.get("leader").and_then(Value::as_str).filter(|l| !l.is_empty()) { + text += &format!(" {leader}"); + } + let row = ui.selectable_label(i == sel, text); + if reveal && i == sel { + ui.scroll_to_rect_animation(row.rect, None, egui::style::ScrollAnimation::none()); + } + if row.clicked() { + sel = i; + } + } + }); + d.fields.insert("__tabSel".into(), json!(sel)); + if add { + add_tab(d); + } else if delete { + delete_tab(d); + } else if clear { + clear_tabs(d); + } +} + +/// The `tabs` entries of `d` (none while unset), the selected one's index (`__tabSel`, at most the +/// last) and the index of the one edited last (`__tabEdited`). +fn tab_rows(d: &Dialog) -> (Vec, usize, Option) { + let rows: Vec = d.fields.get("tabs").and_then(Value::as_array).cloned().unwrap_or_default(); + let index = |key: &str| d.fields.get(key).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + let sel = index("__tabSel").unwrap_or(0).min(rows.len().saturating_sub(1)); + (rows, sel, index("__tabEdited")) +} + +/// The position (pt) Add gives a new stop among `rows`: half an inch past the furthest stop that +/// reads, half an inch when none does, and at most 100000 pt. `None` while the list holds 256 stops +/// or when a stop already sits at that position. +fn new_tab_position(rows: &[Value], unit: Unit) -> Option { + if rows.len() >= TabStop::MAX_STOPS { + return None; + } + let positions: Vec = rows.iter().filter_map(|r| stop_position(r, unit)).collect(); + let furthest = positions.iter().copied().filter(|pt| (0.0..=TabStop::MAX_POSITION).contains(pt)).reduce(f64::max); + let position = furthest.map_or(DEFAULT_TAB_INTERVAL, |pt| pt + DEFAULT_TAB_INTERVAL).min(TabStop::MAX_POSITION); + (!positions.iter().any(|pt| (pt - position).abs() < 1e-6)).then_some(position) +} + +/// Add in Tabs: a left stop at [`new_tab_position`], which becomes the selected and last edited +/// stop and is scrolled into view in the list. Nothing when that gives no position. +pub(crate) fn add_tab(d: &mut Dialog) { + let unit = unit_of(d); + let (mut rows, _, _) = tab_rows(d); + let Some(position) = new_tab_position(&rows, unit) else { return }; + rows.push(stop_field(&TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: '.' }, unit)); + let at = rows.len() - 1; + d.fields.insert("tabs".into(), Value::Array(rows)); + d.fields.insert("__tabSel".into(), json!(at)); + d.fields.insert("__tabEdited".into(), json!(at)); + d.fields.insert("__tabScroll".into(), json!(true)); +} + +/// Delete Tab in Tabs: remove the selected stop; the next one (or the last) becomes selected. +pub(crate) fn delete_tab(d: &mut Dialog) { + let (mut rows, sel, edited) = tab_rows(d); + if sel >= rows.len() { + return; + } + rows.remove(sel); + match edited { + Some(e) if e == sel => { + d.fields.remove("__tabEdited"); + } + Some(e) if e > sel => { + d.fields.insert("__tabEdited".into(), json!(e - 1)); + } + _ => {} + } + d.fields.insert("__tabSel".into(), json!(sel.min(rows.len().saturating_sub(1)))); + d.fields.insert("tabs".into(), Value::Array(rows)); + d.fields.insert("__tabScroll".into(), json!(true)); +} + +/// Clear All Tabs in Tabs: an empty list of stops, which the style then sets. +pub(crate) fn clear_tabs(d: &mut Dialog) { + d.fields.insert("tabs".into(), json!([])); + d.fields.insert("__tabSel".into(), json!(0)); + d.fields.remove("__tabEdited"); +} + /// Composition: the Single-line or Every-line Composer, and Roman Hanging Punctuation. pub(crate) fn composition_body(ui: &mut egui::Ui, d: &mut Dialog) { egui::Grid::new("paragraph-style-composition").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { @@ -624,6 +912,7 @@ pub(crate) fn general_body(ui: &mut egui::Ui, d: &mut Dialog) { pub(crate) fn section_body(ui: &mut egui::Ui, d: &mut Dialog) { match d.str("section").as_str() { "basic" => basic_body(ui, d), + "tabs" => tabs_body(ui, d), "composition" => composition_body(ui, d), "hyphenation" => hyphenation_body(ui, d), "justification" => justification_body(ui, d), diff --git a/crates/ui-egui/src/dialogs/para_style_options/tests.rs b/crates/ui-egui/src/dialogs/para_style_options/tests.rs index 42fa0bd49..38d158b10 100644 --- a/crates/ui-egui/src/dialogs/para_style_options/tests.rs +++ b/crates/ui-egui/src/dialogs/para_style_options/tests.rs @@ -162,6 +162,7 @@ fn reset_panel_clears_only_its_section() { let stored = def(&app, "Body"); for (section, cleared) in [ ("basic", &["justify", "space_after"][..]), + ("tabs", &["tabs"]), ("composition", &["composer", "roman_hanging"]), ("hyphenation", &["hyphenate", "hyphenation"]), ("justification", &["justification"]), @@ -239,7 +240,15 @@ fn new_paragraph_style_offers_the_sections() { set(&mut app, "section", json!(section)); texts += &crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); } - for label in ["Basic Paragraph Formats", "Composition", "First-line Left Indent", "Hyphenate Capitalized Words", "Single Word Justification:"] { + for label in [ + "Basic Paragraph Formats", + "Tabs", + "Composition", + "First-line Left Indent", + "Align On:", + "Hyphenate Capitalized Words", + "Single Word Justification:", + ] { assert!(texts.contains(label), "{label} is drawn: {texts}"); } set(&mut app, "section", json!("basic")); @@ -257,6 +266,237 @@ fn new_paragraph_style_offers_the_sections() { assert_eq!(paras(&app, id)[0].justify, Justify::Center); } +/// The rows of the `tabs` field of the open dialog. +fn tab_rows(app: &VectorcraftApp) -> Vec { + app.ui.dialog.as_ref().unwrap().fields["tabs"].as_array().cloned().unwrap_or_default() +} + +/// Tabs shows a style's stops in the General unit with their alignment, leader and Align On +/// character; a stop left as it was keeps its position exactly, a changed one is read in the unit, +/// and the stops are stored sorted by position. Add puts a left stop half an inch past the furthest +/// stop, Delete Tab removes the selected stop and Clear All Tabs leaves the style an empty list. +#[test] +fn tabs_section_edits_the_style_stops() { + let mut app = app(); + let stops = json!([{"position": 36.0, "align": "right"}, {"position": 72.123456, "align": "decimal", "leader": ".", "alignOn": ","}]); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"left_indent": 3.0, "tabs": stops}})).unwrap(); + let stored = def(&app, "Body"); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let rows = tab_rows(&app); + let shown: Vec<(&str, &str, &str, &str)> = rows + .iter() + .map(|r| (r["position"].as_str().unwrap(), r["align"].as_str().unwrap(), r["leader"].as_str().unwrap(), r["alignOn"].as_str().unwrap())) + .collect(); + assert_eq!(shown, [("36 pt", "right", "", "."), ("72.123 pt", "decimal", ".", ",")]); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(), stored, "nothing changed"); + + // The first stop moves past the second and turns centered; the second keeps its exact position. + let mut rows = rows; + rows[0]["position"] = json!("1.5 in"); + rows[0]["align"] = json!("center"); + set(&mut app, "tabs", Value::from(rows)); + let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(); + assert_eq!( + a["tabs"], + json!([{"position": 72.123456, "align": "decimal", "leader": ".", "alignOn": ","}, {"position": 108.0, "align": "center", "alignOn": "."}]) + ); + assert_eq!(a["left_indent"], json!(3.0), "other attributes stay"); + + let d = app.ui.dialog.as_mut().unwrap(); + add_tab(d); + assert_eq!((tab_rows(&app).len(), app.ui.dialog.as_ref().unwrap().fields["__tabSel"].clone()), (3, json!(2))); + let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(); + assert_eq!(a["tabs"][2], json!({"position": 144.0, "align": "left", "alignOn": "."}), "half an inch past the furthest"); + set(&mut app, "__tabSel", json!(0)); + delete_tab(app.ui.dialog.as_mut().unwrap()); + let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(); + assert_eq!(a["tabs"].as_array().unwrap().iter().map(|s| s["position"].clone()).collect::>(), [json!(72.123456), json!(144.0)]); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body")["tabs"].as_array().map(Vec::len), Some(2)); + + // Clear All Tabs leaves an empty list set, which Reset Panel removes; a new stop on a style + // without stops goes at half an inch. + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + clear_tabs(app.ui.dialog.as_mut().unwrap()); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap()["tabs"], json!([])); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body")["tabs"], json!([]), "an empty list is stored"); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "section", json!("tabs")); + app.run("ui.paraStyleOptions.resetPanel", json!({})).unwrap(); + let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(); + assert!(!a.contains_key("tabs") && a["left_indent"] == json!(3.0), "{a:?}"); + add_tab(app.ui.dialog.as_mut().unwrap()); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap()["tabs"], json!([{"position": 36.0, "align": "left", "alignOn": "."}])); +} + +/// Of stops typed at one position, the one edited last is kept; with none edited, the first in the +/// list is. +#[test] +fn a_repeated_tab_position_keeps_the_stop_edited_last() { + let mut app = app(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {}})).unwrap(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let rows = json!([{"position": "36", "align": "right"}, {"position": "0.5 in", "align": "center"}, {"position": "10", "align": "left"}]); + set(&mut app, "tabs", rows); + let kept = |app: &VectorcraftApp| edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap()["tabs"].clone(); + assert_eq!(kept(&app), json!([{"position": 10.0, "align": "left", "alignOn": "."}, {"position": 36.0, "align": "right", "alignOn": "."}])); + set(&mut app, "__tabEdited", json!(1)); + assert_eq!(kept(&app)[1]["align"], json!("center")); +} + +/// The list of stops scrolls, so a style with 256 stops keeps the Tabs section short enough for the +/// dialog's buttons to stay on screen. +#[test] +fn a_long_tab_list_scrolls() { + let mut app = app(); + let stops: Vec = (0..256).map(|i| json!({"position": 36.0 * f64::from(i + 1), "align": "left"})).collect(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut height = f32::INFINITY; + let mut out = ctx.run_ui(Default::default(), |ui| { + let top = ui.cursor().top(); + tabs_body(ui, d); + height = ui.cursor().top() - top; + }); + out.textures_delta.clear(); + assert!(height < 400.0, "the Tabs section is {height} points tall"); +} + +/// The stop Add puts at the end of a long list is scrolled into view: its row in the list is drawn +/// inside the list's visible area. +#[test] +fn an_added_tab_stop_is_scrolled_into_view() { + let mut app = app(); + let stops: Vec = (0..100).map(|i| json!({"position": 36.0 * f64::from(i + 1), "align": "left"})).collect(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let draw = |d: &mut Dialog| { + let mut out = ctx.run_ui(Default::default(), |ui| tabs_body(ui, d)); + out.textures_delta.clear(); + out.shapes.iter().any(|c| match &c.shape { + egui::Shape::Text(t) => t.galley.text().starts_with("3636 pt ") && c.clip_rect.intersects(t.visual_bounding_rect()), + _ => false, + }) + }; + draw(d); + add_tab(d); + let shown: Vec = (0..3).map(|_| draw(d)).collect(); + assert_eq!(shown.last(), Some(&true), "{shown:?}"); +} + +/// Add does not put a stop where one already is: with a stop at 100000 pt, the largest position, +/// it adds nothing and the Add button is disabled, so the existing stop keeps its settings. +#[test] +fn add_leaves_a_stop_at_the_largest_position_alone() { + let mut app = app(); + let stops = json!([{"position": 100000.0, "align": "right", "leader": "-"}]); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let before = tab_rows(&app); + add_tab(app.ui.dialog.as_mut().unwrap()); + assert_eq!(tab_rows(&app), before, "no stop is added"); + assert!(new_tab_position(&before, Unit::Points).is_none(), "Add is disabled"); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(), def(&app, "Body"), "the stop is kept"); +} + +/// A tab position out of range or not a distance, an unknown alignment, a leader of more than eight +/// characters, an Align On entry that is not one character, more than 256 stops, a `tabs` value that +/// is not a list and a stop that is not an object are errors that keep the dialog open and the style +/// as it was. Vertical type names the position Y. +#[test] +fn invalid_tab_stops_keep_the_dialog_open() { + let mut app = app(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {}})).unwrap(); + let stop = |key: &str, value: Value| { + let mut stop = json!({"position": "36", "align": "decimal", "leader": "", "alignOn": "."}); + stop[key] = value; + json!([stop]) + }; + let many = Value::from((0..257).map(|i| json!({"position": format!("{i}"), "align": "left"})).collect::>()); + for (tabs, why) in [ + (stop("position", json!("-1")), "X must be a number from 0 pt to 100000 pt"), + (stop("position", json!("100001 pt")), "X must be a number from 0 pt to 100000 pt"), + (stop("position", json!("far")), "X must be a number"), + (stop("align", json!("diagonal")), "unknown align `diagonal`"), + (stop("leader", json!("123456789")), "Leader must be at most 8 characters"), + (stop("alignOn", json!("")), "Align On must be one character"), + (stop("alignOn", json!(",.")), "Align On must be one character"), + (many, "at most 256 tab stops"), + (json!("36"), "`tabs` must be an array"), + (json!({"position": "36"}), "`tabs` must be an array"), + (json!([5]), "X must be a number"), + ] { + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "tabs", tabs.clone()); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap_err(), why, "{tabs}"); + assert!(super::super::confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + super::super::cancel(&mut app); + assert_eq!(def(&app, "Body"), Map::new()); + } + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "__vertical", json!(true)); + set(&mut app, "tabs", stop("position", json!("far"))); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap_err(), "Y must be a number"); + // Stops a style read from a file holds out of range stay while another stop is added. + super::super::cancel(&mut app); + let doc = std::sync::Arc::make_mut(&mut app.session.doc_mut().unwrap().doc); + let held = json!([{"position": 200000.0, "align": "left", "leader": "123456789", "alignOn": "."}]); + doc.para_styles.iter_mut().find(|d| d.name == "Body").unwrap().attrs.insert("tabs".into(), held.clone()); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + add_tab(app.ui.dialog.as_mut().unwrap()); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body")["tabs"][1], held[0]); +} + +/// Preview shows a style's new stops on the paragraphs that use it, and Cancel puts the document +/// and its history back. OK sets them in one undo step, a paragraph whose stops the Tabs panel set +/// keeps its own, and Undo and Redo restore both. Saving and opening the document keeps the stops. +#[test] +fn tabs_preview_cancel_and_ok_update_the_paragraphs_in_one_undo_step() { + let mut app = app(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "a\tb\nc\td"})).unwrap()["id"].as_u64().unwrap(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": [{"position": 36.0}]}})).unwrap(); + app.run("paraStyle.apply", json!({"name": "Body", "id": id})).unwrap(); + app.run("text.tabs.set", json!({"ids": [id], "start": 4, "end": 4, "stops": [{"position": 50.0}]})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let undo = app.session.doc().unwrap().history.undo.len(); + let new_stops = json!([{"position": "20", "align": "right", "leader": "."}, {"position": "80", "align": "decimal", "alignOn": ","}]); + let positions = |app: &VectorcraftApp| paras(app, id).iter().map(|p| p.tabs.iter().map(|t| t.position).collect::>()).collect::>(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "section", json!("tabs")); + set(&mut app, "tabs", new_stops.clone()); + frame(&mut app); + assert_eq!(positions(&app), [vec![20.0, 80.0], vec![50.0]], "Preview shows the change"); + super::super::cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo); + + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "tabs", new_stops); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(positions(&app), [vec![20.0, 80.0], vec![50.0]]); + let p = paras(&app, id); + assert_eq!((p[0].tabs[0].leader.as_str(), p[0].tabs[1].align_on), (".", ',')); + let changed = app.session.doc().unwrap().doc.clone(); + let saved = vectorcraft_format::load(&vectorcraft_format::save(&changed, false)).unwrap(); + let def_of = |d: &vectorcraft_doc::Document| d.para_styles.iter().find(|s| s.name == "Body").map(|s| s.attrs.clone()); + assert_eq!(def_of(&saved), def_of(&changed)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, changed); +} + /// The section list, Reset Panel and both buttons are drawn in every section, and the Hyphenation /// options are drawn whether or not Hyphenation is on. #[test] @@ -269,6 +509,7 @@ fn every_section_draws_its_fields() { for (section, label) in [ ("general", "Style Name:"), ("basic", "Space After Paragraph"), + ("tabs", "Clear All Tabs"), ("composition", "Roman Hanging Punctuation"), ("hyphenation", "Hyphenation Zone:"), ("justification", "Glyph Scaling:"), @@ -282,7 +523,7 @@ fn every_section_draws_its_fields() { out.textures_delta.clear(); texts = crate::tests_labels::shapes_text(&out); } - for shown in [label, "General", "Basic Paragraph Formats", "Justification", "Reset Panel", "OK", "Cancel"] { + for shown in [label, "General", "Basic Paragraph Formats", "Tabs", "Justification", "Reset Panel", "OK", "Cancel"] { assert!(texts.contains(shown), "{shown} is drawn in {section}: {texts}"); } } diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 21ac28a10..f3d701139 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5081,6 +5081,7 @@ digits Malayalam Malaiàlam @msg `sampleSize` must be 1, 3 or 5 `sampleSize` ha de ser 1, 3 o 5 @msg `select` must be true or false `select` ha de ser true o false @msg `stops` must be an array `stops` ha de ser una matriu +@msg `{key}` must be an array `{key}` ha de ser una matriu @msg `svg` must be an object of SVG options (see document.formats) `svg` ha de ser un objecte d’opcions SVG (vegeu document.formats) @msg `to` must be "corner" or "smooth" `to` ha de ser "corner" o "smooth" @msg `{_1}` has no brushes to apply `{_1}` no té pinzells per aplicar @@ -6482,6 +6483,10 @@ anchor Points: Punts: @msg features lists at most {max} tags features admet com a màxim {max} etiquetes @msg unknown OpenType feature `{t}` funció OpenType desconeguda `{t}` @msg {label} must be a number above 0 and at most {max} {label} ha de ser un nombre superior a 0 i com a màxim {max} +@msg {label} must be at most {max} characters {label} ha de tenir com a màxim {max} caràcters +@msg {label} must be one character {label} ha de ser un sol caràcter +@msg at most {max} tab stops com a màxim {max} tabulacions +@msg unknown align `{other}` alineació desconeguda `{other}` @msg no character style `{name}` No s'ha trobat l'estil de caràcter `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 536e37353..d61db843e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4560,6 +4560,7 @@ imageTrace Outline Obrys @msg `globalAdjust` must be an object `globalAdjust` musí být objekt @msg `sampleSize` must be 1, 3 or 5 `sampleSize` musí být 1, 3 nebo 5 @msg `stops` must be an array `stops` musí být pole +@msg `{key}` must be an array `{key}` musí být pole @msg `svg` must be an object of SVG options (see document.formats) `svg` musí být objekt s možnostmi SVG (viz document.formats) @msg `{_1}` is no save command `{_1}` není příkaz pro uložení @msg `{area}` is no recovery area name `{area}` není název oblasti obnovení @@ -6510,6 +6511,10 @@ anchor Points: Body: @msg features lists at most {max} tags features obsahuje nejvýše {max} značek @msg unknown OpenType feature `{t}` neznámá funkce OpenType `{t}` @msg {label} must be a number above 0 and at most {max} {label} musí být číslo větší než 0 a nejvýše {max} +@msg {label} must be at most {max} characters {label} smí mít nejvýše {max} znaků +@msg {label} must be one character {label} musí být jeden znak +@msg at most {max} tab stops nejvýše {max} zarážek tabulátoru +@msg unknown align `{other}` neznámé zarovnání `{other}` @msg no character style `{name}` styl znaků `{name}` neexistuje # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c5604d4a6..7c2f446ff 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4175,6 +4175,7 @@ theme Appearance Erscheinungsbild @msg `globalAdjust` must be an object `globalAdjust` muss ein Objekt sein @msg `sampleSize` must be 1, 3 or 5 `sampleSize` muss 1, 3 oder 5 sein @msg `stops` must be an array `stops` muss ein Array sein +@msg `{key}` must be an array `{key}` muss ein Array sein @msg `svg` must be an object of SVG options (see document.formats) `svg` muss ein Objekt mit SVG-Optionen sein (siehe document.formats) @msg `{_1}` is no save command `{_1}` ist kein Speicherbefehl @msg `{area}` is no recovery area name `{area}` ist kein Name eines Wiederherstellungsbereichs @@ -6104,6 +6105,10 @@ anchor Points: Punkte: @msg features lists at most {max} tags features enthält höchstens {max} Tags @msg unknown OpenType feature `{t}` unbekannte OpenType-Funktion `{t}` @msg {label} must be a number above 0 and at most {max} {label} muss eine Zahl über 0 und höchstens {max} sein +@msg {label} must be at most {max} characters {label} darf höchstens {max} Zeichen lang sein +@msg {label} must be one character {label} muss genau ein Zeichen sein +@msg at most {max} tab stops höchstens {max} Tabstopps +@msg unknown align `{other}` unbekannte Ausrichtung `{other}` @msg no character style `{name}` Zeichenformat `{name}` nicht gefunden # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7663ef504..1dc7281b2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2374,6 +2374,7 @@ axis Both Και τα δύο @msg `globalAdjust` must be an object Το `globalAdjust` πρέπει να είναι αντικείμενο @msg `sampleSize` must be 1, 3 or 5 Το `sampleSize` πρέπει να είναι 1, 3 ή 5 @msg `stops` must be an array Το `stops` πρέπει να είναι πίνακας +@msg `{key}` must be an array Το `{key}` πρέπει να είναι πίνακας @msg `svg` must be an object of SVG options (see document.formats) Το `svg` πρέπει να είναι αντικείμενο επιλογών SVG (δείτε document.formats) @msg `{_1}` is no save command Το `{_1}` δεν είναι εντολή αποθήκευσης @msg `{area}` is no recovery area name Το `{area}` δεν είναι όνομα περιοχής ανάκτησης @@ -6461,6 +6462,10 @@ anchor Points: Σημεία: @msg features lists at most {max} tags Το features περιέχει έως {max} ετικέτες @msg unknown OpenType feature `{t}` άγνωστο χαρακτηριστικό OpenType `{t}` @msg {label} must be a number above 0 and at most {max} Το {label} πρέπει να είναι αριθμός μεγαλύτερος από 0 και έως {max} +@msg {label} must be at most {max} characters Το {label} πρέπει να έχει έως {max} χαρακτήρες +@msg {label} must be one character Το {label} πρέπει να είναι ένας χαρακτήρας +@msg at most {max} tab stops έως {max} στάσεις στηλοθέτη +@msg unknown align `{other}` άγνωστη στοίχιση `{other}` @msg no character style `{name}` Δεν βρέθηκε το στυλ χαρακτήρων `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2e7a2baed..40dc19cf8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4251,6 +4251,7 @@ axis Both Ambos @msg `globalAdjust` must be an object `globalAdjust` debe ser un objeto @msg `sampleSize` must be 1, 3 or 5 `sampleSize` debe ser 1, 3 o 5 @msg `stops` must be an array `stops` debe ser una lista +@msg `{key}` must be an array `{key}` debe ser una lista @msg `svg` must be an object of SVG options (see document.formats) `svg` debe ser un objeto con opciones de SVG (consulta document.formats) @msg `{_1}` is no save command `{_1}` no es un comando de guardado @msg `{area}` is no recovery area name `{area}` no es el nombre de un área de recuperación @@ -6453,6 +6454,10 @@ anchor Points: Puntos: @msg features lists at most {max} tags features admite como máximo {max} etiquetas @msg unknown OpenType feature `{t}` función OpenType desconocida `{t}` @msg {label} must be a number above 0 and at most {max} {label} debe ser un número mayor que 0 y como máximo {max} +@msg {label} must be at most {max} characters {label} debe tener como máximo {max} caracteres +@msg {label} must be one character {label} debe ser un solo carácter +@msg at most {max} tab stops como máximo {max} tabulaciones +@msg unknown align `{other}` alineación desconocida `{other}` @msg no character style `{name}` No existe el estilo de carácter `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 2967ba432..7d05378fa 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4474,6 +4474,7 @@ imageTrace Outline Ääriviiva @msg `globalAdjust` must be an object `globalAdjust` on oltava objekti @msg `sampleSize` must be 1, 3 or 5 `sampleSize` on oltava 1, 3 tai 5 @msg `stops` must be an array `stops` on oltava taulukko +@msg `{key}` must be an array `{key}` on oltava taulukko @msg `svg` must be an object of SVG options (see document.formats) `svg` on oltava SVG-asetusten objekti (ks. document.formats) @msg `{_1}` is no save command `{_1}` ei ole tallennuskomento @msg `{area}` is no recovery area name `{area}` ei ole palautusalueen nimi @@ -6540,6 +6541,10 @@ anchor Points: Pisteet: @msg features lists at most {max} tags features sisältää enintään {max} tunnistetta @msg unknown OpenType feature `{t}` tuntematon OpenType-ominaisuus `{t}` @msg {label} must be a number above 0 and at most {max} {label} on oltava luku, joka on yli 0 ja enintään {max} +@msg {label} must be at most {max} characters {label} saa olla enintään {max} merkkiä +@msg {label} must be one character {label} on oltava yksi merkki +@msg at most {max} tab stops enintään {max} sarkainkohtaa +@msg unknown align `{other}` tuntematon tasaus `{other}` @msg no character style `{name}` Merkkityyliä `{name}` ei löytynyt # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 187b0a89a..daa43db14 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4240,6 +4240,7 @@ axis Both Les deux @msg `globalAdjust` must be an object `globalAdjust` doit être un objet @msg `sampleSize` must be 1, 3 or 5 `sampleSize` doit valoir 1, 3 ou 5 @msg `stops` must be an array `stops` doit être un tableau +@msg `{key}` must be an array `{key}` doit être un tableau @msg `svg` must be an object of SVG options (see document.formats) `svg` doit être un objet d’options SVG (voir document.formats) @msg `{_1}` is no save command `{_1}` n’est pas une commande d’enregistrement @msg `{area}` is no recovery area name `{area}` n’est pas un nom de zone de récupération @@ -6505,6 +6506,10 @@ anchor Points: Points : @msg features lists at most {max} tags features contient au plus {max} balises @msg unknown OpenType feature `{t}` fonction OpenType inconnue `{t}` @msg {label} must be a number above 0 and at most {max} {label} doit être un nombre supérieur à 0 et au plus égal à {max} +@msg {label} must be at most {max} characters {label} doit compter au plus {max} caractères +@msg {label} must be one character {label} doit être un seul caractère +@msg at most {max} tab stops au plus {max} taquets de tabulation +@msg unknown align `{other}` alignement inconnu `{other}` @msg no character style `{name}` Style de caractère `{name}` introuvable # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 4eb6f4f69..6a21fb024 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5545,6 +5545,9 @@ antiAlias Sharp शार्प @msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} डॉक्यूमेंट के {n} आर्टबोर्ड में से 1 रखता है: फ़ाइल एक के साथ फिर से खुलती है। उन्हें रखने के लिए संरक्षित संपादन क्षमताओं को चालू करें। @msg {label} must be a number {label} एक नंबर होना चाहिए @msg {label} must be a number above 0 and at most {max} {label} 0 से ऊपर और सबसे {max} पर होना चाहिए +@msg {label} must be at most {max} characters {label} में अधिकतम {max} वर्ण होने चाहिए +@msg {label} must be one character {label} एक वर्ण होना चाहिए +@msg at most {max} tab stops अधिकतम {max} टैब स्टॉप @msg {label} must be a number at most {max} {label} सबसे {max} पर एक नंबर होना चाहिए @msg {label} must be a number from -1296 to 1296 {label} को -1296 से 1296 के बीच की संख्या होना चाहिए @msg {label} must be a number from {min} to {max} {label} को {min} से {max} तक एक नंबर होना चाहिए @@ -5900,6 +5903,7 @@ antiAlias Sharp शार्प @msg `status` must be linked, embedded, missing or modified, or a list of them `status` को लिंक, एम्बेडेड, लापता या संशोधित किया जाना चाहिए, या उनमें से एक सूची @msg `stop` must be a stop index `stop` एक स्टॉप इंडेक्स होना चाहिए @msg `stops` must be an array `stops` एक सारणी होना चाहिए +@msg `{key}` must be an array `{key}` एक सारणी होना चाहिए @msg `story` styles the whole story: give no `start` or `end` `story` पूरी स्टोरी को स्टाइल करता है: `start` या `end` न दें @msg `story` takes the whole story: give no `start` or `end` `story` पूरी स्टोरी लेता है: `start` या `end` न दें @msg `strokeMode` applies to strokes (stroke: true) `strokeMode` स्ट्रोक पर लागू होता है (stroke: true) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c60668d35..5c7abf4b5 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4759,6 +4759,7 @@ imageTrace Outline Körvonal @msg `globalAdjust` must be an object `globalAdjust`: objektum kell @msg `sampleSize` must be 1, 3 or 5 `sampleSize`: 1, 3 vagy 5 kell @msg `stops` must be an array `stops`: tömb kell +@msg `{key}` must be an array `{key}`: tömb kell @msg `svg` must be an object of SVG options (see document.formats) `svg`: SVG-beállításokat tartalmazó objektum kell (lásd: document.formats) @msg `to` must be "corner" or "smooth" `to`: "corner" vagy "smooth" kell @msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference`: "emBox", "capHeight", "xHeight" vagy "icfBox" kell @@ -6494,6 +6495,10 @@ anchor Points: Pontok: @msg features lists at most {max} tags a features legfeljebb {max} címkét tartalmazhat @msg unknown OpenType feature `{t}` ismeretlen OpenType-funkció: `{t}` @msg {label} must be a number above 0 and at most {max} {label} 0-nál nagyobb és legfeljebb {max} értékű szám kell legyen +@msg {label} must be at most {max} characters {label} legfeljebb {max} karakter lehet +@msg {label} must be one character {label} pontosan egy karakter kell legyen +@msg at most {max} tab stops legfeljebb {max} tabulátor +@msg unknown align `{other}` ismeretlen igazítás: `{other}` @msg no character style `{name}` A(z) `{name}` karakterstílus nem található # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9711fbd15..8cf28b15d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4302,6 +4302,7 @@ axis Both Entrambi @msg `globalAdjust` must be an object `globalAdjust` deve essere un oggetto @msg `sampleSize` must be 1, 3 or 5 `sampleSize` deve essere 1, 3 o 5 @msg `stops` must be an array `stops` deve essere un array +@msg `{key}` must be an array `{key}` deve essere un array @msg `svg` must be an object of SVG options (see document.formats) `svg` deve essere un oggetto di opzioni SVG (vedi document.formats) @msg `{_1}` is no save command `{_1}` non è un comando di salvataggio @msg `{area}` is no recovery area name `{area}` non è il nome di un'area di recupero @@ -6138,6 +6139,10 @@ anchor Points: Punti: @msg features lists at most {max} tags features contiene al massimo {max} tag @msg unknown OpenType feature `{t}` funzione OpenType sconosciuta `{t}` @msg {label} must be a number above 0 and at most {max} {label} deve essere un numero maggiore di 0 e al massimo {max} +@msg {label} must be at most {max} characters {label} deve avere al massimo {max} caratteri +@msg {label} must be one character {label} deve essere un solo carattere +@msg at most {max} tab stops al massimo {max} tabulazioni +@msg unknown align `{other}` allineamento sconosciuto `{other}` @msg no character style `{name}` Stile di carattere `{name}` non trovato # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 571df3519..19c379a84 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4400,6 +4400,7 @@ swatch Bright Violet ビビッドバイオレット @msg `preserve` must be an object `preserve` にはオブジェクトを指定してください @msg `globalAdjust` must be an object `globalAdjust` にはオブジェクトを指定してください @msg `stops` must be an array `stops` には配列を指定してください +@msg `{key}` must be an array `{key}` には配列を指定してください @msg `{k}` must be [x, y] `{k}` は [x, y] の形式で指定してください @msg a gradient needs at least two stops グラデーションには 2 つ以上の分岐点が必要です @msg give both `start` and `end` [x, y] (or neither) `start` と `end` は両方とも [x, y] で指定するか、どちらも省略してください @@ -6203,6 +6204,10 @@ anchor Points: ポイント: @msg features lists at most {max} tags features に指定できるタグは {max} 個までです @msg unknown OpenType feature `{t}` 不明な OpenType 機能 `{t}` @msg {label} must be a number above 0 and at most {max} {label} には 0 より大きく {max} 以下の数値を指定してください +@msg {label} must be at most {max} characters {label} には {max} 文字以内で指定してください +@msg {label} must be one character {label} には 1 文字を指定してください +@msg at most {max} tab stops タブストップは {max} 個までです +@msg unknown align `{other}` 不明な揃え `{other}` @msg no character style `{name}` 文字スタイル `{name}` が見つかりません # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 98281d92d..4ece97cb9 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4033,6 +4033,7 @@ font-technology Variable ცვლადი @msg `globalAdjust` must be an object `globalAdjust` უნდა იყოს ობიექტი @msg `sampleSize` must be 1, 3 or 5 `sampleSize` უნდა იყოს 1, 3 ან 5 @msg `stops` must be an array `stops` უნდა იყოს მასივი +@msg `{key}` must be an array `{key}` უნდა იყოს მასივი @msg `svg` must be an object of SVG options (see document.formats) `svg` უნდა იყოს SVG-ის პარამეტრების ობიექტი (იხ. document.formats) @msg `{_1}` is no save command `{_1}` შენახვის ბრძანება არ არის @msg `{area}` is no recovery area name `{area}` აღდგენის არის სახელი არ არის @@ -6536,6 +6537,10 @@ anchor Points: წერტილები: @msg features lists at most {max} tags features შეიძლება შეიცავდეს არაუმეტეს {max} ტეგს @msg unknown OpenType feature `{t}` უცნობი OpenType შესაძლებლობა `{t}` @msg {label} must be a number above 0 and at most {max} {label} უნდა იყოს 0-ზე მეტი და არაუმეტეს {max}-ისა რიცხვი +@msg {label} must be at most {max} characters {label} უნდა შეიცავდეს არაუმეტეს {max} სიმბოლოს +@msg {label} must be one character {label} უნდა იყოს ერთი სიმბოლო +@msg at most {max} tab stops არაუმეტეს {max} ტაბულაციის გაჩერება +@msg unknown align `{other}` უცნობი სწორება `{other}` @msg no character style `{name}` სიმბოლოს სტილი `{name}` ვერ მოიძებნა # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index cad90f44e..e8849fb1a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4327,6 +4327,7 @@ theme Appearance 외관 @msg `preserve` must be an object `preserve`는 개체여야 합니다 @msg `globalAdjust` must be an object `globalAdjust`는 개체여야 합니다 @msg `stops` must be an array `stops`는 배열이어야 합니다 +@msg `{key}` must be an array `{key}`은(는) 배열이어야 합니다 @msg `{k}` must be [x, y] `{k}`은(는) [x, y]여야 합니다 @msg a gradient needs at least two stops 그레이디언트에는 정지점이 두 개 이상 필요합니다 @msg give both `start` and `end` [x, y] (or neither) `start`와 `end` [x, y]를 모두 지정하세요(또는 둘 다 생략) @@ -6099,6 +6100,10 @@ anchor Points: 포인트: @msg features lists at most {max} tags features에는 태그를 최대 {max}개까지 지정할 수 있습니다 @msg unknown OpenType feature `{t}` 알 수 없는 OpenType 기능 `{t}` @msg {label} must be a number above 0 and at most {max} {label}은(는) 0 초과 {max} 이하의 숫자여야 합니다 +@msg {label} must be at most {max} characters {label}은(는) 최대 {max}자여야 합니다 +@msg {label} must be one character {label}은(는) 한 글자여야 합니다 +@msg at most {max} tab stops 탭 정지는 최대 {max}개까지 지정할 수 있습니다 +@msg unknown align `{other}` 알 수 없는 정렬 `{other}` @msg no character style `{name}` 문자 스타일 `{name}`을 찾을 수 없습니다 # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 1e4ff15ac..e3d39e33e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4383,6 +4383,7 @@ imageTrace Outline Kontur @msg `globalAdjust` must be an object `globalAdjust` musi być obiektem @msg `sampleSize` must be 1, 3 or 5 `sampleSize` musi wynosić 1, 3 lub 5 @msg `stops` must be an array `stops` musi być tablicą +@msg `{key}` must be an array `{key}` musi być tablicą @msg `svg` must be an object of SVG options (see document.formats) `svg` musi być obiektem opcji SVG (patrz document.formats) @msg `{_1}` is no save command `{_1}` nie jest poleceniem zapisu @msg `{area}` is no recovery area name `{area}` nie jest nazwą obszaru odzyskiwania @@ -6365,6 +6366,10 @@ anchor Points: Punkty: @msg features lists at most {max} tags features może zawierać najwyżej {max} znaczników @msg unknown OpenType feature `{t}` nieznana funkcja OpenType `{t}` @msg {label} must be a number above 0 and at most {max} {label} musi być liczbą większą od 0 i nie większą niż {max} +@msg {label} must be at most {max} characters {label} może mieć najwyżej {max} znaków +@msg {label} must be one character {label} musi być jednym znakiem +@msg at most {max} tab stops najwyżej {max} tabulatorów +@msg unknown align `{other}` nieznane wyrównanie `{other}` @msg no character style `{name}` Nie znaleziono stylu znakowego `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 028582361..e870f39fc 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3798,6 +3798,7 @@ axis Both Ambos @msg `globalAdjust` must be an object `globalAdjust` deve ser um objeto @msg `sampleSize` must be 1, 3 or 5 `sampleSize` deve ser 1, 3 ou 5 @msg `stops` must be an array `stops` deve ser uma matriz +@msg `{key}` must be an array `{key}` deve ser uma matriz @msg `svg` must be an object of SVG options (see document.formats) `svg` deve ser um objeto com opções de SVG (veja document.formats) @msg `{_1}` is no save command `{_1}` não é um comando de salvar @msg `{area}` is no recovery area name `{area}` não é o nome de uma área de recuperação @@ -6426,6 +6427,10 @@ anchor Points: Pontos: @msg features lists at most {max} tags features aceita no máximo {max} marcas @msg unknown OpenType feature `{t}` recurso OpenType desconhecido `{t}` @msg {label} must be a number above 0 and at most {max} {label} deve ser um número maior que 0 e no máximo {max} +@msg {label} must be at most {max} characters {label} deve ter no máximo {max} caracteres +@msg {label} must be one character {label} deve ser um único caractere +@msg at most {max} tab stops no máximo {max} paradas de tabulação +@msg unknown align `{other}` alinhamento desconhecido `{other}` @msg no character style `{name}` Estilo de caractere `{name}` não encontrado # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 342d9884e..20baacc5f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4288,6 +4288,7 @@ axis Both Оба @msg `globalAdjust` must be an object `globalAdjust` должно быть объектом @msg `sampleSize` must be 1, 3 or 5 `sampleSize` должно быть 1, 3 или 5 @msg `stops` must be an array `stops` должно быть массивом +@msg `{key}` must be an array `{key}` должно быть массивом @msg `svg` must be an object of SVG options (see document.formats) `svg` должно быть объектом параметров SVG (см. document.formats) @msg `{_1}` is no save command `{_1}` не является командой сохранения @msg `{area}` is no recovery area name `{area}` не является именем области восстановления @@ -6487,6 +6488,10 @@ anchor Points: Точки: @msg features lists at most {max} tags features содержит не более {max} тегов @msg unknown OpenType feature `{t}` неизвестная функция OpenType `{t}` @msg {label} must be a number above 0 and at most {max} {label} должно быть числом больше 0 и не более {max} +@msg {label} must be at most {max} characters {label} должно содержать не более {max} символов +@msg {label} must be one character {label} должно быть одним символом +@msg at most {max} tab stops не более {max} позиций табуляции +@msg unknown align `{other}` неизвестное выравнивание `{other}` @msg no character style `{name}` Стиль символов `{name}` не найден # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index cca9d052c..3eca0248c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4161,6 +4161,7 @@ axis Both Båda @msg `globalAdjust` must be an object `globalAdjust` måste vara ett objekt @msg `sampleSize` must be 1, 3 or 5 `sampleSize` måste vara 1, 3 eller 5 @msg `stops` must be an array `stops` måste vara en array +@msg `{key}` must be an array `{key}` måste vara en array @msg `svg` must be an object of SVG options (see document.formats) `svg` måste vara ett objekt med SVG-alternativ (se document.formats) @msg `{_1}` is no save command `{_1}` är inget sparkommando @msg `{area}` is no recovery area name `{area}` är inget namn på ett återställningsområde @@ -6556,6 +6557,9 @@ shape Open Öppen @msg features lists at most {max} tags features får innehålla högst {max} taggar @msg unknown OpenType feature `{t}` okänd OpenType-funktion `{t}` @msg {label} must be a number above 0 and at most {max} {label} måste vara ett tal över 0 och högst {max} +@msg {label} must be at most {max} characters {label} får innehålla högst {max} tecken +@msg {label} must be one character {label} måste vara ett tecken +@msg at most {max} tab stops högst {max} tabbstopp @msg no character style `{name}` Teckenformatet `{name}` hittades inte # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 3a2177b40..45ae89ed7 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4041,6 +4041,7 @@ axis Both Her İkisi @msg `globalAdjust` must be an object `globalAdjust` bir nesne olmalıdır @msg `sampleSize` must be 1, 3 or 5 `sampleSize` 1, 3 veya 5 olmalıdır @msg `stops` must be an array `stops` bir dizi olmalıdır +@msg `{key}` must be an array `{key}` bir dizi olmalıdır @msg `svg` must be an object of SVG options (see document.formats) `svg`, SVG seçeneklerinin bir nesnesi olmalıdır (bkz. document.formats) @msg `{_1}` is no save command `{_1}` kaydetme komutu değil @msg `{area}` is no recovery area name `{area}`, kurtarma alanı adı değildir @@ -6466,6 +6467,10 @@ anchor Points: Noktalar: @msg features lists at most {max} tags features en fazla {max} etiket içerebilir @msg unknown OpenType feature `{t}` bilinmeyen OpenType özelliği `{t}` @msg {label} must be a number above 0 and at most {max} {label} 0'ın üzerinde ve en fazla {max} olan bir sayı olmalıdır +@msg {label} must be at most {max} characters {label} en fazla {max} karakter olmalıdır +@msg {label} must be one character {label} tek bir karakter olmalıdır +@msg at most {max} tab stops en fazla {max} sekme durağı +@msg unknown align `{other}` bilinmeyen hizalama `{other}` @msg no character style `{name}` `{name}` karakter stili bulunamadı # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 67df48310..9801460d1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4286,6 +4286,7 @@ axis Both Обидві @msg `globalAdjust` must be an object `globalAdjust` має бути об’єктом @msg `sampleSize` must be 1, 3 or 5 `sampleSize` має бути 1, 3 або 5 @msg `stops` must be an array `stops` має бути масивом +@msg `{key}` must be an array `{key}` має бути масивом @msg `svg` must be an object of SVG options (see document.formats) `svg` має бути об’єктом параметрів SVG (див. document.formats) @msg `{_1}` is no save command `{_1}` не є командою збереження @msg `{area}` is no recovery area name `{area}` не є ім’ям області відновлення @@ -6118,6 +6119,10 @@ anchor Points: Точки: @msg features lists at most {max} tags features містить не більше {max} тегів @msg unknown OpenType feature `{t}` невідома функція OpenType `{t}` @msg {label} must be a number above 0 and at most {max} {label} має бути числом більше 0 і не більше {max} +@msg {label} must be at most {max} characters {label} має містити не більше {max} символів +@msg {label} must be one character {label} має бути одним символом +@msg at most {max} tab stops не більше {max} позицій табуляції +@msg unknown align `{other}` невідоме вирівнювання `{other}` @msg no character style `{name}` Стиль символів `{name}` не знайдено # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 174b7c7dc..1a642bb61 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4420,6 +4420,7 @@ theme Appearance Giao diện @msg `globalAdjust` must be an object `globalAdjust` phải là một đối tượng @msg `sampleSize` must be 1, 3 or 5 `sampleSize` phải là 1, 3 hoặc 5 @msg `stops` must be an array `stops` phải là một mảng +@msg `{key}` must be an array `{key}` phải là một mảng @msg `svg` must be an object of SVG options (see document.formats) `svg` phải là đối tượng tùy chọn SVG (xem document.formats) @msg `to` must be "corner" or "smooth" `to` phải là "corner" hoặc "smooth" @msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` phải là "emBox", "capHeight", "xHeight" hoặc "icfBox" @@ -6119,6 +6120,10 @@ anchor Points: Điểm: @msg features lists at most {max} tags features chứa tối đa {max} thẻ @msg unknown OpenType feature `{t}` tính năng OpenType không xác định `{t}` @msg {label} must be a number above 0 and at most {max} {label} phải là một số lớn hơn 0 và tối đa {max} +@msg {label} must be at most {max} characters {label} chỉ được có tối đa {max} ký tự +@msg {label} must be one character {label} phải là một ký tự +@msg at most {max} tab stops tối đa {max} điểm dừng tab +@msg unknown align `{other}` căn chỉnh không xác định `{other}` @msg no character style `{name}` Không tìm thấy kiểu ký tự `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 515600d03..0cd303e14 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4108,6 +4108,7 @@ imageTrace Outline 轮廓 @msg `preserve` must be an object `preserve` 必须是对象 @msg `globalAdjust` must be an object `globalAdjust` 必须是对象 @msg `stops` must be an array `stops` 必须是数组 +@msg `{key}` must be an array `{key}` 必须是数组 @msg `{k}` must be [x, y] `{k}` 必须是 [x, y] @msg a gradient needs at least two stops 渐变至少需要两个色标 @msg give both `start` and `end` [x, y] (or neither) 请同时给出 `start` 和 `end` [x, y] (或都不给出) @@ -6351,6 +6352,10 @@ anchor Points: 点: @msg features lists at most {max} tags features 最多包含 {max} 个标签 @msg unknown OpenType feature `{t}` 未知的 OpenType 特性 `{t}` @msg {label} must be a number above 0 and at most {max} {label} 必须是大于 0 且不超过 {max} 的数字 +@msg {label} must be at most {max} characters {label} 最多 {max} 个字符 +@msg {label} must be one character {label} 必须是一个字符 +@msg at most {max} tab stops 最多 {max} 个制表位 +@msg unknown align `{other}` 未知的对齐方式 `{other}` @msg no character style `{name}` 找不到字符样式 `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 31472f0d4..5ddd837a9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1902,6 +1902,7 @@ file-filter Templates 範本 @msg `preserve` must be an object `preserve` 必須是物件 @msg `globalAdjust` must be an object `globalAdjust` 必須是物件 @msg `stops` must be an array `stops` 必須是陣列 +@msg `{key}` must be an array `{key}` 必須是陣列 @msg `{k}` must be [x, y] `{k}` 必須是 [x, y] @msg a gradient needs at least two stops 漸層至少需要兩個色標 @msg give both `start` and `end` [x, y] (or neither) 請同時提供 `start` 和 `end` [x, y] (或兩者都不提供) @@ -6156,6 +6157,10 @@ anchor Points: 點: @msg features lists at most {max} tags features 最多包含 {max} 個標籤 @msg unknown OpenType feature `{t}` 未知的 OpenType 功能 `{t}` @msg {label} must be a number above 0 and at most {max} {label} 必須是大於 0 且不超過 {max} 的數字 +@msg {label} must be at most {max} characters {label} 最多 {max} 個字元 +@msg {label} must be one character {label} 必須是一個字元 +@msg at most {max} tab stops 最多 {max} 個定位點 +@msg unknown align `{other}` 未知的對齊方式 `{other}` @msg no character style `{name}` 找不到字元樣式 `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 7e58a218a..fb12e4fdc 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -20,7 +20,7 @@ const ALIGNS: [(TabAlign, &str); 4] = [(TabAlign::Left, "⇥"), (TabAlign::Cente /// The name of alignment `a`'s button: its tool tip and what screen readers call it. A line of /// vertical type runs from top to bottom, so in vertical type a Left stop is named Top-Justified /// and a Right stop is named Bottom-Justified. -fn align_name(a: TabAlign, vertical: bool) -> &'static str { +pub(crate) fn align_name(a: TabAlign, vertical: bool) -> &'static str { match (a, vertical) { (TabAlign::Left, false) => tl!("Left-Justified Tab"), (TabAlign::Left, true) => tl!("Top-Justified Tab"), diff --git a/docs/mcp.md b/docs/mcp.md index aeed51157..335445a3f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7807,7 +7807,9 @@ touches (a caret, `start == end`, touches its paragraph). Without a range they c targeted objects, as before. With a range, the character attributes of `text.setStyle` and `text.setFormat` style that range of characters. When every paragraph that `text.tabs.set` or `text.tabs.clear` targets already has those stops, the command changes nothing and records no undo step. `text.tabs.set` rejects a supplied `align`, `leader`, or -`alignOn` value that is not a string, and an empty `alignOn` value. +`alignOn` value that is not a string, an empty `alignOn` value or one of more than one character, a leader of more than +8 characters, a position outside 0 to 100000 pt, and more than 256 stops. It sorts the stops by position and keeps the +first of stops at one position. `text.tabs.get {start?}` and `paraStyle.new` / `paraStyle.redefine {id, start?}` read the paragraph at `start`; `paraStyle.list` counts paragraphs as uses, and `charStyle.list` counts runs; both count the type in @@ -7824,7 +7826,7 @@ is the document's. A loaded style that is the same as the style of its name, or of its name with a number (`Body 2`), is not added again; any other is added under a free name. The document's styles and the type that uses them keep their attributes, and the file's Normal styles are not loaded. The reply lists the names `added` and `existing` in the document and the names `skipped` in the file (a style whose -attributes do not read or are out of the range `charStyle.new` takes, or whose name is empty); `textStyle.loadAll` +attributes do not read or are out of the range `charStyle.new` or `paraStyle.new` takes, or whose name is empty); `textStyle.loadAll` replies `{charStyles, paraStyles}`, and `charStyle.load` and `paraStyle.load` list only the styles of their own kind. A Return the Type tool types (or `text.editRange` inserting `\n`) continues the style of the paragraph it splits; deleting a break keeps the first paragraph's style; `text.setText` gives every paragraph the first one's attributes. Threaded text keeps each paragraph's attributes as @@ -7889,12 +7891,19 @@ the Paragraph Styles panel, or Paragraph Style Options… in its menu, does the `paraStyle.edit {name, to, attrs}`, which takes the same arguments as `charStyle.edit` and replaces the style's name and attributes in one undo step; the paragraphs that use the style keep their local overrides. OK with no field changed records no undo step, and Preview updates the paragraphs while the dialog is open (Cancel restores them). -The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `composition`, `hyphenation` or +The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `tabs`, `composition`, `hyphenation` or `justification`) and `preview`, and these fields: - Basic Paragraph Formats: `justify` (`Auto`, `Left`, `Center`, `Right`, `JustifyLeft`, `JustifyCenter`, `JustifyRight` or `JustifyAll`), and `left_indent`, `right_indent`, `first_line_indent`, `space_before` and `space_after`, typed in the General unit of Preferences or with a unit of their own. +- Tabs: `tabs`, the style's stops, each `{position, align, leader, alignOn}`: `position` typed like the distances + above, `align` `left`, `center`, `right` or `decimal`, `leader` up to 8 characters, and `alignOn` one character + (used by decimal stops). The section edits the selected stop with Alignment, X: (Y: for vertical type), Leader: + and Align On:. Add puts a left stop 36 pt past the furthest stop (at 36 pt when there is none, and at most at + 100000 pt); it is disabled at 256 stops or when a stop is already at that position. Delete Tab removes the selected + stop, and Clear All Tabs leaves the style with no stops, which it then sets. OK sorts the stops by + position; of two stops at one position it keeps the one edited last. - Composition: `composer` (`singleLine` or `everyLine`) and the `roman_hanging` checkbox. - Hyphenation: the `hyphenate` checkbox, `words_longer_than`, `after_first`, `before_last`, `hyphen_limit`, `hyphenation_zone` and the `hyphenate_capitalized` checkbox. The options are inactive while Hyphenation is off, and @@ -7905,15 +7914,17 @@ The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `co A field left as it was keeps the attribute as the style has it, and an empty field leaves its attribute unset; an empty Hyphenation or Justification field takes its default while another field of that section is set. Attributes -the dialog does not show, such as tabs and character attributes, stay as they are. +the dialog does not show, such as character attributes, stay as they are. `paraStyle.new`, `paraStyle.setAttrs` and `paraStyle.edit` reject a changed indent, space before or space after outside -1296 to 1296 pt, Words Longer Than outside 1 to 25, After First or Before Last outside 1 to 15, Hyphen Limit outside 0 to 25, Hyphenation Zone outside 0 to 1296 pt, Word Spacing outside 0 to 1000%, Letter Spacing outside -100 to 500%, Glyph Scaling outside 50 to 200%, Minimum, Desired and Maximum values out of that order, Auto Leading outside 0 to -500%, and a tab position outside 0 to 100000 pt. A value the style already has is kept. An invalid value leaves the +500%, and changed tabs with more than 256 stops, or with a changed stop whose position is outside 0 to 100000 pt or +whose leader is longer than 8 characters. They sort changed tabs by position and keep the first of stops at one +position. A value the style already has is kept. An invalid value leaves the style, its paragraphs and Undo history unchanged, and keeps the dialog open. Reset Panel removes the section's attributes from the definition: Basic Paragraph Formats removes `justify` and the -five distances, Composition removes `composer` and `roman_hanging`, Hyphenation removes `hyphenate` and +five distances, Tabs removes `tabs`, Composition removes `composer` and `roman_hanging`, Hyphenation removes `hyphenate` and `hyphenation`, and Justification removes `justification`. `ui.paraStyleOptions.resetPanel {}` performs that reset for the section selected in Paragraph Style Options or New Paragraph Style; it is disabled on General. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2bf314ce8..7b06a3804 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -641,7 +641,8 @@ since 2026-10-01. - **Character Style Options, Character Color and OpenType Features:** Character Style Options and New Character Style set a style's fill and stroke (None or a document swatch; a global swatch stays linked, with a Tint from 0% to 100%), stroke Weight, Overprint Fill and Overprint Stroke, and its Figure, Position, Standard and Discretionary Ligatures, Contextual, Stylistic and Titling Alternates, Swash, Ordinals, Fractions and Stylistic Sets 1 to 20; text that uses the style takes them and keeps its local overrides, and editing a global swatch later updates the style and its text. Empty controls remain unset, an explicit None, off, Default Figure or Default Position stays distinct from unset, each stylistic set is on, off or unset, a gradient or pattern paint stays until another paint is chosen, a changed OpenType control keeps the style's other feature tags, and each section's Reset Panel clears only its own attributes. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html). - **Opening style options from the style panels:** a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), as the panel menu's item does; a paragraph style's row opens its Paragraph Style Options (`ui.paraStyleOptions`), as the panel menu's item does. A single click still applies the style, the second click of the double-click does not apply it again, and the built-in Normal style opens no dialog. Test `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`. - **Character Style Options window size:** the dialog keeps a fixed width wide enough for the widest tab's controls and scrolls its sections inside a window shorter than them, so it stays on screen and its OK and Cancel buttons stay visible; the width is the same in every section and the height follows the section. Test `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. -- **Paragraph Style Options, General, Basic Paragraph Formats, Composition, Hyphenation and Justification:** Paragraph Style Options (`ui.paraStyleOptions`, `paraStyle.edit`) and New Paragraph Style edit a style's name, Alignment, left, right and first-line indents, space before and after, composer, Roman Hanging Punctuation, Hyphenation with its options, and Word Spacing, Letter Spacing, Glyph Scaling, Auto Leading and Single Word Justification; paragraphs that use the style take them and keep their local overrides. Fields left as they were keep the style's values, empty fields remain unset, each section's Reset Panel clears only its own attributes, Preview can be canceled without changing history, OK saves one undo step, and out-of-range values keep the dialog open. Tabs and the character formats of a paragraph style are not in the dialog yet. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). +- **Paragraph Style Options, General, Basic Paragraph Formats, Composition, Hyphenation and Justification:** Paragraph Style Options (`ui.paraStyleOptions`, `paraStyle.edit`) and New Paragraph Style edit a style's name, Alignment, left, right and first-line indents, space before and after, composer, Roman Hanging Punctuation, Hyphenation with its options, and Word Spacing, Letter Spacing, Glyph Scaling, Auto Leading and Single Word Justification; paragraphs that use the style take them and keep their local overrides. Fields left as they were keep the style's values, empty fields remain unset, each section's Reset Panel clears only its own attributes, Preview can be canceled without changing history, OK saves one undo step, and out-of-range values keep the dialog open. The character formats of a paragraph style are not in the dialog yet. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). +- **Paragraph Style Options, Tabs:** the Tabs section of Paragraph Style Options and New Paragraph Style lists a style's tab stops and edits the selected stop's Alignment (Left, Center, Right or Decimal), position (X:, or Y: for vertical type), Leader and Align On character; Add, Delete Tab and Clear All Tabs add a stop 36 pt past the furthest, remove the selected one, or leave the style with no stops. OK sorts the stops by position, keeps the stop edited last of two at one position, and saves one undo step; Preview and Cancel restore the paragraphs, Reset Panel removes the style's tabs, and a position outside 0 to 100000 pt, a leader longer than 8 characters or more than 256 stops keeps the dialog open. Tests `dialogs::para_style_options::tests::tabs_section_edits_the_style_stops` and `tabs_preview_cancel_and_ok_update_the_paragraphs_in_one_undo_step`. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Text styles redefined to the defaults:** New and Redefine Character Style and Paragraph Style capture every attribute of the selected text, including those at their defaults (Auto kerning, Roman baseline alignment, the default Hyphenation options, the Every-line Composer, no tab stops), and `charStyle.setAttrs` and `paraStyle.setAttrs` keep an attribute set to its default value. After a redefinition, text whose value still equals the old definition's takes the new value, a default included; local overrides stay, and text that still has the old value of an attribute the new definition leaves out goes back to the Normal style's value. The style panels show "+" only where the text differs from its style, including type with Auto alignment. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Text styles dragged onto the panel's buttons:** a style dragged from the Character Styles or Paragraph Styles panel onto Create New Style is copied (`charStyle.duplicate`, `paraStyle.duplicate`, which name the copy ` copy`) and the copy is highlighted; dragged onto Delete it is deleted (`charStyle.delete`, `paraStyle.delete`); the Normal style is not deleted. Each drop is one undo step. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Auto Leading outside the layout:** a paragraph's Justification… › Auto Leading () also sets the Leading the Character panel shows for Auto type (blank where the selected paragraphs differ), the leading the Alt+↑/↓ nudge (`type.step`) steps from, each paragraph's own, and the lines Fill with Placeholder Text counts in area type. Evidence: `ParaStyle::effective_leading` (`crates/doc/src/text.rs` test `paragraph_effective_leading_follows_auto_leading`), `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading`.