mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 05:24:44 +00:00
Merge pull request #7311 from dotCooCoo/fix/ai-graphs-keep-data
PDF/.ai import: graphs keep their data and settings when their records read whole
This commit is contained in:
@@ -6182,3 +6182,66 @@ fn graph_commands_refuse_params_of_the_wrong_type() {
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, null], [2, 3]], "series": null, "transpose": null})).unwrap();
|
||||
assert_eq!(spec(&s).cells(), vec![vec![Some(1.0), None], vec![Some(2.0), Some(3.0)]]);
|
||||
}
|
||||
|
||||
/// A `.ai` in PostScript form whose graph's records read whole (version 7 specification, appendix A)
|
||||
/// opens as a graph whose data can be edited: Graph Data redraws it on its plot. Saving it as `.ai`
|
||||
/// says its data and settings aren't written, in either form.
|
||||
#[test]
|
||||
fn a_graph_opened_from_ai_keeps_its_data_for_editing() {
|
||||
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\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-open");
|
||||
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 found = vec![];
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if n.extras().graph.is_some() {
|
||||
found.push(n.id);
|
||||
}
|
||||
});
|
||||
assert_eq!(found.len(), 1);
|
||||
let id = found[0];
|
||||
let before = spec(&s, id);
|
||||
assert_eq!(
|
||||
(before.series.as_slice(), before.categories.as_slice(), before.rows.as_slice()),
|
||||
(&["East".to_string()][..], &["Q1".to_string()][..], &[vec![5.0]][..])
|
||||
);
|
||||
// The fill of the series' first mark.
|
||||
let series_fill = |s: &Session| {
|
||||
let mut fill = None;
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if n.extras().series_index == Some(0) && fill.is_none() {
|
||||
let first = n.children().and_then(|c| c.first().cloned());
|
||||
fill = first.and_then(|m| m.appearance.items.iter().find(|i| i.is_fill()).map(|i| i.paint().clone()));
|
||||
}
|
||||
});
|
||||
fill
|
||||
};
|
||||
let painted = series_fill(&s);
|
||||
assert!(painted.is_some());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.setData", &json!({"id": id.0, "csv": ",East\nQ1,8\nQ2,2"})).unwrap();
|
||||
let after = spec(&s, id);
|
||||
assert_eq!(after.rows, [[8.0], [2.0]]);
|
||||
assert_eq!(after.rect, before.rect, "the graph is redrawn on its plot");
|
||||
assert_eq!(series_fill(&s), painted, "the series keeps the file's paint");
|
||||
for (name, version) in [("v7.ai", Some("7")), ("pdf.ai", None)] {
|
||||
let mut p = json!({"path": dir.join(name), "format": "ai"});
|
||||
if let Some(v) = version {
|
||||
p["aiVersion"] = json!(v);
|
||||
}
|
||||
let r = s.execute("file.saveAs", &p).unwrap();
|
||||
let notes = r["warnings"].as_array().cloned().unwrap_or_default();
|
||||
assert!(notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))), "{name}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7488,3 +7488,33 @@ proptest! {
|
||||
prop_assert!(!notes.iter().any(|n| n.contains("art this can't read")), "the records aren't art: {:?}", notes);
|
||||
}
|
||||
}
|
||||
|
||||
/// A graph in `.ai` editing data whose records read whole (version 7 specification, appendix A), of
|
||||
/// a table `rows` × `cols` (its label row and column included): a series group tagged by its
|
||||
/// column, and the axes on the plot's edges.
|
||||
fn live_graph_text(rows: &str, cols: &str) -> String {
|
||||
format!(
|
||||
"%_Gs\n%_10 90 110 10 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\
|
||||
%_{rows} {cols} 1 1 Gz\n%_() Gc (East) Gc (Q1) Gc 5 Gc GC\n\
|
||||
u\nu\n0 0 0 1 k\n20 10 m\n20 50 L\n40 50 L\n40 10 L\nf\nU\n%_2 1 0 0 Go\nu\n0 0 0 1 K\n10 90 m\n10 10 L\n110 10 L\nS\nU\nU\n%_GS\n"
|
||||
)
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// A `.ai` graph that reads whole, then cut or damaged, or with a table of any size (zero, past
|
||||
/// the limits, past what memory holds), never panics, and its drawn art always comes in.
|
||||
#[test]
|
||||
fn ai_live_graph_never_panics(cut in 0usize..800, edits in prop::collection::vec((0usize..800, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '%', '_', 'G', 'c', 'C', 'o', 's', 'S', 'u', 'U'])), 0..12), rows in prop_oneof![Just(2u64), 0u64..20_000, Just(u64::MAX)], cols in prop_oneof![Just(2u64), 0u64..400, Just(4_000_000_000u64)]) {
|
||||
let text = mutate_text(&live_graph_text("2", "2"), cut, &edits);
|
||||
survive("mutated live .ai graph", || Some(vectorcraft_eps::layered_ai(&ai_private(&text), Document::new(100.0, 100.0), vec![], false, true, 1.0).0))?;
|
||||
let sized = live_graph_text(&rows.to_string(), &cols.to_string());
|
||||
let (doc, notes, read) = vectorcraft_eps::layered_ai(&ai_private(&sized), Document::new(4000.0, 200.0), vec![], false, true, 1.0);
|
||||
prop_assert!(read, "{:?}", notes);
|
||||
let mut paths = 0;
|
||||
doc.walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. })));
|
||||
prop_assert_eq!(paths, 2, "{:?}", notes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ pub(super) const CLIPPING: &str = "clipping groups and clipping layers whose cli
|
||||
pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths that a plain fill and line can't draw are written as paths of their own inside the clipping (fills under the clipped art, strokes over it) in the editing data for other apps";
|
||||
pub(super) const CLIP_PAINT_MASKS: &str = "the opacity masks of clipping paths whose fills and strokes are written as paths of their own stay on the clipping path, not on those paths, in the editing data for other apps";
|
||||
pub(super) const LIVE: &str = "blends, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps";
|
||||
pub(super) const GRAPHS: &str = "graphs are written as their art, without their data or settings, in the editing data for other apps";
|
||||
pub(super) const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps";
|
||||
pub(super) const ITEM_TRANSPARENCY: &str =
|
||||
"the opacity and blending modes of single fills and strokes are left out of the editing data for other apps";
|
||||
@@ -622,6 +623,9 @@ impl<'a> Writer<'a> {
|
||||
if self.in_symbol {
|
||||
self.wrote_fixed |= symbols::redrawn(n);
|
||||
}
|
||||
if n.extras().graph.is_some() && !self.in_look {
|
||||
self.warn(GRAPHS);
|
||||
}
|
||||
// On the layers or in an opacity mask's art, outside art cut to a clipping object: with its
|
||||
// art style, when it has one.
|
||||
if !self.in_look
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
//!
|
||||
//! - Graphs (version 7 specification §17): `%_Gs`, the graph's data and settings on `%_` lines,
|
||||
//! its drawn art as ordinary groups, paths and type (each object followed by `%_… Go`), `%_GS`.
|
||||
//! The drawn art is read; the data and settings are left out, with a note.
|
||||
//! The drawn art is read as it is. Its group keeps the data and settings when they read whole
|
||||
//! ([`graph`]); otherwise they are left out, with a note.
|
||||
//!
|
||||
//! - Swatches: the palette (`%AI5_BeginPalette`), read by [`super::palette`], replaces the
|
||||
//! default swatches.
|
||||
@@ -133,6 +134,7 @@ mod art;
|
||||
mod containers;
|
||||
mod directives;
|
||||
mod document;
|
||||
mod graph;
|
||||
mod masks;
|
||||
mod on_path;
|
||||
mod ops;
|
||||
@@ -146,12 +148,13 @@ pub use document::{
|
||||
symbols, variables,
|
||||
};
|
||||
pub(crate) use document::{slot_frame, slot_of};
|
||||
use graph::Graph;
|
||||
pub(crate) use ops::{read_art_style, read_style_list, style_moves_with};
|
||||
#[cfg(test)]
|
||||
pub(crate) use raster::with_image_limits;
|
||||
pub(crate) use types::MAX_NODES;
|
||||
pub use types::PlacedPage;
|
||||
use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, PlacedTail, Raster, SymbolHead};
|
||||
use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Kind, PatternHead, PendingEnvelope, Place, PlacedTail, Raster, SymbolHead};
|
||||
#[cfg(test)]
|
||||
pub use types::{GRAPH_PLAIN, GRAPH_UNENDED, LINKED_PLACEHOLDERS, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED};
|
||||
pub(super) use types::{blend_mode, blend_name};
|
||||
@@ -670,7 +673,7 @@ struct Reader<'a> {
|
||||
brushes_kept: BrushesKept,
|
||||
/// Brush objects that weren't read.
|
||||
brushes_unread: usize,
|
||||
/// The graph being read (see [`Reader::graph_record`]).
|
||||
/// The graph being read (see [`graph`]).
|
||||
graph: Option<Graph>,
|
||||
/// The multi-layer mask being read (`Mb` … `MB`), which goes on past a layer's end.
|
||||
multi_mask: Option<masks::MultiMask>,
|
||||
|
||||
@@ -1165,7 +1165,9 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
|
||||
pub(super) fn finish(mut self) -> Result<Structure, String> {
|
||||
if self.graph.take().is_some() {
|
||||
if self.graph.is_some() {
|
||||
self.graph_plain();
|
||||
self.graph = None;
|
||||
self.warn(super::types::GRAPH_UNENDED);
|
||||
}
|
||||
// Envelopes whose plugin never closed them keep their drawn art.
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
//! A graph's records (version 7 specification §17 and appendix A) and the live graph they give.
|
||||
//!
|
||||
//! A graph is `%_Gs`, its functional records on `%_` lines (`Gb`, `Gy`, `Gd`, any `Gj`, three
|
||||
//! `Ga` … `GA` axes, any `Gw`, `Gz`, `Gc` cells and `GC`), any `version Gt` … `GT` customizations, its
|
||||
//! drawn art in one group (objects followed by `%_… Go`), then `%_GS`. The drawn art is always
|
||||
//! read as it is. The group also keeps the graph's data and settings ([`GraphSpec`]) when every
|
||||
//! record has the shape and values appendix A gives and the model holds each setting, the table
|
||||
//! has data, the art draws the plot's left and bottom edges, and each series' group is tagged, is
|
||||
//! one of the graph's own groups and paints its paths with one fill and one stroke, which the
|
||||
//! graph keeps as the series' paint.
|
||||
//! Any other graph stays plain art, with a note.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{PathSeg, Point};
|
||||
use vectorcraft_doc::{Appearance, GraphKind, GraphSpec, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, SeriesPaint, TickLength};
|
||||
|
||||
use super::types::{GRAPH_OPS, GRAPH_PLAIN, GRAPH_UNENDED, Kind};
|
||||
use super::{Affine, AppearanceItem, BlendMode, Node, NodeId, NodeKind, Paint, Reader, Rect, V, obj};
|
||||
|
||||
/// Most cells of a graph's table, its label row and column included.
|
||||
const MAX_CELLS: usize = 2_600_000;
|
||||
|
||||
/// Longest cell string (appendix A, `Gc`).
|
||||
const MAX_CELL_TEXT: usize = 255;
|
||||
|
||||
/// Most characters of an axis label's text before or after its numbers (appendix A, `Ga`, `GA`).
|
||||
const MAX_AFFIX: usize = 9;
|
||||
|
||||
/// Most tick marks per division a graph draws.
|
||||
const MAX_TICK_MARKS: usize = 20;
|
||||
|
||||
/// Most divisions of a value axis with set values.
|
||||
const MAX_DIVISIONS: f64 = 100.0;
|
||||
|
||||
/// How far an axis line may be from the plot's edge, in points.
|
||||
const EDGE: f64 = 0.01;
|
||||
|
||||
/// The axes in the order the records give them: bottom, left, right.
|
||||
const AXES: [i64; 3] = [1, 2, 4];
|
||||
|
||||
/// The `Go` targets appendix A lists.
|
||||
const TARGETS: [i64; 11] = [1, 2, 4, 5, 6, 8, 9, 10, 15, 20, 22];
|
||||
|
||||
/// The version of a graph's customizations (appendix A, `Gt`).
|
||||
const CUSTOM_VERSION: i64 = 2;
|
||||
|
||||
/// `Go`'s target for one series, including its legend.
|
||||
const SERIES: i64 = 2;
|
||||
|
||||
/// A graph being read (`%_Gs` … `%_GS`).
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Graph {
|
||||
/// Between `Gt` and `GT`, where the graph's settings may use paint and type operators on `%_`
|
||||
/// lines too.
|
||||
pub(super) settings: bool,
|
||||
/// How many frames were open at its `Gs`: a layer that ends with fewer ends the graph.
|
||||
pub(super) frames: usize,
|
||||
/// How many objects the innermost frame held at `Gs`: the drawn art is the one after them.
|
||||
siblings: usize,
|
||||
/// The records read so far, while they can still give a live graph.
|
||||
live: Option<Box<Records>>,
|
||||
}
|
||||
|
||||
/// Where the records are, in the order appendix A gives.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Stage {
|
||||
#[default]
|
||||
Bounds,
|
||||
Style,
|
||||
Dialog,
|
||||
/// Before the `Ga` of axis `n`.
|
||||
Axis(usize),
|
||||
/// Before the `GA` of axis `n`.
|
||||
AxisEnd(usize),
|
||||
Widths,
|
||||
Cells,
|
||||
/// After `GC`: customizations, then the drawn art.
|
||||
Done,
|
||||
/// Between `Gt` and `GT`.
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// One axis: `whichAxis beforeString Ga` and `useManualValues tickMarkType minimumValue maximumValue
|
||||
/// betweenValue smallTicksPerValue drawMarksBetweenLabels afterString GA`.
|
||||
#[derive(Debug, Default)]
|
||||
struct Axis {
|
||||
before: Vec<u8>,
|
||||
manual: bool,
|
||||
tick: TickLength,
|
||||
min: f64,
|
||||
max: f64,
|
||||
between: f64,
|
||||
small: usize,
|
||||
between_labels: bool,
|
||||
after: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A cell of the table (`Gc`).
|
||||
#[derive(Debug)]
|
||||
enum Cell {
|
||||
Num(f64),
|
||||
Text(Vec<u8>),
|
||||
}
|
||||
|
||||
/// The records of a graph read so far.
|
||||
#[derive(Debug, Default)]
|
||||
struct Records {
|
||||
stage: Stage,
|
||||
spec: GraphSpec,
|
||||
axes: [Axis; 3],
|
||||
/// `Gw`: the first column and how many widths.
|
||||
widths: Vec<(usize, usize)>,
|
||||
/// `Gz`: rows, columns, first data row and first data column.
|
||||
size: Option<[usize; 4]>,
|
||||
cells: Vec<Cell>,
|
||||
/// The groups `Go` tags as one series, and the column each names.
|
||||
series: Vec<(NodeId, usize)>,
|
||||
}
|
||||
|
||||
/// A whole number.
|
||||
fn int(v: &V) -> Option<i64> {
|
||||
v.num().filter(|x| x.is_finite() && x.fract() == 0.0 && x.abs() < 1e15).map(|x| x as i64)
|
||||
}
|
||||
|
||||
/// 0 or 1.
|
||||
fn flag(v: &V) -> Option<bool> {
|
||||
match int(v)? {
|
||||
0 => Some(false),
|
||||
1 => Some(true),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn finite(v: &V) -> Option<f64> {
|
||||
v.num().filter(|x| x.is_finite())
|
||||
}
|
||||
|
||||
/// A count from 0 to `max`.
|
||||
fn count(v: &V, max: usize) -> Option<usize> {
|
||||
usize::try_from(int(v)?).ok().filter(|n| *n <= max)
|
||||
}
|
||||
|
||||
/// `tickMarkType`: 13 none, 14 short, 15 the length of the plot.
|
||||
fn tick(v: &V) -> Option<TickLength> {
|
||||
match int(v)? {
|
||||
13 => Some(TickLength::None),
|
||||
14 => Some(TickLength::Short),
|
||||
15 => Some(TickLength::Full),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `graphType`: 5 grouped column, 6 stacked column, 7 line, 10 area. Pie (8) and scatter (9) graphs
|
||||
/// keep settings the records don't give the model, and other values are not graph types.
|
||||
fn kind(v: &V) -> Option<GraphKind> {
|
||||
match int(v)? {
|
||||
5 => Some(GraphKind::Column),
|
||||
6 => Some(GraphKind::StackedColumn),
|
||||
7 => Some(GraphKind::Line),
|
||||
10 => Some(GraphKind::Area),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A percentage of the available width, 1 to 1000.
|
||||
fn percent(v: &V) -> Option<f64> {
|
||||
finite(v).filter(|p| (1.0..=1000.0).contains(p))
|
||||
}
|
||||
|
||||
/// The tick count and values of an axis: with set values (`useManualValues` 1), the divisions
|
||||
/// between its lowest and highest value, a whole number of `betweenValue` steps from the lowest up.
|
||||
fn values(a: &Axis) -> Option<(usize, Option<f64>, Option<f64>)> {
|
||||
if !a.manual {
|
||||
return Some((0, None, None));
|
||||
}
|
||||
let n = (a.max - a.min) / a.between;
|
||||
let whole = n.round();
|
||||
let exact = (n - whole).abs() <= 1e-6 * whole.max(1.0);
|
||||
(a.between > 0.0 && a.max > a.min && exact && (1.0..=MAX_DIVISIONS).contains(&whole)).then_some((whole as usize, Some(a.min), Some(a.max)))
|
||||
}
|
||||
|
||||
impl Records {
|
||||
/// Take the record `w` with its operands `vals`; `None` when its shape, values or place aren't
|
||||
/// those appendix A gives, or the model doesn't hold a value it sets. `to_doc` takes art space
|
||||
/// to the document.
|
||||
fn step(&mut self, w: &str, vals: &[V], to_doc: Affine) -> Option<()> {
|
||||
match (w, self.stage) {
|
||||
("Gb", Stage::Bounds) => {
|
||||
let [l, t, r, b] = vals else { return None };
|
||||
let [l, t, r, b] = [finite(l)?, finite(t)?, finite(r)?, finite(b)?];
|
||||
let plot = to_doc.transform_rect_bbox(Rect::new(l, b, r, t));
|
||||
let sound = r > l && t > b && plot.width() > 0.0 && plot.height() > 0.0 && plot.width().is_finite() && plot.height().is_finite();
|
||||
sound.then_some(())?;
|
||||
self.spec.rect = plot;
|
||||
self.stage = Stage::Style;
|
||||
}
|
||||
("Gy", Stage::Style) => {
|
||||
self.style(vals)?;
|
||||
self.stage = Stage::Dialog;
|
||||
}
|
||||
("Gd", Stage::Dialog) => {
|
||||
let [col_width, precision, series_order, both_axes, bar, group, edge] = vals else { return None };
|
||||
count(col_width, 20).filter(|w| *w >= 3)?;
|
||||
count(precision, 10)?;
|
||||
// `useBothAxis` copies one axis' values to the other, which the model doesn't do.
|
||||
(!flag(both_axes)?).then_some(())?;
|
||||
self.spec.first_column_in_front = flag(series_order)?;
|
||||
self.spec.column_width = percent(bar)?;
|
||||
self.spec.cluster_width = percent(group)?;
|
||||
self.spec.edge_to_edge = flag(edge)?;
|
||||
self.stage = Stage::Axis(0);
|
||||
}
|
||||
("Ga", Stage::Axis(n)) if n < AXES.len() => {
|
||||
let [axis, V::Str(before)] = vals else { return None };
|
||||
(int(axis)? == *AXES.get(n)?).then_some(())?;
|
||||
self.axes.get_mut(n)?.before = before.clone();
|
||||
self.stage = Stage::AxisEnd(n);
|
||||
}
|
||||
("GA", Stage::AxisEnd(n)) => {
|
||||
let [manual, ticks, min, max, between, small, between_labels, V::Str(after)] = vals else { return None };
|
||||
let a = self.axes.get_mut(n)?;
|
||||
(a.manual, a.tick) = (flag(manual)?, tick(ticks)?);
|
||||
(a.min, a.max, a.between) = (finite(min)?, finite(max)?, finite(between)?);
|
||||
a.small = count(small, 1000)?;
|
||||
a.between_labels = flag(between_labels)?;
|
||||
a.after = after.clone();
|
||||
self.stage = Stage::Axis(n + 1);
|
||||
}
|
||||
("Gw", Stage::Axis(3) | Stage::Widths) => {
|
||||
// `col width1 … widthX numParams Gw`: numParams counts `col` and the 1 to 14 widths.
|
||||
let (params, rest) = vals.split_last()?;
|
||||
let params = count(params, 15).filter(|p| *p >= 2 && *p == rest.len())?;
|
||||
let (col, widths) = rest.split_first()?;
|
||||
let col = count(col, MAX_CELLS)?;
|
||||
widths.iter().all(|w| int(w).is_some_and(|w| w == -1 || (3..=20).contains(&w))).then_some(())?;
|
||||
self.widths.push((col, params - 1));
|
||||
self.stage = Stage::Widths;
|
||||
}
|
||||
("Gz", Stage::Axis(3) | Stage::Widths) => {
|
||||
let [rows, cols, first_row, first_col] = vals else { return None };
|
||||
let (rows, cols) = (count(rows, MAX_CELLS)?, count(cols, MAX_CELLS)?);
|
||||
let (first_row, first_col) = (usize::from(flag(first_row)?), usize::from(flag(first_col)?));
|
||||
rows.checked_sub(first_row).filter(|c| (1..=MAX_GRAPH_CATEGORIES).contains(c))?;
|
||||
cols.checked_sub(first_col).filter(|s| (1..=MAX_GRAPH_SERIES).contains(s))?;
|
||||
rows.checked_mul(cols).filter(|n| *n <= MAX_CELLS)?;
|
||||
self.widths.iter().all(|(col, n)| col.checked_add(*n).is_some_and(|end| end <= cols)).then_some(())?;
|
||||
self.size = Some([rows, cols, first_row, first_col]);
|
||||
self.stage = Stage::Cells;
|
||||
}
|
||||
("Gc", Stage::Cells) => {
|
||||
// One to fifteen values, at most one of them a string.
|
||||
let strings = vals.iter().filter(|v| matches!(v, V::Str(_))).count();
|
||||
((1..=15).contains(&vals.len()) && strings <= 1).then_some(())?;
|
||||
let [rows, cols, ..] = self.size?;
|
||||
(self.cells.len() + vals.len() <= rows * cols).then_some(())?;
|
||||
for v in vals {
|
||||
self.cells.push(match v {
|
||||
V::Str(s) if s.len() <= MAX_CELL_TEXT => Cell::Text(s.clone()),
|
||||
_ => Cell::Num(finite(v)?),
|
||||
});
|
||||
}
|
||||
}
|
||||
("GC", Stage::Cells) if vals.is_empty() => self.stage = Stage::Done,
|
||||
// `version Gt`, of version 2. Customizations are edits made after the graph was built, which
|
||||
// the model can't replay: only an empty list keeps the graph live.
|
||||
("Gt", Stage::Done) => {
|
||||
let [version] = vals else { return None };
|
||||
(int(version)? == CUSTOM_VERSION).then_some(())?;
|
||||
self.stage = Stage::Custom;
|
||||
}
|
||||
("GT", Stage::Custom) if vals.is_empty() => self.stage = Stage::Done,
|
||||
_ => return None,
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// `graphType shadow dataPointOrder pieLegendStyle drawMarks drawLines drawLinesAsShapes
|
||||
/// drawLegendsAcrossTop lineShapeWidth whichAxis [piePercentage piePctDigits] Gy`.
|
||||
fn style(&mut self, vals: &[V]) -> Option<()> {
|
||||
let (base, pie) = match vals.len() {
|
||||
10 => (vals, None),
|
||||
12 => vals.split_at_checked(10).map(|(b, p)| (b, Some(p)))?,
|
||||
_ => return None,
|
||||
};
|
||||
let [ty, shadow, order, legend, marks, lines, shapes, across, width, axis] = base else { return None };
|
||||
self.spec.kind = kind(ty)?;
|
||||
self.spec.drop_shadow = flag(shadow)?;
|
||||
self.spec.first_row_in_front = flag(order)?;
|
||||
// 14: legends as in bar and line graphs; 15 (in wedges) and 16 (none) are pie styles.
|
||||
(int(legend)? == 14).then_some(())?;
|
||||
self.spec.legend = true;
|
||||
self.spec.mark_points = flag(marks)?;
|
||||
self.spec.connect_points = flag(lines)?;
|
||||
// Lines drawn as shapes have a width the model doesn't keep.
|
||||
(!flag(shapes)?).then_some(())?;
|
||||
self.spec.filled_lines = false;
|
||||
self.spec.legend_across_top = flag(across)?;
|
||||
finite(width).filter(|w| (0.0..=100.0).contains(w))?;
|
||||
// 44: the series are measured against the left axis; 45 (right) isn't kept per series.
|
||||
(int(axis)? == 44).then_some(())?;
|
||||
if let Some([percentage, digits]) = pie {
|
||||
// Pie percentages, which only pie graphs show; shown (1) isn't kept.
|
||||
(!flag(percentage)?).then_some(())?;
|
||||
int(digits).filter(|d| *d >= 0)?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The graph's data and settings, its strings read as names in `names`; `None` when the table
|
||||
/// or an axis holds what the model can't.
|
||||
fn finish(&self, names: &mut obj::Names) -> Option<GraphSpec> {
|
||||
let [rows, cols, first_row, first_col] = self.size?;
|
||||
let mut spec = self.spec.clone();
|
||||
let text = |names: &mut obj::Names, b: &[u8], max: usize| {
|
||||
let t = names.quiet(b);
|
||||
(t.chars().count() <= max && !t.chars().any(char::is_control)).then(|| names.bytes(b))
|
||||
};
|
||||
// Axes: the bottom one is the category axis, the left one the value axis; the right one keeps
|
||||
// its settings for when it is shown.
|
||||
let [bottom, left, right] = &self.axes;
|
||||
(bottom.before.is_empty() && bottom.after.is_empty() && !bottom.manual).then_some(())?;
|
||||
(!left.between_labels && !right.between_labels).then_some(())?;
|
||||
(spec.category_tick_length, spec.category_tick_marks) = (bottom.tick, bottom.small.max(1));
|
||||
spec.ticks_between_labels = bottom.between_labels;
|
||||
(spec.tick_length, spec.tick_marks) = (left.tick, left.small.max(1));
|
||||
(spec.right_tick_length, spec.right_tick_marks) = (right.tick, right.small.max(1));
|
||||
(spec.category_tick_marks <= MAX_TICK_MARKS && spec.tick_marks <= MAX_TICK_MARKS && spec.right_tick_marks <= MAX_TICK_MARKS).then_some(())?;
|
||||
(spec.ticks, spec.axis_min, spec.axis_max) = values(left)?;
|
||||
(spec.right_ticks, spec.right_axis_min, spec.right_axis_max) = values(right)?;
|
||||
spec.prefix = text(names, &left.before, MAX_AFFIX)?;
|
||||
spec.suffix = text(names, &left.after, MAX_AFFIX)?;
|
||||
spec.right_prefix = text(names, &right.before, MAX_AFFIX)?;
|
||||
spec.right_suffix = text(names, &right.after, MAX_AFFIX)?;
|
||||
// The table, row by row; cells left out at its end are empty.
|
||||
let cell = |r: usize, c: usize| self.cells.get(r * cols + c);
|
||||
let label = |names: &mut obj::Names, r: usize, c: usize| match cell(r, c) {
|
||||
None => Some(String::new()),
|
||||
Some(Cell::Text(t)) => text(names, t, MAX_CELL_TEXT),
|
||||
Some(Cell::Num(_)) => None,
|
||||
};
|
||||
// The corner of a table with both label row and label column holds nothing.
|
||||
let corner_empty = match cell(0, 0) {
|
||||
None => true,
|
||||
Some(Cell::Text(t)) => t.is_empty(),
|
||||
Some(Cell::Num(_)) => false,
|
||||
};
|
||||
if first_row == 1 && first_col == 1 && !corner_empty {
|
||||
return None;
|
||||
}
|
||||
spec.series = if first_row == 1 { (first_col..cols).map(|c| label(names, 0, c)).collect::<Option<_>>()? } else { vec![] };
|
||||
spec.categories = if first_col == 1 { (first_row..rows).map(|r| label(names, r, 0)).collect::<Option<_>>()? } else { vec![] };
|
||||
let mut data = Vec::with_capacity(rows - first_row);
|
||||
for r in first_row..rows {
|
||||
let row = (first_col..cols)
|
||||
.map(|c| match cell(r, c) {
|
||||
None => Some(None),
|
||||
Some(Cell::Text(t)) if t.is_empty() => Some(None),
|
||||
Some(Cell::Text(_)) => None,
|
||||
Some(Cell::Num(v)) => Some(Some(*v)),
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
data.push(row);
|
||||
}
|
||||
data.iter().flatten().any(Option::is_some).then_some(())?;
|
||||
spec.set_cells(data);
|
||||
Some(spec)
|
||||
}
|
||||
|
||||
/// The series index of each group `Go` tagged as one series, by id: every series once, its
|
||||
/// columns counted in the table or among the series alike. `None` when the tags are missing,
|
||||
/// repeated or out of range: the series' paint is read from its group.
|
||||
fn series_tags(&self) -> Option<HashMap<NodeId, u32>> {
|
||||
let [_, cols, _, first_col] = self.size?;
|
||||
let n = cols - first_col;
|
||||
let ids: HashSet<NodeId> = self.series.iter().map(|(id, _)| *id).collect();
|
||||
(ids.len() == self.series.len() && self.series.len() == n).then_some(())?;
|
||||
let indexes = |offset: usize| -> Option<HashMap<NodeId, u32>> {
|
||||
let mut seen = HashSet::new();
|
||||
let tags: HashMap<NodeId, u32> = self
|
||||
.series
|
||||
.iter()
|
||||
.map(|(id, col)| {
|
||||
let s = col.checked_sub(offset).filter(|s| *s < n && seen.insert(*s))?;
|
||||
Some((*id, u32::try_from(s).ok()?))
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
Some(tags)
|
||||
};
|
||||
// With a label column, the columns read as the table's or as the series' are told apart by the
|
||||
// set they make; without one, they are the same.
|
||||
match (indexes(0), indexes(first_col)) {
|
||||
(Some(a), Some(b)) if a == b => Some(a),
|
||||
(Some(a), None) | (None, Some(a)) => Some(a),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `art` draws the plot `plot`'s left edge and its bottom edge (the document's y runs down)
|
||||
/// as straight stroked lines: the value axis and the category axis, drawn on the plot's edges.
|
||||
fn draws_axes(art: &Node, plot: Rect) -> bool {
|
||||
let near = |a: Point, b: Point| (a - b).hypot() <= EDGE;
|
||||
let on = |s: &PathSeg, a: Point, b: Point| match s {
|
||||
PathSeg::Line(l) => (near(l.p0, a) && near(l.p1, b)) || (near(l.p0, b) && near(l.p1, a)),
|
||||
_ => false,
|
||||
};
|
||||
let (top_left, bottom_left, bottom_right) = (Point::new(plot.x0, plot.y0), Point::new(plot.x0, plot.y1), Point::new(plot.x1, plot.y1));
|
||||
let (mut left, mut bottom) = (false, false);
|
||||
let mut stack = vec![art];
|
||||
while let Some(n) = stack.pop() {
|
||||
// Only shown objects draw the axes, and a path only with a stroke.
|
||||
if !n.visible || n.opacity <= 0.0 || (left && bottom) {
|
||||
continue;
|
||||
}
|
||||
let stroked = n.appearance.items.iter().any(|it| !it.is_fill() && it.visible() && it.opacity() > 0.0 && !it.paint().is_none());
|
||||
if let NodeKind::Path { path, .. } = &n.kind
|
||||
&& stroked
|
||||
{
|
||||
for s in path.to_bezpath().segments() {
|
||||
left |= on(&s, top_left, bottom_left);
|
||||
bottom |= on(&s, bottom_left, bottom_right);
|
||||
}
|
||||
}
|
||||
stack.extend(n.children().into_iter().flatten().map(|c| &**c));
|
||||
}
|
||||
left && bottom
|
||||
}
|
||||
|
||||
/// Give the groups among `n`'s children that `tags` holds their series index.
|
||||
fn tag_series(n: &mut Node, tags: &HashMap<NodeId, u32>) {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
if let Some(i) = tags.get(&c.id) {
|
||||
Arc::make_mut(c).extras_mut().series_index = Some(*i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every node `tags` holds is a group among `n`'s children: the graph draws its series
|
||||
/// groups again as its own children.
|
||||
fn tags_groups(n: &Node, tags: &HashMap<NodeId, u32>) -> bool {
|
||||
let found = n.children().into_iter().flatten().filter(|c| tags.contains_key(&c.id) && matches!(c.kind, NodeKind::Group { .. })).count();
|
||||
found == tags.len()
|
||||
}
|
||||
|
||||
/// The paint of the shown fill (`fill`) or stroke of `p`, with a stroke's weight; `None` when it
|
||||
/// has none. `Err` when it has more than one, or that fill or stroke has an opacity, a blend mode
|
||||
/// or effects of its own, which a series' paint doesn't hold.
|
||||
fn shown(p: &Node, fill: bool) -> Result<Option<(Paint, f64)>, ()> {
|
||||
let mut items = p.appearance.items.iter().filter(|it| it.is_fill() == fill && it.visible());
|
||||
let Some(item) = items.next() else { return Ok(None) };
|
||||
if items.next().is_some() || item.opacity() != 1.0 || item.blend() != BlendMode::Normal || !item.effects().is_empty() {
|
||||
return Err(());
|
||||
}
|
||||
if item.paint().is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let width = match item {
|
||||
AppearanceItem::Stroke(s) => s.width,
|
||||
AppearanceItem::Fill(_) => 0.0,
|
||||
};
|
||||
Ok(Some((item.paint().clone(), width)))
|
||||
}
|
||||
|
||||
/// Whether `n` is shown, fully opaque, in Normal mode, not isolated and without an opacity mask:
|
||||
/// the graph draws its series groups and their marks so.
|
||||
fn as_drawn(n: &Node) -> bool {
|
||||
n.visible && n.opacity == 1.0 && n.blend == BlendMode::Normal && !n.isolate && n.extras().mask.is_none()
|
||||
}
|
||||
|
||||
/// Each series' fill and stroke, read from its group (`tags`), which holds paths only, one at
|
||||
/// least: the paths with a fill share it, and every path has the same stroke or none. Only a line
|
||||
/// graph (`line`) has paths without a fill: its lines. `None` when they differ, or the group, its
|
||||
/// own appearance or a path is drawn otherwise than [`as_drawn`].
|
||||
fn series_paints(art: &Node, tags: &HashMap<NodeId, u32>, line: bool) -> Option<Vec<SeriesPaint>> {
|
||||
let mut paints = vec![SeriesPaint::default(); tags.len()];
|
||||
for group in art.children().into_iter().flatten() {
|
||||
let Some(slot) = tags.get(&group.id).and_then(|i| paints.get_mut(usize::try_from(*i).ok()?)) else { continue };
|
||||
(as_drawn(group) && *group.appearance == Appearance::default()).then_some(())?;
|
||||
let paths: Vec<&Arc<Node>> = group.children().into_iter().flatten().collect();
|
||||
(!paths.is_empty() && paths.iter().all(|p| matches!(p.kind, NodeKind::Path { .. }))).then_some(())?;
|
||||
let (mut fills, mut strokes) = (vec![], vec![]);
|
||||
for p in &paths {
|
||||
as_drawn(p).then_some(())?;
|
||||
fills.extend(shown(p, true).ok()?);
|
||||
strokes.push(shown(p, false).ok()?);
|
||||
}
|
||||
let fill = fills.first().map(|(paint, _)| paint.clone());
|
||||
fills.iter().all(|(paint, _)| Some(paint) == fill.as_ref()).then_some(())?;
|
||||
(line || fills.len() == paths.len()).then_some(())?;
|
||||
let stroke = strokes.first().cloned().flatten();
|
||||
strokes.iter().all(|s| *s == stroke).then_some(())?;
|
||||
slot.fill = fill;
|
||||
(slot.stroke, slot.stroke_width) = match stroke {
|
||||
Some((paint, width)) => (Some(paint), Some(width)),
|
||||
None => (Some(Paint::None), None),
|
||||
};
|
||||
}
|
||||
Some(paints)
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
/// A graph's records: `%_Gs` starts the graph on a layer, `GS` ends it, and its data and settings
|
||||
/// (the operators in [`GRAPH_OPS`], and any operator on a `%_` line between `Gt` and `GT`) are
|
||||
/// taken with their operands `vals`. Its drawn art, and anything else, is read as usual. `true`
|
||||
/// when `w` was one of them. `ended`: whether a group and a mesh had just ended before `w`, which
|
||||
/// a `Go` tag after them leaves so.
|
||||
pub(super) fn graph_record(&mut self, w: &str, vals: &[V], hidden: bool, ended: (bool, bool)) -> bool {
|
||||
let depth = self.frames.len();
|
||||
let Some(g) = self.graph.as_mut() else {
|
||||
// Outside a graph, its operators stay ones the reader doesn't know.
|
||||
if w == "Gs" && hidden && !self.frames.is_empty() {
|
||||
let siblings = self.frames.last().map_or(0, |f| f.children.len());
|
||||
// A graph is live only where its art is in the document's space, in a layer or a group.
|
||||
let placed = self.pattern.is_none()
|
||||
&& self.symbol.is_none()
|
||||
&& vals.is_empty()
|
||||
&& matches!(self.frames.last().map(|f| &f.kind), Some(Kind::Layer(_) | Kind::Group | Kind::Clip));
|
||||
self.graph = Some(Graph { settings: false, frames: depth, siblings, live: placed.then(Box::default) });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// The frame the graph started in closed: its art isn't where it was.
|
||||
if depth < g.frames {
|
||||
self.graph_plain();
|
||||
}
|
||||
let Some(g) = self.graph.as_mut() else { return false };
|
||||
let stage = g.live.as_ref().map(|r| r.stage);
|
||||
match w {
|
||||
// A graph doesn't start inside another: the one being read goes on.
|
||||
"Gs" => {
|
||||
self.graph_plain();
|
||||
self.warn(GRAPH_UNENDED);
|
||||
}
|
||||
"GS" => self.graph_end(vals.is_empty() && hidden),
|
||||
"Gt" | "GT" => {
|
||||
g.settings = w == "Gt";
|
||||
self.graph_step(w, vals, hidden);
|
||||
}
|
||||
"Go" => {
|
||||
self.graph_tag(vals, hidden);
|
||||
(self.group_ended, self.mesh_ended) = ended;
|
||||
}
|
||||
_ if GRAPH_OPS.contains(&w) => self.graph_step(w, vals, hidden),
|
||||
_ if hidden && g.settings => self.graph_plain(),
|
||||
_ => {
|
||||
// Before its table ends, a graph has only its records.
|
||||
if !matches!(stage, None | Some(Stage::Done)) {
|
||||
self.graph_plain();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The graph being read can't be live: its art stays plain, with a note.
|
||||
pub(super) fn graph_plain(&mut self) {
|
||||
if let Some(g) = self.graph.as_mut() {
|
||||
g.live = None;
|
||||
}
|
||||
self.warn(GRAPH_PLAIN);
|
||||
}
|
||||
|
||||
/// Take a functional record or customization on a `%_` line.
|
||||
fn graph_step(&mut self, w: &str, vals: &[V], hidden: bool) {
|
||||
if self.graph.as_ref().is_none_or(|g| g.live.is_none()) {
|
||||
return;
|
||||
}
|
||||
let to_doc = if w == "Gb" { self.space() } else { Affine::IDENTITY };
|
||||
let ok = hidden && self.graph.as_mut().and_then(|g| g.live.as_mut()).and_then(|r| r.step(w, vals, to_doc)).is_some();
|
||||
if !ok {
|
||||
self.graph_plain();
|
||||
}
|
||||
}
|
||||
|
||||
/// `target column row whichAxis Go` after an object of the drawn art: a group tagged as one
|
||||
/// series is kept with the column it names.
|
||||
fn graph_tag(&mut self, vals: &[V], hidden: bool) {
|
||||
let depth = self.frames.len();
|
||||
let last = self
|
||||
.after_object
|
||||
.then(|| self.frames.last().and_then(|f| f.children.last()))
|
||||
.flatten()
|
||||
.map(|n| (n.id, matches!(n.kind, NodeKind::Group { .. })));
|
||||
let Some(g) = self.graph.as_mut() else { return };
|
||||
let inside = depth > g.frames;
|
||||
let Some(r) = g.live.as_mut() else { return };
|
||||
let tag = match vals {
|
||||
[target, column, row, axis] if hidden && r.stage == Stage::Done => {
|
||||
let (row, axis) = (int(row), int(axis));
|
||||
// A series is the whole column, measured on the left axis (2) or with no axis given (0):
|
||||
// the model keeps no series on a row or on the right axis.
|
||||
let whole_column = row == Some(0) && matches!(axis, Some(0 | 2));
|
||||
int(target).filter(|t| TARGETS.contains(t) && row.is_some() && axis.is_some()).zip(int(column)).map(|(t, c)| (t, c, whole_column))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let ok = match (tag, last) {
|
||||
(Some((SERIES, column, true)), Some((id, true))) if inside => usize::try_from(column).ok().map(|c| r.series.push((id, c))).is_some(),
|
||||
(Some((SERIES, ..)), _) => false,
|
||||
(Some(_), Some(_)) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !ok {
|
||||
self.graph_plain();
|
||||
}
|
||||
}
|
||||
|
||||
/// `GS`: the graph ends. Its drawn art is the one object its frame gained, a group; it keeps the
|
||||
/// graph's data and settings when they read whole (`whole`) and the art is registered on its plot.
|
||||
fn graph_end(&mut self, whole: bool) {
|
||||
let Some(g) = self.graph.take() else { return };
|
||||
let depth = self.frames.len();
|
||||
let live = g.live.filter(|r| whole && r.stage == Stage::Done && depth == g.frames);
|
||||
let spec = live.as_ref().and_then(|r| r.finish(&mut self.names).zip(r.series_tags()));
|
||||
let attached = spec.is_some_and(|(mut spec, tags)| {
|
||||
let Some(art) = self.frames.last_mut().filter(|f| f.children.len() == g.siblings + 1).and_then(|f| f.children.last_mut()) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(art.kind, NodeKind::Group { clip: false, .. }) || !tags_groups(art, &tags) || !draws_axes(art, spec.rect) {
|
||||
return false;
|
||||
}
|
||||
let Some(placed) = art.geometric_bounds().filter(|b| b.width() > 0.0 && b.height() > 0.0) else { return false };
|
||||
let r = spec.rect;
|
||||
if r.x0 < placed.x0 - EDGE || r.y0 < placed.y0 - EDGE || r.x1 > placed.x1 + EDGE || r.y1 > placed.y1 + EDGE {
|
||||
return false;
|
||||
}
|
||||
let Some(paints) = series_paints(art, &tags, spec.kind == GraphKind::Line) else { return false };
|
||||
spec.series_paints = paints;
|
||||
spec.placed = Some(placed);
|
||||
tag_series(art, &tags);
|
||||
art.extras_mut().graph = Some(Box::new(spec));
|
||||
true
|
||||
});
|
||||
if !attached {
|
||||
self.warn(GRAPH_PLAIN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
/// A graph's art: one series group (id 2) holding one mark painted `ap`, the group changed by
|
||||
/// `group`.
|
||||
fn art(ap: Appearance, group: impl Fn(&mut Node)) -> Node {
|
||||
let mark = Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), ap));
|
||||
let mut series = Node::group(NodeId(2), vec![mark]);
|
||||
group(&mut series);
|
||||
Node::group(NodeId(1), vec![Arc::new(series)])
|
||||
}
|
||||
|
||||
/// A series whose mark has two fills has no one paint, so the graph can't draw it again.
|
||||
#[test]
|
||||
fn a_series_mark_with_two_fills_has_no_series_paint() {
|
||||
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
|
||||
let one = Appearance::basic(red.clone(), Paint::None, 0.0);
|
||||
let mut two = one.clone();
|
||||
two.items.extend(one.items.iter().find(|i| i.is_fill()).cloned());
|
||||
let tags = HashMap::from([(NodeId(2), 0)]);
|
||||
let kept = series_paints(&art(one, |_| {}), &tags, false).expect("one fill");
|
||||
assert_eq!((kept[0].fill.as_ref(), kept[0].stroke.as_ref()), (Some(&red), Some(&Paint::None)));
|
||||
assert!(series_paints(&art(two, |_| {}), &tags, false).is_none());
|
||||
}
|
||||
|
||||
/// A series group that is hidden, see-through or in another blending mode is not drawn again so.
|
||||
#[test]
|
||||
fn a_series_group_drawn_otherwise_has_no_series_paint() {
|
||||
let ap = || Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let tags = HashMap::from([(NodeId(2), 0)]);
|
||||
assert!(series_paints(&art(ap(), |g| g.visible = false), &tags, false).is_none(), "hidden");
|
||||
assert!(series_paints(&art(ap(), |g| g.opacity = 0.5), &tags, false).is_none(), "50% opaque");
|
||||
assert!(series_paints(&art(ap(), |g| g.blend = BlendMode::Multiply), &tags, false).is_none(), "Multiply");
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
use super::containers::close;
|
||||
use super::document::slot_of;
|
||||
use super::types::{
|
||||
BLENDS, FLIP, Frame, GRAPH_OPS, GRAPH_PLAIN, GRAPH_UNENDED, IGNORED, Kind, MAX_DEPTH, MAX_NODES, MAX_TAG_TEXT, MAX_TAGS, Place, URL_TAG,
|
||||
line_width,
|
||||
};
|
||||
use super::types::{BLENDS, FLIP, Frame, GRAPH_UNENDED, IGNORED, Kind, MAX_DEPTH, MAX_NODES, MAX_TAG_TEXT, MAX_TAGS, Place, URL_TAG, line_width};
|
||||
use super::{
|
||||
Affine, Appearance, AppearanceItem, Arc, BlendMode, BrushesKept, Dash, FillLayer, FillRule, Global, Gradient, GradientDef, Graph, HashMap,
|
||||
HashSet, Instance, Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, ShownNames, StrokeLayer, V, obj, paint,
|
||||
Affine, Appearance, AppearanceItem, Arc, BlendMode, BrushesKept, Dash, FillLayer, FillRule, Global, Gradient, GradientDef, HashMap, HashSet,
|
||||
Instance, Knockout, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, Paint, Reader, Rect, ShownNames, StrokeLayer, V, obj, paint,
|
||||
};
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
@@ -69,6 +66,7 @@ impl<'a> Reader<'a> {
|
||||
/// `LB`: a graph started in this layer that hasn't ended doesn't go on past it; the layer ends.
|
||||
fn layer_end(&mut self) -> Result<(), String> {
|
||||
if self.graph.as_ref().is_some_and(|g| g.frames >= self.frames.len()) {
|
||||
self.graph_plain();
|
||||
self.graph = None;
|
||||
self.warn(GRAPH_UNENDED);
|
||||
}
|
||||
@@ -158,7 +156,7 @@ impl<'a> Reader<'a> {
|
||||
let mesh_ended = std::mem::take(&mut self.mesh_ended);
|
||||
let vals = self.operands();
|
||||
let vals = self.take_head(vals);
|
||||
if self.graph_record(w, hidden) {
|
||||
if self.graph_record(w, &vals, hidden, (group_ended, mesh_ended)) {
|
||||
return Ok(());
|
||||
}
|
||||
// Only `Bh` reads an undefined number. Anywhere else it would reach a point or a size, so
|
||||
@@ -428,33 +426,6 @@ impl<'a> Reader<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A graph's records (version 7 specification §17 and appendix A): `%_Gs` starts the graph on
|
||||
/// a layer, `GS` ends it, and its data and settings (the operators in [`GRAPH_OPS`], and any
|
||||
/// operator on a `%_` line between `Gt` and `GT`) are left out with their operands. Its drawn
|
||||
/// art, and anything else, is read as usual. `true` when `w` was one of them.
|
||||
fn graph_record(&mut self, w: &str, hidden: bool) -> bool {
|
||||
let Some(g) = self.graph.as_mut() else {
|
||||
// Outside a graph, its operators stay ones the reader doesn't know.
|
||||
if w == "Gs" && hidden && !self.frames.is_empty() {
|
||||
self.graph = Some(Graph { settings: false, frames: self.frames.len() });
|
||||
self.warn(GRAPH_PLAIN);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
match w {
|
||||
// A graph doesn't start inside another: the one being read goes on.
|
||||
"Gs" => self.warn(GRAPH_UNENDED),
|
||||
"GS" => self.graph = None,
|
||||
"Gt" => g.settings = true,
|
||||
"GT" => g.settings = false,
|
||||
_ if GRAPH_OPS.contains(&w) => {}
|
||||
_ if hidden && g.settings => {}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `… Lb`: a layer's options, `visible preview unlocked printing dimmed`, then the colour index
|
||||
/// and its RGB. The colour index is at 5 with fewer than ten operands, 6 with ten to twelve
|
||||
/// (`masks` before it) and 7 with thirteen or more (one more flag before it). Four places after
|
||||
@@ -588,9 +559,9 @@ impl<'a> Reader<'a> {
|
||||
return;
|
||||
}
|
||||
self.image_tail = false;
|
||||
// An object's tags (`XT`) follow it one after another, and an image's follow the `XH` that
|
||||
// ends its data.
|
||||
if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB" | "XT" | "XH") {
|
||||
// An object's tags (`XT`, and a graph's `Go`) follow it one after another, and an image's
|
||||
// follow the `XH` that ends its data.
|
||||
if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB" | "XT" | "XH" | "Go") {
|
||||
return;
|
||||
}
|
||||
self.after_object = false;
|
||||
|
||||
@@ -436,8 +436,8 @@ pub const NON_NATIVE_ART_UNREAD: &str = "non-native art (a placed PDF's content)
|
||||
/// The name of the group a `/ForeignObject`'s art comes in as.
|
||||
pub const NON_NATIVE_ART: &str = "Non-native art";
|
||||
|
||||
/// The note for a file with a graph (`%_Gs` … `%_GS`): its drawn art comes in, its data and
|
||||
/// settings don't.
|
||||
/// The note for a file with a graph (`%_Gs` … `%_GS`) whose data and settings can't be kept: its
|
||||
/// drawn art comes in as plain art.
|
||||
pub const GRAPH_PLAIN: &str = "graphs come in as plain art, without their data or settings";
|
||||
|
||||
/// The note for a graph that starts inside another or doesn't end before its layer does: the art
|
||||
@@ -445,18 +445,8 @@ pub const GRAPH_PLAIN: &str = "graphs come in as plain art, without their data o
|
||||
pub const GRAPH_UNENDED: &str = "a graph's records don't end where they should: its art was read as it is";
|
||||
|
||||
/// A graph's records (the version 7 specification's chapter 17 and appendix A): two-letter
|
||||
/// operators starting with `G`, read and left out with their operands.
|
||||
/// operators starting with `G`, read with their operands.
|
||||
pub(super) const GRAPH_OPS: &[&str] = &[
|
||||
"G1", "G+", "Ga", "GA", "Gb", "Gc", "GC", "Gd", "GD", "Ge", "Gf", "Gg", "Gi", "Gj", "Gl", "Gm", "Go", "Gp", "Gr", "Gs", "GS", "Gt", "GT", "Gv",
|
||||
"Gw", "GX", "Gx", "Gy", "Gz",
|
||||
];
|
||||
|
||||
/// A graph being read (`%_Gs` … `%_GS`).
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Graph {
|
||||
/// Between `Gt` and `GT`, where the graph's settings may use paint and type operators on `%_`
|
||||
/// lines too.
|
||||
pub(super) settings: bool,
|
||||
/// How many frames were open at its `Gs`: a layer that ends with fewer ends the graph.
|
||||
pub(super) frames: usize,
|
||||
}
|
||||
|
||||
@@ -10547,6 +10547,7 @@ fn type_with_an_art_brush_keeps_its_drawn_look() {
|
||||
}
|
||||
|
||||
use read::{GRAPH_PLAIN, GRAPH_UNENDED};
|
||||
use vectorcraft_doc::{GraphKind, TickLength};
|
||||
|
||||
/// Editing data in the legacy format (version 7 specification, chapter 2): `%AI5_FileFormat 2.0`,
|
||||
/// the art size and the template box in the header, then `body`.
|
||||
@@ -10688,6 +10689,223 @@ fn a_graphs_records_take_their_operands() {
|
||||
assert_eq!(r.warnings, [GRAPH_PLAIN]);
|
||||
}
|
||||
|
||||
/// A graph's style record (`Gy`) for a grouped column graph: no shadow, legends as in column
|
||||
/// graphs, lines without marks, measured against the left axis.
|
||||
const LIVE_STYLE: &str = "%_5 0 0 14 0 1 0 0 6 44 Gy\n";
|
||||
|
||||
/// A graph's dialog record (`Gd`): columns 90% wide in clusters 80% wide.
|
||||
const LIVE_DIALOG: &str = "%_10 2 0 0 90 80 0 Gd\n";
|
||||
|
||||
/// A graph's axes, bottom, left and right: the bottom one with no tick marks, between its labels;
|
||||
/// the left one labeled `$` … ` units`, with two tick marks a division.
|
||||
const LIVE_AXES: &str = "%_1 () Ga\n%_0 13 0 0 0 0 1 () GA\n%_2 ($) Ga\n%_0 14 0 0 0 2 0 ( units) GA\n%_4 () Ga\n%_0 14 0 0 0 0 0 () GA\n";
|
||||
|
||||
/// A graph's table: series East and West, categories Q1 to Q3, and an empty cell (West in Q2).
|
||||
const LIVE_TABLE: &str = "%_4 3 1 1 Gz\n%_() Gc (East) Gc (West) Gc\n%_(Q1) Gc 5 3 (Q2) Gc 7 () Gc (Q3) Gc 4 6 Gc GC\n";
|
||||
|
||||
/// A column of a graph's series at `x`, tagged as one data bar.
|
||||
fn series_bar(x: f64) -> String {
|
||||
format!("0 0 0 1 k\n{}f\n%_4 1 0 0 Go\n", rect_path(x, 100.0, x + 20.0, 150.0))
|
||||
}
|
||||
|
||||
/// The first series' mark in [`live_art`].
|
||||
fn series_mark() -> String {
|
||||
format!("0 0 0 1 k\n{}f\n", rect_path(110.0, 100.0, 130.0, 150.0))
|
||||
}
|
||||
|
||||
/// [`series_mark`] filled and stroked, then an outline stroked alike with no fill.
|
||||
fn series_mark_and_outline() -> String {
|
||||
format!("0 0 0 1 k\n0 0 0 1 K\n{}B\n{}S\n", rect_path(110.0, 100.0, 130.0, 150.0), rect_path(112.0, 100.0, 118.0, 120.0))
|
||||
}
|
||||
|
||||
/// The drawn art of the graph [`live_graph`] gives: two series groups, each tagged as one series by
|
||||
/// its table column, and the axes on the plot's left and bottom edges.
|
||||
fn live_art() -> String {
|
||||
format!(
|
||||
"u\n{}U\n%_2 1 0 0 Go\nu\n{}U\n%_2 2 0 0 Go\nu\n0 0 0 1 K\n100 200 m\n100 100 L\n300 100 L\nS\nU\n%_9 0 0 2 Go\n",
|
||||
series_bar(110.0),
|
||||
series_bar(140.0)
|
||||
)
|
||||
}
|
||||
|
||||
/// A graph whose records read whole, written from the version 7 specification's appendix A, on the
|
||||
/// plot 100 100 300 200 (art space).
|
||||
fn live_graph() -> String {
|
||||
format!("%_Gs\n%_100 200 300 100 Gb\n{LIVE_STYLE}{LIVE_DIALOG}{LIVE_AXES}{LIVE_TABLE}u\n{}U\n%_GS\n", live_art())
|
||||
}
|
||||
|
||||
/// The graphs in `doc`'s art, and the series groups tagged in them.
|
||||
fn live_graphs(doc: &Document) -> (Vec<Node>, usize) {
|
||||
let (mut graphs, mut tagged) = (vec![], 0);
|
||||
doc.walk(|n| {
|
||||
if n.extras().graph.is_some() {
|
||||
graphs.push(n.clone());
|
||||
}
|
||||
tagged += usize::from(n.extras().series_index.is_some());
|
||||
});
|
||||
(graphs, tagged)
|
||||
}
|
||||
|
||||
fn same_rect(a: Rect, b: Rect) -> bool {
|
||||
(a.x0 - b.x0).abs() + (a.y0 - b.y0).abs() + (a.x1 - b.x1).abs() + (a.y1 - b.y1).abs() < 1e-9
|
||||
}
|
||||
|
||||
/// A graph whose records read whole keeps its data and settings on its group, which holds its
|
||||
/// drawn art as it is, each series group tagged with its index.
|
||||
#[test]
|
||||
fn a_graph_whose_records_read_whole_keeps_its_data_and_settings() {
|
||||
let r = read(stream7(&layer7("Graph", &live_graph())).as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 1);
|
||||
let g = a[0].extras().graph.clone().expect("a live graph");
|
||||
assert_eq!(g.kind, GraphKind::Column);
|
||||
assert_eq!(g.series, ["East", "West"]);
|
||||
assert_eq!(g.categories, ["Q1", "Q2", "Q3"]);
|
||||
assert_eq!(g.cells(), [[Some(5.0), Some(3.0)], [Some(7.0), None], [Some(4.0), Some(6.0)]]);
|
||||
assert_eq!((g.column_width, g.cluster_width), (90.0, 80.0));
|
||||
assert_eq!((g.prefix.as_str(), g.suffix.as_str(), g.tick_length, g.tick_marks), ("$", " units", TickLength::Short, 2));
|
||||
assert_eq!((g.category_tick_length, g.ticks_between_labels), (TickLength::None, true));
|
||||
assert_eq!((g.ticks, g.axis_min, g.axis_max), (0, None, None));
|
||||
assert!(g.legend && !g.legend_across_top && !g.mark_points && g.connect_points && !g.drop_shadow && !g.edge_to_edge, "{g:?}");
|
||||
// The drawn art: two series groups and the axes, as written.
|
||||
let kids = a[0].children().unwrap();
|
||||
assert_eq!(kids.len(), 3);
|
||||
let index: Vec<_> = kids.iter().map(|k| k.extras().series_index).collect();
|
||||
assert_eq!(index, [Some(0), Some(1), None]);
|
||||
assert!(kids[..2].iter().all(|k| k.children().is_some_and(|c| c.len() == 1)));
|
||||
// Each series keeps its marks' fill, and no stroke, for when the graph is drawn again.
|
||||
assert_eq!(g.series_paints.len(), 2);
|
||||
let fill = kids[0].children().unwrap()[0].appearance.items.iter().find(|i| i.is_fill()).map(|i| i.paint().clone());
|
||||
assert!(g.series_paints.iter().all(|p| p.fill == fill && p.stroke.as_ref().is_some_and(|s| s.is_none())), "{:?}", g.series_paints);
|
||||
let axes = kids[2].children().unwrap()[0].geometric_bounds().unwrap();
|
||||
assert!(same_rect(g.rect, axes), "the plot is where the axes are: {:?} {axes:?}", g.rect);
|
||||
assert_eq!(g.placed, a[0].geometric_bounds());
|
||||
}
|
||||
|
||||
/// The graph types the records give: 5 grouped column, 6 stacked column, 7 line, 10 area; and the
|
||||
/// style and dialog settings, each kept as the graph's.
|
||||
#[test]
|
||||
fn a_graphs_type_and_settings_come_from_its_records() {
|
||||
let live = |style: &str, dialog: &str, axes: &str| {
|
||||
let text = live_graph().replace(LIVE_STYLE, style).replace(LIVE_DIALOG, dialog).replace(LIVE_AXES, axes);
|
||||
let r = read(stream7(&layer7("Graph", &text)).as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{style}{dialog}: {:?}", r.warnings);
|
||||
art(&r.doc)[0].extras().graph.clone().expect("a live graph")
|
||||
};
|
||||
for (ty, kind) in [(5, GraphKind::Column), (6, GraphKind::StackedColumn), (7, GraphKind::Line), (10, GraphKind::Area)] {
|
||||
let g = live(&format!("%_{ty} 0 0 14 0 1 0 0 6 44 Gy\n"), LIVE_DIALOG, LIVE_AXES);
|
||||
assert_eq!(g.kind, kind, "{ty}");
|
||||
}
|
||||
let axes = LIVE_AXES.replace("%_0 14 0 0 0 2 0 ( units) GA", "%_1 15 0 100 25 0 0 () GA");
|
||||
let g = live("%_7 1 1 14 1 0 0 1 6 44 0 2 Gy\n", "%_10 2 1 0 120 50 1 Gd\n", &axes);
|
||||
assert!(g.drop_shadow && g.first_row_in_front && g.mark_points && !g.connect_points && g.legend_across_top, "{g:?}");
|
||||
assert!(g.first_column_in_front && g.edge_to_edge, "{g:?}");
|
||||
assert_eq!((g.column_width, g.cluster_width), (120.0, 50.0));
|
||||
assert_eq!((g.ticks, g.axis_min, g.axis_max, g.tick_length, g.tick_marks), (4, Some(0.0), Some(100.0), TickLength::Full, 1));
|
||||
// A line graph's series draws its line unfilled beside its filled marks.
|
||||
let text = live_graph().replace(LIVE_STYLE, "%_7 0 0 14 0 1 0 0 6 44 Gy\n").replacen(&series_mark(), &series_mark_and_outline(), 1);
|
||||
let r = read(stream7(&layer7("Graph", &text)).as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
assert!(art(&r.doc)[0].extras().graph.is_some());
|
||||
// An empty list of customizations, of the version appendix A gives (2), sets nothing.
|
||||
let text = live_graph().replace("GC\n", "GC\n%_2 Gt\n%_GT\n");
|
||||
let r = read(stream7(&layer7("Graph", &text)).as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
assert!(art(&r.doc)[0].extras().graph.is_some());
|
||||
}
|
||||
|
||||
/// A graph keeps its drawn art and stays plain, with one note, when a record has a shape or value
|
||||
/// appendix A doesn't give, sets what the model doesn't keep, or the table, the series tags or the
|
||||
/// art don't fit the graph.
|
||||
#[test]
|
||||
fn graphs_the_model_cant_keep_stay_plain_art() {
|
||||
let cases: Vec<(&str, String)> = vec![
|
||||
("an eleventh style value", live_graph().replace(LIVE_STYLE, "%_5 0 0 14 0 1 0 0 6 44 0 Gy\n")),
|
||||
("eleven dialog values", live_graph().replace(LIVE_DIALOG, "%_10 2 0 0 90 80 0 0 0 0 0 Gd\n")),
|
||||
("a fourth axis", live_graph().replace(LIVE_AXES, &format!("{LIVE_AXES}%_8 () Ga\n%_0 14 0 0 0 0 0 () GA\n"))),
|
||||
("ten axis values", live_graph().replace("%_0 14 0 0 0 0 0 () GA", "%_0 14 0 0 0 0 0 () 0 0 GA")),
|
||||
("a pie graph", live_graph().replace("%_5 0 0 14", "%_8 0 0 14")),
|
||||
("a scatter graph", live_graph().replace("%_5 0 0 14", "%_9 0 0 14")),
|
||||
("type 11", live_graph().replace("%_5 0 0 14", "%_11 0 0 14")),
|
||||
("legends in wedges", live_graph().replace("%_5 0 0 14", "%_5 0 0 15")),
|
||||
("the right axis", live_graph().replace("6 44 Gy", "6 45 Gy")),
|
||||
("lines as shapes", live_graph().replace("%_5 0 0 14 0 1 0", "%_5 0 0 14 0 1 1")),
|
||||
("both axes alike", live_graph().replace(LIVE_DIALOG, "%_10 2 0 1 90 80 0 Gd\n")),
|
||||
("a subscription", live_graph().replace(LIVE_DIALOG, &format!("{LIVE_DIALOG}%_0 0 1 1 1 Gj\n"))),
|
||||
("a customization", live_graph().replace("GC\n", "GC\n%_2 Gt\n%_(Bars) GD\n%_GT\n")),
|
||||
("customizations without their version", live_graph().replace("GC\n", "GC\n%_Gt\n%_GT\n")),
|
||||
("customizations of another version", live_graph().replace("GC\n", "GC\n%_3 Gt\n%_GT\n")),
|
||||
("bounds upside down", live_graph().replace("%_100 200 300 100 Gb", "%_100 100 300 200 Gb")),
|
||||
("a plot the axes aren't on", live_graph().replace("%_100 200 300 100 Gb", "%_50 200 300 100 Gb")),
|
||||
("a text data cell", live_graph().replace("7 () Gc", "(seven) Gc () Gc")),
|
||||
("a table of empty cells", live_graph().replace(LIVE_TABLE, "%_4 3 1 1 Gz\n%_() Gc (East) Gc (West) Gc\n%_GC\n")),
|
||||
("a cell past the table", live_graph().replace("4 6 Gc GC", "4 6 1 Gc GC")),
|
||||
("too many categories", live_graph().replace("%_4 3 1 1 Gz", "%_10002 3 1 1 Gz")),
|
||||
("a table too large", live_graph().replace("%_4 3 1 1 Gz", "%_4000000000 4000000000 1 1 Gz")),
|
||||
("a number as a label", live_graph().replace("(West) Gc", "2025 Gc")),
|
||||
("a series untagged", live_graph().replace("%_2 2 0 0 Go", "%_4 2 0 0 Go")),
|
||||
("a series tagged twice", live_graph().replace("%_2 2 0 0 Go", "%_2 1 0 0 Go")),
|
||||
("a path tagged as a series", live_graph().replacen("%_4 1 0 0 Go", "%_2 1 0 0 Go", 1)),
|
||||
("a target the specification doesn't list", live_graph().replace("%_9 0 0 2 Go", "%_3 0 0 2 Go")),
|
||||
("art before its group", live_graph().replace("GC\nu\n", &format!("GC\n{}f\nu\n", rect_path(0.0, 0.0, 5.0, 5.0)))),
|
||||
("a record on a line that shows", live_graph().replace("%_10 2 0 0 90 80 0 Gd", "10 2 0 0 90 80 0 Gd")),
|
||||
("series groups inside another group", live_graph().replace("GC\nu\n", "GC\nu\nu\n").replace("U\n%_GS", "U\nU\n%_GS")),
|
||||
(
|
||||
"two fills in one series",
|
||||
live_graph().replacen(
|
||||
"f\n%_4 1 0 0 Go\nU\n",
|
||||
&format!("f\n%_4 1 0 0 Go\n0 0 0 0.5 k\n{}f\nU\n", rect_path(112.0, 100.0, 118.0, 120.0)),
|
||||
1,
|
||||
),
|
||||
),
|
||||
("axes filled, not stroked", live_graph().replace("300 100 L\nS\n", "300 100 L\nf\n")),
|
||||
("a series tagged on a row", live_graph().replace("%_2 2 0 0 Go", "%_2 2 1 0 Go")),
|
||||
("a series tagged on the right axis", live_graph().replace("%_2 2 0 0 Go", "%_2 2 0 4 Go")),
|
||||
("an unfilled mark in a column series", live_graph().replacen(&series_mark(), &series_mark_and_outline(), 1)),
|
||||
("a series' mark in a group of its own", live_graph().replacen(&series_mark(), &format!("u\n{}U\n", series_mark()), 1)),
|
||||
(
|
||||
"an untagged graph of one series",
|
||||
live_graph()
|
||||
.replace(LIVE_TABLE, "%_2 2 1 1 Gz\n%_() Gc (East) Gc (Q1) Gc 5 Gc GC\n")
|
||||
.replace("%_2 2 0 0 Go", "%_4 1 0 0 Go")
|
||||
.replace("%_2 1 0 0 Go", "%_4 1 0 0 Go"),
|
||||
),
|
||||
];
|
||||
for (what, text) in cases {
|
||||
let r = read(stream7(&layer7("Graph", &text)).as_bytes()).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert_eq!(r.warnings, [GRAPH_PLAIN], "{what}");
|
||||
assert_eq!(live_graphs(&r.doc), (vec![], 0), "{what}");
|
||||
let mut kept = false;
|
||||
r.doc.walk(|n| kept |= n.children().is_some_and(|c| c.len() == 3));
|
||||
assert!(kept, "{what}: the drawn art is kept");
|
||||
}
|
||||
}
|
||||
|
||||
/// A graph in a group or a clipping group keeps its data on its own group, not the container's; of
|
||||
/// two graphs in one layer, each is kept or left plain on its own, and the note is given once.
|
||||
#[test]
|
||||
fn graphs_are_kept_one_by_one_wherever_they_are() {
|
||||
let clip = format!("q\n{}W n\n{}Q\n", rect_path(0.0, 0.0, 400.0, 400.0), live_graph());
|
||||
for (what, text) in [("in a group", format!("u\n{}U\n", live_graph())), ("in a clipping group", clip)] {
|
||||
let r = read(stream7(&layer7("Graph", &text)).as_bytes()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{what}: {:?}", r.warnings);
|
||||
let outer = &art(&r.doc)[0];
|
||||
assert!(outer.extras().graph.is_none(), "{what}");
|
||||
let (graphs, tagged) = live_graphs(&r.doc);
|
||||
assert_eq!((graphs.len(), tagged), (1, 2), "{what}");
|
||||
assert!(outer.children().unwrap().iter().any(|c| c.id == graphs[0].id), "{what}: the graph's own group");
|
||||
}
|
||||
let pie = live_graph().replace("%_5 0 0 14", "%_8 0 0 14");
|
||||
let body = format!("{}{pie}{}", live_graph(), live_graph());
|
||||
let r = read(stream7(&layer7("Graph", &body)).as_bytes()).unwrap();
|
||||
assert_eq!(r.warnings, [GRAPH_PLAIN]);
|
||||
let a = art(&r.doc);
|
||||
assert_eq!(a.len(), 3);
|
||||
let live: Vec<bool> = a.iter().map(|n| n.extras().graph.is_some()).collect();
|
||||
assert_eq!(live, [true, false, true]);
|
||||
}
|
||||
|
||||
/// Editing data in the legacy format (`%AI5_FileFormat 2.0`, ten `Lb` operands), then `body`.
|
||||
fn mask_stream7(body: &str) -> String {
|
||||
format!(
|
||||
|
||||
@@ -124,6 +124,7 @@ const STROKES: &str =
|
||||
"strokes a plain line can't draw (inside or outside the path, variable width, gradients, brushes, arrowheads) are written as filled outlines";
|
||||
const IMAGE_ALPHA: &str = "transparent image pixels are written over white";
|
||||
const LIVE: &str = "symbols, blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art";
|
||||
const GRAPHS: &str = "graphs are written as their art, without their data or settings";
|
||||
/// The warning for an image left out because it can't be decoded: the note raster exports give
|
||||
/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`).
|
||||
const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture";
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::{
|
||||
ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind,
|
||||
TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke,
|
||||
};
|
||||
use super::{CONIC, CONIC_WEDGES, DIAMOND, HUE_STOPS, SPREAD, SPREAD_CAPPED};
|
||||
use super::{CONIC, CONIC_WEDGES, DIAMOND, GRAPHS, HUE_STOPS, SPREAD, SPREAD_CAPPED};
|
||||
|
||||
pub(super) struct Writer<'a> {
|
||||
doc: &'a Document,
|
||||
@@ -254,6 +254,9 @@ impl<'a> Writer<'a> {
|
||||
if n.name.as_deref().is_some_and(|s| !s.is_empty()) && !n.is_layer() {
|
||||
self.warn(NAMES);
|
||||
}
|
||||
if n.extras().graph.is_some() {
|
||||
self.warn(GRAPHS);
|
||||
}
|
||||
let locked = locked || n.locked;
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, clip, .. } => {
|
||||
|
||||
@@ -237,7 +237,14 @@ private editing data. VectorCraft:
|
||||
from 0 to 9 leaves the mode as it was.
|
||||
Legacy `.ai` placed EPS files in the version 7 form (`` ` `` … `~` with no `%AI5_BeginPlace`) are read like
|
||||
other placed files without a preview: as the page's art in their box. Graphs (`%_Gs` … `%_GS`) come in as their
|
||||
drawn art, as plain groups, paths and type, without their data or settings, with a note.
|
||||
drawn art. A grouped column, stacked column, line or area graph also keeps its table, type, axes and settings
|
||||
when every record has the shape and values the version 7 specification's appendix A gives
|
||||
([the specification](https://13thmonkey.org/documentation/fonts/AI7FileFormat.pdf), printed pp. 109–112
|
||||
and 146–158), its table holds at least one number, its art draws the plot's left and bottom edges, and each series'
|
||||
group is tagged once and paints its marks with one fill and one stroke, which the graph keeps as that series'
|
||||
paint; Graph Data then edits it. Other graphs (pie and scatter graphs, graphs with designs or
|
||||
customizations, records of later versions) come in as plain groups, paths and type, with a note. Saving a graph to
|
||||
`.ai`, in either form, writes its art without its data or settings, with a warning.
|
||||
Legacy `.ai` patterns in the version 7 form (a list of `@` and `&` layers, `_ &` for the tile's background)
|
||||
open as pattern swatches, with a fill's offset, scale, rotation, reflection and shear applied before its
|
||||
matrix. Legacy `.ai` linked images in
|
||||
|
||||
@@ -1652,6 +1652,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
A `.ai` opened from its editing data opens its Objects on Path groups live, with their path, their objects in order along it, Pivot and Rotate, and their art where the file draws it; a group whose spacing has been dragged, or with options an Objects on Path group doesn't hold, opens as a plain group (`crates/eps/src/import/ai/tests.rs`).
|
||||
`.ai` symbol definitions and placed instances open as live symbols, in the Symbols panel's order; an instance of a symbol that isn't read, or whose appearance differs from its symbol's, comes in as the page draws it (`crates/eps/src/import/ai/tests.rs`).
|
||||
In a `.ai` opened from its editing data, an object in a pattern's art or a brush's art with an art style VectorCraft reads opens as itself with its effects live, as an object on a layer does, and an object in a pattern's art keeps the id of a graphic style in the file's list (`crates/eps/src/import/ai/tests.rs`).
|
||||
A graph in a `.ai` whose records have the shape and values the published version 7 specification gives ([the specification](https://13thmonkey.org/documentation/fonts/AI7FileFormat.pdf), printed pp. 109–112 and 146–158) opens as a live grouped column, stacked column, line or area graph with its saved art, table, axes and settings, and Graph Data edits it ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-data.html)); other graphs keep their art as plain objects with a note, and saving a graph to `.ai` warns that its data and settings aren't written (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/cmd/graph/tests.rs`).
|
||||
Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`).
|
||||
A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art (Art brushes in the older and newer forms of their objects). A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush in the newer form at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke. Paths with an Art brush in the older form, and other brushed paths, still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
|
||||
In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
|
||||
|
||||
Reference in New Issue
Block a user