diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index 72b066a06..f3b655d76 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -136,9 +136,11 @@ impl crate::Document { } } -/// The character style a graph's labels keep through regeneration once formatted (Format the text in graphs): the -/// value axis numbers, the category labels (category axis, radar spokes, pie names) and the legend labels (beside or -/// across the top, or in pie wedges). `None` draws the default 9 pt black. +/// The character style and anti-aliasing method a graph's labels keep through regeneration once formatted (Format +/// the text in graphs): the value axis numbers, the category labels (category axis, radar spokes, pie names) and the +/// legend labels (beside or across the top, or in pie wedges). `None` draws the default 9 pt black. The `*_para` +/// fields are the paragraph attributes each kind keeps the same way (`None`: the defaults); the graph still sets each +/// label's alignment. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct LabelStyles { @@ -148,6 +150,22 @@ pub struct LabelStyles { pub categories: Option, #[serde(skip_serializing_if = "Option::is_none")] pub legend: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub values_para: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub categories_para: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub legend_para: Option, + /// The anti-aliasing method the value axis numbers keep once set (Character panel › Anti-aliasing Method). + /// `None` draws them Sharp. + #[serde(skip_serializing_if = "Option::is_none")] + pub values_anti_alias: Option, + /// The anti-aliasing method the category labels keep once set. `None` draws them Sharp. + #[serde(skip_serializing_if = "Option::is_none")] + pub categories_anti_alias: Option, + /// The anti-aliasing method the legend labels keep once set. `None` draws them Sharp. + #[serde(skip_serializing_if = "Option::is_none")] + pub legend_anti_alias: Option, } impl LabelStyles { diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 8c838d421..f4b6c90c1 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -43,8 +43,8 @@ use generate::generate; #[cfg(test)] use series::default_series_color; pub(crate) use series::{ - capture_label_styles, capture_series_object_opacity, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets, - series_object_opacity_differs, shown_paint, + capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity, + capture_series_paints, capture_series_top_paints, paint_targets, series_object_opacity_differs, shown_paint, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index d1a5ddde0..a789304b2 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -2,7 +2,8 @@ use super::axis::{LABEL_SIZE, MAX_CATEGORY_TICKS, MAX_TICK_MARKS, one_line, rada use super::columns::{Allowance, ColumnArt, column_art, column_designs}; use super::data::fmt_value; use super::labels::{ - Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_size, label_width, polyline, recolour_text, runs, shadow_of, + Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_extra_height, label_shift, label_size, label_width, polyline, + recolour_text, runs, shadow_of, }; use super::markers::{MARKER_BUDGET, marker_cost, marker_designs}; use super::series::{series_marks, with_series_opacity}; @@ -901,18 +902,21 @@ impl<'a> Plot<'a> { fn legend_label(&mut self, (x, y): (f64, f64), label: &str) -> Arc { let ls = self.ls; + let (dx, dy) = label_shift(&self.g.label_styles, label, self.b.d.setup.system_layout); + let (x, y) = (x + dx, y + dy); self.b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), label, Justify::Left, false) } /// Add Legend Across Top: swatch and label after swatch and label, in rows as wide as the plot, the last row /// just above it. fn legend_across_top(&mut self) -> Vec> { - let (g, r, ls) = (self.g, self.r, self.ls); + let (g, r, ls, system_layout) = (self.g, self.r, self.ls, self.b.d.setup.system_layout); let entries: Vec<(usize, String)> = (0..self.legend_series()).map(|s| (s, self.legend_text(s))).filter(|(_, l)| !blank_label(l)).collect(); - let mut rows: Vec> = vec![vec![]]; + let mut rows: Vec> = vec![vec![]]; let mut used = 0.0; for (s, label) in entries { - let w = 12.0 + label_width(&g.label_styles, &label) + 12.0; + let w = 12.0 + label_width(&g.label_styles, &label, system_layout) + 12.0; + let h = ls * 1.8 * label.split('|').count() as f64 + label_extra_height(&g.label_styles, &label, system_layout); // The gap after an entry may run past the plot's edge. if used + w - 12.0 > r.width() && rows.last().is_some_and(|row| !row.is_empty()) { rows.push(vec![]); @@ -920,10 +924,10 @@ impl<'a> Plot<'a> { } used += w; if let Some(row) = rows.last_mut() { - row.push((s, label, w)); + row.push((s, label, w, h)); } } - let height = |row: &[(usize, String, f64)]| ls * 1.8 * row.iter().map(|(_, l, _)| l.split('|').count()).max().unwrap_or(1) as f64; + let height = |row: &[(usize, String, f64, f64)]| row.iter().map(|(.., h)| *h).fold(ls * 1.8, f64::max); // Above the plot and anything drawn over it (radar and stacked-pie labels). let top = self.b.out.iter().chain(self.on_top.iter()).filter_map(|n| n.geometric_bounds()).map(|bb| bb.y0).fold(r.y0, f64::min); let top = self.bars_reach.map_or(top, |b| top.min(b.y0)); @@ -931,7 +935,7 @@ impl<'a> Plot<'a> { let mut labels = vec![]; for row in &rows { let mut x = r.x0; - for (s, label, w) in row { + for (s, label, w, _) in row { self.legend_swatch(*s, (x, y)); labels.push(self.legend_label((x, y), label)); x += w; @@ -954,7 +958,7 @@ impl<'a> Plot<'a> { y += ls * 1.8; continue; } - y += ls * 1.8 * label.split('|').count() as f64; + y += ls * 1.8 * label.split('|').count() as f64 + label_extra_height(&self.g.label_styles, &label, self.b.d.setup.system_layout); self.legend_swatch(s, (x, row_y)); labels.push(self.legend_label((x, row_y), &label)); } diff --git a/crates/engine/src/cmd/graph/labels.rs b/crates/engine/src/cmd/graph/labels.rs index fdd640a95..9d521c5d9 100644 --- a/crates/engine/src/cmd/graph/labels.rs +++ b/crates/engine/src/cmd/graph/labels.rs @@ -10,7 +10,7 @@ use super::{ pub(super) struct Gen<'a> { pub(super) d: &'a mut Document, pub(super) out: Vec>, - /// The labels' kept character styles. + /// The labels' kept character styles and paragraph attributes. pub(super) styles: &'a vectorcraft_doc::LabelStyles, /// What the column designs may still add. pub(super) columns: Allowance, @@ -45,6 +45,34 @@ pub(super) fn label_style(styles: &vectorcraft_doc::LabelStyles, role: Role) -> st } +/// The paragraph attributes labels of `role` are set with: the kept ones, else the defaults. The caller sets the +/// alignment. +pub(super) fn label_para(styles: &vectorcraft_doc::LabelStyles, role: Role) -> vectorcraft_doc::ParaStyle { + let mut para = kept_para(styles, role).cloned().unwrap_or_default(); + // A file's attributes within the limits the Paragraph controls keep to (they are copied onto every label). + para.keep_spacing_in_range(); + para +} + +/// The paragraph attributes labels of `role` keep, if any. +fn kept_para(styles: &vectorcraft_doc::LabelStyles, role: Role) -> Option<&vectorcraft_doc::ParaStyle> { + match role { + Role::Value => styles.values_para.as_ref(), + Role::Category => styles.categories_para.as_ref(), + Role::Legend => styles.legend_para.as_ref(), + } +} + +/// The anti-aliasing method labels of `role` are drawn with: the kept one, else Sharp. +pub(super) fn label_anti_alias(styles: &vectorcraft_doc::LabelStyles, role: Role) -> vectorcraft_doc::text::TypeAntiAlias { + let kept = match role { + Role::Value => styles.values_anti_alias, + Role::Category => styles.categories_anti_alias, + Role::Legend => styles.legend_anti_alias, + }; + kept.unwrap_or_default() +} + /// How big labels of `role` are set: a kept style's size, else 9 pt. The labels' spacing follows it. pub(super) fn label_size(styles: &vectorcraft_doc::LabelStyles, role: Role) -> f64 { label_style(styles, role).size @@ -61,19 +89,37 @@ impl Gen<'_> { self.path(PathData::from_bezpath(&bp), Paint::None, Paint::solid(Color::BLACK), 0.5) } pub(super) fn text(&mut self, role: Role, at: Point, s: &str, justify: Justify) -> Arc { + let t = self.text_object(role, at, s, justify); + Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) + } + /// Label `s` of `role` set at `at` and laid out, not yet in the document. + fn text_object(&self, role: Role, at: Point, s: &str, justify: Justify) -> TextObject { let style = label_style(self.styles, role); let mut t = TextObject::point(at, s, style); + t.para = label_para(self.styles, role); + t.anti_alias = label_anti_alias(self.styles, role); t.para.justify = justify; t.system_layout = self.d.setup.system_layout; refresh_bounds(&mut t); - Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) + t } /// A category or series label: a `|` in it starts a new line (Add graph labels). `middle`: the lines are centred /// on `at` (labels beside an axis or in a wedge) instead of running down from it. pub(super) fn label(&mut self, role: Role, at: Point, s: &str, justify: Justify, middle: bool) -> Arc { let lines = s.split('|').count(); - let at = if middle { Point::new(at.x, at.y - (lines - 1) as f64 * label_size(self.styles, role) * 1.2 / 2.0) } else { at }; - self.text(role, at, &s.replace('|', "\n"), justify) + // Default leading puts the first and last baselines this far above and below `at`. + let half = (lines - 1) as f64 * label_size(self.styles, role) * 1.2 / 2.0; + let start = if middle { Point::new(at.x, at.y - half) } else { at }; + let mut t = self.text_object(role, start, &s.replace('|', "\n"), justify); + // Kept leading or paragraph spacing moves the lines: put the middle of the first and last baselines on `at`. An + // empty first or last line has no baseline, and such a label stays where the default leading puts it. + if middle && kept_para(self.styles, role).is_some() { + let ys: Vec = t.baselines().map(|(a, _)| a.y).collect(); + if let (Some(first), Some(last), true) = (ys.first(), ys.last(), ys.len() == lines) { + t.xf = Affine::translate((0.0, half - (first + last) / 2.0)) * t.xf; + } + } + Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) } pub(super) fn group(&mut self, name: &str, children: Vec>) -> Arc { let mut g = Node::group(self.d.alloc_id(), children); @@ -162,12 +208,47 @@ pub(super) fn shadow_of(d: &mut Document, n: &Node) -> Arc { Arc::new(d.reid(&c)) } -/// How wide label `s` is set (its widest line), without adding it to a document. -pub(super) fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str) -> f64 { +/// The bounds of legend label `s` set from the origin with paragraph attributes `para` and the document's System +/// Layout setting `system_layout`, as [`Gen::text`] sets it, without adding it to a document. +fn legend_label_bounds(styles: &vectorcraft_doc::LabelStyles, s: &str, para: vectorcraft_doc::ParaStyle, system_layout: bool) -> Option { let style = label_style(styles, Role::Legend); let mut t = TextObject::point(Point::ZERO, &s.replace('|', "\n"), style); + t.para = para; + t.para.justify = Justify::Left; + t.system_layout = system_layout; refresh_bounds(&mut t); - Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds().map_or(0.0, |b| b.width()) + Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds() +} + +/// How far label `s` reaches right of where it starts (its widest line and any indent), after the legend moves it right +/// by [`label_shift`], without adding it to a document. +pub(super) fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> f64 { + legend_label_bounds(styles, s, label_para(styles, Role::Legend), system_layout) + .map_or(0.0, |b| (b.x1 + label_shift(styles, s, system_layout).0).max(0.0)) +} + +/// The bounds of legend label `s` set with its kept paragraph attributes and with the defaults, when it has kept ones. +fn kept_and_default_bounds(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> Option<(Rect, Rect)> { + styles.legend_para.as_ref()?; + Some(( + legend_label_bounds(styles, s, label_para(styles, Role::Legend), system_layout)?, + legend_label_bounds(styles, s, vectorcraft_doc::ParaStyle::default(), system_layout)?, + )) +} + +/// How much further left and further up legend label `s` reaches with its kept paragraph attributes than with the +/// defaults (a negative indent or negative spacing), so the legend moves it right and down by that much and it stays +/// clear of its swatch and of the entries before it. +pub(super) fn label_shift(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> (f64, f64) { + kept_and_default_bounds(styles, s, system_layout) + .map_or((0.0, 0.0), |(kept, default)| ((default.x0 - kept.x0).max(0.0), (default.y0 - kept.y0).max(0.0))) +} + +/// How much further down legend label `s` reaches with its kept paragraph attributes than with the defaults (more +/// leading, or space before or after), after the legend moves it down by [`label_shift`], so the legend makes room +/// for it. +pub(super) fn label_extra_height(styles: &vectorcraft_doc::LabelStyles, s: &str, system_layout: bool) -> f64 { + kept_and_default_bounds(styles, s, system_layout).map_or(0.0, |(kept, default)| (kept.y1 + (default.y0 - kept.y0).max(0.0) - default.y1).max(0.0)) } /// A label with nothing to show (empty, or only `|` line breaks). diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 7d7d75a16..229a4d4b4 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -1,7 +1,7 @@ use vectorcraft_color::BlendMode; use super::axis::radar_axis; -use super::labels::{Role, filled_lines_on, label_style}; +use super::labels::{Role, filled_lines_on, label_para, label_style}; use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad}; // ---------- generation ---------- @@ -406,23 +406,34 @@ fn graph_labels(doc: &Document) -> HashMap { out } -/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels -/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or -/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so -/// they undo together. Returns whether a kept style changed. -pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool { - if ids.is_empty() { - return false; - } +/// Keep the anti-aliasing method `method` given to graph labels `ids` for every label of their kind, as +/// [`capture_label_styles`] keeps a formatting change. Returns whether a kept method changed. +pub(crate) fn capture_label_anti_alias(doc: &mut Document, ids: &[NodeId], method: vectorcraft_doc::text::TypeAntiAlias) -> bool { let labels = graph_labels(doc); - if labels.is_empty() { - return false; - } let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect(); kinds.sort_by_key(|(g, r)| (g.0, *r as u8)); kinds.dedup(); let mut changed = false; for (graph, role) in kinds { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + let slot = match role { + Role::Value => &mut spec.label_styles.values_anti_alias, + Role::Category => &mut spec.label_styles.categories_anti_alias, + Role::Legend => &mut spec.label_styles.legend_anti_alias, + }; + changed |= *slot != Some(method); + *slot = Some(method); + } + changed +} + +/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels +/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or +/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so +/// they undo together. Returns whether a kept style changed. +pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool { + let mut changed = false; + for (graph, role) in label_kinds(doc, ids) { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; let mut style = label_style(&spec.label_styles, role); change(&mut style); @@ -437,6 +448,42 @@ pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: & changed } +/// Keep a paragraph change made to all of the text of graph labels `ids` (Paragraph, on the whole graph or on labels +/// picked with Group Selection) for every label of their kind, as [`capture_label_styles`] does for character +/// formats. Returns whether kept attributes changed, or the error `change` returns for the kept attributes. +pub(crate) fn capture_label_paragraphs( + doc: &mut Document, + ids: &[NodeId], + change: &dyn Fn(&mut vectorcraft_doc::ParaStyle) -> Result<()>, +) -> Result { + let mut changed = false; + for (graph, role) in label_kinds(doc, ids) { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + let mut para = label_para(&spec.label_styles, role); + change(&mut para)?; + let slot = match role { + Role::Value => &mut spec.label_styles.values_para, + Role::Category => &mut spec.label_styles.categories_para, + Role::Legend => &mut spec.label_styles.legend_para, + }; + changed |= slot.as_ref() != Some(¶); + *slot = Some(para); + } + Ok(changed) +} + +/// The graphs and kinds of label among `ids`, each once. +fn label_kinds(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role)> { + if ids.is_empty() { + return vec![]; + } + let labels = graph_labels(doc); + let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect(); + kinds.sort_by_key(|(g, r)| (g.0, *r as u8)); + kinds.dedup(); + kinds +} + /// The series a command on graph `id` (of `count` series) applies to: `seriesIndexes`, else, with no `id` param, a /// selection made only of this graph's series (Group Selection). Empty: the whole graph. pub(super) fn picked_series(s: &Session, p: &Value, id: NodeId, count: usize, cmd: &str) -> Result> { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 6cb36431e..7f85763a7 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -4055,6 +4055,45 @@ fn formatting_the_whole_graphs_text_survives_it_being_drawn_again() { assert_eq!(spec(&s, id).label_styles, vectorcraft_doc::LabelStyles::default()); } +/// An anti-aliasing method given to one category label is kept for every category label when the graph is drawn +/// again, through save and open, and undoes with the change. +#[test] +fn a_graph_labels_anti_aliasing_method_survives_it_being_drawn_again() { + use vectorcraft_doc::text::TypeAntiAlias; + let methods = |s: &Session, id: NodeId| -> Vec<(String, TypeAntiAlias)> { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut out = vec![]; + for g in ["Axes", "Legend"] { + for c in group(&n, g).children().unwrap() { + if let NodeKind::Text(t) = &c.kind { + out.push((t.plain_text(), t.anti_alias)); + } + } + } + out + }; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("text.setFormat", &json!({"ids": [c.0], "antiAlias": "strong"})).unwrap(); + label_looks(&mut s, id); + let drawn = methods(&s, id); + let method = |t: &str| drawn.iter().find(|(x, _)| x == t).map(|(_, m)| *m).unwrap(); + assert_eq!((method("Q1"), method("Q3")), (TypeAntiAlias::Strong, TypeAntiAlias::Strong), "every category label: {drawn:?}"); + assert_eq!((method("0"), method("2024")), (TypeAntiAlias::Sharp, TypeAntiAlias::Sharp), "other labels: {drawn:?}"); + // One undo takes the data change back, the next the method and the kept method together. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories_anti_alias, None); + s.execute("edit.redo", &json!({})).unwrap(); + let path = vectorcraft_testkit::temp_dir("graph-label-anti-alias").join("labels.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories_anti_alias, Some(TypeAntiAlias::Strong)); + assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none()); +} + /// A stroke given to all of a graph's text without a weight is kept at 1 pt, as `paint.setStroke` keeps it. #[test] fn a_stroke_given_to_the_whole_graphs_text_is_kept_at_1_pt() { @@ -4219,3 +4258,216 @@ fn csv_round_trip_keeps_blank_row_in_single_column_graph() { assert!(c.is_empty()); assert_eq!(r, g.cells()); } + +/// Each generated label's text, paragraph attributes and alignment. +fn label_paras(s: &Session, id: NodeId) -> Vec<(String, vectorcraft_doc::ParaStyle)> { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut out = vec![]; + for name in ["Axes", "Legend"] { + for c in group(&n, name).children().unwrap() { + if let NodeKind::Text(t) = &c.kind { + out.push((t.plain_text(), t.para.clone())); + } + } + } + out +} + +#[test] +fn paragraph_formats_are_kept_when_the_graph_is_drawn_again() { + use vectorcraft_doc::{Composer, ParaDirection, text::Kashida}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let aligned: Vec<_> = label_paras(&s, id).into_iter().map(|(t, p)| (t, p.justify)).collect(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("text.setFormat", &json!({"composer": "singleLine", "kashida": "long", "direction": "rightToLeft", "wordSpacing": [70, 90, 120]})) + .unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let paras = label_paras(&s, id); + assert!(!paras.is_empty()); + for (text, p) in ¶s { + assert_eq!(p.composer, Composer::SingleLine, "{text}"); + assert_eq!(p.kashida, Kashida::Long, "{text}"); + assert_eq!(p.direction, Some(ParaDirection::RightToLeft), "{text}"); + assert_eq!(p.justification.word_spacing, [70.0, 90.0, 120.0], "{text}"); + } + // The graph still aligns each label to its place. + assert_eq!(paras.iter().map(|(t, p)| (t.clone(), p.justify)).collect::>(), aligned); + // One kind of label: only that kind keeps it, also through save and open. + let col = graph(&mut s, "column"); + let v = text_id(&s, col, "Axes", "4"); + s.execute("select.set", &json!({"ids": [v.0]})).unwrap(); + s.execute("text.setFormat", &json!({"composer": "singleLine"})).unwrap(); + let path = vectorcraft_testkit::temp_dir("graph-label-paras").join("labels.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + s.execute("select.set", &json!({"ids": [col.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": col.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let paras = label_paras(&s, col); + let composer = |t: &str| paras.iter().find(|(x, _)| x == t).unwrap().1.composer; + assert_eq!(composer("0"), Composer::SingleLine, "every value axis number"); + assert_eq!(composer("Q1"), Composer::default(), "category labels as they were"); + assert_eq!(composer("2024"), Composer::default(), "legend labels as they were"); + // Part of a label's text: nothing kept. + let q = text_id(&s, col, "Axes", "Q2"); + s.execute("select.set", &json!({"ids": [q.0]})).unwrap(); + s.execute("text.setFormat", &json!({"kashida": "short", "start": 0, "end": 1})).unwrap(); + s.execute("graph.setData", &json!({"id": col.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + assert!(label_paras(&s, col).iter().all(|(_, p)| p.kashida == Kashida::default())); + // Spacing that would be out of order in the kept attributes is refused, even when it is in order on the label. + let v = text_id(&s, col, "Axes", "4"); + s.execute("select.set", &json!({"ids": [col.0]})).unwrap(); + s.execute("text.setFormat", &json!({"wordSpacing": [60, 70, 80]})).unwrap(); + s.execute("select.set", &json!({"ids": [v.0]})).unwrap(); + s.execute("text.setFormat", &json!({"wordSpacing": [80, 100, 133], "start": 0, "end": 1})).unwrap(); + assert!(s.execute("text.setFormat", &json!({"wordSpacing": {"min": 90}})).is_err()); + let kept = spec(&s, col).label_styles.values_para.unwrap(); + assert_eq!(kept.justification.word_spacing, [60.0, 70.0, 80.0]); +} + +#[test] +fn a_legend_across_top_spaces_its_labels_by_their_kept_paragraph_settings() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); + let l = text_id(&s, id, "Legend", "2024"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setFormat", &json!({"leftIndent": 40})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "2024")).unwrap(); + let label = label.geometric_bounds().unwrap(); + let next = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap(); + assert!(next.x0 >= label.x1, "the next swatch starts after the indented label: {label:?} {next:?}"); +} + +#[test] +fn a_centered_label_with_kept_leading_stays_centered_when_the_graph_is_drawn_again() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",2024,2025\nQ|1,3,2\nQ2,5,4\nQ3,-1,6"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "bar", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let middle = |s: &Session| { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let label = group(&n, "Axes").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "Q\n1")).unwrap(); + let b = label.geometric_bounds().unwrap(); + (b.y0 + b.y1) / 2.0 + }; + let before = middle(&s); + let l = text_id(&s, id, "Axes", "Q\n1"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setFormat", &json!({"autoLeading": 400, "spaceBefore": 6})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let after = middle(&s); + assert!((after - before).abs() < 0.01, "the two-line category label keeps its middle: {before} {after}"); +} + +#[test] +fn a_legend_across_top_measures_its_labels_with_system_layout() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + s.execute("type.systemLayout", &json!({"on": true})).unwrap(); + // Each `i` at 2 pt advances a whole point with System Layout, far more than its own width. + let name = "i".repeat(60); + let csv = format!(",{name},2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"); + let id = NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap(); + let l = text_id(&s, id, "Legend", &name); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": 2})).unwrap(); + s.execute("text.setFormat", &json!({"leftIndent": 1})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == name)).unwrap(); + let label = label.geometric_bounds().unwrap(); + let next = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap(); + assert!(next.x0 >= label.x1, "the next swatch starts after the label set with System Layout: {label:?} {next:?}"); +} + +#[test] +fn a_legend_label_with_a_kept_negative_indent_stays_clear_of_its_swatch() { + for across_top in [true, false] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("graph.setType", &json!({"legendAcrossTop": across_top})).unwrap(); + let l = text_id(&s, id, "Legend", "2025"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setFormat", &json!({"leftIndent": -40})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let bounds = |text: &str| { + let label = + group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap(); + label.geometric_bounds().unwrap() + }; + let (first, label) = (bounds("2024"), bounds("2025")); + let swatch = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap(); + assert!(label.x0 >= swatch.x1, "the label starts after its swatch (across top: {across_top}): {swatch:?} {label:?}"); + if across_top { + assert!(swatch.x0 >= first.x1, "the swatch starts after the label before it: {first:?} {swatch:?}"); + } + } +} + +#[test] +fn a_legend_makes_room_for_the_kept_leading_of_its_labels() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",20|24,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let l = text_id(&s, id, "Legend", "20\n24"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setFormat", &json!({"autoLeading": 400})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let bounds = |text: &str| { + let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap(); + label.geometric_bounds().unwrap() + }; + let (first, next) = (bounds("20\n24"), bounds("2025")); + assert!(next.y0 >= first.y1, "the next legend label starts below the first one: {first:?} {next:?}"); +} + +#[test] +fn a_legend_label_with_kept_negative_space_before_stays_below_the_entry_above_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",2024,20|25\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let l = text_id(&s, id, "Legend", "20\n25"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setFormat", &json!({"spaceBefore": -60})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let bounds = |text: &str| { + let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap(); + label.geometric_bounds().unwrap() + }; + let (first, next) = (bounds("2024"), bounds("20\n25")); + assert!(next.y0 >= first.y1, "the second legend label starts below the first one: {first:?} {next:?}"); +} diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index d7788df10..9f50507af 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", has_doc, set_format ), @@ -786,6 +786,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { } }; s.edit("Character", |d, _| { + // The paragraph change, for the graph labels to keep. + let mut para_change = None; + let out_of_order_error = |name: &str| bad(C, format!("`{name}`: Minimum must not exceed Desired, nor Desired Maximum")); for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; let baseline_shift = num("baselineShift").map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose()?; @@ -794,7 +797,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let tcy = (local(tcy_up_down)?, local(tcy_left_right)?); super::typecmd::style_chars(t, range, |st| chars(st, baseline_shift, h_scale, tcy)); let span = super::typecmd::para_span(range, t); - t.edit_paras(span, |para| { + let paras = |para: &mut vectorcraft_doc::ParaStyle, out_of_order: &mut Option<&'static str>| { if let Some(v) = num("leftIndent") { para.left_indent = v.clamp(-MAX_PARA, MAX_PARA); } @@ -882,17 +885,27 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = composer { para.composer = v; } - }); + }; + t.edit_paras(span, |para| paras(para, &mut out_of_order)); if let Some(name) = out_of_order { - return Err(bad(C, format!("`{name}`: Minimum must not exceed Desired, nor Desired Maximum"))); + return Err(out_of_order_error(name)); } super::typecmd::refresh_bounds(t); + para_change = Some(paras); } // Graph labels keep a change made to all of their text through the graph being drawn again. if range.is_none() { let shift = num("baselineShift").map(|v| v.clamp(-1296.0, 1296.0)); let scale = num("hScale").map(|v| v.clamp(1.0, 10000.0)); super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale, (tcy_up_down, tcy_left_right))); + if let Some(paras) = para_change { + // The kept attributes are checked too: they may differ from the labels'. + super::graph::capture_label_paragraphs(d, &ids, &|para| { + let mut out_of_order = None; + paras(para, &mut out_of_order); + out_of_order.map_or(Ok(()), |name| Err(out_of_order_error(name))) + })?; + } } if let Some(v) = anti_alias { for id in &ids { @@ -900,6 +913,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result { t.anti_alias = v; } } + // Graph labels keep the method through the graph being drawn again. + super::graph::capture_label_anti_alias(d, &ids, v); } Ok(()) })?; diff --git a/docs/mcp.md b/docs/mcp.md index 1b165aa43..ee8783ada 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -595,6 +595,12 @@ not store a series' paint. An `appearance.*` command or `graphicStyle.apply` giv `graphicStyle.redefine` on a series group linked to the style, edit the group's own rows, and the graph does not store them. +`text.setFormat` without `start` and `end`, on a graph or on its labels, stores the character and paragraph attributes +it sets for each kind of label it reaches (value axis numbers, category labels, legend labels), and every label of that +kind takes them when the graph is drawn again. The graph saves the paragraph attributes as `valuesPara`, +`categoriesPara` and `legendPara` in its label styles, and still sets each label's alignment for its place. The legend +leaves room for a legend label's stored indents, spacing and leading. + `effect.apply {"effect": "path.outlineObject"}` (Effect › Path › Outline Object) takes no parameters. Type that carries it is drawn, exported and expanded as its glyph outlines and stays editable type, and the effects after it reshape those outlines. On paths, compound paths and images it does not change what is drawn. @@ -6912,6 +6918,9 @@ With Preferences › General › Anti-aliased Artwork off, the canvas draws all so does an export whose own Anti-aliasing option is None. SVG, PDF and EPS export draw the type unchanged. The native document such a file carries (an EPS VectorCraft wrote, or an SVG or PDF saved with Preserve Editing) keeps each frame's method, and opening the file restores it. +A graph label given a method keeps it for every label of its kind (value axis numbers, category labels or legend +labels) when the graph is drawn again; the graph saves it as `valuesAntiAlias`, `categoriesAntiAlias` or +`legendAntiAlias` in its label styles. Reset Panel sets Sharp. Saved as `antiAlias` only when not `sharp`. One undo step. ```json diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7c428723d..764b7ebe8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -963,7 +963,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. @@ -1121,7 +1121,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in rectangular area type, rows and columns included. In area type of another shape, or with text wrap, such a paragraph is broken with the Single-line Composer. The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. - The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. + The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method and graph labels keeping it when the graph is drawn again, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`. The OpenType panel's Position menu lists Default Position, Superscript, Subscript, Numerator and Denominator, as [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) describes; Superscript, Subscript, Numerator and Denominator set `sups`, `subs`, `numr` and `dnom` for the selected type and turn the other three off, and Default Position turns all four off. The menu shows Mixed when the characters it acts on differ, and an entry chosen then applies to all of them. These two rules are VectorCraft's own, as is setting the feature on type whose font lacks the glyphs, which then draws unchanged. The panel's menus and checkboxes change only their own features on each selected character, so characters keep their other OpenType features (`text.setStyle` and `text.setRangeStyle` `{featureChanges}`). Tests: `panels::opentype::tests`, `features::tests::numerators_and_denominators_are_kept_and_shaped`. The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes: the grid lists the alternate glyphs of the character selected with the Type tool, and a double-click draws that character with the glyph as one undo step (`text.glyphAlternates`, `text.setGlyphAlternate`). The alternates come from the font's `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. The text keeps the character, native files keep the choice with the face it was made in, and type set in another font or version draws the default glyph. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`.