Graphs: outlined labels and opened .ai graphs keep the plot in place at the next redraw

- A graph opened from a .ai file now saves `placedArt` too. Moving or
  restyling one of its labels before the first redraw no longer moves or
  stretches the plot.
- Type > Create Outlines on a graph's labels now adjusts the saved bounds,
  so the outlines do not read as a move at the next redraw. A move of
  the graph made before the outlining is still followed.
- A `placedArt` with no width or height falls back to the bounds of all
  of the art, as a graph saved without it does.
- `art_bounds` moves to the doc crate so the .ai reader can use it.
- docs/mcp.md describes `placedArt`, `placed` and when a redraw follows
  a move.
- Tests: a legend across the top, a legend after a right value axis and
  legend labels of several lines keep the plot in place through label
  size changes; moving a category label on its own; outlining labels
  before and after a move; a flat `placedArt`; a label moved in an
  opened .ai graph.
This commit is contained in:
dotCooCoo
2026-11-14 08:47:03 -08:00
parent 5c9717474f
commit d3789f2b0c
8 changed files with 213 additions and 41 deletions
+28 -3
View File
@@ -511,9 +511,8 @@ pub struct GraphSpec {
/// Bounds of the art when it was last generated (detects moves/scales of the graph group).
#[serde(skip_serializing_if = "Option::is_none")]
pub placed: Option<Rect>,
/// Bounds of the art other than type when it was last generated. Moves and scales of the graph group are measured
/// on these when present, so a label edit that resizes its type is not taken for one; graphs saved without them
/// are measured on [`Self::placed`].
/// Bounds of the art other than type when it was last generated ([`art_bounds`]). Moves and scales of the graph
/// group are measured on these when present; graphs saved without them are measured on [`Self::placed`].
#[serde(skip_serializing_if = "Option::is_none")]
pub placed_art: Option<Rect>,
}
@@ -591,6 +590,32 @@ impl GraphSpec {
}
}
/// The geometric bounds of a graph's art `children` leaving out their type: the plot, axes, tick marks and legend
/// swatches without the labels. None when there is nothing else. [`GraphSpec::placed_art`] keeps these.
pub fn art_bounds(children: &[std::sync::Arc<crate::Node>]) -> Option<Rect> {
children.iter().filter_map(|c| art_bounds_of(c).0).reduce(|a, b| a.union(b))
}
/// The geometric bounds of `n` leaving out the type in it, and whether there was type to leave out. A group without type
/// gives its own bounds, so a clipping group gives the bounds of its clip.
fn art_bounds_of(n: &crate::Node) -> (Option<Rect>, bool) {
if matches!(n.kind, crate::NodeKind::Text(_)) {
return (None, true);
}
let Some(children) = n.children() else { return (n.geometric_bounds(), false) };
let mut bounds: Option<Rect> = None;
let mut had_type = false;
for c in children {
let (b, t) = art_bounds_of(c);
had_type |= t;
bounds = match (bounds, b) {
(Some(a), Some(b)) => Some(a.union(b)),
(a, b) => a.or(b),
};
}
if had_type { (bounds, true) } else { (n.geometric_bounds(), false) }
}
impl Default for GraphSpec {
fn default() -> Self {
Self {
+1
View File
@@ -40,6 +40,7 @@ pub use commands::specs;
pub(crate) use data::apply_csv;
#[cfg(test)]
use data::{fmt_value, parse_csv, to_csv};
pub(crate) use edit::{graphs_holding, keep_placed};
#[cfg(test)]
use generate::generate;
#[cfg(test)]
+55 -8
View File
@@ -1,6 +1,8 @@
use vectorcraft_doc::graph::art_bounds;
use super::axis::{MAX_TICK_MARKS, count_given, one_line};
use super::data::apply_data;
use super::generate::{art_bounds, children_bounds, generate};
use super::generate::{children_bounds, generate};
use super::series::{picked_series, reselect_series};
use super::{
Affine, Document, EngineError, GraphKind, GraphSpec, Node, NodeId, Rect, Result, Session, TickLength, Value, ValueAxisSide, bad, f64_req, json,
@@ -17,17 +19,62 @@ fn bound_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Option<f64>>> {
}
}
/// The bounds a redraw of graph `n` measures a move or scale on, as kept at the last generation and as they are now:
/// those of its art without type ([`GraphSpec::placed_art`]), or those of all its art when the graph keeps no such
/// bounds with a width and a height, or has no art without type now.
fn measured(n: &Node) -> Option<(Rect, Rect)> {
let kept = n.extras().graph.as_deref()?;
let c = n.children()?;
let art = kept.placed_art.filter(|p| p.width() > 1e-9 && p.height() > 1e-9).zip(art_bounds(c));
art.or_else(|| kept.placed.zip(children_bounds(c)))
}
/// `r` carried by the change from bounds `from` to bounds `to`: each edge stays at the same place relative to them.
/// `None` when `from` has no width or no height.
fn carried(r: Rect, from: Rect, to: Rect) -> Option<Rect> {
if from.width() <= 1e-9 || from.height() <= 1e-9 {
return None;
}
let (sx, sy) = (to.width() / from.width(), to.height() / from.height());
Some(Rect::new(to.x0 + (r.x0 - from.x0) * sx, to.y0 + (r.y0 - from.y0) * sy, to.x0 + (r.x1 - from.x0) * sx, to.y0 + (r.y1 - from.y0) * sy))
}
/// The graphs holding objects `ids`, each with the bounds of its art without type and of all its art. Give them to
/// [`keep_placed`] after an edit that changes a graph's art without moving the graph.
pub(crate) fn graphs_holding(d: &Document, ids: &[NodeId]) -> Vec<(NodeId, Option<Rect>, Option<Rect>)> {
let mut out: Vec<(NodeId, Option<Rect>, Option<Rect>)> = vec![];
for id in ids {
let Some(chain) = d.ancestry(*id) else { continue };
let holder = chain.iter().rev().skip(1).copied().find(|a| d.node(*a).is_some_and(|n| n.extras().graph.is_some()));
let Some(gid) = holder.filter(|g| !out.iter().any(|(o, ..)| o == g)) else { continue };
let children = d.node(gid).and_then(Node::children);
out.push((gid, children.and_then(|c| art_bounds(c)), children.and_then(|c| children_bounds(c))));
}
out
}
/// After an edit that changed the art of `graphs` (from [`graphs_holding`]) without moving them, set the bounds each
/// keeps so that its next redraw measures the same move or scale as it would have before the edit.
pub(crate) fn keep_placed(d: &mut Document, graphs: &[(NodeId, Option<Rect>, Option<Rect>)]) {
for &(gid, art_was, whole_was) in graphs {
let Some(n) = d.node(gid) else { continue };
let children = n.children();
let (art_now, whole_now) = (children.and_then(|c| art_bounds(c)), children.and_then(|c| children_bounds(c)));
let Some(spec) = d.node_mut(gid).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
if let (Some(placed), Some(was), Some(now)) = (spec.placed_art, art_was, art_now) {
spec.placed_art = carried(now, was, placed).or(spec.placed_art);
}
if let (Some(placed), Some(was), Some(now)) = (spec.placed, whole_was, whole_now) {
spec.placed = carried(now, was, placed).or(spec.placed);
}
}
}
/// Regenerate graph `id` from `spec` (adjusting the plot rect for any move/scale since the last
/// generation, which is recorded in the spec's `placed_art` bounds, or `placed` in graphs saved without them).
pub(super) fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> {
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
let kept = n.extras().graph.as_deref();
let measured = match (kept.and_then(|g| g.placed_art), n.children()) {
(Some(placed), Some(c)) => art_bounds(c).map(|now| (placed, now)),
(None, Some(c)) => kept.and_then(|g| g.placed).zip(children_bounds(c)),
(_, None) => None,
};
if let Some((placed, now)) = measured
if let Some((placed, now)) = measured(n)
&& placed.width() > 1e-9
&& placed.height() > 1e-9
&& (placed.x0 - now.x0).abs() + (placed.y0 - now.y0).abs() + (placed.x1 - now.x1).abs() + (placed.y1 - now.y1).abs() > 1e-6
-26
View File
@@ -1022,29 +1022,3 @@ impl<'a> Plot<'a> {
pub(super) fn children_bounds(children: &[Arc<Node>]) -> Option<Rect> {
children.iter().filter_map(|c| c.geometric_bounds()).reduce(|a, b| a.union(b))
}
/// The geometric bounds of `children` leaving out their type: the plot, axes, tick marks and legend swatches without
/// the labels. None when there is nothing else.
pub(super) fn art_bounds(children: &[Arc<Node>]) -> Option<Rect> {
children.iter().filter_map(|c| art_bounds_of(c).0).reduce(|a, b| a.union(b))
}
/// The geometric bounds of `n` leaving out the type in it, and whether there was type to leave out. A group without type
/// gives its own bounds, so a clipping group gives the bounds of its clip.
fn art_bounds_of(n: &Node) -> (Option<Rect>, bool) {
if matches!(n.kind, NodeKind::Text(_)) {
return (None, true);
}
let Some(children) = n.children() else { return (n.geometric_bounds(), false) };
let mut bounds: Option<Rect> = None;
let mut had_type = false;
for c in children {
let (b, t) = art_bounds_of(c);
had_type |= t;
bounds = match (bounds, b) {
(Some(a), Some(b)) => Some(a.union(b)),
(a, b) => a.or(b),
};
}
if had_type { (bounds, true) } else { (n.geometric_bounds(), false) }
}
+118 -4
View File
@@ -5716,11 +5716,86 @@ fn moving_one_label_leaves_the_plot_in_place() {
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let rect = spec(&s, id).rect;
let l = text_id(&s, id, "Legend", "2025");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("object.move", &json!({"dx": 120, "dy": 60})).unwrap();
for (grp, text) in [("Legend", "2025"), ("Axes", "Q2")] {
let l = text_id(&s, id, grp, text);
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("object.move", &json!({"dx": 120, "dy": 60})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
assert_eq!(spec(&s, id).rect, rect, "after moving {text}");
}
}
/// Label edits leave the plot in place in graphs whose legend swatches follow the size of the labels at the next
/// drawing: a legend across the top, a legend after a right value axis, and legend labels of several lines. A move of
/// the graph made after them is still followed.
#[test]
fn restyling_the_labels_of_a_graph_whose_legend_follows_them_leaves_the_plot_in_place() {
let csv = ",North|East|Coast,South|West,Inland|Hills|Lakes|Plains\nQ1,3,2,4\nQ2,5,4,1\nQ3,-1,6,2";
for options in [json!({"legendAcrossTop": true}), json!({"valueAxis": "right"}), json!({"valueAxis": "both"}), json!({})] {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setType", &options).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let rect = spec(&s, id).rect;
for size in [16, 5] {
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("text.setStyle", &json!({"size": size})).unwrap();
for _ in 0..2 {
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
assert_eq!(spec(&s, id).rect, rect, "{options}: labels at {size} pt");
}
}
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 30, "dy": 20})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let moved = spec(&s, id).rect;
assert!((moved.x0 - (rect.x0 + 30.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 20.0)).abs() < 1e-6, "{options}: {moved:?}");
assert!((moved.width() - rect.width()).abs() < 1e-6 && (moved.height() - rect.height()).abs() < 1e-6, "{options}: {moved:?}");
}
}
/// Outlining a graph's labels leaves the plot where it is when the graph is drawn again, and a move of the graph made
/// before them is still followed.
#[test]
fn outlining_a_graphs_labels_leaves_the_plot_in_place() {
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let rect = spec(&s, id).rect;
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
s.execute("select.set", &json!({"ids": [text_id(&s, id, "Axes", "Q2").0, text_id(&s, id, "Legend", "2025").0]})).unwrap();
s.execute("type.createOutlines", &json!({})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
assert_eq!(spec(&s, id).rect, rect, "after outlining labels");
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 30, "dy": 15})).unwrap();
s.execute("select.set", &json!({"ids": [text_id(&s, id, "Axes", "Q1").0, text_id(&s, id, "Legend", "2024").0]})).unwrap();
s.execute("type.createOutlines", &json!({})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let moved = spec(&s, id).rect;
assert!((moved.x0 - (rect.x0 + 30.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 15.0)).abs() < 1e-6, "{moved:?}");
assert!((moved.width() - rect.width()).abs() < 1e-6 && (moved.height() - rect.height()).abs() < 1e-6, "{moved:?}");
}
/// A graph whose kept bounds of its art without type have no width follows a move with the bounds of all of its art.
#[test]
fn a_graph_whose_art_without_type_kept_no_width_still_follows_a_move() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let rect = spec(&s, id).rect;
let mut flat = spec(&s, id);
let art = flat.placed_art.unwrap();
flat.placed_art = Some(vectorcraft_geom::Rect::new(art.x0, art.y0, art.x0, art.y1));
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().extras_mut().graph = Some(Box::new(flat));
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 25, "dy": 5})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
assert_eq!(spec(&s, id).rect, rect);
let moved = spec(&s, id).rect;
assert!((moved.x0 - (rect.x0 + 25.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 5.0)).abs() < 1e-6, "{moved:?}");
}
/// A graph saved before it kept the bounds of its art without type follows a move made since with its old bounds.
@@ -6659,3 +6734,42 @@ fn a_graph_opened_from_ai_keeps_its_data_for_editing() {
assert!(notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))), "{name}: {r}");
}
}
/// A graph opened from a `.ai` keeps the bounds of its art without type, so moving one of its labels on its own
/// leaves the plot where it is at the first redraw.
#[test]
fn a_graph_opened_from_ai_measures_moves_without_its_labels() {
let graph = "%_Gs\n%_100 200 300 100 Gb\n%_5 0 0 14 0 1 0 0 6 44 Gy\n%_10 2 0 0 90 80 0 Gd\n\
%_1 () Ga\n%_0 13 0 0 0 0 0 () GA\n%_2 () Ga\n%_0 14 0 0 0 0 0 () GA\n%_4 () Ga\n%_0 14 0 0 0 0 0 () GA\n\
%_2 2 1 1 Gz\n%_() Gc (East) Gc (Q1) Gc 5 Gc GC\n\
u\nu\n0.6 0 1 0 k\n110 100 m\n110 150 L\n130 150 L\n130 100 L\nf\nU\n%_2 1 0 0 Go\n\
u\n0 0 0 1 K\n100 200 m\n100 100 L\n300 100 L\nS\nU\n\
0 To\n1 0 0 1 100 220 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n(Sales) Tx\nTO\nU\n%_GS\n";
let text = format!(
"%!PS-Adobe-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 400 400\n%AI5_FileFormat 3\n%AI3_Cropmarks: 0 0 400 400\n%%EndComments\n\
%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Graph) Ln\n0 A\n{graph}LB\n\
%AI5_EndLayer--\n%%PageTrailer\n%%Trailer\n%%EOF\n"
);
let dir = vectorcraft_testkit::temp_dir("graph-ai-label");
let path = dir.join("graph.ai");
std::fs::write(&path, text).unwrap();
let mut s = Session::new();
s.execute("document.open", &json!({"path": path})).unwrap();
let (mut graphs, mut labels) = (vec![], vec![]);
s.doc().unwrap().doc.walk(|n| {
if n.extras().graph.is_some() {
graphs.push(n.id);
}
if matches!(&n.kind, NodeKind::Text(t) if t.plain_text() == "Sales") {
labels.push(n.id);
}
});
assert_eq!((graphs.len(), labels.len()), (1, 1));
let (id, label) = (graphs[0], labels[0]);
let before = spec(&s, id);
assert!(before.placed_art.is_some());
s.execute("select.set", &json!({"ids": [label.0]})).unwrap();
s.execute("object.move", &json!({"dx": 80, "dy": -40})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",East\nQ1,8\nQ2,2"})).unwrap();
assert_eq!(spec(&s, id).rect, before.rect);
}
+3
View File
@@ -601,6 +601,8 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
if texts.is_empty() {
return Err(EngineError::Other("Create Outlines: select text objects".into()));
}
// Outlining a graph's labels does not move or scale its plot at the next redraw.
let graphs = super::graph::graphs_holding(d, &texts);
let before = (!super::dimension::reached(d, &roots).is_empty()).then(|| d.clone());
let ended = super::dimension::end_links(d, &roots);
let mut new_sel: Vec<NodeId> = roots.iter().copied().filter(|r| !texts.contains(r)).collect();
@@ -728,6 +730,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
}
// Outlined frames of threaded text leave their threads with the text they show.
super::threads::leave_threads(d, &threaded);
super::graph::keep_placed(d, &graphs);
sel.set(new_sel);
Ok(out)
})?;
+1
View File
@@ -631,6 +631,7 @@ impl<'a> Reader<'a> {
let Some(paints) = series_paints(art, &tags, spec.kind == GraphKind::Line) else { return false };
spec.series_paints = paints;
spec.placed = Some(placed);
spec.placed_art = art.children().and_then(|c| vectorcraft_doc::graph::art_bounds(c));
tag_series(art, &tags);
art.extras_mut().graph = Some(Box::new(spec));
true
+7
View File
@@ -850,6 +850,13 @@ kind takes them when the graph is drawn again. The graph saves the paragraph att
`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.
When a graph is drawn again, its plot moves and scales with any move or scale of the graph made since it was last
drawn. The graph saves the bounds of its art other than type as `placedArt` (and the bounds of all of its art as
`placed`), and measures the move or scale on those. Changing the size, indents or spacing of the labels, moving a label
on its own and `type.createOutlines` on labels do not move or scale the plot. A graph opened from a `.ai` file saves
`placedArt` too. A graph saved without `placedArt`, or with a `placedArt` that has no width or height, is measured on
`placed`.
The `appearance.*` commands, `graphicStyle.apply` and `graphicStyle.redefine`, and the paint, stroke, gradient and
transparency commands given an `item` (or the Appearance panel's active row) store a graph label's object rows for its
kind of label: the paint, weight, opacity and blend mode of its topmost visible fill and stroke row, their order and