mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 07:10:13 +00:00
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:
+28
-3
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user